diff --git a/.cursor/rules/ui-ux-design-guidelines.mdc b/.cursor/rules/ui-ux-design-guidelines.mdc index 12b9a7ee3..125fc744a 100644 --- a/.cursor/rules/ui-ux-design-guidelines.mdc +++ b/.cursor/rules/ui-ux-design-guidelines.mdc @@ -11,13 +11,13 @@ Use the rules below when: ## Rules for AI (mandatory) -The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) +The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [sure-design-system.css](mdc:app/assets/tailwind/sure-design-system.css) -- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase -- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible. +- Always start by referencing [sure-design-system.css](mdc:app/assets/tailwind/sure-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase +- Always prefer using the functional "tokens" defined in @sure-design-system.css when possible. - Example 1: use `text-primary` rather than `text-white` - Example 2: use `bg-container` rather than `bg-white` - Example 3: use `border border-primary` rather than `border border-gray-200` -- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so +- Never create new styles in [sure-design-system.css](mdc:app/assets/tailwind/sure-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so - Always generate semantic HTML diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b871287d4..119f883aa 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,6 +3,9 @@ FROM ruby:${RUBY_VERSION}-slim-bookworm ENV DEBIAN_FRONTEND=noninteractive +# libvips42 supports ActiveStorage image variants through image_processing/ruby-vips +# for existing profile/avatar images. AccountStatement.original_file only stores +# PDF/CSV/XLSX originals, but the devcontainer needs this broader image stack. RUN apt-get update -qq \ && apt-get -y install --no-install-recommends \ apt-utils \ @@ -11,6 +14,7 @@ RUN apt-get update -qq \ git \ imagemagick \ iproute2 \ + libvips42 \ libpq-dev \ libyaml-dev \ libyaml-0-2 \ diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f622e3686..12b837168 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -5,7 +5,9 @@ x-db-env: &db_env x-rails-env: &rails_env DB_HOST: db - HOST: "0.0.0.0" + # Bind the dev server to all interfaces inside the container so Docker's + # published port reaches it from the host. Rails reads BINDING natively. + BINDING: "0.0.0.0" POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres BUNDLE_PATH: /bundle @@ -68,6 +70,9 @@ services: - "7900:7900" shm_size: 2gb restart: unless-stopped + environment: + SE_NODE_MAX_SESSIONS: 4 + SE_NODE_OVERRIDE_MAX_SESSIONS: "true" volumes: postgres-data: diff --git a/.env.example b/.env.example index f5e98e7a4..e0bc454b1 100644 --- a/.env.example +++ b/.env.example @@ -25,19 +25,34 @@ OPENAI_ACCESS_TOKEN= OPENAI_MODEL= OPENAI_URI_BASE= +# Optional: LLM token budget (applies to chat, auto-categorize, merchant detection, PDF processing). +# Lower these for small-context local models (Ollama, LM Studio, LocalAI). +# Defaults work for modern cloud OpenAI models without configuration. +# LLM_CONTEXT_WINDOW=2048 +# LLM_MAX_RESPONSE_TOKENS=512 +# LLM_MAX_HISTORY_TOKENS= +# LLM_SYSTEM_PROMPT_RESERVE=256 +# LLM_MAX_ITEMS_PER_CALL=25 + +# Optional: OpenAI-compatible capability flags +# OPENAI_REQUEST_TIMEOUT=60 # HTTP timeout in seconds; raise for slow local models +# OPENAI_SUPPORTS_PDF_PROCESSING=true # Set to false for endpoints without vision support +# OPENAI_SUPPORTS_RESPONSES_ENDPOINT= # Override Responses-API vs chat.completions routing +# LLM_JSON_MODE= # auto | strict | json_object | none + # Optional: External AI Assistant — delegates chat to a remote AI agent # instead of calling LLMs directly. The agent calls back to Sure's /mcp endpoint. # See docs/hosting/ai.md for full details. # ASSISTANT_TYPE=external # EXTERNAL_ASSISTANT_URL=https://your-agent-host/v1/chat/completions -# EXTERNAL_ASSISTANT_TOKEN=your-api-token +# EXTERNAL_ASSISTANT_TOKEN=your-api-token # pipelock:ignore # EXTERNAL_ASSISTANT_AGENT_ID=main # EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main # EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com # Optional: MCP server endpoint — enables /mcp for external AI assistants. # Both values are required. MCP_USER_EMAIL must match an existing user's email. -# MCP_API_TOKEN=your-random-bearer-token +# MCP_API_TOKEN=your-random-bearer-token # pipelock:ignore # MCP_USER_EMAIL=user@example.com # Optional: Langfuse config @@ -82,7 +97,7 @@ EMAIL_SENDER= # Database Configuration DB_HOST=localhost # May need to be changed to `DB_HOST=db` if using devcontainer DB_PORT=5432 -POSTGRES_PASSWORD=postgres +POSTGRES_PASSWORD=postgres # pipelock:ignore POSTGRES_USER=postgres # Redis configuration @@ -94,12 +109,18 @@ REDIS_URL=redis://localhost:6379/1 # REDIS_SENTINEL_HOSTS=sentinel1:26379,sentinel2:26379,sentinel3:26379 # REDIS_SENTINEL_MASTER=mymaster # REDIS_SENTINEL_USERNAME=default -# REDIS_PASSWORD=your-redis-password +# REDIS_PASSWORD=your-redis-password # pipelock:ignore # App Domain # This is the domain that your Sure instance will be hosted at. It is used to generate links in emails and other places. APP_DOMAIN= +# WebAuthn / passkey MFA configuration +# RP ID is usually the registrable domain (example.com), not a full URL. +# Allowed origins are full HTTPS origins where users access Sure. +WEBAUTHN_RP_ID= +WEBAUTHN_ALLOWED_ORIGINS= + # OpenID Connect configuration OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= @@ -130,7 +151,7 @@ POSTHOG_HOST= # Active Storage Configuration - responsible for storing file uploads # ====================================================================================================== # -# * Defaults to disk storage but you can also use Amazon S3 or Cloudflare R2 +# * Defaults to disk storage but you can also use Amazon S3, Cloudflare R2, or Google Cloud Storage # * Set the appropriate environment variables to use these services. # * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips # @@ -159,3 +180,11 @@ POSTHOG_HOST= # GENERIC_S3_BUCKET= # GENERIC_S3_ENDPOINT= # GENERIC_S3_FORCE_PATH_STYLE= <- defaults to false +# +# Google Cloud Storage +# ==================== +# ACTIVE_STORAGE_SERVICE=google <- Enables Google Cloud Storage +# GCS_PROJECT= +# GCS_BUCKET= +# GCS_KEYFILE_JSON= <- JSON content of service account key (preferred) +# GCS_KEYFILE= <- path to service account JSON key file diff --git a/.env.local.example b/.env.local.example index 75a8de778..cbe138774 100644 --- a/.env.local.example +++ b/.env.local.example @@ -28,8 +28,22 @@ TWELVE_DATA_API_KEY = OPENAI_ACCESS_TOKEN = OPENAI_URI_BASE = OPENAI_MODEL = -# OPENAI_REQUEST_TIMEOUT: Request timeout in seconds (default: 60) -# OPENAI_SUPPORTS_PDF_PROCESSING: Set to false for endpoints without vision support (default: true) + +# LLM token budget. Applies to ALL outbound LLM calls: chat history, +# auto-categorize, merchant detection, provider enhancer, PDF processing. +# Defaults to Ollama's historical 2048-token baseline so small local models +# work out of the box — raise explicitly for cloud or larger-context models. +# LLM_CONTEXT_WINDOW = 2048 # Total tokens the model will accept +# LLM_MAX_RESPONSE_TOKENS = 512 # Reserved for the model's reply +# LLM_MAX_HISTORY_TOKENS = # Derived if unset (context - response - system_reserve) +# LLM_SYSTEM_PROMPT_RESERVE = 256 # Tokens reserved for the system prompt +# LLM_MAX_ITEMS_PER_CALL = 25 # Upper bound on auto-categorize / merchant batches + +# OpenAI-compatible capability flags (custom/self-hosted providers) +# OPENAI_REQUEST_TIMEOUT = 60 # HTTP timeout in seconds; raise for slow local models +# OPENAI_SUPPORTS_PDF_PROCESSING = true # Set to false for endpoints without vision support +# OPENAI_SUPPORTS_RESPONSES_ENDPOINT = # true to force Responses API on custom providers +# LLM_JSON_MODE = # auto | strict | json_object | none # (example: LM Studio/Docker config) OpenAI-compatible API endpoint config # OPENAI_URI_BASE = http://host.docker.internal:1234/ @@ -41,6 +55,11 @@ OIDC_CLIENT_SECRET= OIDC_ISSUER= OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback +# WebAuthn / passkey MFA development defaults +# RP ID must match the domain where credentials are registered. +WEBAUTHN_RP_ID=localhost +WEBAUTHN_ALLOWED_ORIGINS=http://localhost:3000 + # Langfuse config LANGFUSE_PUBLIC_KEY = LANGFUSE_SECRET_KEY = @@ -81,3 +100,10 @@ AI_DEBUG_MODE = # SSL_DEBUG: "true" # volumes: # - ./my-ca.crt:/certs/my-ca.crt:ro + +# Active Storage Configuration +# ACTIVE_STORAGE_SERVICE=google +# GCS_PROJECT= +# GCS_BUCKET= +# GCS_KEYFILE_JSON= +# GCS_KEYFILE= diff --git a/.env.test.example b/.env.test.example index 9b0fbb62b..02d7fea24 100644 --- a/.env.test.example +++ b/.env.test.example @@ -26,6 +26,10 @@ OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback +# WebAuthn / passkey MFA test defaults +WEBAUTHN_RP_ID=www.example.com +WEBAUTHN_ALLOWED_ORIGINS=http://www.example.com + # ================ # Data Providers # --------------------------------------------------------------------------------- diff --git a/.erb_lint.yml b/.erb_lint.yml index 5b4eaa6b3..d9521ca07 100644 --- a/.erb_lint.yml +++ b/.erb_lint.yml @@ -6,4 +6,72 @@ linters: rubocop_config: Style/StringLiterals: Enabled: true - EnforcedStyle: double_quotes \ No newline at end of file + EnforcedStyle: double_quotes + DeprecatedClasses: + enabled: true + addendum: "Design tokens live in design/tokens/sure.tokens.json; semantic utilities are documented in app/assets/tailwind/sure-design-system.css." + rule_set: + - suggestion: "Use text-primary, text-secondary, text-subdued, text-inverse, text-link, or a semantic color token (text-success, text-warning, text-destructive)." + deprecated: + - 'text-gray-50' + - 'text-gray-100' + - 'text-gray-200' + - 'text-gray-500' + - 'text-gray-600' + - 'text-gray-700' + - 'text-gray-800' + - 'text-gray-900' + - 'text-white' + - suggestion: "Use bg-container, bg-container-inset, bg-surface, bg-surface-inset, bg-inverse, button-bg-primary, button-bg-secondary, or one of their hover variants." + deprecated: + - 'bg-gray-50' + - 'bg-gray-100' + - 'bg-gray-200' + - 'bg-gray-500' + - 'bg-gray-600' + - 'bg-gray-700' + - 'bg-gray-800' + - 'bg-gray-900' + - 'bg-white' + - suggestion: "Use border-primary, border-secondary, border-tertiary, border-subdued, border-inverse, or border-destructive." + deprecated: + - 'border-gray-200' + - 'border-gray-300' + - 'border-gray-500' + - 'border-gray-700' + - 'border-gray-900' + - 'border-white' + # Custom @utility tokens (bg-inverse, text-inverse, text-primary, etc.) do + # NOT support Tailwind's `/N` opacity modifier syntax — modifiers like + # `text-inverse/70` silently compile to nothing. Use `opacity-N` on the + # parent element, or migrate the token to `@theme --color-X` in + # design/tokens/sure.tokens.json so Tailwind auto-generates the + # color-mix pipeline. See #1653. + - suggestion: "Custom @utility tokens drop `/N` opacity modifiers silently. Use `opacity-N` instead, or migrate the token to @theme in design/tokens/sure.tokens.json (see #1653)." + deprecated: + - 'text-inverse\/\d+' + - 'bg-inverse\/\d+' + - 'bg-inverse-hover\/\d+' + - 'border-inverse\/\d+' + - 'text-primary\/\d+' + - 'text-secondary\/\d+' + - 'text-subdued\/\d+' + - 'border-primary\/\d+' + - 'border-secondary\/\d+' + - 'border-subdued\/\d+' + - 'border-destructive\/\d+' + - 'border-solid\/\d+' + - 'button-bg-primary\/\d+' + - 'button-bg-primary-hover\/\d+' + - 'button-bg-secondary\/\d+' + - 'button-bg-secondary-hover\/\d+' + - 'button-bg-secondary-strong\/\d+' + - 'button-bg-secondary-strong-hover\/\d+' + - 'button-bg-disabled\/\d+' + - 'button-bg-destructive\/\d+' + - 'button-bg-destructive-hover\/\d+' + - 'button-bg-ghost-hover\/\d+' + - 'button-bg-outline-hover\/\d+' + - 'tab-item-active\/\d+' + - 'tab-item-hover\/\d+' + - 'tab-bg-group\/\d+' diff --git a/.gitattributes b/.gitattributes index 767c681d5..b387bb10a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,9 @@ # Mark the database schema as having been generated. db/schema.rb linguist-generated +# Mark generated design system CSS (built from tokens/sure.tokens.json). +app/assets/tailwind/sure-design-system/_generated.css linguist-generated + # Mark any vendored files as having been vendored. vendor/* linguist-vendored config/credentials/*.yml.enc diff=rails_credentials diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c098fd39..87726005e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -86,7 +86,7 @@ Sidekiq handles asynchronous tasks: - **Stimulus Controllers**: Handle interactivity, organized alongside components - **Charts**: D3.js for financial visualizations (time series, donut, sankey) - **Styling**: Tailwind CSS v4.x with custom design system - - Design system defined in `app/assets/tailwind/maybe-design-system.css` + - Design system defined in `app/assets/tailwind/sure-design-system.css` - Always use functional tokens (e.g., `text-primary` not `text-white`) - Prefer semantic HTML elements over JS components - Use `icon` helper for icons, never `lucide_icon` directly @@ -261,7 +261,7 @@ end ## TailwindCSS Design System ### Design System Rules -- **Always reference `app/assets/tailwind/maybe-design-system.css`** for primitives and tokens +- **Always reference `app/assets/tailwind/sure-design-system.css`** for primitives and tokens - **Use functional tokens** defined in design system: - `text-primary` instead of `text-white` - `bg-container` instead of `bg-white` diff --git a/.github/workflows/chart-ci.yml b/.github/workflows/chart-ci.yml index d5ce7c532..9f3b25958 100644 --- a/.github/workflows/chart-ci.yml +++ b/.github/workflows/chart-ci.yml @@ -4,14 +4,14 @@ on: pull_request: paths: - 'charts/**' - - 'config/initializers/version.rb' + - '.sure-version' - '.github/workflows/chart-ci.yml' push: branches: - main paths: - 'charts/**' - - 'config/initializers/version.rb' + - '.sure-version' - '.github/workflows/chart-ci.yml' jobs: @@ -20,22 +20,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Check version alignment shell: bash run: | set -euo pipefail - RAILS_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?' config/initializers/version.rb | head -n 1 || true) + RAILS_VERSION=$(cat .sure-version | tr -d '[:space:]') if [ -z "$RAILS_VERSION" ]; then - echo "::error::Could not extract a version string from config/initializers/version.rb — ensure it contains a quoted semver like VERSION = \"1.2.3\"" + echo "::error::Could not read version from .sure-version" exit 1 fi CHART_VERSION=$(sed -n 's/^version: //p' charts/sure/Chart.yaml | head -n 1) APP_VERSION=$(sed -n 's/^appVersion: "\{0,1\}\([^"]*\)"\{0,1\}/\1/p' charts/sure/Chart.yaml | head -n 1) - echo "Rails version (version.rb): $RAILS_VERSION" + echo "App version (.sure-version): $RAILS_VERSION" echo "Helm chart version (Chart.yaml): $CHART_VERSION" echo "Helm appVersion (Chart.yaml): $APP_VERSION" @@ -53,7 +53,7 @@ jobs: if [ "$ERRORS" -gt 0 ]; then echo "" - echo "To fix: ensure version in config/initializers/version.rb matches" + echo "To fix: ensure version in .sure-version matches" echo "both 'version' and 'appVersion' in charts/sure/Chart.yaml" exit 1 fi @@ -64,10 +64,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Helm - uses: azure/setup-helm@v4.3.1 + uses: azure/setup-helm@v5 - name: Add chart dependencies repositories run: | diff --git a/.github/workflows/chart-release.yml b/.github/workflows/chart-release.yml index 23fac9b88..e09bd9483 100644 --- a/.github/workflows/chart-release.yml +++ b/.github/workflows/chart-release.yml @@ -18,7 +18,7 @@ jobs: app_version: ${{ steps.tag.outputs.app_version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -28,20 +28,20 @@ jobs: run: | set -euo pipefail - # Read the canonical version from the Rails app (single source of truth) - APP_SEMVER=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?' config/initializers/version.rb | head -n 1 || true) + # Read the canonical version from .sure-version (single source of truth) + APP_SEMVER=$(cat .sure-version | tr -d '[:space:]') if [ -z "$APP_SEMVER" ]; then - echo "::error::Could not extract version from config/initializers/version.rb" + echo "::error::Could not read version from .sure-version" exit 1 fi - echo "App version from version.rb: $APP_SEMVER" + echo "App version from .sure-version: $APP_SEMVER" if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then # Use the app version as the chart version (monorepo: versions stay in sync) TAG_NAME="chart-v${APP_SEMVER}" if git rev-parse "refs/tags/${TAG_NAME}" >/dev/null 2>&1; then - echo "::error::Tag ${TAG_NAME} already exists. Bump the version in config/initializers/version.rb and charts/sure/Chart.yaml first." + echo "::error::Tag ${TAG_NAME} already exists. Bump the version in .sure-version and charts/sure/Chart.yaml first." exit 1 fi @@ -79,13 +79,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Download Helm chart artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: helm-chart-package path: ${{ runner.temp }}/helm-artifacts - name: Create chart GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.prepare_release.outputs.tag_name }} name: ${{ needs.prepare_release.outputs.tag_name }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae6bb5e3..0859eca33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -25,7 +25,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -41,7 +41,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -57,12 +57,12 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js environment - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24" cache: "npm" - name: Install dependencies @@ -72,7 +72,7 @@ jobs: - name: Lint/Format js code run: npm run lint - test: + test_unit: runs-on: ubuntu-latest timeout-minutes: 10 @@ -104,7 +104,7 @@ jobs: run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -121,11 +121,57 @@ jobs: - name: Unit and integration tests run: bin/rails test + test_system: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + PLAID_CLIENT_ID: foo + PLAID_SECRET: bar + DATABASE_URL: postgres://postgres:postgres@localhost:5432 + REDIS_URL: redis://localhost:6379 + RAILS_ENV: test + + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + redis: + image: redis + ports: + - 6379:6379 + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev + + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: DB setup and smoke test + run: | + bin/rails db:create + bin/rails db:schema:load + bin/rails db:seed + - name: System tests run: DISABLE_PARALLELIZATION=true bin/rails test:system - name: Keep screenshots from failed system tests - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 if: failure() with: name: screenshots diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 198a2ea41..696a8e9f7 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -2,6 +2,10 @@ name: Flutter Mobile Build on: workflow_call: + outputs: + has_app_release_aab: + description: "Whether a signed release AAB artifact was produced" + value: ${{ jobs.build-android.outputs.has_app_release_aab }} workflow_dispatch: permissions: @@ -12,13 +16,15 @@ jobs: name: Build Android APK runs-on: ubuntu-latest timeout-minutes: 30 + outputs: + has_app_release_aab: ${{ steps.check_secrets.outputs.has_keystore }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' @@ -87,7 +93,7 @@ jobs: fi - name: Upload APK artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: app-release-apk path: | @@ -103,7 +109,7 @@ jobs: - name: Upload AAB artifact if: steps.check_secrets.outputs.has_keystore == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: app-release-aab path: mobile/build/app/outputs/bundle/release/app-release.aab @@ -116,7 +122,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Flutter uses: subosito/flutter-action@v2 @@ -161,7 +167,7 @@ jobs: echo "For distribution, you need to configure code signing with Apple certificates" >> build/ios-build-info.txt - name: Upload iOS build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ios-build-unsigned path: | diff --git a/.github/workflows/google-play-upload.yml b/.github/workflows/google-play-upload.yml new file mode 100644 index 000000000..efb762e02 --- /dev/null +++ b/.github/workflows/google-play-upload.yml @@ -0,0 +1,108 @@ +name: Google Play Upload + +on: + workflow_call: + inputs: + notes: + description: "Google Play release notes" + required: false + type: string + track: + description: "Google Play track (internal, alpha, beta, production)" + required: false + default: "internal" + type: string + secrets: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: + required: false + +permissions: + contents: read + +jobs: + upload: + name: Upload Android AAB to Google Play + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check Google Play credentials + id: check_prereqs + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }} + run: | + set -eu + + missing=() + if [ -z "${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64-}" ]; then + missing+=("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64") + fi + + if [ "${#missing[@]}" -eq 0 ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "enabled=false" >> "$GITHUB_OUTPUT" + { + echo "Missing required Google Play secrets:" + printf " - %s\n" "${missing[@]}" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Skip Google Play upload + if: ${{ steps.check_prereqs.outputs.enabled != 'true' }} + run: | + echo "Skipping Google Play upload because required credentials are not configured." + + - name: Download Android AAB artifact + if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} + uses: actions/download-artifact@v7 + with: + name: app-release-aab + path: ${{ runner.temp }}/android-aab + + - name: Prepare Google Play credentials + id: play_creds + if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }} + run: | + set -euo pipefail + CREDENTIALS_PATH="$RUNNER_TEMP/google-play-service-account.json" + echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64" | base64 --decode > "$CREDENTIALS_PATH" + echo "credentials-path=$CREDENTIALS_PATH" >> "$GITHUB_OUTPUT" + + - name: Resolve AAB path + id: aab + if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} + run: | + set -euo pipefail + AAB_PATH="$(find "${{ runner.temp }}/android-aab" -name '*.aab' | head -n 1)" + if [ -z "$AAB_PATH" ]; then + echo "::error::No Android App Bundle (.aab) found in downloaded artifacts" + exit 1 + fi + echo "aab-path=$AAB_PATH" >> "$GITHUB_OUTPUT" + + - name: Create release notes file + id: notes + if: ${{ steps.check_prereqs.outputs.enabled == 'true' && inputs.notes != '' }} + env: + NOTES: ${{ inputs.notes }} + run: | + set -euo pipefail + NOTES_DIR="$RUNNER_TEMP/google-play-whatsnew" + mkdir -p "$NOTES_DIR" + printf '%s\n' "$NOTES" > "$NOTES_DIR/whatsnew-en-US" + echo "notes-dir=$NOTES_DIR" >> "$GITHUB_OUTPUT" + + - name: Upload to Google Play + if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJson: ${{ steps.play_creds.outputs.credentials-path }} + packageName: am.sure.mobile + releaseFiles: ${{ steps.aab.outputs.aab-path }} + tracks: ${{ inputs.track }} + status: completed + whatsNewDirectory: ${{ steps.notes.outputs.notes-dir }} diff --git a/.github/workflows/helm-publish.yml b/.github/workflows/helm-publish.yml index f38a786da..9f6c6b3b6 100644 --- a/.github/workflows/helm-publish.yml +++ b/.github/workflows/helm-publish.yml @@ -29,12 +29,12 @@ jobs: app_version: ${{ steps.version.outputs.app_version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Install Helm - uses: azure/setup-helm@v4.3.1 + uses: azure/setup-helm@v5 - name: Resolve chart and app versions id: version @@ -88,7 +88,7 @@ jobs: helm package charts/sure -d .cr-release-packages - name: Upload packaged chart artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: helm-chart-package path: .cr-release-packages/*.tgz @@ -98,7 +98,7 @@ jobs: - name: Checkout gh-pages if: ${{ inputs.update_gh_pages }} - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: gh-pages path: gh-pages diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 1852a2c52..6e058642c 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -15,7 +15,7 @@ on: type: string push: tags: - - 'ios-v*' + - 'v*' permissions: contents: read @@ -23,12 +23,12 @@ permissions: jobs: build-and-upload: name: Build signed IPA and upload to TestFlight - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 120 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Check TestFlight credentials id: check_prereqs @@ -82,6 +82,14 @@ jobs: run: | echo "Skipping TestFlight upload because required credentials are not configured." + - name: Select Xcode 26 + if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} + run: | + set -euo pipefail + sudo xcode-select -s /Applications/Xcode_26.4.app/Contents/Developer + xcodebuild -version + xcrun --sdk iphoneos --show-sdk-version + - name: Set up Flutter uses: subosito/flutter-action@v2 if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} @@ -95,10 +103,6 @@ jobs: if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} run: flutter pub get - - name: Copy app icon source - if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} - run: cp public/android-chrome-512x512.png mobile/assets/icon/app_icon.png - - name: Generate app icons working-directory: mobile if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} @@ -154,7 +158,44 @@ jobs: run: | set -euo pipefail + IOS_VERSION="$(tr -d '[:space:]' < ../.sure-version | sed 's/-.*$//')" + IOS_BUILD_NUMBER="$(date -u +%Y%m%d%H%M)" + ARCHIVE_PATH="$RUNNER_TEMP/Runner.xcarchive" + EXPORT_PATH="$PWD/build/ios/ipa" EXPORT_PLIST="$RUNNER_TEMP/ExportOptions.plist" + python3 <<'PY' + import os + import re + from pathlib import Path + + path = Path("ios/Runner.xcodeproj/project.pbxproj") + text = path.read_text() + + team = os.environ["IOS_TEAM_ID"] + profile = os.environ["PROFILE_NAME"] + identity = os.environ["IOS_DISTRIBUTION_CERT_NAME"] + + def patch_block(match): + block = match.group(0) + if "PRODUCT_BUNDLE_IDENTIFIER = am.sure.mobile;" not in block: + return block + if "CODE_SIGN_STYLE = Manual;" not in block: + block = block.replace("CURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";", "CURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tCODE_SIGN_STYLE = Manual;") + if '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";' not in block: + block = re.sub(r'DEVELOPMENT_TEAM = .*?;', f'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "{identity}";\n\t\t\t\tDEVELOPMENT_TEAM = {team};', block, count=1) + else: + block = re.sub(r'"CODE_SIGN_IDENTITY\[sdk=iphoneos\*\]" = ".*?";', f'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "{identity}";', block) + block = re.sub(r'DEVELOPMENT_TEAM = .*?;', f'DEVELOPMENT_TEAM = {team};', block) + if "PROVISIONING_PROFILE_SPECIFIER = " in block: + block = re.sub(r'PROVISIONING_PROFILE_SPECIFIER = .*?;', f'PROVISIONING_PROFILE_SPECIFIER = "{profile}";', block) + else: + block = block.replace(f'DEVELOPMENT_TEAM = {team};', f'DEVELOPMENT_TEAM = {team};\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = "{profile}";') + return block + + text = re.sub(r'isa = XCBuildConfiguration;\n\s+baseConfigurationReference = .*?;\n\s+buildSettings = \{.*?\n\s+name = (?:Debug|Release|Profile);\n\s+\};', patch_block, text, flags=re.S) + path.write_text(text) + PY + cat > "$EXPORT_PLIST" < @@ -181,11 +222,37 @@ jobs: EOF - CODE_SIGN_IDENTITY="${IOS_DISTRIBUTION_CERT_NAME}" \ - CODE_SIGN_STYLE=Manual \ - DEVELOPMENT_TEAM="${IOS_TEAM_ID}" \ - PROVISIONING_PROFILE_SPECIFIER="${PROFILE_NAME}" \ - flutter build ipa --release --export-options-plist="$EXPORT_PLIST" + if [ -z "$IOS_VERSION" ]; then + echo "::error::.sure-version is empty or unreadable" + exit 1 + fi + + echo "Using iOS version: $IOS_VERSION" + echo "Using iOS build number: $IOS_BUILD_NUMBER" + + flutter build ios \ + --release \ + --no-codesign \ + --build-name="$IOS_VERSION" \ + --build-number="$IOS_BUILD_NUMBER" + + xcodebuild \ + -workspace ios/Runner.xcworkspace \ + -scheme Runner \ + -configuration Release \ + -archivePath "$ARCHIVE_PATH" \ + -destination 'generic/platform=iOS' \ + MARKETING_VERSION="$IOS_VERSION" \ + CURRENT_PROJECT_VERSION="$IOS_BUILD_NUMBER" \ + archive + + mkdir -p "$EXPORT_PATH" + + xcodebuild \ + -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist "$EXPORT_PLIST" - name: Prepare TestFlight auth key id: testflight_key @@ -195,8 +262,11 @@ jobs: APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }} run: | set -euo pipefail - KEY_FILE="$RUNNER_TEMP/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8" + KEY_DIR="$HOME/.appstoreconnect/private_keys" + mkdir -p "$KEY_DIR" + KEY_FILE="$KEY_DIR/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8" echo "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > "$KEY_FILE" + chmod 600 "$KEY_FILE" echo "key-file=$KEY_FILE" >> "$GITHUB_OUTPUT" - name: Upload IPA to TestFlight @@ -219,12 +289,11 @@ jobs: --file "$IPA_PATH" \ --type ios \ --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \ - --apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID" \ - --apiPrivateKey "$APP_STORE_CONNECT_API_KEY_FILE" + --apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID" - name: Upload build artifact if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ios-ipa-testflight path: mobile/build/ios/ipa/*.ipa @@ -232,9 +301,14 @@ jobs: - name: Cleanup signing keychain if: ${{ always() && steps.check_prereqs.outputs.enabled == 'true' }} + env: + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} run: | set -euo pipefail KEYCHAIN_PATH="${{ steps.signing.outputs.keychain-path }}" if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then security delete-keychain "$KEYCHAIN_PATH" fi + + KEY_FILE="$HOME/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8" + rm -f "$KEY_FILE" diff --git a/.github/workflows/llm-evals.yml b/.github/workflows/llm-evals.yml new file mode 100644 index 000000000..13b608336 --- /dev/null +++ b/.github/workflows/llm-evals.yml @@ -0,0 +1,457 @@ +name: LLM Evals + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +env: + EVAL_MODELS: gpt-4.1 + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432 + REDIS_URL: redis://localhost:6379 + PLAID_CLIENT_ID: foo + PLAID_SECRET: bar + +jobs: + check_openai: + name: Check OpenAI credentials + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.gate.outputs.should_run }} + reason: ${{ steps.gate.outputs.reason }} + + steps: + - name: Validate OpenAI token and quota + id: gate + env: + OPENAI_ACCESS_TOKEN: ${{ secrets.OPENAI_ACCESS_TOKEN }} + shell: bash + run: | + set -euo pipefail + + if [ -z "${OPENAI_ACCESS_TOKEN:-}" ]; then + echo "OpenAI token is not configured; skipping eval workflow." + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "reason=OPENAI_ACCESS_TOKEN secret is missing" >> "$GITHUB_OUTPUT" + exit 0 + fi + + TEST_MODEL=$(printf '%s' "$EVAL_MODELS" | cut -d',' -f1 | xargs) + if [ -z "$TEST_MODEL" ]; then + TEST_MODEL="gpt-4.1" + fi + + echo "Checking API access with model: ${TEST_MODEL}" + + RESPONSE_FILE="$(mktemp)" + STATUS_CODE=$(curl -sS -o "$RESPONSE_FILE" -w "%{http_code}" \ + https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer ${OPENAI_ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"model\":\"${TEST_MODEL}\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1}") + + if [ "$STATUS_CODE" = "200" ]; then + echo "OpenAI token check passed." + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "reason=ok" >> "$GITHUB_OUTPUT" + exit 0 + fi + + ERROR_MESSAGE=$(ruby -rjson -e ' + body = File.read(ARGV[0]) rescue "" + data = JSON.parse(body) rescue {} + message = data.dig("error", "message") || data["message"] || "unknown error" + puts message.gsub(/\s+/, " ").strip + ' "$RESPONSE_FILE") + + echo "OpenAI check failed (${STATUS_CODE}): ${ERROR_MESSAGE}" + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "reason=OpenAI token invalid or insufficient quota (${STATUS_CODE})" >> "$GITHUB_OUTPUT" + exit 0 + + discover_datasets: + name: Discover eval datasets + needs: check_openai + if: needs.check_openai.outputs.should_run == 'true' + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + redis: + image: redis:7.2 + ports: + - 6379:6379 + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + outputs: + datasets: ${{ steps.datasets.outputs.datasets }} + models: ${{ steps.models.outputs.models }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Prepare database + run: | + bin/rails db:create + bin/rails db:schema:load + + - name: Import eval datasets + shell: bash + run: | + set -euo pipefail + + shopt -s nullglob + dataset_files=(db/eval_data/*.yml) + + if [ ${#dataset_files[@]} -eq 0 ]; then + echo "::error::No eval dataset files found under db/eval_data/*.yml" + exit 1 + fi + + for dataset_file in "${dataset_files[@]}"; do + echo "Importing ${dataset_file}" + bundle exec rake "evals:import_dataset[${dataset_file}]" + done + + + - name: Resolve eval models + id: models + shell: bash + run: | + set -euo pipefail + + MODELS_JSON=$(bin/rails runner ' + models = ENV.fetch("EVAL_MODELS", "").split(",").map(&:strip).reject(&:blank?) + puts models.to_json + ') + + if [ "$MODELS_JSON" = "[]" ]; then + echo "::error::EVAL_MODELS is empty. Set at least one model, for example: EVAL_MODELS=gpt-4.1" + exit 1 + fi + + { + echo "models<> "$GITHUB_OUTPUT" + + - name: Resolve available eval datasets + id: datasets + shell: bash + run: | + set -euo pipefail + + DATASETS_JSON=$(bin/rails runner 'puts Eval::Dataset.order(:name).pluck(:name).to_json') + + if [ "$DATASETS_JSON" = "[]" ]; then + echo "::error::No eval datasets found. Import one first with: rake evals:import_dataset[path/to/file.yml]" + exit 1 + fi + + { + echo "datasets<> "$GITHUB_OUTPUT" + + run_evals: + name: Run eval for ${{ matrix.dataset }} on ${{ matrix.model }} + needs: [check_openai, discover_datasets] + if: needs.check_openai.outputs.should_run == 'true' + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + dataset: ${{ fromJson(needs.discover_datasets.outputs.datasets) }} + model: ${{ fromJson(needs.discover_datasets.outputs.models) }} + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + redis: + image: redis:7.2 + ports: + - 6379:6379 + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Prepare database + run: | + bin/rails db:create + bin/rails db:schema:load + + - name: Import eval datasets + shell: bash + run: | + set -euo pipefail + + shopt -s nullglob + dataset_files=(db/eval_data/*.yml) + + if [ ${#dataset_files[@]} -eq 0 ]; then + echo "::error::No eval dataset files found under db/eval_data/*.yml" + exit 1 + fi + + for dataset_file in "${dataset_files[@]}"; do + echo "Importing ${dataset_file}" + bundle exec rake "evals:import_dataset[${dataset_file}]" + done + + - name: Prepare dataset artifact names + id: dataset_slug + env: + DATASET: ${{ matrix.dataset }} + MODEL: ${{ matrix.model }} + shell: bash + run: | + set -euo pipefail + + slug=$(printf '%s' "$DATASET" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g') + + if [ -z "$slug" ]; then + echo "::error::Could not generate dataset slug from '$DATASET'" + exit 1 + fi + + echo "slug=$slug" >> "$GITHUB_OUTPUT" + model_slug=$(printf '%s' "$MODEL" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g') + + if [ -z "$model_slug" ]; then + echo "::error::Could not generate model slug from '$MODEL'" + exit 1 + fi + + echo "model_slug=$model_slug" >> "$GITHUB_OUTPUT" + echo "log_path=tmp/evals/${slug}-${model_slug}.log" >> "$GITHUB_OUTPUT" + echo "json_path=tmp/evals/${slug}-${model_slug}.json" >> "$GITHUB_OUTPUT" + + - name: Verify dataset exists + env: + DATASET: ${{ matrix.dataset }} + MODEL: ${{ matrix.model }} + run: | + bin/rails runner 'dataset = Eval::Dataset.find_by(name: ENV.fetch("DATASET")); abort("Dataset not found: #{ENV.fetch("DATASET")}") if dataset.nil?' + + - name: Run eval + env: + DATASET: ${{ matrix.dataset }} + MODEL: ${{ matrix.model }} + OPENAI_ACCESS_TOKEN: ${{ secrets.OPENAI_ACCESS_TOKEN }} + run: | + set -euo pipefail + mkdir -p tmp/evals + bundle exec rake "evals:run[${DATASET},${MODEL}]" | tee "${{ steps.dataset_slug.outputs.log_path }}" + + - name: Export run summary + id: export_summary + env: + DATASET: ${{ matrix.dataset }} + MODEL: ${{ matrix.model }} + JSON_PATH: ${{ steps.dataset_slug.outputs.json_path }} + run: | + set -euo pipefail + mkdir -p "$(dirname "$JSON_PATH")" + + bin/rails runner ' + dataset = Eval::Dataset.find_by!(name: ENV.fetch("DATASET")) + run = Eval::Run.where(dataset: dataset, model: ENV.fetch("MODEL")).order(created_at: :desc).first + abort("No eval run found for dataset #{dataset.name} and model #{ENV.fetch("MODEL")}") if run.nil? + payload = { + dataset: dataset.name, + dataset_metadata: dataset.metadata, + model: ENV.fetch("MODEL"), + run_id: run.id, + status: run.status, + created_at: run.created_at, + completed_at: run.completed_at, + total_prompt_tokens: run.total_prompt_tokens, + total_completion_tokens: run.total_completion_tokens, + total_cost: run.total_cost, + metrics: run.metrics, + accuracy: run.accuracy || 0.0, + duration_seconds: run.duration_seconds + } + File.write(ENV.fetch("JSON_PATH"), JSON.pretty_generate(payload)) + ' + + echo "accuracy=$(jq -r '.accuracy // 0' "$JSON_PATH")" >> "$GITHUB_OUTPUT" + echo "status=$(jq -r '.status' "$JSON_PATH")" >> "$GITHUB_OUTPUT" + + - name: Upload eval artifact + uses: actions/upload-artifact@v6 + with: + name: llm-evals-${{ steps.dataset_slug.outputs.slug }}-${{ steps.dataset_slug.outputs.model_slug }} + path: | + ${{ steps.dataset_slug.outputs.log_path }} + ${{ steps.dataset_slug.outputs.json_path }} + if-no-files-found: error + retention-days: 30 + + - name: Output eval result + shell: bash + run: | + echo "### Eval Result: ${{ matrix.dataset }} / ${{ matrix.model }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- **Status**: ${{ steps.export_summary.outputs.status }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Accuracy**: ${{ steps.export_summary.outputs.accuracy }}%" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + summarize_evals: + name: Summarize LLM Evals + needs: [check_openai, run_evals] + if: always() && needs.check_openai.outputs.should_run == 'true' + runs-on: ubuntu-latest + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v7 + with: + path: eval-artifacts + pattern: llm-evals-* + + - name: Generate summary + shell: bash + run: | + set -euo pipefail + + echo "# 🧪 LLM Evals Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + printf "Triggered by: \`%s\`\n" "$GITHUB_REF" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "---" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + # Find all JSON result files + shopt -s globstar nullglob + json_files=(eval-artifacts/**/*.json) + + if [ ${#json_files[@]} -eq 0 ]; then + echo "⚠️ No eval results found." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + # Table header + echo "| Dataset | Model | Status | Accuracy | Cost | Duration |" >> "$GITHUB_STEP_SUMMARY" + echo "|---------|-------|--------|----------|------|----------|" >> "$GITHUB_STEP_SUMMARY" + + all_passed=true + accuracy_threshold=70 + for json_file in "${json_files[@]}"; do + dataset=$(jq -r '.dataset' "$json_file") + model=$(jq -r '.model' "$json_file") + status=$(jq -r '.status' "$json_file") + accuracy=$(jq -r '.accuracy // 0' "$json_file") + cost=$(jq -r '.total_cost // 0' "$json_file") + duration=$(jq -r '.duration_seconds // 0' "$json_file") + + if [ "$status" = "completed" ] && awk -v accuracy="$accuracy" -v threshold="$accuracy_threshold" 'BEGIN { exit !((accuracy + 0) >= threshold) }'; then + icon="✅" + else + icon="❌" + all_passed=false + fi + + printf '| %s | %s | %s %s | %s%% | \\$%s | %ss |\n' \ + "$dataset" "$model" "$icon" "$status" "$accuracy" "$cost" "$duration" >> "$GITHUB_STEP_SUMMARY" + done + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "---" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [ "$all_passed" = "true" ]; then + echo "✅ **All evals passed!**" >> "$GITHUB_STEP_SUMMARY" + else + echo "❌ **Some evals failed. Check the details above.**" >> "$GITHUB_STEP_SUMMARY" + fi + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "📦 Artifacts with full logs are available for download." >> "$GITHUB_STEP_SUMMARY" + + - name: Check eval thresholds + shell: bash + run: | + set -euo pipefail + + shopt -s globstar nullglob + json_files=(eval-artifacts/**/*.json) + + failed=0 + accuracy_threshold=70 + for json_file in "${json_files[@]}"; do + status=$(jq -r '.status' "$json_file") + accuracy=$(jq -r '.accuracy // 0' "$json_file") + dataset=$(jq -r '.dataset' "$json_file") + model=$(jq -r '.model' "$json_file") + + if [ "$status" != "completed" ]; then + echo "::error::Eval for $dataset / $model did not complete successfully" + failed=$((failed + 1)) + fi + + # Fail if accuracy is below 70% + if awk -v accuracy="$accuracy" -v threshold="$accuracy_threshold" 'BEGIN { exit !((accuracy + 0) < threshold) }'; then + echo "::error::Accuracy for $dataset / $model is below threshold: ${accuracy}%" + failed=$((failed + 1)) + fi + done + + if [ $failed -gt 0 ]; then + echo "::error::$failed eval(s) failed or below threshold" + exit 1 + fi + + echo "All evals passed with acceptable accuracy." + + skip_evals: + name: Skip evals (no valid OpenAI token/quota) + needs: check_openai + if: needs.check_openai.outputs.should_run != 'true' + runs-on: ubuntu-latest + steps: + - name: Report skip reason + run: | + echo "LLM evals were skipped gracefully." + echo "Reason: ${{ needs.check_openai.outputs.reason }}" diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index 38507c273..fe985bb01 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -64,21 +64,21 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ github.ref }} fetch-depth: 0 - name: Download Android APK artifact continue-on-error: true - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v7 with: name: app-release-apk path: ${{ runner.temp }}/mobile-artifacts - name: Download iOS build artifact continue-on-error: true - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v7 with: name: ios-build-unsigned path: ${{ runner.temp }}/ios-build @@ -170,7 +170,7 @@ jobs: ${{ runner.temp }}/release-assets/* - name: Checkout gh-pages branch - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: gh-pages path: gh-pages diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index 5de88a6d3..0852a7208 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -71,12 +71,22 @@ jobs: uses: ./.github/workflows/flutter-build.yml secrets: inherit + play-store: + name: Upload Android to Google Play + if: ${{ needs.build.outputs.has_app_release_aab == 'true' }} + needs: [build, prepare_release] + uses: ./.github/workflows/google-play-upload.yml + with: + notes: "Mobile release ${{ needs.prepare_release.outputs.tag_name }}" + track: internal + secrets: inherit + testflight: name: Upload iOS to TestFlight - needs: [build, release] + needs: [build, prepare_release] uses: ./.github/workflows/ios-testflight.yml with: - notes: "Mobile release ${{ needs.release.outputs.tag_name }}" + notes: "Mobile release ${{ needs.prepare_release.outputs.tag_name }}" secrets: inherit release: @@ -102,13 +112,13 @@ jobs: echo "Extracted version: $VERSION" - name: Download Android APK artifact - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v7 with: name: app-release-apk path: ${{ runner.temp }}/mobile-artifacts - name: Download iOS build artifact - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v7 with: name: ios-build-unsigned path: ${{ runner.temp }}/ios-build @@ -160,13 +170,23 @@ jobs: exit 1 fi - - name: Create GitHub Release + - name: Create or update GitHub Release env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail VERSION="${{ steps.version.outputs.version }}" TAG="${{ steps.version.outputs.tag_name }}" + REPO="${{ github.repository }}" + + mapfile -d '' -t RELEASE_ASSETS < <( + find "${{ runner.temp }}/release-assets" -maxdepth 1 -type f -print0 + ) + + if [[ "${#RELEASE_ASSETS[@]}" -eq 0 ]]; then + echo "::error::No release assets were produced" + exit 1 + fi cat > /tmp/release-notes.md <&1)"; then + is_immutable="$(gh release view "$TAG" --repo "$REPO" --json isImmutable --jq '.isImmutable')" + existing_assets="$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name')" + echo "Release ${TAG} already exists. Updating metadata." + gh release edit "$TAG" "${RELEASE_ARGS[@]}" + + if [[ "$is_immutable" == "true" ]]; then + echo "Release is immutable. Verifying existing release assets are already present." + missing_assets=0 + for asset in "${RELEASE_ASSETS[@]}"; do + asset_name="$(basename "$asset")" + if ! grep -Fxq "$asset_name" <<<"$existing_assets"; then + echo "::error::Immutable release ${TAG} is missing asset: ${asset_name}" + missing_assets=1 + fi + done + + if [[ "$missing_assets" != "0" ]]; then + echo "::error::Release assets cannot be changed on immutable releases. Recreate the release to update assets." + exit 1 + fi + + echo "Immutable release already has required assets. Skipping asset upload." + exit 0 + fi + else + if ! echo "$release_view_output" | grep -Eiq "(not found|404)"; then + echo "::error::Failed to inspect existing release ${TAG}." + echo "$release_view_output" + exit 1 + fi + + echo "Creating release ${TAG} with attached assets." + gh release create "$TAG" "${RELEASE_ARGS[@]}" "${RELEASE_ASSETS[@]}" + exit 0 + fi + + for asset in "${RELEASE_ASSETS[@]}"; do + gh release upload "$TAG" "$asset" --repo "$REPO" --clobber + done - name: Checkout gh-pages branch - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: gh-pages path: gh-pages diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml index deef6acd9..7b3b46af9 100644 --- a/.github/workflows/pipelock.yml +++ b/.github/workflows/pipelock.yml @@ -11,7 +11,7 @@ jobs: security-scan: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 persist-credentials: false @@ -29,3 +29,4 @@ jobs: config/locales/views/reports/ docs/hosting/ai.md app/models/provider/binance.rb + workers/preview/package-lock.json diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml new file mode 100644 index 000000000..b36c2cba4 --- /dev/null +++ b/.github/workflows/preview-cleanup.yml @@ -0,0 +1,216 @@ +name: Cleanup PR Previews + +on: + # Run hourly to check for expired previews + schedule: + - cron: '0 * * * *' + + # Immediately cleanup when PR is closed + pull_request: + types: [closed, unlabeled] + + # Allow manual trigger + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to cleanup (optional, cleans all expired if empty)' + required: false + type: string + +permissions: + contents: read + deployments: write + +jobs: + cleanup-on-close: + name: Cleanup closed PR preview + if: github.event_name == 'pull_request' && (github.event.action == 'closed' || (github.event.action == 'unlabeled' && github.event.label.name == 'preview-cf')) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "24" + + - name: Install Wrangler + run: npm install -g wrangler + + - name: Delete preview Worker + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + WORKER_NAME="sure-preview-${{ github.event.pull_request.number }}" + echo "Deleting Worker: $WORKER_NAME" + + # Delete the worker (this also stops any running containers) + wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist" + + - name: Delete GitHub Deployment + uses: actions/github-script@v7 + with: + script: | + const environment = `preview-pr-${{ github.event.pull_request.number }}`; + const description = context.payload.action === 'closed' + ? 'PR closed - preview deleted' + : 'preview-cf label removed - preview deleted'; + + try { + // Get deployments for this environment + const { data: deployments } = await github.rest.repos.listDeployments({ + owner: context.repo.owner, + repo: context.repo.repo, + environment: environment + }); + + // Mark all deployments as inactive + for (const deployment of deployments) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.id, + state: 'inactive', + description + }); + } + + console.log(`Marked ${deployments.length} deployments as inactive`); + } catch (error) { + console.log('No deployments to cleanup or error:', error.message); + } + + cleanup-expired: + name: Cleanup expired previews + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "24" + + - name: Install Wrangler + run: npm install -g wrangler + + - name: Cleanup expired previews + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_INPUT: ${{ inputs.pr_number }} + run: | + # If specific PR number provided, only cleanup that one + if [ -n "$PR_INPUT" ]; then + if [[ "$PR_INPUT" =~ ^[1-9][0-9]*$ ]]; then + PR_NUM="$PR_INPUT" + WORKER_NAME="sure-preview-$PR_NUM" + echo "Manually deleting Worker: $WORKER_NAME" + wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist" + + # Cleanup GitHub deployment for this PR + echo "Cleaning up GitHub deployment for PR #$PR_NUM" + gh api \ + -X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \ + --jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do + if [ -n "$DEPLOY_ID" ]; then + gh api \ + -X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \ + -f state=inactive \ + -f description="Preview manually deleted" || true + fi + done || echo "No deployments to cleanup or error occurred" + else + echo "Invalid PR number input '$PR_INPUT'; skipping manual cleanup" + fi + + exit 0 + fi + + # Get list of all preview workers + echo "Fetching list of preview workers..." + + # Use Cloudflare API to list workers and read modified_on from the list response. + # The per-script endpoint returns raw script content, not JSON metadata. + WORKERS_RESPONSE=$(curl -fsS -X GET \ + "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \ + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + -H "Content-Type: application/json") || { + echo "Failed to fetch preview worker list from Cloudflare" + exit 1 + } + + if ! echo "$WORKERS_RESPONSE" | jq -e '.success == true and (.result | type == "array")' >/dev/null 2>&1; then + echo "Cloudflare API returned an invalid worker list response" + echo "$WORKERS_RESPONSE" | jq -c '.errors // .' + exit 1 + fi + + WORKERS=$(echo "$WORKERS_RESPONSE" | jq -r ' + .result[] + | select(.id | startswith("sure-preview-")) + | [.id, (.modified_on // "")] + | @tsv + ') + + if [ -z "$WORKERS" ]; then + echo "No preview workers found" + exit 0 + fi + + echo "Found preview workers:" + echo "$WORKERS" | cut -f1 + + # Check each worker's deployment time + CUTOFF_TIME=$(date -d '24 hours ago' +%s) + + while IFS=$'\t' read -r WORKER MODIFIED_ON; do + [ -n "$WORKER" ] || continue + echo "Checking $WORKER..." + + if [ -z "$MODIFIED_ON" ]; then + echo "No modified_on timestamp for $WORKER; skipping" + continue + fi + + if ! MODIFIED_TS=$(date -d "$MODIFIED_ON" +%s 2>/dev/null); then + echo "Invalid modified_on timestamp for $WORKER ($MODIFIED_ON); skipping" + continue + fi + + if [ "$MODIFIED_TS" -lt "$CUTOFF_TIME" ]; then + echo "Worker $WORKER is older than 24 hours, deleting..." + if wrangler delete --name "$WORKER" --force; then + # Extract PR number and cleanup GitHub deployment + PR_NUM=$(echo "$WORKER" | sed 's/sure-preview-//') + if [[ "$PR_NUM" =~ ^[1-9][0-9]*$ ]]; then + echo "Cleaning up GitHub deployment for PR #$PR_NUM" + gh api \ + -X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \ + --jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do + gh api \ + -X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \ + -f state=inactive \ + -f description="Preview expired after 24 hours" || true + done || echo "No deployments to cleanup or error occurred" + else + echo "Could not extract a valid PR number from $WORKER; skipping deployment cleanup" + fi + else + echo "Failed to delete $WORKER; skipping deployment status update" + fi + else + echo "Worker $WORKER is still within 24-hour window, keeping..." + fi + done <<< "$WORKERS" + + echo "Cleanup complete" diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 000000000..eff9c847a --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,189 @@ +name: Deploy PR Preview + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + paths-ignore: + - 'charts/**' + - 'docs/**' + - '*.md' + +jobs: + deploy-preview: + if: contains(github.event.pull_request.labels.*.name, 'preview-cf') + name: Deploy to Cloudflare Containers + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + pull-requests: write + deployments: write + + steps: + - name: Wait for PR CI to pass + uses: actions/github-script@v7 + with: + script: | + const headSha = context.payload.pull_request.head.sha; + const timeoutMs = 10 * 60 * 1000; + const pollMs = 15 * 1000; + const startedAt = Date.now(); + let lastState = 'not found'; + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + while (Date.now() - startedAt < timeoutMs) { + const { data } = await github.rest.actions.listWorkflowRunsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + event: 'pull_request', + head_sha: headSha, + per_page: 20, + }); + + const prRun = data.workflow_runs.find((run) => run.name === 'Pull Request' && run.head_sha === headSha); + + if (prRun) { + lastState = `${prRun.status}/${prRun.conclusion ?? 'pending'}`; + core.info(`Pull Request workflow ${prRun.id}: ${lastState}`); + + if (prRun.status === 'completed') { + if (prRun.conclusion === 'success') { + return; + } + + core.setFailed(`Pull Request workflow concluded with ${prRun.conclusion}`); + return; + } + } + + await sleep(pollMs); + } + + core.setFailed(`Timed out waiting for Pull Request workflow for ${headSha}. Last state: ${lastState}`); + + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install Wrangler dependencies + working-directory: workers/preview + run: npm install + + - name: Configure preview files for this PR + working-directory: workers/preview + run: | + sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" wrangler.toml + sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" src/index.ts + cat wrangler.toml + + - name: Create GitHub Deployment + id: deployment + uses: actions/github-script@v7 + with: + script: | + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.payload.pull_request.head.sha, + environment: `preview-pr-${{ github.event.pull_request.number }}`, + auto_merge: false, + required_contexts: [], + description: 'PR Preview Deployment' + }); + return deployment.data.id; + result-encoding: string + + - name: Deploy to Cloudflare Containers + id: deploy + working-directory: workers/preview + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + npx wrangler deploy --var "PR_NUMBER:${{ github.event.pull_request.number }}" + + # Get the deployment URL + PREVIEW_URL="https://sure-preview-${{ github.event.pull_request.number }}.${{ secrets.CLOUDFLARE_WORKERS_SUBDOMAIN }}.workers.dev" + echo "preview_url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT" + + - name: Warm preview container + env: + PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }} + run: | + echo "Triggering preview wake-up..." + curl -fsS "$PREVIEW_URL/" >/dev/null || true + + - name: Update Deployment Status + if: always() && steps.deployment.outputs.result + uses: actions/github-script@v7 + with: + script: | + const state = '${{ job.status }}' === 'success' ? 'success' : 'failure'; + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: ${{ steps.deployment.outputs.result }}, + state: state, + environment_url: state === 'success' ? '${{ steps.deploy.outputs.preview_url }}' : undefined, + description: state === 'success' ? 'Preview deployed successfully' : 'Preview deployment failed' + }); + + - name: Comment on PR + if: success() + uses: actions/github-script@v7 + with: + script: | + const previewUrl = '${{ steps.deploy.outputs.preview_url }}'; + const commentBody = `## 🚀 Preview Deployment Ready + + Your preview environment has been deployed to Cloudflare Containers with the PR's Docker image. + + **Preview URL:** ${previewUrl} + + > ⏰ This preview is intended to be cleaned up after **24 hours** of the last deployment once the cleanup workflow is live on the default branch. + > 💤 The container will sleep after 30 minutes of inactivity and wake on the next request. + + --- + Deployed from commit ${{ github.event.pull_request.head.sha }}`; + + // Find existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }} + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Preview Deployment Ready') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: commentBody + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ github.event.pull_request.number }}, + body: commentBody + }); + } + - name: Store cleanup metadata + if: success() + uses: actions/upload-artifact@v6 + with: + name: preview-cleanup-pr-${{ github.event.pull_request.number }} + path: | + workers/preview/wrangler.toml + retention-days: 2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 12473d176..225886dea 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -55,9 +55,14 @@ jobs: fail-fast: false matrix: platform: [amd64, arm64] + include: + - platform: amd64 + runs-on: ubuntu-24.04 + - platform: arm64 + runs-on: ubuntu-24.04-arm timeout-minutes: 60 - runs-on: ${{ matrix.platform == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + runs-on: ${{ matrix.runs-on }} outputs: tags: ${{ steps.meta.outputs.tags }} @@ -68,15 +73,15 @@ jobs: steps: - name: Check out the repo - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v5 with: ref: ${{ github.event.inputs.ref || github.ref }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.10.0 + uses: docker/setup-buildx-action@v4 - name: Log in to the container registry - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -98,7 +103,6 @@ jobs: BASE_CONFIG+=$'\n'"type=raw,value=latest" else BASE_CONFIG+=$'\n'"type=raw,value=stable" - BASE_CONFIG+=$'\n'"type=raw,value=latest" fi fi fi @@ -114,7 +118,7 @@ jobs: - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5.6.0 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} flavor: latest=false @@ -128,7 +132,7 @@ jobs: org.opencontainers.image.description=A multi-arch Docker image for the Sure Rails app - name: Publish 'linux/${{ matrix.platform }}' image by digest - uses: docker/build-push-action@v6.16.0 + uses: docker/build-push-action@v7 id: build with: context: . @@ -154,7 +158,7 @@ jobs: - name: Upload the Docker image digest if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'schedule' || github.event.inputs.push }} - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v6 with: name: digest-${{ matrix.platform }} path: ${{ runner.temp }}/digests/* @@ -174,17 +178,17 @@ jobs: steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.10.0 + uses: docker/setup-buildx-action@v4 - name: Download Docker image digests - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v7 with: path: ${{ runner.temp }}/digests pattern: digest-* merge-multiple: true - name: Log in to the container registry - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -271,19 +275,19 @@ jobs: steps: - name: Download Android APK artifact - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v7 with: name: app-release-apk path: ${{ runner.temp }}/mobile-artifacts - name: Download iOS build artifact - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v7 with: name: ios-build-unsigned path: ${{ runner.temp }}/ios-build - name: Download Helm chart artifact - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v7 with: name: helm-chart-package path: ${{ runner.temp }}/helm-artifacts @@ -334,7 +338,7 @@ jobs: ls -la "${{ runner.temp }}/release-assets/" - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} @@ -421,14 +425,14 @@ jobs: echo "branch=$SOURCE_BRANCH" >> $GITHUB_OUTPUT - name: Check out source branch - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v5 with: ref: ${{ steps.source_branch.outputs.branch }} - token: ${{ secrets.GH_PAT }} + token: ${{ github.token }} - name: Bump pre-release version run: | - VERSION_FILE="config/initializers/version.rb" + VERSION_FILE=".sure-version" CHART_FILE="charts/sure/Chart.yaml" # Ensure version file exists @@ -444,7 +448,7 @@ jobs: fi # Extract current version - CURRENT_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+' "$VERSION_FILE") + CURRENT_VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]') if [ -z "$CURRENT_VERSION" ]; then echo "ERROR: Could not extract version from $VERSION_FILE" exit 1 @@ -475,11 +479,11 @@ jobs: echo "New version: $NEW_VERSION" # Update the version file - sed -i "s/\"$CURRENT_VERSION\"/\"$NEW_VERSION\"/" "$VERSION_FILE" + echo "$NEW_VERSION" > "$VERSION_FILE" # Verify the change - echo "Updated version.rb:" - grep "semver" "$VERSION_FILE" + echo "Updated .sure-version:" + cat "$VERSION_FILE" # Update Helm chart version and appVersion sed -i -E "s/^version: .*/version: ${NEW_VERSION}/" "$CHART_FILE" @@ -496,7 +500,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add config/initializers/version.rb + git add .sure-version git add charts/sure/Chart.yaml # Check if there are changes to commit diff --git a/.gitignore b/.gitignore index 731dd2f9e..6a4d77fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -123,4 +123,4 @@ scripts/ .auto-claude-status .claude_settings.json .security-key -logs/security/ \ No newline at end of file +logs/security/ diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 88c9eb4e9..2097634b8 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -470,14 +470,14 @@ Use the rules below when: ## Rules for AI (mandatory) -The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](app/assets/tailwind/maybe-design-system.css) +The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [sure-design-system.css](app/assets/tailwind/sure-design-system.css) -- Always start by referencing [maybe-design-system.css](app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase -- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible. +- Always start by referencing [sure-design-system.css](app/assets/tailwind/sure-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase +- Always prefer using the functional "tokens" defined in @sure-design-system.css when possible. - Example 1: use `text-primary` rather than `text-white` - Example 2: use `bg-container` rather than `bg-white` - Example 3: use `border border-primary` rather than `border border-gray-200` -- Never create new styles in [maybe-design-system.css](app/assets/tailwind/maybe-design-system.css) or [application.css](app/assets/tailwind/application.css) without explicitly receiving permission to do so +- Never create new styles in [sure-design-system.css](app/assets/tailwind/sure-design-system.css) or [application.css](app/assets/tailwind/application.css) without explicitly receiving permission to do so - Always generate semantic HTML ``` diff --git a/.sure-version b/.sure-version new file mode 100644 index 000000000..38d939e70 --- /dev/null +++ b/.sure-version @@ -0,0 +1 @@ +0.7.1-alpha.10 diff --git a/AGENTS.md b/AGENTS.md index d3e52c168..c5359f025 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,21 @@ When adding or modifying API endpoints in `app/controllers/api/v1/`, you **MUST* ### Post-commit API consistency (LLM checklist) After every API endpoint commit, ensure: (1) **Minitest** behavioral coverage in `test/controllers/api/v1/{resource}_controller_test.rb` (no behavioral assertions in rswag); (2) **rswag** remains docs-only (no `expect`/`assert_*` in `spec/requests/api/v1/`); (3) **rswag auth** uses the same API key pattern everywhere (`X-Api-Key`, not OAuth/Bearer). Full checklist: [.cursor/rules/api-endpoint-consistency.mdc](.cursor/rules/api-endpoint-consistency.mdc). +## Design System Hygiene (UI PRs) + +When a PR touches `.erb`, view components, or `.css`: + +1. **Tokens, not palette.** Use functional tokens from `app/assets/tailwind/sure-design-system.css` (`bg-warning/10`, `text-destructive`, `bg-container`, `text-primary`, `border-primary`). No raw Tailwind palette (`bg-blue-50`, `text-red-500`, hex literals). +2. **Reach for `DS::*` first.** Check `app/components/DS/` (`DS::Alert`, `DS::Button`, `DS::Disclosure`, `DS::Dialog`, `DS::Menu`, etc.) before writing an alert, badge, button, disclosure, dialog, or input shape. +3. **Two copies → lift to DS.** Same hand-rolled shape ≥2× in a diff with no DS equivalent → propose a new `DS::*` primitive before the second copy lands. +4. **Conventions.** Use the `icon` helper (never `lucide_icon` directly), no raw SVG outside DS primitives, user-facing strings via `t()`, avoid arbitrary `*-[Npx]` values when a scale token fits. + +Reviewers escalate violations of (2)–(3) to close/rewrite; (1) and (4) are request-changes. + +## Securities Providers + +If you need to add a new securities price provider (Tiingo, EODHD, Binance-style crypto, etc.), see [adding-a-securities-provider.md](./docs/llm-guides/adding-a-securities-provider.md) for the full walkthrough — provider class, registry wiring, MIC handling, settings UI, locales, and tests. + ## Providers: Pending Transactions and FX Metadata (SimpleFIN/Plaid/Lunchflow) - Pending detection diff --git a/CLAUDE.md b/CLAUDE.md index ddd38afd0..4fc9fcf10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,7 +138,7 @@ Sidekiq handles asynchronous tasks: - **Stimulus Controllers**: Handle interactivity, organized alongside components - **Charts**: D3.js for financial visualizations (time series, donut, sankey) - **Styling**: Tailwind CSS v4.x with custom design system - - Design system defined in `app/assets/tailwind/maybe-design-system.css` + - Design system defined in `app/assets/tailwind/sure-design-system.css` - Always use functional tokens (e.g., `text-primary` not `text-white`) - Prefer semantic HTML elements over JS components - Use `icon` helper for icons, never `lucide_icon` directly @@ -222,7 +222,7 @@ Sidekiq handles asynchronous tasks: ## TailwindCSS Design System ### Design System Rules -- **Always reference `app/assets/tailwind/maybe-design-system.css`** for primitives and tokens +- **Always reference `app/assets/tailwind/sure-design-system.css`** for primitives and tokens - **Use functional tokens** defined in design system: - `text-primary` instead of `text-white` - `bg-container` instead of `bg-white` diff --git a/Dockerfile.preview b/Dockerfile.preview new file mode 100644 index 000000000..3b687b964 --- /dev/null +++ b/Dockerfile.preview @@ -0,0 +1,247 @@ +# syntax = docker/dockerfile:1 + +# Preview Dockerfile for Cloudflare Containers +# Includes PostgreSQL and Redis for self-contained development testing + +ARG RUBY_VERSION=3.4.7 +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base + +WORKDIR /rails + +# Install base packages including PostgreSQL and Redis servers +RUN apt-get update -qq \ + && apt-get install --no-install-recommends -y \ + curl libvips postgresql postgresql-client redis-server libyaml-0-2 procps sudo openssl strace \ + && rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set development environment +ARG BUILD_COMMIT_SHA +ENV RAILS_ENV="development" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA} + +# Build stage +FROM base AS build + +RUN apt-get update -qq \ + && apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev \ + && rm -rf /var/lib/apt/lists /var/cache/apt/archives + +COPY .ruby-version Gemfile Gemfile.lock ./ +RUN bundle install \ + && rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git \ + && bundle exec bootsnap precompile --gemfile -j 0 + +COPY . . + +RUN bundle exec bootsnap precompile -j 0 app/ lib/ + +# Precompile assets +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + +# Final stage +FROM base + +# Create rails user and configure PostgreSQL/Redis permissions +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + echo "rails ALL=(ALL) NOPASSWD: /usr/bin/pg_ctlcluster, /usr/bin/redis-server" > /etc/sudoers.d/rails && \ + chmod 0440 /etc/sudoers.d/rails + +# Configure PostgreSQL to allow local connections +RUN PG_HBA=$(find /etc/postgresql -name pg_hba.conf 2>/dev/null | head -1) && \ + if [ -n "$PG_HBA" ]; then \ + echo "local all all trust" > "$PG_HBA" && \ + echo "host all all 127.0.0.1/32 trust" >> "$PG_HBA" && \ + echo "host all all ::1/128 trust" >> "$PG_HBA"; \ + fi + +# Create database directory with correct permissions +RUN mkdir -p /var/run/postgresql && \ + chown -R postgres:postgres /var/run/postgresql && \ + chmod 2775 /var/run/postgresql + +# Copy built artifacts +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +# Create preview entrypoint script inline +RUN cat > /rails/bin/preview-entrypoint << 'ENTRYPOINT_EOF' +#!/bin/bash +set -e + +cd /rails + +emit_status() { + if [ -n "$PREVIEW_ORIGIN" ]; then + local stage="$1" + local detail="$2" + local payload + payload=$(STAGE="$stage" DETAIL="$detail" ruby -rjson -e 'print JSON.generate({stage: ENV.fetch("STAGE"), detail: ENV.fetch("DETAIL", "")})' 2>/dev/null) || return 0 + curl -fsS -X POST "$PREVIEW_ORIGIN/_container_event" \ + -H 'content-type: application/json' \ + --data "$payload" >/dev/null || true + fi +} + +trap 'emit_status failed "preview-entrypoint failed on line ${LINENO}"' ERR +emit_status boot "preview-entrypoint started" + +REDIS_READY=0 +POSTGRES_READY=0 + +# Start Redis +echo "Starting Redis..." +emit_status redis-start "starting redis" +sudo redis-server --daemonize yes --bind 127.0.0.1 + +# Wait for Redis to be ready +echo "Waiting for Redis to be ready..." +for i in {1..10}; do + if redis-cli ping > /dev/null 2>&1; then + echo "Redis is ready" + emit_status redis-ready "redis is ready" + REDIS_READY=1 + break + fi + sleep 1 +done + +if [ "$REDIS_READY" -ne 1 ]; then + echo "Redis did not become ready in time" + exit 1 +fi + +# Start PostgreSQL +echo "Starting PostgreSQL..." +emit_status postgres-start "starting postgres" +PG_VERSION=$(ls /etc/postgresql/ | sort -V | tail -1) +if [ -z "$PG_VERSION" ]; then + echo "Could not determine installed PostgreSQL version" + exit 1 +fi +if sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main status > /dev/null 2>&1; then + emit_status postgres-already-running "postgres cluster already running" +else + sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main start +fi + +# Wait for PostgreSQL to be ready +echo "Waiting for PostgreSQL to be ready..." +for i in {1..30}; do + if pg_isready -h localhost -U postgres > /dev/null 2>&1; then + echo "PostgreSQL is ready" + emit_status postgres-ready "postgres is ready" + POSTGRES_READY=1 + break + fi + sleep 1 +done + +if [ "$POSTGRES_READY" -ne 1 ]; then + echo "PostgreSQL did not become ready in time" + exit 1 +fi + +# Create database user and database if they don't exist +echo "Setting up database..." +emit_status db-setup "setting up database" +psql -h localhost -U postgres -tc "SELECT 1 FROM pg_roles WHERE rolname='rails'" | grep -q 1 || \ + psql -h localhost -U postgres -c "CREATE USER rails WITH SUPERUSER PASSWORD 'rails';" + +psql -h localhost -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='sure_development'" | grep -q 1 || \ + psql -h localhost -U postgres -c "CREATE DATABASE sure_development OWNER rails;" + +# Set DATABASE_URL if not already set +export DATABASE_URL="${DATABASE_URL:-postgres://rails:rails@localhost:5432/sure_development}" + +# Set REDIS_URL if not already set +export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" + +# Generate SECRET_KEY_BASE if not set +export SECRET_KEY_BASE="${SECRET_KEY_BASE:-$(openssl rand -hex 64)}" + +# Run database migrations +echo "Running database migrations..." +emit_status db-prepare "running rails db:prepare" +/rails/bin/rails db:prepare +emit_status db-prepare-done "rails db:prepare finished" + +# Defer all demo-data creation until after Rails is up so preview can boot first +echo "Checking demo dataset..." +emit_status demo-data-check "checking for default demo user" +DEMO_EMAIL="${DEMO_USER_EMAIL:-user@example.com}" +DEMO_EMAIL_SQL=${DEMO_EMAIL//\'/\'\'} +DEMO_SEED="${DEMO_DATA_SEED:-880}" +DEMO_HAS_USER=0 +DEMO_HAS_DATA=0 + +if psql "$DATABASE_URL" -tAc "SELECT 1 FROM users WHERE email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then + DEMO_HAS_USER=1 + emit_status demo-data-user-present "default demo user already exists" +fi + +if psql "$DATABASE_URL" -tAc "SELECT 1 FROM accounts a JOIN users u ON u.family_id = a.family_id WHERE u.email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then + DEMO_HAS_DATA=1 + emit_status demo-data-skip "demo financial data already exists" +else + emit_status demo-data-deferred "deferring demo data creation until after rails boot" +fi + +# Execute the main command with an internal readiness probe +echo "Starting Rails server..." +emit_status rails-start "starting rails server" +"$@" > /tmp/rails.log 2>&1 & +RAILS_PID=$! + +for i in {1..180}; do + if curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then + emit_status rails-up-ready "rails responded on localhost:3000/up" + + if [ "$DEMO_HAS_USER" -ne 1 ] || [ "$DEMO_HAS_DATA" -ne 1 ]; then + emit_status demo-data-load "creating/backfilling demo dataset in background (seed=${DEMO_SEED})" + ( + ( + DEMO_USER_EMAIL="$DEMO_EMAIL" DEMO_DATA_SEED="$DEMO_SEED" /rails/bin/rails runner ' + email = ENV.fetch("DEMO_USER_EMAIL") + generator = Demo::Generator.new(seed: ENV.fetch("DEMO_DATA_SEED")) + user = User.find_by(email: email) + + unless user + generator.generate_empty_data!(skip_clear: true) + user = User.find_by!(email: email) + end + + has_accounts = user.family.accounts.exists? + generator.generate_new_user_data_for!(user.family, email: user.email) unless has_accounts + ' + ) > /tmp/demo-data.log 2>&1 && \ + emit_status demo-data-ready "default demo dataset loaded in background" || \ + emit_status demo-data-failed "background demo dataset load failed" + ) & + fi + + break + fi + sleep 1 +done + +if ! curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then + emit_status rails-up-timeout "rails did not answer localhost:3000/up in time" + emit_status rails-process-status "$(ps -o pid=,ppid=,stat=,comm=,args= -p "$RAILS_PID" 2>/dev/null | tr -s ' ' | sed 's/^ //')" + emit_status rails-process-wchan "$(cat /proc/$RAILS_PID/wchan 2>/dev/null | tr '\n' ' ' | cut -c 1-200)" + emit_status rails-process-children "$(ps -o pid=,ppid=,stat=,comm=,args= --ppid "$RAILS_PID" 2>/dev/null | tail -n +2 | tr '\n' '|' | cut -c 1-600)" + emit_status rails-socket-state "$(ruby -e 'hex="0BB8"; rows=File.readlines("/proc/net/tcp")+File.readlines("/proc/net/tcp6"); hits=rows.select{|l| l.include?(":#{hex} ")}.map{|l| l.strip.split[3] rescue nil}.compact; puts(hits.empty? ? "no-listener" : hits.join(","))' 2>&1 | tr '\n' ' ' | cut -c 1-400)" + emit_status rails-log-tail "$(tail -n 40 /tmp/rails.log 2>&1 | sed 's/"/'"'"'/g' | tr '\n' ' ' | cut -c 1-1200)" +fi + +wait "$RAILS_PID" +ENTRYPOINT_EOF +RUN chmod 755 /rails/bin/preview-entrypoint && chown rails:rails /rails/bin/preview-entrypoint + +USER 1000:1000 + +ENTRYPOINT ["/rails/bin/preview-entrypoint"] + +EXPOSE 3000 +CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"] diff --git a/Gemfile b/Gemfile index de23ef7d8..19fdf8a17 100644 --- a/Gemfile +++ b/Gemfile @@ -48,6 +48,7 @@ gem "skylight", groups: [ :production ] # Active Storage gem "aws-sdk-s3", "~> 1.208.0", require: false +gem "google-cloud-storage", "~> 1.59", require: false gem "image_processing", ">= 1.2" # Other @@ -81,6 +82,7 @@ gem "snaptrade", "~> 2.0" gem "httparty" gem "rotp", "~> 6.3" gem "rqrcode", "~> 3.0" +gem "webauthn", "~> 3.4" gem "activerecord-import" gem "rubyzip", "~> 2.3" gem "pdf-reader", "~> 2.12" diff --git a/Gemfile.lock b/Gemfile.lock index a858755ec..2b3ab3c5f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,6 +86,7 @@ GEM after_commit_everywhere (1.6.0) activerecord (>= 4.2) activesupport + android_key_attestation (0.3.0) ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) @@ -135,6 +136,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cbor (0.5.10.2) cgi (0.5.1) childprocess (5.1.0) logger (~> 1.5) @@ -142,6 +144,9 @@ GEM climate_control (1.2.0) concurrent-ruby (1.3.6) connection_pool (2.5.5) + cose (1.3.1) + cbor (~> 0.5.9) + openssl-signature_algorithm (~> 1.0) countries (8.0.3) unaccent (~> 0.3) crack (1.0.0) @@ -158,6 +163,7 @@ GEM debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) + declarative (0.0.20) derailed_benchmarks (2.2.1) base64 benchmark-ips (~> 2) @@ -177,6 +183,8 @@ GEM ruby2_keywords thor (>= 0.19, < 2) diff-lcs (1.6.2) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) docile (1.4.1) doorkeeper (5.8.2) railties (>= 5) @@ -231,6 +239,43 @@ GEM ffi (~> 1.0) globalid (1.3.0) activesupport (>= 6.1) + google-apis-core (1.0.2) + addressable (~> 2.8, >= 2.8.7) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + representable (~> 3.0) + retriable (~> 3.1) + google-apis-iamcredentials_v1 (0.26.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.61.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.3.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.59.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.2.0) + googleauth (1.16.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) hashdiff (1.2.0) hashery (2.1.2) hashie (5.0.0) @@ -361,6 +406,7 @@ GEM mocha (2.7.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) + multi_json (1.20.1) multi_xml (0.8.0) bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) @@ -441,6 +487,10 @@ GEM tzinfo validate_url webfinger (~> 2.0) + openssl (4.0.1) + openssl-signature_algorithm (1.3.0) + openssl (> 2.0) + os (1.1.4) ostruct (0.6.2) pagy (9.3.5) parallel (1.27.0) @@ -500,7 +550,7 @@ GEM base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) - rack-session (2.1.1) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -563,6 +613,11 @@ GEM regexp_parser (2.10.0) reline (0.6.1) io-console (~> 0.5) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.4.1) rexml (3.4.2) rotp (6.3.0) rouge (4.5.2) @@ -647,6 +702,8 @@ GEM logger ruby2_keywords (0.0.5) rubyzip (2.4.1) + safety_net_attestation (0.5.0) + jwt (>= 2.0, < 4.0) sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) @@ -681,6 +738,11 @@ GEM concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 7.0.0, < 9.0.0) thor (>= 1.0, < 3.0) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -720,6 +782,11 @@ GEM unicode-display_width (>= 1.1.1, < 4) thor (1.4.0) timeout (0.6.1) + tpm-key_attestation (0.14.1) + bindata (~> 2.4) + openssl (> 2.0) + openssl-signature_algorithm (~> 1.0) + trailblazer-option (0.1.2) tsort (0.2.0) ttfunk (1.8.0) bigdecimal (~> 3.1) @@ -728,6 +795,7 @@ GEM railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + uber (0.1.0) unaccent (0.4.0) unicode (0.4.4.5) unicode-display_width (3.1.4) @@ -751,6 +819,14 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webauthn (3.4.3) + android_key_attestation (~> 0.3.0) + bindata (~> 2.4) + cbor (~> 0.5.9) + cose (~> 1.1) + openssl (>= 2.2) + safety_net_attestation (~> 0.5.0) + tpm-key_attestation (~> 0.14.0) webfinger (2.1.3) activesupport faraday (~> 2.0) @@ -804,6 +880,7 @@ DEPENDENCIES faraday-multipart faraday-retry foreman + google-cloud-storage (~> 1.59) hotwire-livereload hotwire_combobox httparty @@ -874,6 +951,7 @@ DEPENDENCIES vernier view_component web-console + webauthn (~> 3.4) webmock RUBY VERSION diff --git a/Procfile.dev b/Procfile.dev index eb6eadebd..a868e97a4 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ -web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 +web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server css: bundle exec bin/rails tailwindcss:watch 2>/dev/null worker: bundle exec sidekiq diff --git a/README.md b/README.md index fe45b8bbb..41c93b4c6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/we-promise/sure) [![View performance data on Skylight](https://badges.skylight.io/typical/s6PEZSKwcklL.svg)](https://oss.skylight.io/app/applications/s6PEZSKwcklL) [![Dosu](https://raw.githubusercontent.com/dosu-ai/assets/main/dosu-badge.svg)](https://app.dosu.dev/a72bdcfd-15f5-4edc-bd85-ea0daa6c3adc/ask) +[![Pipelock Security Scan](https://github.com/we-promise/sure/actions/workflows/pipelock.yml/badge.svg)](https://github.com/we-promise/sure/actions/workflows/pipelock.yml) sure_shot @@ -27,7 +28,7 @@ involved: [Discord](https://discord.gg/36ZGBsxYEK) • [Website](https://sure.am ## Backstory -The Maybe Finance team spent most of 2021–2022 building a full-featured personal finance and wealth management app. It even included an “Ask an Advisor” feature that connected users with a real CFP/CFA — all included with your subscription. +The [Maybe Finance](https://github.com/maybe-finance/maybe) (archived/abandoned repo) team spent most of 2021–2022 building a full-featured personal finance and wealth management app. It even included an “Ask an Advisor” feature that connected users with a real CFP/CFA — all included with your subscription. The business end of things didn't work out, and so they stopped developing the app in mid-2023. @@ -100,12 +101,16 @@ For further instructions, see guides below. - [Windows dev setup](https://github.com/we-promise/sure/wiki/Windows-Dev-Setup-Guide) - Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) -### One-click +### One-click Install [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=sure) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/T_draF?referralCode=CW_fPQ) +### Managed OpenClaw for Sure Finances + +Managed OpenClaw for Sure Finances + ## License and Trademarks @@ -113,3 +118,5 @@ Maybe and Sure are both distributed under an [AGPLv3 license](https://github.com/we-promise/sure/blob/main/LICENSE). - "Maybe" is a trademark of Maybe Finance, Inc. - "Sure" is not, and refers to this community fork. + +![Alt](https://repobeats.axiom.co/api/embed/3a9753cff07501fba8a6749d0ebd567ff63848c8.svg "Repobeats analytics image") diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 36b65b467..3d868c741 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -1,6 +1,6 @@ @import 'tailwindcss'; -@import "./maybe-design-system.css"; +@import "./sure-design-system.css"; @import "./geist-font.css"; @import "./geist-mono-font.css"; @@ -56,7 +56,7 @@ } .hw-combobox__label { - @apply block text-xs text-gray-500 peer-disabled:text-gray-400; + @apply block text-xs text-secondary peer-disabled:text-subdued; } .hw-combobox__option { @@ -155,12 +155,12 @@ } ::-webkit-scrollbar-thumb { - background: #d6d6d6; + background: var(--color-gray-300); border-radius: 10px; } ::-webkit-scrollbar-thumb:hover { - background: #a6a6a6; + background: var(--color-gray-400); } } @@ -170,12 +170,12 @@ } &::-webkit-scrollbar-thumb { - background: #d6d6d6; + background: var(--color-gray-300); border-radius: 10px; } &::-webkit-scrollbar-thumb:hover { - background: #a6a6a6; + background: var(--color-gray-400); } } @@ -203,3 +203,30 @@ .turbo-progress-bar { margin-top: env(safe-area-inset-top); } + +.table-divider { + position: relative; +} + +.table-divider { + background-image: linear-gradient( + to right, + transparent 1rem, + var(--color-alpha-black-100) 1rem, + var(--color-alpha-black-100) calc(100% - 1rem), + transparent calc(100% - 1rem) + ); + background-repeat: no-repeat; + background-size: 100% 1px; + background-position: bottom; +} + +[data-theme="dark"] .table-divider { + background-image: linear-gradient( + to right, + transparent 1rem, + var(--color-alpha-white-200) 1rem, + var(--color-alpha-white-200) calc(100% - 1rem), + transparent calc(100% - 1rem) + ); +} \ No newline at end of file diff --git a/app/assets/tailwind/maybe-design-system/background-utils.css b/app/assets/tailwind/maybe-design-system/background-utils.css deleted file mode 100644 index f11a7621a..000000000 --- a/app/assets/tailwind/maybe-design-system/background-utils.css +++ /dev/null @@ -1,91 +0,0 @@ -@utility bg-surface { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-black; - } -} - -@utility bg-surface-hover { - @apply bg-gray-100; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility bg-surface-inset { - @apply bg-gray-100; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility bg-surface-inset-hover { - @apply bg-gray-200; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility bg-container { - @apply bg-white; - - @variant theme-dark { - @apply bg-gray-900; - } -} - -@utility bg-container-hover { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility bg-container-inset { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility bg-container-inset-hover { - @apply bg-gray-100; - - @variant theme-dark { - @apply bg-gray-700; - } -} - -@utility bg-inverse { - @apply bg-gray-800; - - @variant theme-dark { - @apply bg-white; - } -} - -@utility bg-inverse-hover { - @apply bg-gray-700; - - @variant theme-dark { - @apply bg-gray-100; - } -} - -@utility bg-overlay { - background-color: --alpha(var(--color-gray-100) / 50%); - - @variant theme-dark { - background-color: var(--color-alpha-black-900); - } -} - -@utility bg-loader { - @apply bg-surface-inset animate-pulse; -} diff --git a/app/assets/tailwind/maybe-design-system/border-utils.css b/app/assets/tailwind/maybe-design-system/border-utils.css deleted file mode 100644 index 8fcc1c9cd..000000000 --- a/app/assets/tailwind/maybe-design-system/border-utils.css +++ /dev/null @@ -1,92 +0,0 @@ -/* Custom shadow borders used for surfaces / containers */ -@utility shadow-border-xs { - box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50); - - @variant theme-dark { - box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-white-50); - } -} - -@utility shadow-border-sm { - box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50); - - @variant theme-dark { - box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-white-50); - } -} - -@utility shadow-border-md { - box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50); - - @variant theme-dark { - box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-white-50); - } -} - -@utility shadow-border-lg { - box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50); - - @variant theme-dark { - box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-white-50); - } -} - -@utility shadow-border-xl { - box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50); - - @variant theme-dark { - box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-white-50); - } -} - -@utility border-primary { - @apply border-alpha-black-300; - - @variant theme-dark { - @apply border-alpha-white-400; - } -} - -@utility border-secondary { - @apply border-alpha-black-200; - - @variant theme-dark { - @apply border-alpha-white-300; - } -} - -@utility border-tertiary { - @apply border-alpha-black-100; - - @variant theme-dark { - @apply border-alpha-white-200; - } -} - -@utility border-divider { - @apply border-tertiary; -} - -@utility border-subdued { - @apply border-alpha-black-50; - - @variant theme-dark { - @apply border-alpha-white-100; - } -} - -@utility border-solid { - @apply border-black; - - @variant theme-dark { - @apply border-white; - } -} - -@utility border-destructive { - @apply border-red-500; - - @variant theme-dark { - @apply border-red-400; - } -} diff --git a/app/assets/tailwind/maybe-design-system/component-utils.css b/app/assets/tailwind/maybe-design-system/component-utils.css deleted file mode 100644 index 597b50921..000000000 --- a/app/assets/tailwind/maybe-design-system/component-utils.css +++ /dev/null @@ -1,109 +0,0 @@ -/* Button Backgrounds */ -@utility button-bg-primary { - @apply bg-gray-900; - /* Maps to fg-primary light */ - - @variant theme-dark { - @apply bg-white; - /* Maps to fg-primary dark */ - } -} - -@utility button-bg-primary-hover { - @apply bg-gray-800; - /* Maps to fg-primary-variant light */ - - @variant theme-dark { - @apply bg-gray-50; - /* Maps to fg-primary-variant dark */ - } -} - -@utility button-bg-secondary { - @apply bg-gray-50; /* Maps to fg-secondary light */ - - @variant theme-dark { - @apply bg-gray-700; /* Maps to fg-secondary dark */ - } -} - -@utility button-bg-secondary-hover { - @apply bg-gray-100; /* Maps to fg-secondary-variant light */ - - @variant theme-dark { - @apply bg-gray-600; /* Maps to fg-secondary-variant dark */ - } -} - -@utility button-bg-disabled { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-gray-700; - } -} - -@utility button-bg-destructive { - @apply bg-red-500; - - @variant theme-dark { - @apply bg-red-400; - } -} - -@utility button-bg-destructive-hover { - @apply bg-red-600; - - @variant theme-dark { - @apply bg-red-500; - } -} - -@utility button-bg-ghost-hover { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-gray-800 fg-inverse; - } -} - -@utility button-bg-outline-hover { - @apply bg-gray-100; - - @variant theme-dark { - @apply bg-gray-700; - } -} - -/* Tab Styles */ -@utility tab-item-active { - @apply bg-white; - - @variant theme-dark { - @apply bg-gray-700; - } -} - -@utility tab-item-hover { - @apply bg-gray-200; - - @variant theme-dark { - @apply bg-gray-800; - } -} - -@utility tab-bg-group { - @apply bg-gray-50; - - @variant theme-dark { - @apply bg-alpha-black-700; - } -} - -@utility bg-nav-indicator { - @apply bg-black; - - @variant theme-dark { - @apply bg-white; - } -} \ No newline at end of file diff --git a/app/assets/tailwind/maybe-design-system/foreground-utils.css b/app/assets/tailwind/maybe-design-system/foreground-utils.css deleted file mode 100644 index 5697d2e9a..000000000 --- a/app/assets/tailwind/maybe-design-system/foreground-utils.css +++ /dev/null @@ -1,63 +0,0 @@ -@utility fg-gray { - @apply text-gray-500; - - @variant theme-dark { - @apply text-gray-400; - } -} - -@utility fg-contrast { - @apply text-gray-400; - - @variant theme-dark { - @apply text-gray-500; - } -} - -@utility fg-inverse { - @apply text-white; - - @variant theme-dark { - @apply text-gray-900; - } -} - -@utility fg-primary { - @apply text-gray-900; - - @variant theme-dark { - @apply text-white; - } -} - -@utility fg-primary-variant { - @apply text-gray-800; - - @variant theme-dark { - @apply text-gray-50; - } -} - -@utility fg-secondary { - @apply text-gray-50; - - @variant theme-dark { - @apply text-gray-400; - } -} - -@utility fg-secondary-variant { - @apply text-gray-100; - - @variant theme-dark { - @apply text-gray-500; - } -} - -@utility fg-subdued { - @apply text-gray-400; - - @variant theme-dark { - @apply text-gray-500; - } -} \ No newline at end of file diff --git a/app/assets/tailwind/maybe-design-system/text-utils.css b/app/assets/tailwind/maybe-design-system/text-utils.css deleted file mode 100644 index 7aa8f2e67..000000000 --- a/app/assets/tailwind/maybe-design-system/text-utils.css +++ /dev/null @@ -1,39 +0,0 @@ -@utility text-primary { - @apply text-gray-900; - - @variant theme-dark { - @apply text-white; - } -} - -@utility text-inverse { - @apply text-white; - - @variant theme-dark { - @apply text-gray-900; - } -} - -@utility text-secondary { - @apply text-gray-500; - - @variant theme-dark { - @apply text-gray-300; - } -} - -@utility text-subdued { - @apply text-gray-400; - - @variant theme-dark { - @apply text-gray-500; - } -} - -@utility text-link { - @apply text-blue-600; - - @variant theme-dark { - @apply text-blue-500; - } -} \ No newline at end of file diff --git a/app/assets/tailwind/privacy-mode.css b/app/assets/tailwind/privacy-mode.css index e57eaa246..425bf6c0a 100644 --- a/app/assets/tailwind/privacy-mode.css +++ b/app/assets/tailwind/privacy-mode.css @@ -2,10 +2,13 @@ html.privacy-mode .privacy-sensitive { filter: blur(8px); user-select: none; - pointer-events: none; transition: filter 0.2s ease; } +html.privacy-mode .privacy-sensitive:not(.privacy-sensitive-interactive) { + pointer-events: none; +} + html:not(.privacy-mode) .privacy-sensitive { transition: filter 0.2s ease; -} \ No newline at end of file +} diff --git a/app/assets/tailwind/sure-design-system.css b/app/assets/tailwind/sure-design-system.css new file mode 100644 index 000000000..53ad19e26 --- /dev/null +++ b/app/assets/tailwind/sure-design-system.css @@ -0,0 +1,12 @@ +/* + * Sure design system entry. + * Tokens (theme, dark overrides, utilities) are generated from design/tokens/sure.tokens.json: see _generated.css. + * Element resets, components, and prose overrides remain hand-written. + */ + +@custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *)); + +@import './sure-design-system/_generated.css'; +@import './sure-design-system/base.css'; +@import './sure-design-system/components.css'; +@import './sure-design-system/prose.css'; diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/sure-design-system/_generated.css similarity index 53% rename from app/assets/tailwind/maybe-design-system.css rename to app/assets/tailwind/sure-design-system/_generated.css index e4ff87051..79ae54d43 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/sure-design-system/_generated.css @@ -1,37 +1,32 @@ -/* - This file contains all of the Figma design tokens, components, etc. that - are used globally across the app. - - One-off styling (3rd party overrides, etc.) should be done in the application.css file. -*/ - -@import './maybe-design-system/background-utils.css'; -@import './maybe-design-system/foreground-utils.css'; -@import './maybe-design-system/text-utils.css'; -@import './maybe-design-system/border-utils.css'; -@import './maybe-design-system/component-utils.css'; - -@custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *)); +/* + * GENERATED — do not edit by hand. + * Source: design/tokens/sure.tokens.json + * Build: npm run tokens:build + */ @theme { - /* Font families */ --font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - - /* Base colors */ --color-white: #ffffff; --color-black: #0B0B0B; - --color-success: var(--color-green-600); + --color-success: var(--color-green-700); --color-warning: var(--color-yellow-600); --color-destructive: var(--color-red-600); + --color-info: var(--color-blue-600); --color-shadow: --alpha(var(--color-black) / 6%); - - /* Colors used in Stimulus controllers with SVGs (easier to define light/dark mode here than toggle within the controllers) */ - /* See @layer base block below for dark mode overrides */ - --budget-unused-fill: var(--color-gray-200); - --budget-unallocated-fill: var(--color-gray-50); - - /* Gray scale */ + --color-link: var(--color-blue-600); + --color-tertiary: var(--color-alpha-black-100); + --color-surface: var(--color-gray-50); + --color-surface-hover: var(--color-gray-100); + --color-surface-inset: var(--color-gray-100); + --color-surface-inset-hover: var(--color-gray-200); + --color-container: var(--color-white); + --color-container-hover: var(--color-gray-50); + --color-container-inset: var(--color-gray-50); + --color-container-inset-hover: var(--color-gray-100); + --color-nav-indicator: var(--color-black); + --color-toggle-track: var(--color-gray-100); + --color-destructive-subtle: var(--color-red-200); --color-gray-25: #FAFAFA; --color-gray-50: #F7F7F7; --color-gray-100: #F0F0F0; @@ -46,8 +41,6 @@ --color-gray: var(--color-gray-500); --color-gray-tint-5: --alpha(var(--color-gray-500) / 5%); --color-gray-tint-10: --alpha(var(--color-gray-500) / 10%); - - /* Alpha colors */ --color-alpha-white-25: --alpha(var(--color-white) / 3%); --color-alpha-white-50: --alpha(var(--color-white) / 5%); --color-alpha-white-100: --alpha(var(--color-white) / 8%); @@ -59,7 +52,6 @@ --color-alpha-white-700: --alpha(var(--color-white) / 50%); --color-alpha-white-800: --alpha(var(--color-white) / 70%); --color-alpha-white-900: --alpha(var(--color-white) / 85%); - --color-alpha-black-25: --alpha(var(--color-black) / 3%); --color-alpha-black-50: --alpha(var(--color-black) / 5%); --color-alpha-black-100: --alpha(var(--color-black) / 8%); @@ -71,8 +63,6 @@ --color-alpha-black-700: --alpha(var(--color-black) / 50%); --color-alpha-black-800: --alpha(var(--color-black) / 70%); --color-alpha-black-900: --alpha(var(--color-black) / 85%); - - /* Red scale */ --color-red-25: #FFFBFB; --color-red-50: #FFF1F0; --color-red-100: #FFDEDB; @@ -86,8 +76,6 @@ --color-red-900: #7E0707; --color-red-tint-5: --alpha(var(--color-red-500) / 5%); --color-red-tint-10: --alpha(var(--color-red-500) / 10%); - - /* Green scale */ --color-green-25: #F6FEF9; --color-green-50: #ECFDF3; --color-green-100: #D1FADF; @@ -101,8 +89,6 @@ --color-green-900: #054F31; --color-green-tint-5: --alpha(var(--color-green-500) / 5%); --color-green-tint-10: --alpha(var(--color-green-500) / 10%); - - /* Yellow scale */ --color-yellow-25: #FFFCF5; --color-yellow-50: #FFFAEB; --color-yellow-100: #FEF0C7; @@ -116,8 +102,6 @@ --color-yellow-900: #7A2E0E; --color-yellow-tint-5: --alpha(var(--color-yellow-500) / 5%); --color-yellow-tint-10: --alpha(var(--color-yellow-500) / 10%); - - /* Cyan scale */ --color-cyan-25: #F5FEFF; --color-cyan-50: #ECFDFF; --color-cyan-100: #CFF9FE; @@ -128,11 +112,9 @@ --color-cyan-600: #088AB2; --color-cyan-700: #0E7090; --color-cyan-800: #155B75; - --color-cyan-900: #155B75; + --color-cyan-900: #164E63; --color-cyan-tint-5: --alpha(var(--color-cyan-500) / 5%); --color-cyan-tint-10: --alpha(var(--color-cyan-500) / 10%); - - /* Blue scale */ --color-blue-25: #F5FAFF; --color-blue-50: #EFF8FF; --color-blue-100: #D1E9FF; @@ -146,8 +128,6 @@ --color-blue-900: #194185; --color-blue-tint-5: --alpha(var(--color-blue-500) / 5%); --color-blue-tint-10: --alpha(var(--color-blue-500) / 10%); - - /* Indigo scale */ --color-indigo-25: #F5F8FF; --color-indigo-50: #EFF4FF; --color-indigo-100: #E0EAFF; @@ -161,8 +141,6 @@ --color-indigo-900: #2D3282; --color-indigo-tint-5: --alpha(var(--color-indigo-500) / 5%); --color-indigo-tint-10: --alpha(var(--color-indigo-500) / 10%); - - /* Violet scale */ --color-violet-25: #FBFAFF; --color-violet-50: #F5F3FF; --color-violet-100: #ECE9FE; @@ -174,8 +152,6 @@ --color-violet-700: #6927DA; --color-violet-tint-5: --alpha(var(--color-violet-500) / 5%); --color-violet-tint-10: --alpha(var(--color-violet-500) / 10%); - - /* Fuchsia scale */ --color-fuchsia-25: #FEFAFF; --color-fuchsia-50: #FDF4FF; --color-fuchsia-100: #FBE8FF; @@ -189,8 +165,6 @@ --color-fuchsia-900: #6F1877; --color-fuchsia-tint-5: --alpha(var(--color-fuchsia-500) / 5%); --color-fuchsia-tint-10: --alpha(var(--color-fuchsia-500) / 10%); - - /* Pink scale */ --color-pink-25: #FFFAFC; --color-pink-50: #FEF0F7; --color-pink-100: #FFD1E2; @@ -204,8 +178,6 @@ --color-pink-900: #840B45; --color-pink-tint-5: --alpha(var(--color-pink-500) / 5%); --color-pink-tint-10: --alpha(var(--color-pink-500) / 10%); - - /* Orange scale */ --color-orange-25: #FFF9F5; --color-orange-50: #FFF4ED; --color-orange-100: #FFE6D5; @@ -219,243 +191,313 @@ --color-orange-900: #771A0D; --color-orange-tint-5: --alpha(var(--color-orange-500) / 5%); --color-orange-tint-10: --alpha(var(--color-orange-500) / 10%); - - /* Border radius overrides */ + --budget-unused-fill: var(--color-gray-200); + --budget-unallocated-fill: var(--color-gray-50); --border-radius-md: 8px; --border-radius-lg: 10px; - --shadow-xs: 0px 1px 2px 0px --alpha(var(--color-black) / 6%); --shadow-sm: 0px 1px 6px 0px --alpha(var(--color-black) / 6%); --shadow-md: 0px 4px 8px -2px --alpha(var(--color-black) / 6%); --shadow-lg: 0px 12px 16px -4px --alpha(var(--color-black) / 6%); --shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%); - --animate-stroke-fill: stroke-fill 3s 300ms forwards; @keyframes stroke-fill { - 0% { - stroke-dashoffset: 43.9822971503; - } - - 100% { - stroke-dashoffset: 0; - } - } -} - -/* Specific override for strong tags in prose under dark mode */ -.prose:where([data-theme=dark], [data-theme=dark] *) strong { - color: theme(colors.white) !important; -} - -/* Specific override for headings in prose under dark mode */ -.prose:where([data-theme=dark], [data-theme=dark] *) h1, -.prose:where([data-theme=dark], [data-theme=dark] *) h2, -.prose:where([data-theme=dark], [data-theme=dark] *) h3, -.prose:where([data-theme=dark], [data-theme=dark] *) h4, -.prose:where([data-theme=dark], [data-theme=dark] *) h5, -.prose:where([data-theme=dark], [data-theme=dark] *) h6, -.prose:where([data-theme=dark], [data-theme=dark] *) blockquote, -.prose:where([data-theme=dark], [data-theme=dark] *) thead th { - color: theme(colors.white) !important; + 0% { stroke-dashoffset: 43.9822971503; } + 100% { stroke-dashoffset: 0; } + } } @layer base { - [data-theme="dark"] { - --color-success: var(--color-green-500); + [data-theme="dark"] { + --color-success: var(--color-green-400); --color-warning: var(--color-yellow-400); --color-destructive: var(--color-red-400); + --color-info: var(--color-blue-500); --color-shadow: --alpha(var(--color-white) / 8%); - - /* Dark mode overrides for colors used in Stimulus controllers with SVGs */ + --color-link: var(--color-blue-500); + --color-tertiary: var(--color-alpha-white-200); + --color-surface: var(--color-black); + --color-surface-hover: var(--color-gray-800); + --color-surface-inset: var(--color-gray-800); + --color-surface-inset-hover: var(--color-gray-800); + --color-container: var(--color-gray-900); + --color-container-hover: var(--color-gray-800); + --color-container-inset: var(--color-gray-800); + --color-container-inset-hover: var(--color-gray-700); + --color-nav-indicator: var(--color-white); + --color-toggle-track: var(--color-gray-700); + --color-destructive-subtle: var(--color-red-800); --budget-unused-fill: var(--color-gray-500); --budget-unallocated-fill: var(--color-gray-700); - --shadow-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%); --shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%); --shadow-md: 0px 4px 8px -2px --alpha(var(--color-white) / 8%); --shadow-lg: 0px 12px 16px -4px --alpha(var(--color-white) / 8%); --shadow-xl: 0px 20px 24px -4px --alpha(var(--color-white) / 8%); } +} - button { - @apply cursor-pointer focus-visible:outline-gray-900; - } +@utility bg-inverse { + @apply bg-gray-800; - hr { - @apply text-gray-200; - } - - /* We control the sizing through DialogComponent, so reset this value */ - dialog:modal { - max-width: 100dvw; - max-height: 100dvh; - } - - details>summary::-webkit-details-marker { - @apply hidden; - } - - details>summary { - @apply list-none; - } - - input[type='radio'] { - @apply border-gray-300 text-indigo-600 focus:ring-indigo-600; - /* Default light mode */ - - @variant theme-dark { - /* Dark mode radio button base and checked styles */ - @apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800; - } + @variant theme-dark { + @apply bg-white; } } -@layer components { - /* Forms */ - .form-field { - @apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full; - @apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200; - @apply transition-all duration-300; +@utility bg-inverse-hover { + @apply bg-gray-700; - @variant theme-dark { - @apply focus-within:ring-alpha-white-300; - } - - /* Add styles for multiple select within form fields */ - select[multiple] { - @apply py-2 pr-2 space-y-0.5 overflow-y-auto; - - option { - @apply py-2 rounded-md; - } - - option:checked { - @apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2; - } - - option:active, - option:focus { - @apply bg-container-inset; - } - } + @variant theme-dark { + @apply bg-gray-100; } +} - /* New form field structure components */ - .form-field__header { - @apply flex items-center justify-between gap-2; +@utility bg-overlay { + background-color: --alpha(var(--color-gray-100) / 50%); + + @variant theme-dark { + background-color: var(--color-alpha-black-900); } +} - .form-field__body { - @apply flex flex-col gap-1; +@utility bg-loader { + @apply bg-surface-inset animate-pulse; +} + +@utility text-primary { + @apply text-gray-900; + + @variant theme-dark { + @apply text-white; } +} - .form-field__actions { - @apply flex items-center gap-1; +@utility text-inverse { + @apply text-white; + + @variant theme-dark { + @apply text-gray-900; } +} - .form-field__label { - @apply block text-xs text-secondary peer-disabled:text-subdued; +@utility text-secondary { + @apply text-gray-500; + + @variant theme-dark { + @apply text-gray-300; } +} - .form-field__input { - @apply text-primary border-none bg-container text-sm opacity-100 w-full p-0; - @apply focus:opacity-100 focus:outline-hidden focus:ring-0; - @apply placeholder-shown:opacity-50; - @apply disabled:text-subdued; - @apply text-ellipsis overflow-hidden whitespace-nowrap; - @apply transition-opacity duration-300; - @apply placeholder:text-subdued; +@utility text-subdued { + @apply text-gray-400; - @variant theme-dark { - &::-webkit-calendar-picker-indicator { - filter: invert(1); - cursor: pointer; - } - } + @variant theme-dark { + @apply text-gray-500; } +} - textarea.form-field__input { - @apply whitespace-normal overflow-auto; - text-overflow: clip; +@utility shadow-border-xs { + box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50); + + @variant theme-dark { + box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-white-50); } - - select.form-field__input, - button.form-field__input { - @apply pr-10 appearance-none; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right -0.15rem center; - background-repeat: no-repeat; - background-size: 1.25rem 1.25rem; - text-align: left; +} + +@utility shadow-border-sm { + box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50); + + @variant theme-dark { + box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-white-50); } +} - .form-field__radio { - @apply text-primary; +@utility shadow-border-md { + box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50); + + @variant theme-dark { + box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-white-50); } +} - .form-field__submit { - @apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover; +@utility shadow-border-lg { + box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50); + + @variant theme-dark { + box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-white-50); } +} - /* Checkboxes */ - .checkbox { - &[type='checkbox'] { - @apply rounded-sm; - @apply transition-colors duration-300; - } +@utility shadow-border-xl { + box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50); + + @variant theme-dark { + box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-white-50); } +} - .checkbox--light { - &[type='checkbox'] { - @apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-300 hover:bg-gray-300; - } +@utility border-primary { + @apply border-alpha-black-300; - &[type='checkbox']:disabled { - @apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400; - } - - @variant theme-dark { - &[type='checkbox'] { - @apply ring-gray-900 checked:text-white; - background-color: var(--color-gray-100); - } - - &[type='checkbox']:disabled { - @apply cursor-not-allowed opacity-80; - background-color: var(--color-gray-600); - } - - &[type='checkbox']:checked { - background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23808080' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); - background-color: var(--color-gray-100); - } - } + @variant theme-dark { + @apply border-alpha-white-400; } +} - .checkbox--dark { - &[type='checkbox'] { - @apply ring-gray-900 checked:text-white; - } +@utility border-secondary { + @apply border-alpha-black-200; - &[type='checkbox']:disabled { - @apply cursor-not-allowed opacity-80 ring-gray-600; - } - - &[type='checkbox']:checked { - background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); - } + @variant theme-dark { + @apply border-alpha-white-300; } +} - /* Tooltips */ - .tooltip { - @apply hidden absolute; - } +@utility border-divider { + @apply border-tertiary; +} - .qrcode svg path { - fill: var(--color-black); - @variant theme-dark { - fill: var(--color-white); - } +@utility border-subdued { + @apply border-alpha-black-50; + + @variant theme-dark { + @apply border-alpha-white-100; } -} \ No newline at end of file +} + +@utility border-solid { + @apply border-black; + + @variant theme-dark { + @apply border-white; + } +} + +@utility border-destructive { + @apply border-red-500; + + @variant theme-dark { + @apply border-red-400; + } +} + +@utility border-inverse { + @apply border-alpha-white-200; + + @variant theme-dark { + @apply border-alpha-black-300; + } +} + +@utility button-bg-primary { + @apply bg-gray-900; + + @variant theme-dark { + @apply bg-white; + } +} + +@utility button-bg-primary-hover { + @apply bg-gray-800; + + @variant theme-dark { + @apply bg-gray-50; + } +} + +@utility button-bg-secondary { + @apply bg-gray-50; + + @variant theme-dark { + @apply bg-gray-700; + } +} + +@utility button-bg-secondary-hover { + @apply bg-gray-100; + + @variant theme-dark { + @apply bg-gray-600; + } +} + +@utility button-bg-secondary-strong { + @apply bg-gray-200; + + @variant theme-dark { + @apply bg-gray-700; + } +} + +@utility button-bg-secondary-strong-hover { + @apply bg-gray-300; + + @variant theme-dark { + @apply bg-gray-600; + } +} + +@utility button-bg-disabled { + @apply bg-gray-50; + + @variant theme-dark { + @apply bg-gray-700; + } +} + +@utility button-bg-destructive { + @apply bg-red-500; + + @variant theme-dark { + @apply bg-red-400; + } +} + +@utility button-bg-destructive-hover { + @apply bg-red-600; + + @variant theme-dark { + @apply bg-red-500; + } +} + +@utility button-bg-ghost-hover { + @apply bg-gray-50; + + @variant theme-dark { + @apply bg-gray-800 text-inverse; + } +} + +@utility button-bg-outline-hover { + @apply bg-gray-100; + + @variant theme-dark { + @apply bg-gray-700; + } +} + +@utility tab-item-active { + @apply bg-white; + + @variant theme-dark { + @apply bg-gray-700; + } +} + +@utility tab-item-hover { + @apply bg-gray-200; + + @variant theme-dark { + @apply bg-gray-800; + } +} + +@utility tab-bg-group { + @apply bg-gray-50; + + @variant theme-dark { + @apply bg-alpha-black-700; + } +} diff --git a/app/assets/tailwind/sure-design-system/base.css b/app/assets/tailwind/sure-design-system/base.css new file mode 100644 index 000000000..522cdcf37 --- /dev/null +++ b/app/assets/tailwind/sure-design-system/base.css @@ -0,0 +1,37 @@ +@layer base { + button { + @apply cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-alpha-black-300; + + @variant theme-dark { + @apply focus-visible:ring-alpha-white-300; + } + } + + hr { + @apply text-gray-200; + } + + /* We control the sizing through DialogComponent, so reset this value */ + dialog:modal { + max-width: 100dvw; + max-height: 100dvh; + } + + details>summary::-webkit-details-marker { + @apply hidden; + } + + details>summary { + @apply list-none; + } + + input[type='radio'] { + @apply border-gray-300 text-indigo-600 focus:ring-indigo-600; + /* Default light mode */ + + @variant theme-dark { + /* Dark mode radio button base and checked styles */ + @apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800; + } + } +} diff --git a/app/assets/tailwind/sure-design-system/components.css b/app/assets/tailwind/sure-design-system/components.css new file mode 100644 index 000000000..8a4c08b98 --- /dev/null +++ b/app/assets/tailwind/sure-design-system/components.css @@ -0,0 +1,148 @@ +@layer components { + /* Forms */ + .form-field { + @apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full; + @apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200; + @apply transition-all duration-300; + + @variant theme-dark { + @apply focus-within:ring-alpha-white-300; + } + + /* Add styles for multiple select within form fields */ + select[multiple] { + @apply py-2 pr-2 space-y-0.5 overflow-y-auto; + + option { + @apply py-2 rounded-md; + } + + option:checked { + @apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2; + } + + option:active, + option:focus { + @apply bg-container-inset; + } + } + } + + /* New form field structure components */ + .form-field__header { + @apply flex items-center justify-between gap-2; + } + + .form-field__body { + @apply flex flex-col gap-1; + } + + .form-field__actions { + @apply flex items-center gap-1; + } + + .form-field__label { + @apply block text-xs text-secondary peer-disabled:text-subdued; + } + + .form-field__input { + @apply text-primary border-none bg-container text-sm opacity-100 w-full p-0; + @apply focus:opacity-100 focus:outline-hidden focus:ring-0; + @apply placeholder-shown:opacity-50; + @apply disabled:text-subdued; + @apply text-ellipsis overflow-hidden whitespace-nowrap; + @apply transition-opacity duration-300; + @apply placeholder:text-subdued; + + @variant theme-dark { + &::-webkit-calendar-picker-indicator { + filter: invert(1); + cursor: pointer; + } + } + } + + textarea.form-field__input { + @apply whitespace-normal overflow-auto; + text-overflow: clip; + } + + select.form-field__input, + button.form-field__input { + @apply pr-10 appearance-none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right -0.15rem center; + background-repeat: no-repeat; + background-size: 1.25rem 1.25rem; + text-align: left; + } + + .form-field__radio { + @apply text-primary; + } + + .form-field__submit { + @apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover; + } + + /* Checkboxes */ + .checkbox { + &[type='checkbox'] { + @apply rounded-sm; + @apply transition-colors duration-300; + } + } + + .checkbox--light { + &[type='checkbox'] { + @apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-300 hover:bg-gray-300; + } + + &[type='checkbox']:disabled { + @apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400; + } + + @variant theme-dark { + &[type='checkbox'] { + @apply ring-gray-900 checked:text-white; + background-color: var(--color-gray-100); + } + + &[type='checkbox']:disabled { + @apply cursor-not-allowed opacity-80; + background-color: var(--color-gray-600); + } + + &[type='checkbox']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23808080' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); + background-color: var(--color-gray-100); + } + } + } + + .checkbox--dark { + &[type='checkbox'] { + @apply ring-gray-900 checked:text-white; + } + + &[type='checkbox']:disabled { + @apply cursor-not-allowed opacity-80 ring-gray-600; + } + + &[type='checkbox']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); + } + } + + /* Tooltips */ + .tooltip { + @apply hidden absolute; + } + + .qrcode svg path { + fill: var(--color-black); + @variant theme-dark { + fill: var(--color-white); + } + } +} diff --git a/app/assets/tailwind/sure-design-system/prose.css b/app/assets/tailwind/sure-design-system/prose.css new file mode 100644 index 000000000..68f6ad482 --- /dev/null +++ b/app/assets/tailwind/sure-design-system/prose.css @@ -0,0 +1,24 @@ +/* Specific override for strong tags in prose under dark mode */ +.prose:where([data-theme=dark], [data-theme=dark] *) strong { + color: theme(colors.white) !important; +} + +/* Specific override for headings in prose under dark mode */ +.prose:where([data-theme=dark], [data-theme=dark] *) h1, +.prose:where([data-theme=dark], [data-theme=dark] *) h2, +.prose:where([data-theme=dark], [data-theme=dark] *) h3, +.prose:where([data-theme=dark], [data-theme=dark] *) h4, +.prose:where([data-theme=dark], [data-theme=dark] *) h5, +.prose:where([data-theme=dark], [data-theme=dark] *) h6, +.prose:where([data-theme=dark], [data-theme=dark] *) blockquote, +.prose:where([data-theme=dark], [data-theme=dark] *) thead th { + color: theme(colors.white) !important; +} + +/* Inline code in prose under dark mode. Without this the Tailwind Typography + plugin's default near-black foreground falls through and disappears against + the dark page background. */ +.prose:where([data-theme=dark], [data-theme=dark] *) code { + color: theme(colors.white) !important; + background-color: theme(colors.gray.800) !important; +} diff --git a/app/components/DS/alert.html.erb b/app/components/DS/alert.html.erb index e56c9fc40..efda08221 100644 --- a/app/components/DS/alert.html.erb +++ b/app/components/DS/alert.html.erb @@ -1,7 +1,37 @@ -
- <%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %> +<%= tag.div(class: container_classes, role: aria_role, "aria-labelledby": (aria_role && title.present?) ? title_id : nil) do %> + <% if title.present? %> +
+ <%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 -mt-0.5" %> +

+ <%= variant_label %>: + <%= title %> +

+
-
- <%= message %> -
-
+ <% if content.present? || message.present? %> +
+ <% if content.present? %> + <%= content %> + <% else %> + <%= message %> + <% end %> +
+ <% end %> + <% elsif content.present? %> +
+ <%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %> +
+ <%= variant_label %>: + <%= content %> +
+
+ <% elsif message.present? %> +
+ <%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 -mt-0.5" %> +

+ <%= variant_label %>: + <%= message %> +

+
+ <% end %> +<% end %> diff --git a/app/components/DS/alert.rb b/app/components/DS/alert.rb index 22241133f..18204cc5e 100644 --- a/app/components/DS/alert.rb +++ b/app/components/DS/alert.rb @@ -1,24 +1,44 @@ class DS::Alert < DesignSystemComponent - def initialize(message:, variant: :info) + VARIANTS = %i[info success warning error destructive].freeze + LIVE_MODES = %i[none status alert].freeze + + def initialize(message: nil, title: nil, variant: :info, live: :none) @message = message - @variant = variant + @title = title + @variant = normalize_variant(variant) + @live = normalize_live(live) end private - attr_reader :message, :variant + attr_reader :message, :title, :variant, :live + + def normalize_variant(raw) + sym = raw.respond_to?(:to_sym) ? raw.to_sym : nil + VARIANTS.include?(sym) ? sym : :info + end + + def normalize_live(raw) + sym = raw.respond_to?(:to_sym) ? raw.to_sym : nil + case sym + when :polite then :status + when :assertive then :alert + when *LIVE_MODES then sym + else :none + end + end def container_classes - base_classes = "flex items-start gap-3 p-4 rounded-lg border" + base_classes = "p-4 rounded-lg border" variant_classes = case variant when :info - "bg-blue-50 text-blue-700 border-blue-200 theme-dark:bg-blue-900/20 theme-dark:text-blue-400 theme-dark:border-blue-800" + "bg-info/10 border-info/20" when :success - "bg-green-50 text-green-700 border-green-200 theme-dark:bg-green-900/20 theme-dark:text-green-400 theme-dark:border-green-800" + "bg-success/10 border-success/20" when :warning - "bg-yellow-50 text-yellow-700 border-yellow-200 theme-dark:bg-yellow-900/20 theme-dark:text-yellow-400 theme-dark:border-yellow-800" + "bg-warning/10 border-warning/20" when :error, :destructive - "bg-red-50 text-red-700 border-red-200 theme-dark:bg-red-900/20 theme-dark:text-red-400 theme-dark:border-red-800" + "bg-destructive/10 border-destructive-subtle" end "#{base_classes} #{variant_classes}" @@ -46,7 +66,22 @@ class DS::Alert < DesignSystemComponent when :error, :destructive "destructive" else - "blue-600" + "info" end end + + def aria_role + case live + when :status then "status" + when :alert then "alert" + end + end + + def variant_label + I18n.t("ds.alert.variants.#{variant}") + end + + def title_id + @title_id ||= "DS-alert-title-#{SecureRandom.hex(4)}" + end end diff --git a/app/components/DS/button.html.erb b/app/components/DS/button.html.erb index 0b69c4649..557db0bd9 100644 --- a/app/components/DS/button.html.erb +++ b/app/components/DS/button.html.erb @@ -4,7 +4,7 @@ <% end %> <% unless icon_only? %> - <%= text %> + <%= text %> <% end %> <% if icon && icon_position == :right %> diff --git a/app/components/DS/button.rb b/app/components/DS/button.rb index a253c04c7..ca5644225 100644 --- a/app/components/DS/button.rb +++ b/app/components/DS/button.rb @@ -22,7 +22,6 @@ class DS::Button < DS::Buttonish def merged_opts merged_opts = opts.dup || {} extra_classes = merged_opts.delete(:class) - href = merged_opts.delete(:href) data = merged_opts.delete(:data) || {} if confirm.present? @@ -33,6 +32,28 @@ class DS::Button < DS::Buttonish data = data.merge(turbo_frame: frame) end + # `content_tag(:button, ...)` defaults to `type="submit"` per the HTML + # spec — meaning a DS::Button rendered inside a form will steal Enter-key + # submission from the first text input. Default to `type="button"` so + # callers must opt into submit behavior explicitly. `button_to` (href + # branch) wraps the button in its own form, so submit there is correct + # and we leave its default alone. + if href.blank? + merged_opts[:type] ||= "button" + end + + # Icon-only buttons have no visible text node, so screen readers fall + # back to announcing "button" with no name. Derive a humanized fallback + # from the icon key so AT users hear *something* meaningful; explicit + # `aria: { label: }` on the caller still wins. + if icon_only? && icon.present? + aria = (merged_opts[:aria] || {}).symbolize_keys + if aria[:label].blank? && merged_opts[:"aria-label"].blank? + aria[:label] = icon.to_s.tr("-_", " ").capitalize + merged_opts[:aria] = aria + end + end + merged_opts.merge( class: class_names(container_classes, extra_classes), data: data diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb index 0c68d1dbe..f1e864511 100644 --- a/app/components/DS/buttonish.rb +++ b/app/components/DS/buttonish.rb @@ -2,35 +2,35 @@ class DS::Buttonish < DesignSystemComponent VARIANTS = { primary: { container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400", - icon_classes: "fg-inverse" + icon_classes: "text-inverse" }, secondary: { container_classes: "text-primary bg-gray-200 theme-dark:bg-gray-700 hover:bg-gray-300 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600", - icon_classes: "fg-primary" + icon_classes: "text-primary" }, destructive: { container_classes: "text-inverse bg-red-500 theme-dark:bg-red-400 hover:bg-red-600 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600", - icon_classes: "fg-white" + icon_classes: "text-inverse" }, outline: { container_classes: "text-primary border border-secondary bg-transparent hover:bg-surface-hover", - icon_classes: "fg-gray" + icon_classes: "text-secondary" }, outline_destructive: { - container_classes: "text-destructive border border-secondary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700", - icon_classes: "fg-gray" + container_classes: "text-destructive border border-secondary bg-transparent hover:bg-container-inset-hover", + icon_classes: "text-secondary" }, ghost: { - container_classes: "text-primary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700", - icon_classes: "fg-gray" + container_classes: "text-primary bg-transparent hover:bg-container-inset-hover", + icon_classes: "text-secondary" }, icon: { - container_classes: "hover:bg-gray-100 theme-dark:hover:bg-gray-700", - icon_classes: "fg-gray" + container_classes: "hover:bg-container-inset-hover", + icon_classes: "text-secondary" }, icon_inverse: { container_classes: "bg-inverse hover:bg-inverse-hover", - icon_classes: "fg-inverse" + icon_classes: "text-inverse" } }.freeze @@ -43,13 +43,13 @@ class DS::Buttonish < DesignSystemComponent }, md: { container_classes: "px-3 py-2", - icon_container_classes: "inline-flex items-center justify-center w-9 h-9", + icon_container_classes: "inline-flex items-center justify-center w-11 h-11", radius_classes: "rounded-lg", text_classes: "text-sm" }, lg: { container_classes: "px-4 py-3", - icon_container_classes: "inline-flex items-center justify-center w-10 h-10", + icon_container_classes: "inline-flex items-center justify-center w-12 h-12", radius_classes: "rounded-xl", text_classes: "text-base" } diff --git a/app/components/DS/dialog.html.erb b/app/components/DS/dialog.html.erb index 307303970..97bac18d6 100644 --- a/app/components/DS/dialog.html.erb +++ b/app/components/DS/dialog.html.erb @@ -1,5 +1,23 @@ <%= wrapper_element do %> - <%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] #{(drawer? || responsive?) ? "lg:p-3" : "lg:p-1"}", **merged_opts do %> + <%# `role="dialog"` + `aria-modal="true"` is redundant with `` + in modern browsers, but Safari and screen readers older than the + 2024 WAI-ARIA Mapping still benefit from the explicit role. + `aria-labelledby` is always emitted with the stable `title_id`. When + the header slot rendered an auto-title (the common case), AT + resolves the reference and announces the title. When no title is + rendered (`custom_header: true`, body-only dialogs), the dangling + reference is silently ignored per the WAI-ARIA spec, and callers + can supply `aria-label` / `aria-labelledby` via `**opts` (last-wins) + for an explicit accessible name. The conditional `if has_auto_title?` + gate was a no-op here: the `renders_one :header` slot lambda is + evaluated lazily at slot-render time (after the `` opening + tag attributes are computed), so the flag was never `true` when + this attribute was read. %> + <%= tag.dialog class: "w-full h-full bg-transparent text-primary theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] #{(drawer? || responsive?) ? "lg:p-3" : "lg:p-1"}", + role: "dialog", + "aria-modal": "true", + "aria-labelledby": title_id, + **merged_opts do %> <%= tag.div class: dialog_outer_classes do %> <%= tag.div class: dialog_inner_classes, data: { DS__dialog_target: "content" } do %>
"> diff --git a/app/components/DS/dialog.rb b/app/components/DS/dialog.rb index e9c4eb3ae..fe2611339 100644 --- a/app/components/DS/dialog.rb +++ b/app/components/DS/dialog.rb @@ -2,7 +2,11 @@ class DS::Dialog < DesignSystemComponent renders_one :header, ->(title: nil, subtitle: nil, custom_header: false, **opts, &block) do content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do - title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title + # `id: title_id` lets the host `` reference the title via + # `aria-labelledby` so AT users hear the title when focus lands + # in the dialog. `content_tag("h#{heading_level}", ...)` builds an + # h2/h3/etc based on the caller's `heading_level:`. + title = content_tag("h#{heading_level}", title, id: title_id, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title close_icon = close_button unless custom_header safe_join([ title, close_icon ].compact) end @@ -33,7 +37,7 @@ class DS::Dialog < DesignSystemComponent end end - attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :content_class, :disable_click_outside, :opts, :responsive, :scrollable + attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :content_class, :disable_click_outside, :opts, :responsive, :scrollable, :heading_level, :title_id VARIANTS = %w[modal drawer].freeze WIDTHS = { @@ -42,8 +46,13 @@ class DS::Dialog < DesignSystemComponent lg: "lg:max-w-[700px]", full: "lg:max-w-full" }.freeze + VALID_HEADING_LEVELS = (1..6).freeze + + def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: false, responsive: false, scrollable: true, heading_level: 2, **opts) + unless heading_level.is_a?(Integer) && VALID_HEADING_LEVELS.cover?(heading_level) + raise ArgumentError, "heading_level must be an Integer between 1 and 6, got: #{heading_level.inspect}" + end - def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: false, responsive: false, scrollable: true, **opts) @variant = variant.to_sym @auto_open = auto_open @reload_on_close = reload_on_close @@ -54,6 +63,8 @@ class DS::Dialog < DesignSystemComponent @disable_click_outside = disable_click_outside @responsive = responsive @scrollable = scrollable + @heading_level = heading_level + @title_id = "dialog-title-#{SecureRandom.hex(4)}" @opts = opts end @@ -133,8 +144,8 @@ class DS::Dialog < DesignSystemComponent variant: "icon", class: classes, icon: "x", - title: I18n.t("common.close"), - aria_label: I18n.t("common.close"), + title: I18n.t("ds.dialog.close"), + aria_label: I18n.t("ds.dialog.close"), data: { action: "DS--dialog#close" } ) end diff --git a/app/components/DS/disclosure.html.erb b/app/components/DS/disclosure.html.erb index bf6f61d1d..c323aa76c 100644 --- a/app/components/DS/disclosure.html.erb +++ b/app/components/DS/disclosure.html.erb @@ -1,13 +1,25 @@ -
> - <%= tag.summary class: class_names( - "px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface" - ) do %> +<%= tag.details class: details_classes, open: open, **details_opts do %> + <%= tag.summary class: summary_classes do %> <% if summary_content? %> - <%= summary_content %> + <% if variant == :default %> + <%# `:default` summary is already `flex justify-between`, so + caller-provided sibling divs get distributed directly. + Wrapping would collapse them into a single flex child and + kill the justify-between distribution. %> + <%= summary_content %> + <% else %> + <%# Non-default summaries are `list-item` (no flex), so a flex + caller div would shrink-wrap to content width and any + `justify-between` inside has nothing to distribute. Wrap + in `w-full` so caller flex rows stretch across the card. %> +
+ <%= summary_content %> +
+ <% end %> <% else %>
<% if align == :left %> - <%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + <%= helpers.icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> <% end %> <%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %> @@ -16,7 +28,7 @@
<% if align == :right %> - <%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %> + <%= helpers.icon "chevron-down", class: "group-open:rotate-180 motion-safe:transition-transform motion-safe:duration-150" %> <% end %> <% end %> <% end %> @@ -24,4 +36,4 @@
<%= content %>
-
+<% end %> diff --git a/app/components/DS/disclosure.rb b/app/components/DS/disclosure.rb index d301d671e..5f993d6c1 100644 --- a/app/components/DS/disclosure.rb +++ b/app/components/DS/disclosure.rb @@ -1,12 +1,79 @@ class DS::Disclosure < DesignSystemComponent renders_one :summary_content - attr_reader :title, :align, :open, :opts + VARIANTS = %i[default card card_inset inline].freeze - def initialize(title: nil, align: "right", open: false, **opts) + attr_reader :title, :align, :open, :variant, :opts + + # `:default` — bg-surface summary, no chrome on the `
`. Use + # for inline expanders that sit inside a parent card (the summary + # itself reads as the surface). + # + # `:card` — `
` itself becomes a `bg-container shadow-border-xs + # rounded-xl` card; the summary inherits the container (no own bg). + # Use for provider-item rows (binance, lunchflow, plaid, etc.) where + # each card is the surface and the summary is custom rich content. + # + # `:card_inset` — `
` is `bg-surface-inset rounded-xl` (no + # shadow). Use for inset sub-panels inside a parent card surface + # (e.g. the IBKR flex-query "report details" panel embedded inside + # the IBKR settings flow). Same summary contract as `:card`. + # + # `:inline` — no surface, no padding, no shadow. The disclosure reads + # as a plain text-link-style toggle (e.g. "Alternative auth" inside + # a form, or a "Manage connections" lazy-load opener). Caller provides + # the summary text (and optional chevron) via the `summary_content` + # slot. + # + # In card / inline variants, callers should pass their own + # `summary_content` slot; the built-in title rendering assumes the + # `:default` shape. + def initialize(title: nil, align: "right", open: false, variant: :default, **opts) @title = title @align = align.to_sym @open = open + @variant = variant&.to_sym @opts = opts + + raise ArgumentError, "Invalid variant: #{@variant.inspect}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant) + end + + def details_classes + base = case variant + when :card + "group bg-container p-4 shadow-border-xs rounded-xl" + when :card_inset + "group bg-surface-inset rounded-xl p-4" + else + "group" + end + + class_names(base, opts[:class]) + end + + # `opts` minus the `:class` key, since `details_classes` merges that + # separately to avoid duplicate-keyword collisions when forwarding to + # `tag.details`. + def details_opts + opts.except(:class) + end + + def summary_classes + case variant + when :card, :card_inset + # Card variants: no bg on summary — the parent details *is* the + # surface. Keep cursor + focus-visible ring + flex baseline. + # Ring token matches `settings/provider_card.html.erb` (the + # established focus pattern on container cards). + "list-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 rounded-xl" + when :inline + # Inline variant: no surface, no padding — the summary reads as + # plain text-link copy. Caller markup (text + optional chevron) + # provides the visual. Keep cursor + focus-visible ring + matching + # alpha-black-300 token used by the card variants for consistency. + "list-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 rounded-sm" + else + "px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300" + end end end diff --git a/app/components/DS/filled_icon.html.erb b/app/components/DS/filled_icon.html.erb index 49adba9e1..9721a26b6 100644 --- a/app/components/DS/filled_icon.html.erb +++ b/app/components/DS/filled_icon.html.erb @@ -1,5 +1,8 @@ <%= tag.div style: transparent? ? container_styles : nil, - class: container_classes do %> + class: container_classes, + role: (!aria_hidden && description.present?) ? "img" : nil, + "aria-label": aria_hidden ? nil : description.presence, + "aria-hidden": aria_hidden ? "true" : nil do %> <% if icon %> <%= helpers.icon(icon, size: icon_size, color: "current") %> <% elsif text %> diff --git a/app/components/DS/filled_icon.rb b/app/components/DS/filled_icon.rb index 73113a0bc..19ed2e5fa 100644 --- a/app/components/DS/filled_icon.rb +++ b/app/components/DS/filled_icon.rb @@ -1,5 +1,5 @@ class DS::FilledIcon < DesignSystemComponent - attr_reader :icon, :text, :hex_color, :size, :rounded, :variant + attr_reader :icon, :text, :hex_color, :size, :rounded, :variant, :description, :aria_hidden VARIANTS = %i[default text surface container inverse].freeze @@ -24,13 +24,26 @@ class DS::FilledIcon < DesignSystemComponent } }.freeze - def initialize(variant: :default, icon: nil, text: nil, hex_color: nil, size: "md", rounded: false) + # `description:` makes the icon meaningful — emits `role="img"` + + # `aria-label=description` so AT users hear it. Without `description:`, + # the wrapper defaults to `aria-hidden="true"` (decorative) on the + # assumption that adjacent DOM carries the accessible name. Pass + # `aria_hidden: false` if you want the visual exposed but the name + # already lives in surrounding text (rare). + # + # NOTE on the `:text` variant: only `text.first` is rendered (e.g. + # "Apple" → "A"). The single letter is decorative — relying on AT + # users to infer "Apple" from "A" is broken. Use `description:` to + # surface the full label, or ensure the adjacent text node carries it. + def initialize(variant: :default, icon: nil, text: nil, hex_color: nil, size: "md", rounded: false, description: nil, aria_hidden: nil) @variant = variant.to_sym @icon = icon @text = text @hex_color = hex_color @size = size.to_sym @rounded = rounded + @description = description.presence + @aria_hidden = aria_hidden.nil? ? @description.blank? : aria_hidden end def container_classes diff --git a/app/components/DS/link.html.erb b/app/components/DS/link.html.erb index dae100eaa..f9c4cff5d 100644 --- a/app/components/DS/link.html.erb +++ b/app/components/DS/link.html.erb @@ -10,4 +10,8 @@ <% if icon && icon_position == "right" %> <%= helpers.icon(icon, size: size, color: icon_color) %> <% end %> + + <% if opens_in_new_tab? %> + <%= t("ds.link.opens_in_new_tab", default: "(opens in new tab)") %> + <% end %> <% end %> diff --git a/app/components/DS/link.rb b/app/components/DS/link.rb index 209f86b09..9ca73aacb 100644 --- a/app/components/DS/link.rb +++ b/app/components/DS/link.rb @@ -5,8 +5,14 @@ class DS::Link < DS::Buttonish VARIANTS = VARIANTS.reverse_merge( default: { - container_classes: "", - icon_classes: "fg-gray" + # Underline + `text-link` so the link is distinguishable by more + # than color alone (WCAG 1.4.1). Focus ring uses the established + # alpha-ring DS pattern (also used by DS::Toggle, DS::Tooltip, + # provider_card, form-field) so theming stays centralized. + container_classes: "text-link underline underline-offset-2 hover:no-underline " \ + "focus-visible:ring-2 focus-visible:ring-alpha-black-300 " \ + "theme-dark:focus-visible:ring-alpha-white-300", + icon_classes: "text-secondary" } ).freeze @@ -18,12 +24,48 @@ class DS::Link < DS::Buttonish data = data.merge(turbo_frame: frame) end + # External link hardening: `target="_blank"` without `rel="noopener"` + # exposes window.opener to the new tab (reverse-tabnabbing). Always + # set `noopener noreferrer` when we send the user off-tab. Authors + # can override by passing `rel:` explicitly. + if merged_opts[:target].to_s == "_blank" + merged_opts[:rel] ||= "noopener noreferrer" + end + + # Icon-only links have no visible text node, so screen readers fall + # back to announcing the href. Derive a humanized fallback from the + # icon key so AT users hear *something* meaningful; explicit + # `aria: { label: }` on the caller still wins. Mirrors DS::Button. + # + # When the link also opens in a new tab, fold the cue into the + # generated `aria-label` itself — `aria-label` overrides the + # descendant accessible name, so the sr-only "(opens in new tab)" + # span in the template would otherwise be masked. + if icon_only? && icon.present? + aria = (merged_opts[:aria] || {}).symbolize_keys + if aria[:label].blank? && merged_opts[:"aria-label"].blank? + label = icon.to_s.tr("-_", " ").humanize + if merged_opts[:target].to_s == "_blank" + label = "#{label} #{I18n.t("ds.link.opens_in_new_tab", default: "(opens in new tab)")}" + end + aria[:label] = label + merged_opts[:aria] = aria + end + end + merged_opts.merge( class: class_names(container_classes, extra_classes), data: data ) end + # Render an sr-only suffix when the link opens in a new tab so AT + # users hear "(opens in new tab)" — visual is a separate concern + # (callers can render a `external-link` icon if they want a glyph). + def opens_in_new_tab? + opts[:target].to_s == "_blank" + end + private def container_size_classes super unless variant == :default diff --git a/app/components/DS/menu.html.erb b/app/components/DS/menu.html.erb index ed6490184..c77895cbd 100644 --- a/app/components/DS/menu.html.erb +++ b/app/components/DS/menu.html.erb @@ -1,26 +1,16 @@ <%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, DS__menu_mobile_fullwidth_value: mobile_fullwidth, testid: testid } do %> - <% if variant == :icon %> - <%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %> + <% if icon_only? %> + <%= render DS::Button.new(variant: "icon", size: icon_button_size, icon: icon_vertical ? "more-vertical" : "more-horizontal", aria: { haspopup: "menu", expanded: "false", controls: menu_id }, data: { DS__menu_target: "button" }) %> <% elsif variant == :button %> <%= button %> - <% elsif variant == :avatar %> - <% end %> -
diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb index 00adaf3fd..8639ab62a 100644 --- a/app/components/UI/account/activity_feed.html.erb +++ b/app/components/UI/account/activity_feed.html.erb @@ -54,7 +54,7 @@ data: { controller: "auto-submit-form" } do |form| %>
-
+
<%= helpers.icon("search") %> <%= hidden_field_tag :account_id, account.id %> @@ -67,8 +67,8 @@
- <%= render DS::Menu.new(variant: "button", no_padding: true) do |menu| %> - <% menu.with_button( + <%= render DS::Popover.new(variant: "button", no_padding: true) do |popover| %> + <% popover.with_button( id: "activity-status-filter-button", type: "button", text: t("accounts.show.activity.filter"), @@ -76,7 +76,7 @@ icon: "list-filter" ) %> - <% menu.with_custom_content do %> + <% popover.with_custom_content do %>

<%= t("accounts.show.activity.status") %>

diff --git a/app/components/UI/account/balance_reconciliation.rb b/app/components/UI/account/balance_reconciliation.rb index 980fad60d..0839bd1e1 100644 --- a/app/components/UI/account/balance_reconciliation.rb +++ b/app/components/UI/account/balance_reconciliation.rb @@ -27,105 +27,108 @@ class UI::Account::BalanceReconciliation < ApplicationComponent private + def t_label(key) + I18n.t("UI.account.balance_reconciliation.labels.#{key}") + end + + def t_tooltip(key) + I18n.t("UI.account.balance_reconciliation.tooltips.#{key}") + end + def default_items items = [ - { label: "Start balance", value: balance.start_balance_money, tooltip: "The account balance at the beginning of this day", style: :start }, - { label: "Net cash flow", value: net_cash_flow, tooltip: "Net change in balance from all transactions during the day", style: :flow } + { label: t_label(:start_balance), value: balance.start_balance_money, tooltip: t_tooltip(:start_balance), style: :start }, + { label: t_label(:net_cash_flow), value: net_cash_flow, tooltip: t_tooltip(:net_cash_flow), style: :flow } ] if has_adjustments? - items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal } - items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + items << { label: t_label(:end_balance), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_balance), style: :subtotal } + items << { label: t_label(:adjustments), value: total_adjustments, tooltip: t_tooltip(:adjustments), style: :adjustment } end - items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final account balance for the day", style: :final } + items << { label: t_label(:final_balance), value: balance.end_balance_money, tooltip: t_tooltip(:final_balance), style: :final } items end def credit_card_items items = [ - { label: "Start balance", value: balance.start_balance_money, tooltip: "The balance owed at the beginning of this day", style: :start }, - { label: "Charges", value: balance.cash_outflows_money, tooltip: "New charges made during the day", style: :flow }, - { label: "Payments", value: balance.cash_inflows_money * -1, tooltip: "Payments made to the card during the day", style: :flow } + { label: t_label(:start_balance), value: balance.start_balance_money, tooltip: t_tooltip(:start_balance_credit), style: :start }, + { label: t_label(:charges), value: balance.cash_outflows_money, tooltip: t_tooltip(:charges), style: :flow }, + { label: t_label(:payments), value: balance.cash_inflows_money * -1, tooltip: t_tooltip(:payments), style: :flow } ] if has_adjustments? - items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal } - items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + items << { label: t_label(:end_balance), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_balance), style: :subtotal } + items << { label: t_label(:adjustments), value: total_adjustments, tooltip: t_tooltip(:adjustments), style: :adjustment } end - items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final balance owed for the day", style: :final } + items << { label: t_label(:final_balance), value: balance.end_balance_money, tooltip: t_tooltip(:final_balance_credit), style: :final } items end def investment_items items = [ - { label: "Start balance", value: balance.start_balance_money, tooltip: "The total portfolio value at the beginning of this day", style: :start } + { label: t_label(:start_balance), value: balance.start_balance_money, tooltip: t_tooltip(:start_balance_investment), style: :start } ] - # Change in brokerage cash (includes deposits, withdrawals, and cash from trades) - items << { label: "Change in brokerage cash", value: net_cash_flow, tooltip: "Net change in cash from deposits, withdrawals, and trades", style: :flow } - - # Change in holdings from trading activity - items << { label: "Change in holdings (buys/sells)", value: net_non_cash_flow, tooltip: "Impact on holdings from buying and selling securities", style: :flow } - - # Market price changes - items << { label: "Change in holdings (market price activity)", value: balance.net_market_flows_money, tooltip: "Change in holdings value from market price movements", style: :flow } + items << { label: t_label(:change_in_brokerage_cash), value: net_cash_flow, tooltip: t_tooltip(:change_in_brokerage_cash), style: :flow } + items << { label: t_label(:change_in_holdings_trades), value: net_non_cash_flow, tooltip: t_tooltip(:change_in_holdings_trades), style: :flow } + items << { label: t_label(:change_in_holdings_market), value: balance.net_market_flows_money, tooltip: t_tooltip(:change_in_holdings_market), style: :flow } if has_adjustments? - items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal } - items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + items << { label: t_label(:end_balance), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_balance_investment), style: :subtotal } + items << { label: t_label(:adjustments), value: total_adjustments, tooltip: t_tooltip(:adjustments), style: :adjustment } end - items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final portfolio value for the day", style: :final } + items << { label: t_label(:final_balance), value: balance.end_balance_money, tooltip: t_tooltip(:final_balance_investment), style: :final } items end def loan_items items = [ - { label: "Start principal", value: balance.start_balance_money, tooltip: "The principal balance at the beginning of this day", style: :start }, - { label: "Net principal change", value: net_non_cash_flow, tooltip: "Principal payments and new borrowing during the day", style: :flow } + { label: t_label(:start_principal), value: balance.start_balance_money, tooltip: t_tooltip(:start_principal), style: :start }, + { label: t_label(:net_principal_change), value: net_non_cash_flow, tooltip: t_tooltip(:net_principal_change), style: :flow } ] if has_adjustments? - items << { label: "End principal", value: end_balance_before_adjustments, tooltip: "The calculated principal after all transactions", style: :subtotal } - items << { label: "Adjustments", value: balance.non_cash_adjustments_money, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + items << { label: t_label(:end_principal), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_principal), style: :subtotal } + items << { label: t_label(:adjustments), value: balance.non_cash_adjustments_money, tooltip: t_tooltip(:adjustments), style: :adjustment } end - items << { label: "Final principal", value: balance.end_balance_money, tooltip: "The final principal balance for the day", style: :final } + items << { label: t_label(:final_principal), value: balance.end_balance_money, tooltip: t_tooltip(:final_principal), style: :final } items end - def asset_items # Property/Vehicle + def asset_items items = [ - { label: "Start value", value: balance.start_balance_money, tooltip: "The asset value at the beginning of this day", style: :start }, - { label: "Net value change", value: net_total_flow, tooltip: "All value changes including improvements and depreciation", style: :flow } + { label: t_label(:start_value), value: balance.start_balance_money, tooltip: t_tooltip(:start_value), style: :start }, + { label: t_label(:net_value_change), value: net_total_flow, tooltip: t_tooltip(:net_value_change), style: :flow } ] if has_adjustments? - items << { label: "End value", value: end_balance_before_adjustments, tooltip: "The calculated value after all changes", style: :subtotal } - items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual value adjustments or appraisals", style: :adjustment } + items << { label: t_label(:end_value), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_value), style: :subtotal } + items << { label: t_label(:adjustments), value: total_adjustments, tooltip: t_tooltip(:adjustments_asset), style: :adjustment } end - items << { label: "Final value", value: balance.end_balance_money, tooltip: "The final asset value for the day", style: :final } + items << { label: t_label(:final_value), value: balance.end_balance_money, tooltip: t_tooltip(:final_value), style: :final } items end def crypto_items items = [ - { label: "Start balance", value: balance.start_balance_money, tooltip: "The crypto holdings value at the beginning of this day", style: :start } + { label: t_label(:start_balance), value: balance.start_balance_money, tooltip: t_tooltip(:start_balance_crypto), style: :start } ] - items << { label: "Buys", value: balance.cash_outflows_money * -1, tooltip: "Crypto purchases during the day", style: :flow } if balance.cash_outflows != 0 - items << { label: "Sells", value: balance.cash_inflows_money, tooltip: "Crypto sales during the day", style: :flow } if balance.cash_inflows != 0 - items << { label: "Market changes", value: balance.net_market_flows_money, tooltip: "Value changes from market price movements", style: :flow } if balance.net_market_flows != 0 + items << { label: t_label(:buys), value: balance.cash_outflows_money * -1, tooltip: t_tooltip(:buys), style: :flow } if balance.cash_outflows != 0 + items << { label: t_label(:sells), value: balance.cash_inflows_money, tooltip: t_tooltip(:sells), style: :flow } if balance.cash_inflows != 0 + items << { label: t_label(:market_changes), value: balance.net_market_flows_money, tooltip: t_tooltip(:market_changes), style: :flow } if balance.net_market_flows != 0 if has_adjustments? - items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal } - items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment } + items << { label: t_label(:end_balance), value: end_balance_before_adjustments, tooltip: t_tooltip(:end_balance_crypto), style: :subtotal } + items << { label: t_label(:adjustments), value: total_adjustments, tooltip: t_tooltip(:adjustments), style: :adjustment } end - items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final crypto holdings value for the day", style: :final } + items << { label: t_label(:final_balance), value: balance.end_balance_money, tooltip: t_tooltip(:final_balance_crypto), style: :final } items end diff --git a/app/components/UI/account/chart.html.erb b/app/components/UI/account/chart.html.erb index efcdca7d6..91fb0b69c 100644 --- a/app/components/UI/account/chart.html.erb +++ b/app/components/UI/account/chart.html.erb @@ -21,7 +21,7 @@
<% if account.investment? %> <%= form.select :chart_view, - [["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]], + [[t(".views.total_value"), "balance"], [t(".views.holdings"), "holdings_balance"], [t(".views.cash"), "cash_balance"]], { selected: view }, class: "bg-container border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0", data: { "auto-submit-form-target": "auto" } %> @@ -50,7 +50,7 @@ data-time-series-chart-data-value="<%= series.to_json %>">
<% else %>
-

No data available

+

<%= t(".no_data_available") %>

<% end %>
diff --git a/app/components/UI/account/chart.rb b/app/components/UI/account/chart.rb index 61ae4d6fc..25c5880ab 100644 --- a/app/components/UI/account/chart.rb +++ b/app/components/UI/account/chart.rb @@ -31,20 +31,22 @@ class UI::Account::Chart < ApplicationComponent when "Investment", "Crypto" case view when "balance" - "Total account value" + I18n.t("UI.account.chart.title.total_account_value") when "holdings_balance" - "Holdings value" + I18n.t("UI.account.chart.title.holdings_value") when "cash_balance" - "Cash value" + I18n.t("UI.account.chart.title.cash_value") end - when "Property", "Vehicle" - "Estimated #{account.accountable_type.humanize.downcase} value" + when "Property" + I18n.t("UI.account.chart.title.estimated_property_value") + when "Vehicle" + I18n.t("UI.account.chart.title.estimated_vehicle_value") when "CreditCard", "OtherLiability" - "Debt balance" + I18n.t("UI.account.chart.title.debt_balance") when "Loan" - "Remaining principal balance" + I18n.t("UI.account.chart.title.remaining_principal_balance") else - "Balance" + I18n.t("UI.account.chart.title.balance") end end @@ -55,7 +57,11 @@ class UI::Account::Chart < ApplicationComponent def converted_balance_money return nil unless foreign_currency? - account.balance_money.exchange_to(account.family.currency, fallback_rate: 1) + begin + account.balance_money.exchange_to(account.family.currency) + rescue Money::ConversionError + nil + end end def view @@ -75,7 +81,7 @@ class UI::Account::Chart < ApplicationComponent return period.comparison_label if start_date.blank? if start_date > period.start_date - "vs. available history" + I18n.t("UI.account.chart.vs_available_history") else period.comparison_label end diff --git a/app/components/UI/account_page.html.erb b/app/components/UI/account_page.html.erb index d1c9a5add..820d3a521 100644 --- a/app/components/UI/account_page.html.erb +++ b/app/components/UI/account_page.html.erb @@ -6,12 +6,21 @@ <%= render UI::Account::Chart.new(account: account, period: chart_period, view: chart_view) %> + <% if (fx_coverage_start = fx_coverage_start_date).present? %> +
+ <%= render DS::Alert.new( + message: t("accounts.show.limited_fx_history_warning", date: l(fx_coverage_start, format: :long)), + variant: :warning + ) %> +
+ <% end %> +
<% if tabs.count > 1 %> <%= render DS::Tabs.new(active_tab: active_tab, url_param_key: "tab") do |tabs_container| %> <% tabs_container.with_nav(classes: "max-w-fit") do |nav| %> <% tabs.each do |tab| %> - <% nav.with_btn(id: tab, label: tab.to_s.humanize, classes: "px-6") %> + <% nav.with_btn(id: tab, label: t("accounts.show.tabs.#{tab}", default: tab.to_s.humanize), classes: "px-6") %> <% end %> <% end %> diff --git a/app/components/UI/account_page.rb b/app/components/UI/account_page.rb index 69e77c516..36be3c2b2 100644 --- a/app/components/UI/account_page.rb +++ b/app/components/UI/account_page.rb @@ -1,13 +1,19 @@ class UI::AccountPage < ApplicationComponent - attr_reader :account, :chart_view, :chart_period + attr_reader :account, :chart_view, :chart_period, :statement_coverage, :statements, :reconciliation_statuses, + :can_manage_statements renders_one :activity_feed, ->(feed_data:, pagy:, search:) { UI::Account::ActivityFeed.new(feed_data: feed_data, pagy: pagy, search: search) } - def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil) + def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil, statement_coverage: nil, statements: [], + reconciliation_statuses: {}, can_manage_statements: false) @account = account @chart_view = chart_view @chart_period = chart_period @active_tab = active_tab + @statement_coverage = statement_coverage + @statements = statements + @reconciliation_statuses = reconciliation_statuses + @can_manage_statements = can_manage_statements end def id @@ -37,7 +43,7 @@ class UI::AccountPage < ApplicationComponent end def tabs - case account.accountable_type + base_tabs = case account.accountable_type when "Investment", "Crypto" [ :activity, :holdings ] when "Property", "Vehicle", "Loan" @@ -45,6 +51,25 @@ class UI::AccountPage < ApplicationComponent else [ :activity ] end + + base_tabs + [ :statements ] + end + + def fx_coverage_start_date + return @fx_coverage_start_date if defined?(@fx_coverage_start_date) + + result = nil + if account.family.present? && account.currency != account.family.currency + pair = ExchangeRatePair.for_pair(from: account.currency, to: account.family.currency) + if pair.first_provider_rate_on.present? + oldest_entry = account.entries.minimum(:date) + if oldest_entry.present? && oldest_entry < pair.first_provider_rate_on + result = pair.first_provider_rate_on + end + end + end + + @fx_coverage_start_date = result end def tab_content_for(tab) @@ -54,6 +79,32 @@ class UI::AccountPage < ApplicationComponent when :holdings, :overview # Accountable is responsible for implementing the partial in the correct folder render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account + when :statements + render_statement_tab end end + + def render_statement_tab + return render "accounts/show/statements_frame", **statement_tab_locals if statement_tab_loaded? + + turbo_frame_tag statement_tab_frame_id, src: helpers.account_path(account, tab: "statements"), loading: :lazy + end + + def statement_tab_loaded? + statement_coverage.present? + end + + def statement_tab_frame_id + dom_id(account, :statements_tab) + end + + def statement_tab_locals + { + account: account, + coverage: statement_coverage, + statements: statements, + reconciliation_statuses: reconciliation_statuses, + can_manage_statements: can_manage_statements + } + end end diff --git a/app/components/provider_sync_summary.html.erb b/app/components/provider_sync_summary.html.erb index b6156edf7..2ddab4b76 100644 --- a/app/components/provider_sync_summary.html.erb +++ b/app/components/provider_sync_summary.html.erb @@ -1,4 +1,4 @@ -
+
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> @@ -123,7 +123,7 @@ <% if error_details.any? %>
<%= t("provider_sync_summary.health.view_error_details") %> -
+
<% error_details.each do |detail| %>

<% if detail["name"].present? %><%= detail["name"] %>: <% end %><%= detail["message"] %> diff --git a/app/components/settings/provider_card.html.erb b/app/components/settings/provider_card.html.erb new file mode 100644 index 000000000..6d0210a17 --- /dev/null +++ b/app/components/settings/provider_card.html.erb @@ -0,0 +1,25 @@ +<%= link_to connect_path, + class: "bg-container shadow-border-xs hover:bg-surface-inset rounded-xl p-4 flex flex-col gap-2.5 text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300", + data: { turbo_frame: "drawer", turbo_prefetch: "false" }.merge(filter_data) do %> +

+
+ <%= logo_text %> +
+
+
+ <%= name %> + <%= render "settings/providers/maturity_badge", label: maturity_label %> +
+ <% if meta_line.present? %> +

<%= meta_line %>

+ <% end %> +
+
+ <% if tagline.present? %> +

<%= tagline %>

+ <% end %> +
+ <%= t("settings.providers.connect") %> + <%= helpers.icon "arrow-right", size: "sm", class: "!w-3.5 !h-3.5" %> +
+<% end %> diff --git a/app/components/settings/provider_card.rb b/app/components/settings/provider_card.rb new file mode 100644 index 000000000..c57657a2e --- /dev/null +++ b/app/components/settings/provider_card.rb @@ -0,0 +1,47 @@ +class Settings::ProviderCard < ApplicationComponent + MATURITY_LABELS = { + beta: "settings.providers.maturity.beta", + alpha: "settings.providers.maturity.alpha" + }.freeze + + def self.maturity_label(maturity) + key = MATURITY_LABELS[maturity&.to_sym] + I18n.t(key) if key + end + + def initialize(provider_key:, name:, tagline: nil, region: nil, kind: nil, tier: nil, + maturity: :stable, logo_bg: "bg-gray-500", logo_text: nil) + @provider_key = provider_key + @name = name + @tagline = tagline + @region = region + @kind = kind + @tier = tier + @maturity = maturity.to_sym + @logo_bg = logo_bg + @logo_text = logo_text || name.first(2).upcase + end + + attr_reader :name, :tagline, :logo_bg, :logo_text + + def maturity_label + self.class.maturity_label(@maturity) + end + + def meta_line + [ @region, @kind, @tier ].compact.join(" · ") + end + + def connect_path + helpers.connect_form_settings_providers_path(provider_key: @provider_key) + end + + def filter_data + { + providers_filter_target: "card", + provider_name: @name.to_s.downcase, + provider_region: @region.to_s.downcase, + provider_kind: @kind.to_s.downcase + } + end +end diff --git a/app/controllers/account_statements_controller.rb b/app/controllers/account_statements_controller.rb new file mode 100644 index 000000000..27793c4b6 --- /dev/null +++ b/app/controllers/account_statements_controller.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +class AccountStatementsController < ApplicationController + before_action :set_statement, only: %i[show update destroy link unlink reject] + before_action :ensure_statement_manager!, only: %i[index create update destroy link unlink reject] + + def index + accessible_account_ids = Current.user.accessible_accounts.select(:id) + account_statements = Current.family.account_statements + .with_attached_original_file + .includes(:account, :suggested_account) + .ordered + visible_storage_scope = Current.family.account_statements + .where(account_id: nil) + .or(Current.family.account_statements.where(account_id: accessible_account_ids)) + linked_statement_scope = account_statements.with_account.where(account_id: accessible_account_ids) + + @unmatched_pagy, @unmatched_statements = pagy(account_statements.unmatched, limit: safe_per_page, page_param: :unmatched_page) + @linked_pagy, @linked_statements = pagy(linked_statement_scope, limit: safe_per_page, page_param: :linked_page) + @total_storage_bytes = visible_storage_scope.sum(:byte_size) + @accounts = Current.user.accessible_accounts.visible.alphabetically + @breadcrumbs = [ + [ t("breadcrumbs.home"), root_path ], + [ t("account_statements.index.title"), account_statements_path ] + ] + render layout: "settings" + end + + def show + @accounts = Current.user.accessible_accounts.visible.alphabetically + @can_manage_statement = @statement.manageable_by?(Current.user) + @reconciliation_checks = @statement.reconciliation_checks + @breadcrumbs = [ + [ t("breadcrumbs.home"), root_path ], + [ t("account_statements.index.title"), account_statements_path ], + [ @statement.filename, nil ] + ] + render layout: "settings" + end + + def create + files = Array(statement_upload_params[:files]).reject(&:blank?).select { |file| file.respond_to?(:read) } + account = target_account + + if files.empty? + redirect_back_or_to account_statements_path, alert: t("account_statements.create.no_files") + return + end + + return if account && !require_account_permission!(account) + + created = [] + duplicates = [] + validation_errors = [] + + files.each do |file| + prepared_upload = AccountStatement.prepare_upload!(file) + created << AccountStatement.create_from_prepared_upload!(family: Current.family, account: account, prepared_upload: prepared_upload) + rescue AccountStatement::InvalidUploadError + validation_errors << t("account_statements.create.invalid_file_type") + rescue AccountStatement::DuplicateUploadError => e + duplicates << e.statement + rescue ActiveRecord::RecordInvalid => e + validation_errors << e.record.errors.full_messages.to_sentence + end + + redirect_to redirect_after_create(account, created.first || duplicates.first), + flash_for_upload(created:, duplicates:, validation_errors:) + end + + def update + return if @statement.account && !require_account_permission!(@statement.account) + + target = statement_account_id.present? ? Current.user.accessible_accounts.find(statement_account_id) : nil + return if target && !require_account_permission!(target) + + attrs = statement_params.to_h + attrs[:account] = target if statement_account_id_provided? + + @statement.assign_attributes(attrs) + @statement.assign_account_match if @statement.account.nil? && !@statement.rejected? + + if @statement.save + redirect_to account_statement_path(@statement), notice: t("account_statements.update.success") + else + @accounts = Current.user.accessible_accounts.visible.alphabetically + @can_manage_statement = @statement.manageable_by?(Current.user) + @reconciliation_checks = @statement.reconciliation_checks + flash.now[:alert] = @statement.errors.full_messages.to_sentence + render :show, status: :unprocessable_entity, layout: "settings" + end + end + + def link + return if @statement.account && !require_account_permission!(@statement.account) + + account_id = params[:account_id].presence || @statement.suggested_account_id + if account_id.blank? + redirect_to account_statement_path(@statement), alert: t("account_statements.link.no_account") + return + end + + account = Current.user.accessible_accounts.find(account_id) + return unless require_account_permission!(account) + + @statement.link_to_account!(account) + redirect_to post_link_path(@statement), notice: t("account_statements.link.success", account: account.name) + end + + def unlink + return if @statement.account && !require_account_permission!(@statement.account) + + @statement.unlink! + redirect_to account_statement_path(@statement), notice: t("account_statements.unlink.success") + end + + def reject + return if @statement.account && !require_account_permission!(@statement.account) + + @statement.reject_match! + redirect_to account_statements_path, notice: t("account_statements.reject.success") + end + + def destroy + return if @statement.account && !require_account_permission!(@statement.account) + + redirect_path = @statement.account ? account_path(@statement.account, tab: "statements") : account_statements_path + if @statement.destroy + redirect_to redirect_path, notice: t("account_statements.destroy.success") + else + redirect_back_or_to redirect_path, alert: t("account_statements.destroy.failure") + end + end + + private + + def set_statement + @statement = Current.family.account_statements + .with_attached_original_file + .includes(:account, :suggested_account) + .find(params[:id]) + + raise ActiveRecord::RecordNotFound unless @statement.viewable_by?(Current.user) + end + + def ensure_statement_manager! + return if AccountStatement.statement_manager?(Current.user) + + redirect_to accounts_path, alert: t("accounts.not_authorized") + end + + def statement_upload_params + params.fetch(:account_statement, ActionController::Parameters.new).permit(files: []) + end + + def statement_params + params.require(:account_statement).permit( + :institution_name_hint, + :account_name_hint, + :account_last4_hint, + :period_start_on, + :period_end_on, + :opening_balance, + :closing_balance, + :currency + ) + end + + def target_account + account_id = statement_account_id.presence + return nil if account_id.blank? + + Current.user.accessible_accounts.find(account_id) + end + + def statement_account_id + params.fetch(:account_statement, ActionController::Parameters.new)[:account_id] + end + + def statement_account_id_provided? + params.fetch(:account_statement, ActionController::Parameters.new).key?(:account_id) + end + + def redirect_after_create(account, statement = nil) + if account + account_path(account, tab: "statements") + elsif statement + account_statement_path(statement) + else + account_statements_path + end + end + + def post_link_path(statement) + statement.account ? account_path(statement.account, tab: "statements") : account_statement_path(statement) + end + + def flash_for_upload(created:, duplicates:, validation_errors: []) + alerts = [] + alerts << t("account_statements.create.duplicates", count: duplicates.size) if duplicates.any? + alerts.concat(validation_errors.compact_blank) + + if created.any? + flash = { notice: t("account_statements.create.success", count: created.size) } + flash[:alert] = alerts.to_sentence if alerts.any? + flash + else + { alert: alerts.to_sentence } + end + end +end diff --git a/app/controllers/accountable_sparklines_controller.rb b/app/controllers/accountable_sparklines_controller.rb index 9ba69ff56..e32500773 100644 --- a/app/controllers/accountable_sparklines_controller.rb +++ b/app/controllers/accountable_sparklines_controller.rb @@ -43,8 +43,14 @@ class AccountableSparklinesController < ApplicationController ).balance_series end + # balance_type is derived purely from accountable_type, so only Investment/Crypto + # can yield :investment. Short-circuit to avoid an N+1 `account.linked?` check + # on every account for non-investment accountable types (loan, credit_card, etc). + # The `Account.linked` scope is the SQL-level mirror of `Account#linked?`. def requires_normalized_aggregation? - accounts.any? { |account| account.linked? && account.balance_type == :investment } + return false unless %w[Investment Crypto].include?(@accountable.name) + + accounts.linked.exists? end def aggregate_normalized_series diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 9ae5767e5..2becdd3b3 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -10,6 +10,7 @@ class AccountsController < ApplicationController @manual_accounts = family.accounts .listable_manual .where(id: @accessible_account_ids) + .includes(:accountable, :account_providers, :plaid_account, :simplefin_account) .order(:name) @plaid_items = visible_provider_items(family.plaid_items.ordered.includes(:syncs, :plaid_accounts)) @simplefin_items = visible_provider_items(family.simplefin_items.ordered.includes(:syncs)) @@ -17,9 +18,12 @@ class AccountsController < ApplicationController @enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs)) @coinstats_items = visible_provider_items(family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs)) @mercury_items = visible_provider_items(family.mercury_items.ordered.includes(:syncs, :mercury_accounts)) + @brex_items = visible_provider_items(family.brex_items.ordered.includes(:accounts, :syncs, brex_accounts: :account_provider)) @coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs)) @snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts)) + @ibkr_items = visible_provider_items(family.ibkr_items.ordered.includes(:syncs, :ibkr_accounts)) @indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts)) + @sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts)) # Build sync stats maps for all providers build_sync_stats_maps @@ -45,13 +49,18 @@ class AccountsController < ApplicationController @chart_view = params[:chart_view] || "balance" @tab = params[:tab] @q = params.fetch(:q, {}).permit(:search, status: []) - entries = @account.entries.where(excluded: false).search(@q).reverse_chronological + entries = @account.entries.where(excluded: false).search(@q).reverse_chronological.includes(:entryable) + if statement_tab_active? + build_statement_tab_data + return render_statement_tab_frame if statement_tab_frame_request? + end @pagy, @entries = pagy( entries, limit: safe_per_page, params: request.query_parameters.except("tab").merge("tab" => "activity") ) + Transaction::ActivitySecurityPreloader.new(@entries).preload @activity_feed_data = Account::ActivityFeedData.new(@account, @entries) end @@ -232,6 +241,39 @@ class AccountsController < ApplicationController end end + def build_statement_tab_data + return unless statement_tab_active? + + @statement_coverage = AccountStatement::Coverage.for_year(@account, params[:statement_year]) + @account_statements = @account.account_statements.with_attached_original_file.ordered.to_a + @statement_reconciliation_statuses = AccountStatement.reconciliation_statuses_for(@account_statements, account: @account) + permission = @account.permission_for(Current.user) + @can_manage_statements = AccountStatement.statement_manager?(Current.user) && + permission.in?([ :owner, :full_control ]) + end + + def statement_tab_frame_request? + turbo_frame_request? && request.headers["Turbo-Frame"] == helpers.dom_id(@account, :statements_tab) + end + + def render_statement_tab_frame + render partial: "accounts/show/statements_frame", locals: statement_tab_locals, layout: false + end + + def statement_tab_locals + { + account: @account, + coverage: @statement_coverage, + statements: @account_statements, + reconciliation_statuses: @statement_reconciliation_statuses, + can_manage_statements: @can_manage_statements + } + end + + def statement_tab_active? + @tab == "statements" + end + # Builds sync stats maps for all provider types to avoid N+1 queries in views def build_sync_stats_maps # SimpleFIN sync stats @@ -299,6 +341,13 @@ class AccountsController < ApplicationController @coinstats_sync_stats_map[item.id] = latest_sync&.sync_stats || {} end + # Sophtron sync stats + @sophtron_sync_stats_map = {} + @sophtron_items.each do |item| + latest_sync = item.syncs.ordered.first + @sophtron_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + end + # Mercury sync stats @mercury_sync_stats_map = {} @mercury_items.each do |item| @@ -306,6 +355,27 @@ class AccountsController < ApplicationController @mercury_sync_stats_map[item.id] = latest_sync&.sync_stats || {} end + # Brex sync stats + @brex_sync_stats_map = {} + @brex_account_counts_map = {} + @brex_institutions_count_map = {} + @brex_items.each do |item| + latest_sync = item.syncs.ordered.first + @brex_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + brex_accounts = item.brex_accounts.to_a + linked_count = brex_accounts.count { |brex_account| brex_account.account_provider.present? } + total_count = brex_accounts.count + @brex_account_counts_map[item.id] = { + linked: linked_count, + unlinked: total_count - linked_count, + total: total_count + } + @brex_institutions_count_map[item.id] = brex_accounts + .filter_map(&:institution_metadata) + .uniq { |institution| institution["name"] || institution["institution_name"] } + .count + end + # Coinbase sync stats @coinbase_sync_stats_map = {} @coinbase_unlinked_count_map = {} diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index e5e4cad3c..8b03a5f1f 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -7,53 +7,66 @@ class Api::V1::AccountsController < Api::V1::BaseController before_action :ensure_read_scope def index - # Test with Pagy pagination - family = current_resource_owner.family - accounts_query = family.accounts.accessible_by(current_resource_owner).visible.alphabetically - - # Handle pagination with Pagy - @pagy, @accounts = pagy( - accounts_query, - page: safe_page_param, - limit: safe_per_page_param - ) - @per_page = safe_per_page_param - # Rails will automatically use app/views/api/v1/accounts/index.json.jbuilder + @pagy, @accounts = pagy( + accounts_scope.alphabetically, + page: safe_page_param, + limit: @per_page + ) + render :index rescue => e - Rails.logger.error "AccountsController error: #{e.message}" + Rails.logger.error "AccountsController#index error: #{e.message}" Rails.logger.error e.backtrace.join("\n") render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error -end - - private - - def ensure_read_scope - authorize_scope!(:read) - end - - - - def safe_page_param - page = params[:page].to_i - page > 0 ? page : 1 - end - - def safe_per_page_param - per_page = params[:per_page].to_i - - # Default to 25, max 100 - case per_page - when 1..100 - per_page - else - 25 - end - end + end + + def show + unless valid_uuid?(params[:id]) + render json: { + error: "not_found", + message: "Account not found" + }, status: :not_found + return + end + + @account = accounts_scope.find(params[:id]) + + render :show + rescue ActiveRecord::RecordNotFound + render json: { + error: "not_found", + message: "Account not found" + }, status: :not_found + rescue => e + Rails.logger.error "AccountsController#show error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "An unexpected error occurred" + }, status: :internal_server_error + end + + private + + def ensure_read_scope + authorize_scope!(:read) + end + + def accounts_scope + scope = current_resource_owner.family.accounts + .accessible_by(current_resource_owner) + .includes(:accountable, account_providers: :provider) + include_disabled_accounts? ? scope : scope.visible + end + + def include_disabled_accounts? + ActiveModel::Type::Boolean.new.cast(params[:include_disabled]) + end end diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index e522fb03f..c92e80334 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -45,23 +45,28 @@ module Api user.family = family user.role = User.role_for_new_family_creator - if user.save - # Claim invite code if provided - InviteCode.claim!(params[:invite_code]) if params[:invite_code].present? - - # Create device and OAuth token - begin + # Atomic: user creation, invite-code claim, and device/token issuance + # either all commit or none do. Without this, a post-commit device + # failure (e.g., racing uniqueness) would leave the user/invite/family + # committed while the client got a 422 "Failed to register device". + token_response = nil + begin + ActiveRecord::Base.transaction do + unless user.save + render json: { errors: user.errors.full_messages }, status: :unprocessable_entity + raise ActiveRecord::Rollback + end + InviteCode.claim!(params[:invite_code]) if params[:invite_code].present? device = MobileDevice.upsert_device!(user, device_params) token_response = device.issue_token! - rescue ActiveRecord::RecordInvalid => e - render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity - return end - - render json: token_response.merge(user: mobile_user_payload(user)), status: :created - else - render json: { errors: user.errors.full_messages }, status: :unprocessable_entity + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error("[Auth] Device registration failed: #{e.class} - #{e.message}") + render json: { error: "Failed to register device" }, status: :unprocessable_entity + return end + + render json: token_response.merge(user: mobile_user_payload(user)), status: :created if token_response end def login @@ -90,7 +95,8 @@ module Api device = MobileDevice.upsert_device!(user, device_params) token_response = device.issue_token! rescue ActiveRecord::RecordInvalid => e - render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity + Rails.logger.error("[Auth] Device registration failed: #{e.message}") + render json: { error: "Failed to register device" }, status: :unprocessable_entity return end @@ -312,7 +318,20 @@ module Api return false if device.nil? required_fields = %w[device_id device_name device_type os_version app_version] - required_fields.all? { |field| device[field].present? } + return false unless required_fields.all? { |field| device[field].present? } + + # Run MobileDevice's attribute-level validations up front (e.g., + # device_type must be ios/android/web) so a misconfigured client + # is rejected BEFORE signup commits user/family/invite. Skip + # errors we can't evaluate without a user: the :user belongs_to + # presence check, and device_id uniqueness scoped to user_id + # (upsert_device! treats collisions as updates anyway). + preview = MobileDevice.new(device_params) + preview.valid? + relevant_errors = preview.errors.errors.reject do |err| + err.type == :taken || err.attribute == :user + end + relevant_errors.empty? end def device_params @@ -373,7 +392,8 @@ module Api render json: token_response.merge(user: mobile_user_payload(user)) rescue ActiveRecord::RecordInvalid => e - render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity + Rails.logger.error("[Auth] Device registration failed: #{e.message}") + render json: { error: "Failed to register device" }, status: :unprocessable_entity end def ensure_write_scope diff --git a/app/controllers/api/v1/balances_controller.rb b/app/controllers/api/v1/balances_controller.rb new file mode 100644 index 000000000..0e08d030a --- /dev/null +++ b/app/controllers/api/v1/balances_controller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Api::V1::BalancesController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope + before_action :set_balance, only: :show + helper_method :format_money, :money_to_minor_units + + def index + balances_query = apply_filters(balances_scope).order(date: :desc, created_at: :desc) + @per_page = safe_per_page_param + + @pagy, @balances = pagy( + balances_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue InvalidFilterError => e + render json: { + error: "validation_failed", + message: e.message, + errors: [ e.message ] + }, status: :unprocessable_entity + end + + def show + render :show + end + + private + + def set_balance + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @balance = balances_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def balances_scope + Balance + .joins(:account) + .where(accounts: { id: accessible_account_ids }) + .includes(:account) + end + + def accessible_account_ids + @accessible_account_ids ||= current_resource_owner.family.accounts.accessible_by(current_resource_owner).select(:id) + end + + def apply_filters(query) + if params[:account_id].present? + raise InvalidFilterError, "account_id must be a valid UUID" unless valid_uuid?(params[:account_id]) + + query = query.where(account_id: params[:account_id]) + end + + query = query.where(currency: params[:currency].to_s.upcase) if params[:currency].present? + query = query.where("balances.date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("balances.date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + query + end + + def format_money(money) + money&.format + end + + def money_to_minor_units(money) + (money.amount * money.currency.minor_unit_conversion).round(0).to_i if money + end +end diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index d768bdf3f..f1631ec1c 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -3,6 +3,14 @@ class Api::V1::BaseController < ApplicationController include Doorkeeper::Rails::Helpers + InvalidFilterError = Class.new(StandardError) + + class << self + def valid_uuid?(value) + UuidFormat.valid?(value) + end + end + # Skip regular session-based authentication for API skip_authentication @@ -30,6 +38,7 @@ class Api::V1::BaseController < ApplicationController rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found rescue_from Doorkeeper::Errors::DoorkeeperError, with: :handle_unauthorized rescue_from ActionController::ParameterMissing, with: :handle_bad_request + rescue_from InvalidFilterError, with: :handle_invalid_filter private @@ -56,7 +65,7 @@ class Api::V1::BaseController < ApplicationController # Check token validity and scope (read_write includes read access) has_sufficient_scope = access_token&.scopes&.include?("read") || access_token&.scopes&.include?("read_write") - unless access_token && !access_token.expired? && has_sufficient_scope + unless access_token&.accessible? && has_sufficient_scope render_json({ error: "unauthorized", message: "Access token is invalid, expired, or missing required scope" }, status: :unauthorized) return false end @@ -204,11 +213,41 @@ class Api::V1::BaseController < ApplicationController true end + def ensure_read_scope + authorize_scope!(:read) + end + # Consistent JSON response method def render_json(data, status: :ok) render json: data, status: status end + def valid_uuid?(value) + self.class.valid_uuid?(value) + end + + def safe_page_param + page = params[:page].to_i + page > 0 ? page : 1 + end + + def safe_per_page_param + per_page = params[:per_page].to_i + case per_page + when 1..100 then per_page + when (101..) then 100 + else 25 + end + end + + def render_validation_error(message) + render_json({ + error: "validation_failed", + message: message, + errors: [ message ] + }, status: :unprocessable_entity) + end + # Error handlers def handle_not_found(exception) Rails.logger.warn "API Record Not Found: #{exception.message}" @@ -225,6 +264,16 @@ class Api::V1::BaseController < ApplicationController render_json({ error: "bad_request", message: "Required parameters are missing or invalid" }, status: :bad_request) end + def handle_invalid_filter(exception) + render_validation_error(exception.message) + end + + def parse_date_param(key) + Date.iso8601(params[key].to_s) + rescue ArgumentError + raise InvalidFilterError, "#{key} must be an ISO 8601 date" + end + # Log API access for monitoring and debugging def log_api_access return unless current_resource_owner diff --git a/app/controllers/api/v1/budget_categories_controller.rb b/app/controllers/api/v1/budget_categories_controller.rb new file mode 100644 index 000000000..5628e749e --- /dev/null +++ b/app/controllers/api/v1/budget_categories_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class Api::V1::BudgetCategoriesController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope + before_action :set_budget_category, only: :show + + def index + budget_categories_query = apply_filters(budget_categories_scope) + .order("budgets.start_date DESC", "categories.name ASC") + @per_page = safe_per_page_param + + @pagy, @budget_categories = pagy( + budget_categories_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + end + + def show + render :show + end + + private + + def set_budget_category + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @budget_category = budget_categories_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def budget_categories_scope + BudgetCategory + .joins(:budget, :category) + .where(budgets: { family_id: current_resource_owner.family_id }) + .includes({ budget: { budget_categories: { category: :parent } } }, category: :parent) + end + + def apply_filters(query) + if params[:budget_id].present? + raise InvalidFilterError, "budget_id must be a valid UUID" unless valid_uuid?(params[:budget_id]) + + query = query.where(budget_id: params[:budget_id]) + end + + if params[:category_id].present? + raise InvalidFilterError, "category_id must be a valid UUID" unless valid_uuid?(params[:category_id]) + + query = query.where(category_id: params[:category_id]) + end + + query = query.where("budgets.start_date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("budgets.end_date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + query + end +end diff --git a/app/controllers/api/v1/budgets_controller.rb b/app/controllers/api/v1/budgets_controller.rb new file mode 100644 index 000000000..d9203faaa --- /dev/null +++ b/app/controllers/api/v1/budgets_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Api::V1::BudgetsController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope + before_action :set_budget, only: :show + + def index + budgets_query = apply_filters(budgets_scope).order(start_date: :desc) + @per_page = safe_per_page_param + + @pagy, @budgets = pagy( + budgets_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + end + + def show + render :show + end + + private + + def set_budget + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @budget = budgets_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def apply_filters(query) + query = query.where("budgets.start_date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("budgets.end_date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + query + end + + def budgets_scope + current_resource_owner.family.budgets.includes(budget_categories: :category) + end +end diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb index c810ffa25..302b97225 100644 --- a/app/controllers/api/v1/categories_controller.rb +++ b/app/controllers/api/v1/categories_controller.rb @@ -3,7 +3,8 @@ class Api::V1::CategoriesController < Api::V1::BaseController include Pagy::Backend - before_action :ensure_read_scope + before_action :ensure_read_scope, only: %i[index show] + before_action :ensure_write_scope, only: :create before_action :set_category, only: :show def index @@ -29,7 +30,7 @@ class Api::V1::CategoriesController < Api::V1::BaseController render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end @@ -41,10 +42,34 @@ class Api::V1::CategoriesController < Api::V1::BaseController render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end + def create + family = current_resource_owner.family + attrs = category_params + + if attrs[:parent_id].present? && !family.categories.exists?(id: attrs[:parent_id]) + return render json: { + error: "unprocessable_entity", + message: "Parent must be a category in your family" + }, status: :unprocessable_entity + end + + @category = family.categories.new(attrs) + @category.lucide_icon = Category.suggested_icon(@category.name) if @category.lucide_icon.blank? + + if @category.save + render :show, status: :created + else + render json: { + error: "unprocessable_entity", + message: @category.errors.full_messages.join(", ") + }, status: :unprocessable_entity + end + end + private def set_category @@ -61,6 +86,17 @@ class Api::V1::CategoriesController < Api::V1::BaseController authorize_scope!(:read) end + def ensure_write_scope + authorize_scope!(:read_write) + end + + def category_params + permitted = params.require(:category).permit(:name, :color, :icon, :parent_id) + icon = permitted.delete(:icon) + permitted[:lucide_icon] = icon if icon.present? + permitted + end + def apply_filters(query) # Filter for root categories only (no parent) if params[:roots_only].present? && ActiveModel::Type::Boolean.new.cast(params[:roots_only]) diff --git a/app/controllers/api/v1/family_exports_controller.rb b/app/controllers/api/v1/family_exports_controller.rb new file mode 100644 index 000000000..e3ad3fcfb --- /dev/null +++ b/app/controllers/api/v1/family_exports_controller.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +class Api::V1::FamilyExportsController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope, only: [ :index, :show, :download ] + before_action :ensure_write_scope, only: [ :create ] + before_action :ensure_admin + before_action :set_family_export, only: [ :show, :download ] + + def index + family_exports_query = current_resource_owner.family + .family_exports + .with_attached_export_file + .ordered + + @per_page = safe_per_page_param + @pagy, @family_exports = pagy( + family_exports_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue StandardError => e + Rails.logger.error "FamilyExportsController#index error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "An unexpected error occurred" + }, status: :internal_server_error + end + + def show + render :show + end + + def create + if unsupported_create_params? + render json: { + error: "invalid_params", + message: "Family export creation does not accept request parameters" + }, status: :unprocessable_entity + return + end + + @family_export = current_resource_owner.family.family_exports.create! + FamilyDataExportJob.perform_later(@family_export) + + render :show, status: :accepted + rescue StandardError => e + Rails.logger.error "FamilyExportsController#create error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "An unexpected error occurred" + }, status: :internal_server_error + end + + def download + unless @family_export.downloadable? + render json: { + error: "export_not_ready", + message: "Export is not ready for download" + }, status: :conflict + return + end + + redirect_to rails_blob_url(@family_export.export_file, disposition: "attachment"), allow_other_host: true + rescue StandardError => e + Rails.logger.error "FamilyExportsController#download error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "An unexpected error occurred" + }, status: :internal_server_error + end + + private + + def set_family_export + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @family_export = current_resource_owner.family.family_exports.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def ensure_write_scope + authorize_scope!(:write) + end + + def ensure_admin + return if current_resource_owner.admin? + + render json: { + error: "forbidden", + message: "Family exports require a family admin" + }, status: :forbidden + end + + def unsupported_create_params? + params.except(:controller, :action, :format).present? + end + + def safe_page_param + page = params[:page].to_i + page > 0 ? page : 1 + end + + def safe_per_page_param + per_page = params[:per_page].to_i + + case per_page + when 1..100 + per_page + else + 25 + end + end +end diff --git a/app/controllers/api/v1/family_settings_controller.rb b/app/controllers/api/v1/family_settings_controller.rb new file mode 100644 index 000000000..bf5ece2f3 --- /dev/null +++ b/app/controllers/api/v1/family_settings_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Api::V1::FamilySettingsController < Api::V1::BaseController + before_action :ensure_read_scope + + def show + @family = current_resource_owner.family + end + + private + + def ensure_read_scope + authorize_scope!(:read) + end +end diff --git a/app/controllers/api/v1/holdings_controller.rb b/app/controllers/api/v1/holdings_controller.rb index 58d094abd..7f08dfe3b 100644 --- a/app/controllers/api/v1/holdings_controller.rb +++ b/app/controllers/api/v1/holdings_controller.rb @@ -102,7 +102,7 @@ class Api::V1::HoldingsController < Api::V1::BaseController Rails.logger.error exception.backtrace.join("\n") render json: { error: "internal_server_error", - message: "Error: #{exception.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end end diff --git a/app/controllers/api/v1/imports_controller.rb b/app/controllers/api/v1/imports_controller.rb index e6b78f8bd..5f1e9cf3e 100644 --- a/app/controllers/api/v1/imports_controller.rb +++ b/app/controllers/api/v1/imports_controller.rb @@ -4,9 +4,10 @@ class Api::V1::ImportsController < Api::V1::BaseController include Pagy::Backend # Ensure proper scope authorization - before_action :ensure_read_scope, only: [ :index, :show ] + before_action :ensure_read_scope, only: [ :index, :show, :rows, :preflight ] before_action :ensure_write_scope, only: [ :create ] - before_action :set_import, only: [ :show ] + before_action :set_import_with_rows, only: [ :show ] + before_action :set_import, only: [ :rows ] def index family = current_resource_owner.family @@ -44,12 +45,29 @@ class Api::V1::ImportsController < Api::V1::BaseController render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error end + def rows + @per_page = safe_per_page_param + @pagy, @rows = pagy( + @import.rows_ordered, + page: safe_page_param, + limit: @per_page + ) + @rows.each(&:valid?) + @row_mapping_lookup = @import.mappings.includes(:mappable).index_by { |mapping| [ mapping.type, mapping.key.to_s ] } + + render :rows + rescue StandardError => e + Rails.logger.error "ImportsController#rows error: #{e.message}" + render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error + end + def create family = current_resource_owner.family # 1. Determine type and validate type = params[:type].to_s type = "TransactionImport" unless Import::TYPES.include?(type) + return create_sure_import(family) if type == "SureImport" # 2. Build the import object with permitted config attributes @import = family.imports.build(import_config_params.merge(type: type)) @@ -59,10 +77,10 @@ class Api::V1::ImportsController < Api::V1::BaseController if params[:file].present? file = params[:file] - if file.size > Import::MAX_CSV_SIZE + if file.size > Import.max_csv_size return render json: { error: "file_too_large", - message: "File is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB." + message: "File is too large. Maximum size is #{Import.max_csv_size / 1.megabyte}MB." }, status: :unprocessable_entity end @@ -75,10 +93,10 @@ class Api::V1::ImportsController < Api::V1::BaseController @import.raw_file_str = file.read elsif params[:raw_file_content].present? - if params[:raw_file_content].bytesize > Import::MAX_CSV_SIZE + if params[:raw_file_content].bytesize > Import.max_csv_size return render json: { error: "content_too_large", - message: "Content is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB." + message: "Content is too large. Maximum size is #{Import.max_csv_size / 1.megabyte}MB." }, status: :unprocessable_entity end @@ -118,11 +136,49 @@ class Api::V1::ImportsController < Api::V1::BaseController render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error end + def preflight + preflight_result = Import::Preflight.new(family: current_resource_owner.family, params: preflight_params).call + render json: preflight_result.payload, status: preflight_result.status + rescue ActiveRecord::RecordNotFound + render json: { + error: "record_not_found", + message: "The requested resource was not found" + }, status: :not_found + rescue CSV::MalformedCSVError => e + render json: { + error: "invalid_csv", + message: "CSV content could not be parsed", + errors: [ e.message ] + }, status: :unprocessable_entity + rescue StandardError => e + Rails.logger.error "ImportsController#preflight error: #{e.message}" + e.backtrace&.each { |line| Rails.logger.error line } + + render json: { + error: "internal_server_error", + message: "Error: #{e.message}" + }, status: :internal_server_error + end + private def set_import - @import = current_resource_owner.family.imports.includes(:rows).find(params[:id]) + @import = import_scope.find(params[:id]) rescue ActiveRecord::RecordNotFound + render_import_not_found + end + + def set_import_with_rows + @import = import_scope.includes(:rows).find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_import_not_found + end + + def import_scope + current_resource_owner.family.imports + end + + def render_import_not_found render json: { error: "not_found", message: "Import not found" }, status: :not_found end @@ -154,10 +210,160 @@ class Api::V1::ImportsController < Api::V1::BaseController :signage_convention, :col_sep, :amount_type_strategy, - :amount_type_inflow_value + :amount_type_inflow_value, + :rows_to_skip ) end + def preflight_params + params.permit(*Import::Preflight::PARAM_KEYS) + end + + def create_sure_import(family) + content, filename, content_type = sure_import_upload_attributes + return unless content + + begin + @import = persist_sure_import!(family, content, filename, content_type) + rescue ActiveRecord::RecordInvalid => e + render json: { + error: "validation_failed", + message: "Import could not be created", + errors: e.record&.errors&.full_messages || @import&.errors&.full_messages || [] + }, status: :unprocessable_entity + return + rescue StandardError => e + Rails.logger.error "Sure import creation failed: #{e.message}" + render json: { + error: "internal_server_error", + message: "Import could not be created" + }, status: :internal_server_error + return + end + + begin + @import.publish_later if @import.publishable? && params[:publish] == "true" + rescue Import::MaxRowCountExceededError + render json: { + error: "max_row_count_exceeded", + message: "Import was uploaded but has too many rows to publish automatically.", + import_id: @import.id + }, status: :unprocessable_entity + return + rescue StandardError => e + Rails.logger.error "Sure import publish failed for import #{@import.id}: #{e.message}" + restore_pending_sure_import_after_publish_failure + render json: { + error: "publish_failed", + message: "Import was uploaded but could not be queued for processing.", + import_id: @import.id + }, status: :internal_server_error + return + end + + render :show, status: :created + end + + def persist_sure_import!(family, content, filename, content_type) + import = nil + import = family.imports.create!(type: "SureImport") + import.ndjson_file.attach( + io: StringIO.new(content), + filename: filename, + content_type: content_type + ) + import.sync_ndjson_rows_count! + import + rescue StandardError => e + clean_up_failed_sure_import(import) + raise + end + + def restore_pending_sure_import_after_publish_failure + # Import#publish_later flips status to importing before enqueueing the job. + @import.update_column(:status, "pending") if @import&.persisted? && @import.importing? + end + + def clean_up_failed_sure_import(import) + return unless import + + begin + import.ndjson_file.purge if import.ndjson_file.attached? + rescue StandardError => e + Rails.logger.warn "Failed to purge Sure import attachment #{import.id}: #{e.message}" + ensure + import.destroy if import.persisted? + end + end + + def sure_import_upload_attributes + if params[:file].present? + sure_import_file_upload_attributes(params[:file]) + elsif params[:raw_file_content].present? + sure_import_raw_content_attributes(params[:raw_file_content].to_s) + else + render json: { + error: "missing_content", + message: "Provide a Sure NDJSON file or raw_file_content." + }, status: :unprocessable_entity + nil + end + end + + def sure_import_file_upload_attributes(file) + if file.size > SureImport.max_ndjson_size + render json: { + error: "file_too_large", + message: "File is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB." + }, status: :unprocessable_entity + return + end + + extension = File.extname(file.original_filename.to_s).downcase + unless SureImport::ALLOWED_NDJSON_CONTENT_TYPES.include?(file.content_type) || extension.in?(%w[.ndjson .json]) + render json: { + error: "invalid_file_type", + message: "Invalid file type. Please upload a Sure NDJSON file." + }, status: :unprocessable_entity + return + end + + content = file.read + sure_import_validated_attributes( + content: content, + filename: file.original_filename.presence || "sure-import.ndjson", + content_type: file.content_type.presence || "application/x-ndjson" + ) + end + + def sure_import_raw_content_attributes(content) + if content.bytesize > SureImport.max_ndjson_size + render json: { + error: "content_too_large", + message: "Content is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB." + }, status: :unprocessable_entity + return + end + + sure_import_validated_attributes( + content: content, + filename: "sure-import.ndjson", + content_type: "application/x-ndjson" + ) + end + + def sure_import_validated_attributes(content:, filename:, content_type:) + unless SureImport.valid_ndjson_first_line?(content) + render json: { + error: "invalid_ndjson", + message: "Invalid Sure NDJSON content." + }, status: :unprocessable_entity + return + end + + [ content, filename, content_type ] + end + def safe_page_param page = params[:page].to_i page > 0 ? page : 1 diff --git a/app/controllers/api/v1/provider_connections_controller.rb b/app/controllers/api/v1/provider_connections_controller.rb new file mode 100644 index 000000000..11e0c6f7b --- /dev/null +++ b/app/controllers/api/v1/provider_connections_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Api::V1::ProviderConnectionsController < Api::V1::BaseController + before_action :ensure_read_scope + + def index + @provider_connections = ProviderConnectionStatus.for_family(Current.family) + render :index + rescue StandardError => e + Rails.logger.error "ProviderConnectionsController#index error: #{e.message}" + e.backtrace&.each { |line| Rails.logger.error line } + + render_json({ + error: "internal_server_error", + message: "An unexpected error occurred" + }, status: :internal_server_error) + end + + private + + def ensure_read_scope + authorize_scope!(:read) + end +end diff --git a/app/controllers/api/v1/recurring_transactions_controller.rb b/app/controllers/api/v1/recurring_transactions_controller.rb new file mode 100644 index 000000000..9e56464c9 --- /dev/null +++ b/app/controllers/api/v1/recurring_transactions_controller.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +class Api::V1::RecurringTransactionsController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope, only: %i[index show] + before_action :ensure_write_scope, only: %i[create update destroy] + before_action :set_readable_recurring_transaction, only: :show + before_action :set_writable_recurring_transaction, only: %i[update destroy] + + def index + return render_invalid_account_filter if params[:account_id].present? && !valid_uuid?(params[:account_id]) + + @per_page = safe_per_page_param + recurring_transactions_query = read_recurring_transactions_scope + .includes(:account, :merchant) + .order(status: :asc, next_expected_date: :asc) + + recurring_transactions_query = apply_filters(recurring_transactions_query) + + @pagy, @recurring_transactions = pagy( + recurring_transactions_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue => e + Rails.logger.error "RecurringTransactionsController#index error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Internal server error" + }, status: :internal_server_error + end + + def show + render :show + rescue => e + Rails.logger.error "RecurringTransactionsController#show error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Internal server error" + }, status: :internal_server_error + end + + def create + @recurring_transaction = current_resource_owner.family.recurring_transactions.new( + recurring_transaction_create_attributes + ) + validate_create_write_params(@recurring_transaction) + + if @recurring_transaction.errors.empty? && @recurring_transaction.save + render :show, status: :created + else + render json: { + error: "validation_failed", + message: "Recurring transaction could not be created", + errors: @recurring_transaction.errors.full_messages + }, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotFound + raise + rescue ActionController::ParameterMissing, ArgumentError => e + render json: { + error: "validation_failed", + message: e.message + }, status: :unprocessable_entity + rescue ActiveRecord::RecordNotUnique + render json: { + error: "conflict", + message: "Recurring transaction already exists" + }, status: :conflict + rescue => e + Rails.logger.error "RecurringTransactionsController#create error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Internal server error" + }, status: :internal_server_error + end + + def update + @recurring_transaction.assign_attributes(recurring_transaction_update_attributes) + validate_update_write_params(@recurring_transaction) + + if @recurring_transaction.errors.empty? && @recurring_transaction.save + render :show + else + render json: { + error: "validation_failed", + message: "Recurring transaction could not be updated", + errors: @recurring_transaction.errors.full_messages + }, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotFound + raise + rescue ActionController::ParameterMissing, ArgumentError => e + render json: { + error: "validation_failed", + message: e.message + }, status: :unprocessable_entity + rescue ActiveRecord::RecordNotUnique + render json: { + error: "conflict", + message: "Recurring transaction already exists" + }, status: :conflict + rescue => e + Rails.logger.error "RecurringTransactionsController#update error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Internal server error" + }, status: :internal_server_error + end + + def destroy + @recurring_transaction.destroy! + + render json: { message: "Recurring transaction deleted successfully" }, status: :ok + rescue ActiveRecord::RecordNotFound + raise + rescue => e + Rails.logger.error "RecurringTransactionsController#destroy error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Internal server error" + }, status: :internal_server_error + end + + private + def set_readable_recurring_transaction + @recurring_transaction = find_recurring_transaction(read_recurring_transactions_scope) + end + + def set_writable_recurring_transaction + @recurring_transaction = find_recurring_transaction(write_recurring_transactions_scope) + end + + def find_recurring_transaction(scope) + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + scope.includes(:account, :merchant).find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def ensure_write_scope + authorize_scope!(:write) + end + + def read_recurring_transactions_scope + current_resource_owner.family.recurring_transactions.accessible_by(current_resource_owner) + end + + def write_recurring_transactions_scope + scope = current_resource_owner.family.recurring_transactions + writable_account_ids = current_resource_owner.family.accounts.writable_by(current_resource_owner).select(:id) + scope.where(account_id: writable_account_ids).or(scope.where(account_id: nil)) + end + + def apply_filters(query) + query = query.where(status: params[:status]) if params[:status].present? + if params[:account_id].present? + return query.none unless valid_uuid?(params[:account_id]) + + query = query.where(account_id: params[:account_id]) + end + query + end + + def recurring_transaction_create_attributes + attrs = recurring_transaction_create_params.to_h.symbolize_keys + attrs[:manual] = true if attrs[:manual].nil? + input = recurring_transaction_input + + attrs[:account] = writable_account(input[:account_id]) if input.key?(:account_id) + attrs[:merchant] = family_merchant(input[:merchant_id]) if input.key?(:merchant_id) + + attrs + end + + def recurring_transaction_update_attributes + recurring_transaction_update_params.to_h.symbolize_keys + end + + def writable_account(account_id) + return nil if account_id.blank? + raise ActiveRecord::RecordNotFound, "Account not found" unless valid_uuid?(account_id) + + current_resource_owner.family.accounts.writable_by(current_resource_owner).find_by(id: account_id) || + raise(ActiveRecord::RecordNotFound, "Account not found") + end + + def family_merchant(merchant_id) + return nil if merchant_id.blank? + raise ActiveRecord::RecordNotFound, "Merchant not found" unless valid_uuid?(merchant_id) + + current_resource_owner.family.merchants.find_by(id: merchant_id) || + raise(ActiveRecord::RecordNotFound, "Merchant not found") + end + + def validate_create_write_params(recurring_transaction) + input = recurring_transaction_input + recurring_transaction.errors.add(:last_occurrence_date, :blank) if input[:last_occurrence_date].blank? + recurring_transaction.errors.add(:next_expected_date, :blank) if input[:next_expected_date].blank? + end + + def validate_update_write_params(recurring_transaction) + input = recurring_transaction_input + if input.key?(:next_expected_date) && input[:next_expected_date].blank? + recurring_transaction.errors.add(:next_expected_date, :blank) + end + end + + def recurring_transaction_input + params.require(:recurring_transaction) + end + + def render_invalid_account_filter + render json: { + error: "validation_failed", + message: "account_id must be a valid UUID" + }, status: :unprocessable_entity + end + + def recurring_transaction_create_params + params.require(:recurring_transaction).permit( + :name, + :amount, + :currency, + :expected_day_of_month, + :last_occurrence_date, + :next_expected_date, + :status, + :occurrence_count, + :manual, + :expected_amount_min, + :expected_amount_max, + :expected_amount_avg + ) + end + + def recurring_transaction_update_params + params.require(:recurring_transaction).permit( + :status, + :expected_day_of_month, + :next_expected_date + ) + end + + def safe_page_param + page = params[:page].to_i + page > 0 ? page : 1 + end + + def safe_per_page_param + per_page = params[:per_page].to_i + case per_page + when 1..100 + per_page + else + 25 + end + end +end diff --git a/app/controllers/api/v1/rejected_transfers_controller.rb b/app/controllers/api/v1/rejected_transfers_controller.rb new file mode 100644 index 000000000..e341341bd --- /dev/null +++ b/app/controllers/api/v1/rejected_transfers_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Api::V1::RejectedTransfersController < Api::V1::BaseController + include Pagy::Backend + include Api::V1::TransferDecisionFiltering + + before_action :ensure_read_scope + before_action :set_rejected_transfer, only: :show + + def index + rejected_transfers_query = apply_transfer_decision_filters(rejected_transfers_scope).order(created_at: :desc) + @per_page = safe_per_page_param + + @pagy, @rejected_transfers = pagy( + rejected_transfers_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue Api::V1::TransferDecisionFiltering::InvalidFilterError => e + render_validation_error(e.message) + end + + def show + render :show + end + + private + + def set_rejected_transfer + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @rejected_transfer = rejected_transfers_scope.find(params[:id]) + end + + def rejected_transfers_scope + transfer_decision_scope(RejectedTransfer) + end +end diff --git a/app/controllers/api/v1/rule_runs_controller.rb b/app/controllers/api/v1/rule_runs_controller.rb new file mode 100644 index 000000000..4aeae3a62 --- /dev/null +++ b/app/controllers/api/v1/rule_runs_controller.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class Api::V1::RuleRunsController < Api::V1::BaseController + include Pagy::Backend + + STATUSES = %w[pending success failed].freeze + EXECUTION_TYPES = %w[manual scheduled].freeze + InvalidFilterError = Class.new(StandardError) + + before_action :ensure_read_scope + before_action :set_rule_run, only: :show + + def index + rule_runs_query = apply_filters(rule_runs_scope).recent + @per_page = safe_per_page_param + + @pagy, @rule_runs = pagy( + rule_runs_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue InvalidFilterError => e + render_validation_error(e.message) + end + + def show + render :show + end + + private + + def set_rule_run + raise ActiveRecord::RecordNotFound, "Rule run not found" unless valid_uuid?(params[:id]) + + @rule_run = rule_runs_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def rule_runs_scope + RuleRun + .joins(:rule) + .where(rules: { family_id: Current.family.id }) + .includes(:rule) + end + + def apply_filters(query) + if params[:rule_id].present? + raise InvalidFilterError, "rule_id must be a valid UUID" unless valid_uuid?(params[:rule_id]) + + query = query.where(rule_id: params[:rule_id]) + end + + if params[:status].present? + raise InvalidFilterError, "status must be one of: #{STATUSES.join(', ')}" unless STATUSES.include?(params[:status]) + + query = query.where(status: params[:status]) + end + + if params[:execution_type].present? + unless EXECUTION_TYPES.include?(params[:execution_type]) + raise InvalidFilterError, "execution_type must be one of: #{EXECUTION_TYPES.join(', ')}" + end + + query = query.where(execution_type: params[:execution_type]) + end + + query = query.where("rule_runs.executed_at >= ?", parse_time_param(:start_executed_at)) if params[:start_executed_at].present? + query = query.where("rule_runs.executed_at <= ?", parse_time_param(:end_executed_at)) if params[:end_executed_at].present? + query + end + + def parse_time_param(key) + Time.iso8601(params[key].to_s) + rescue ArgumentError + raise InvalidFilterError, "#{key} must be an ISO 8601 timestamp" + end +end diff --git a/app/controllers/api/v1/rules_controller.rb b/app/controllers/api/v1/rules_controller.rb new file mode 100644 index 000000000..06b9f753a --- /dev/null +++ b/app/controllers/api/v1/rules_controller.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +class Api::V1::RulesController < Api::V1::BaseController + include Pagy::Backend + + BOOLEAN_FILTERS = { + "true" => true, + "1" => true, + "false" => false, + "0" => false + }.freeze + RESOURCE_TYPES = %w[transaction].freeze + + before_action :ensure_read_scope + before_action :set_rule, only: :show + + def index + return render_invalid_resource_type_filter if invalid_resource_type_filter? + + @per_page = safe_per_page_param + rules_query = current_resource_owner.family.rules + .includes(:actions, conditions: :sub_conditions) + .order(:created_at, :id) + + rules_query = rules_query.where(resource_type: params[:resource_type]) if params[:resource_type].present? + if params[:active].present? + active = parse_boolean_filter(params[:active]) + return if performed? + + rules_query = rules_query.where(active: active) + end + + @pagy, @rules = pagy( + rules_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + end + + def show + render :show + end + + private + + def set_rule + @rule = current_resource_owner.family.rules + .includes(:actions, conditions: :sub_conditions) + .find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def parse_boolean_filter(value) + normalized = value.to_s.downcase + return BOOLEAN_FILTERS[normalized] if BOOLEAN_FILTERS.key?(normalized) + + render_validation_error("active must be one of: true, false, 1, 0") + nil + end + + def invalid_resource_type_filter? + params[:resource_type].present? && !params[:resource_type].in?(RESOURCE_TYPES) + end + + def render_invalid_resource_type_filter + render_validation_error("resource_type must be one of: #{RESOURCE_TYPES.join(", ")}") + end +end diff --git a/app/controllers/api/v1/securities_controller.rb b/app/controllers/api/v1/securities_controller.rb new file mode 100644 index 000000000..bc0715d10 --- /dev/null +++ b/app/controllers/api/v1/securities_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Api::V1::SecuritiesController < Api::V1::BaseController + include Pagy::Backend + include Api::V1::SecurityResourceFiltering + + before_action :ensure_read_scope + before_action :set_security, only: :show + + def index + securities_query = apply_filters(securities_scope).order(:ticker, :exchange_operating_mic, :name) + @per_page = safe_per_page_param + + @pagy, @securities = pagy( + securities_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue Api::V1::SecurityResourceFiltering::InvalidFilterError => e + render_validation_error(e.message) + end + + def show + render :show + end + + private + + def set_security + raise ActiveRecord::RecordNotFound, "Security not found" unless valid_uuid?(params[:id]) + + @security = securities_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def securities_scope + Security + .where(id: scoped_security_ids) + end + + def apply_filters(query) + query = query.where("LOWER(securities.ticker) = ?", params[:ticker].to_s.strip.downcase) if params[:ticker].present? + query = query.where(exchange_operating_mic: params[:exchange_operating_mic].to_s.strip.upcase) if params[:exchange_operating_mic].present? + if params[:kind].present? + invalid_filter!("kind must be one of: #{Security::KINDS.join(', ')}") unless Security::KINDS.include?(params[:kind]) + + query = query.where(kind: params[:kind]) + end + if params.key?(:offline) + offline = parse_boolean_filter_param(:offline) + query = query.where(offline: offline) + end + query + end +end diff --git a/app/controllers/api/v1/security_prices_controller.rb b/app/controllers/api/v1/security_prices_controller.rb new file mode 100644 index 000000000..5463f0f97 --- /dev/null +++ b/app/controllers/api/v1/security_prices_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class Api::V1::SecurityPricesController < Api::V1::BaseController + include Pagy::Backend + include Api::V1::SecurityResourceFiltering + + before_action :ensure_read_scope + before_action :set_security_price, only: :show + + def index + security_prices_query = apply_filters(security_prices_scope).order(date: :desc, created_at: :desc) + @per_page = safe_per_page_param + + @pagy, @security_prices = pagy( + security_prices_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue Api::V1::SecurityResourceFiltering::InvalidFilterError => e + render_validation_error(e.message) + end + + def show + render :show + end + + private + + def set_security_price + raise ActiveRecord::RecordNotFound, "Security price not found" unless valid_uuid?(params[:id]) + + @security_price = security_prices_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def security_prices_scope + Security::Price + .where(security_id: scoped_security_ids) + .includes(:security) + end + + def apply_filters(query) + if params[:security_id].present? + invalid_filter!("security_id must be a valid UUID") unless valid_uuid?(params[:security_id]) + + query = query.where(security_id: params[:security_id]) + end + + query = query.where(currency: params[:currency].to_s.strip.upcase) if params[:currency].present? + query = query.where("security_prices.date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("security_prices.date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + if params.key?(:provisional) + provisional = parse_boolean_filter_param(:provisional) + query = query.where(provisional: provisional) + end + query + end +end diff --git a/app/controllers/api/v1/sync_controller.rb b/app/controllers/api/v1/sync_controller.rb index b5f89fb60..add56fd44 100644 --- a/app/controllers/api/v1/sync_controller.rb +++ b/app/controllers/api/v1/sync_controller.rb @@ -21,7 +21,7 @@ class Api::V1::SyncController < Api::V1::BaseController render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end diff --git a/app/controllers/api/v1/syncs_controller.rb b/app/controllers/api/v1/syncs_controller.rb new file mode 100644 index 000000000..401362392 --- /dev/null +++ b/app/controllers/api/v1/syncs_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Api::V1::SyncsController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope + before_action :set_sync, only: [ :show ] + + def index + @per_page = safe_per_page_param + @pagy, @syncs = pagy( + family_syncs_query.preload(:syncable, :children).ordered, + page: safe_page_param, + limit: @per_page + ) + + render :index + end + + def latest + @sync = family_syncs_query.preload(:syncable, :children).ordered.first + return render json: { data: nil } unless @sync + + render :show + end + + def show + render :show + end + + private + + def set_sync + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @sync = family_syncs_query.preload(:syncable, :children).find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def family_syncs_query + Sync.for_family(Current.family, resource_owner: Current.user) + end +end diff --git a/app/controllers/api/v1/trades_controller.rb b/app/controllers/api/v1/trades_controller.rb index 8f442c81a..0538fe321 100644 --- a/app/controllers/api/v1/trades_controller.rb +++ b/app/controllers/api/v1/trades_controller.rb @@ -292,7 +292,7 @@ class Api::V1::TradesController < Api::V1::BaseController Rails.logger.error exception.backtrace.join("\n") render json: { error: "internal_server_error", - message: "Error: #{exception.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end diff --git a/app/controllers/api/v1/transactions_controller.rb b/app/controllers/api/v1/transactions_controller.rb index b5942b7fc..f208d83b2 100644 --- a/app/controllers/api/v1/transactions_controller.rb +++ b/app/controllers/api/v1/transactions_controller.rb @@ -10,8 +10,11 @@ class Api::V1::TransactionsController < Api::V1::BaseController def index family = current_resource_owner.family - accessible_account_ids = family.accounts.accessible_by(current_resource_owner).select(:id) - transactions_query = family.transactions.visible + accessible_account_ids = family.accounts + .accessible_by(current_resource_owner) + .where.not(status: "pending_deletion") + .select(:id) + transactions_query = family.transactions .joins(:entry).where(entries: { account_id: accessible_account_ids }) # Apply filters @@ -47,7 +50,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end @@ -61,7 +64,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end @@ -69,7 +72,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController family = current_resource_owner.family # Validate account_id is present - unless transaction_params[:account_id].present? + unless account_id_param.present? render json: { error: "validation_failed", message: "Account ID is required", @@ -78,7 +81,21 @@ class Api::V1::TransactionsController < Api::V1::BaseController return end - account = family.accounts.writable_by(current_resource_owner).find(transaction_params[:account_id]) + if idempotency_source_param.present? && idempotency_external_id.blank? + render json: { + error: "validation_failed", + message: "Source requires external_id", + errors: [ "Source requires external_id" ] + }, status: :unprocessable_entity + return + end + + account = family.accounts.writable_by(current_resource_owner).find(account_id_param) + + if idempotency_key_requested? && (existing_entry = existing_idempotent_entry(account)) + return render_existing_idempotent_entry(existing_entry) + end + @entry = account.entries.new(entry_params_for_create) if @entry.save @@ -96,13 +113,19 @@ class Api::V1::TransactionsController < Api::V1::BaseController }, status: :unprocessable_entity end + rescue ActiveRecord::RecordNotUnique + if idempotency_key_requested? && account && (existing_entry = existing_idempotent_entry(account)) + render_existing_idempotent_entry(existing_entry) + else + raise + end rescue => e Rails.logger.error "TransactionsController#create error: #{e.message}" Rails.logger.error e.backtrace.join("\n") render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end @@ -148,7 +171,7 @@ end render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end @@ -171,13 +194,15 @@ end render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end private def set_transaction + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + family = current_resource_owner.family @transaction = family.transactions .joins(entry: :account) @@ -282,11 +307,15 @@ end def transaction_params params.require(:transaction).permit( - :account_id, :date, :amount, :name, :description, :notes, :currency, + :date, :amount, :name, :description, :notes, :currency, :category_id, :merchant_id, :nature, tag_ids: [] ) end + def account_id_param + params.dig(:transaction, :account_id).presence + end + def entry_params_for_create entry_params = { name: transaction_params[:name] || transaction_params[:description], @@ -301,6 +330,10 @@ end tag_ids: transaction_params[:tag_ids] || [] } } + if idempotency_key_requested? + entry_params[:external_id] = idempotency_external_id + entry_params[:source] = idempotency_source + end entry_params.compact end @@ -339,6 +372,49 @@ end params.dig(:transaction, :nature).present? end + def idempotency_key_requested? + idempotency_external_id.present? + end + + def idempotency_external_id + idempotency_param_value(:external_id) + end + + def idempotency_source + idempotency_source_param.presence || "api" + end + + def idempotency_source_param + idempotency_param_value(:source) + end + + def idempotency_param_value(key) + value = params.dig(:transaction, key) + value.to_s.presence if value.is_a?(String) || value.is_a?(Numeric) + end + + def existing_idempotent_entry(account) + account.entries.find_by( + external_id: idempotency_external_id, + source: idempotency_source + ) + end + + def render_existing_idempotent_entry(entry) + unless entry.entryable.is_a?(Transaction) + render json: { + error: "validation_failed", + message: "External ID already exists for a non-transaction entry", + errors: [ "External ID already exists for a non-transaction entry" ] + }, status: :unprocessable_entity + return + end + + @entry = entry + @transaction = entry.transaction + render :show, status: :ok + end + def calculate_signed_amount amount = transaction_params[:amount].to_f nature = transaction_params[:nature] diff --git a/app/controllers/api/v1/transfers_controller.rb b/app/controllers/api/v1/transfers_controller.rb new file mode 100644 index 000000000..bfbfe9c68 --- /dev/null +++ b/app/controllers/api/v1/transfers_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Api::V1::TransfersController < Api::V1::BaseController + include Pagy::Backend + include Api::V1::TransferDecisionFiltering + + before_action :ensure_read_scope + before_action :set_transfer, only: :show + + def index + transfers_query = apply_transfer_decision_filters(transfers_scope, status_model: Transfer).order(created_at: :desc) + @per_page = safe_per_page_param + + @pagy, @transfers = pagy( + transfers_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue Api::V1::TransferDecisionFiltering::InvalidFilterError => e + render_validation_error(e.message) + end + + def show + render :show + end + + private + + def set_transfer + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @transfer = transfers_scope.find(params[:id]) + end + + def transfers_scope + transfer_decision_scope(Transfer) + end +end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 0a87bf43e..121fded34 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -1,12 +1,44 @@ # frozen_string_literal: true class Api::V1::UsersController < Api::V1::BaseController - before_action :ensure_write_scope - before_action :ensure_admin, only: :reset + before_action :ensure_read_scope, only: :reset_status + before_action :ensure_write_scope, except: :reset_status + before_action :ensure_admin, only: %i[reset reset_status] def reset - FamilyResetJob.perform_later(Current.family) - render json: { message: "Account reset has been initiated" } + family = current_resource_owner.family + begin + job = FamilyResetJob.perform_later(family) + rescue StandardError => e + Rails.logger.error "Failed to enqueue FamilyResetJob for family #{family.id}: #{e.message}" + + render json: { + error: "reset_enqueue_failed", + message: "Account reset could not be queued" + }, status: :internal_server_error + return + end + + render json: { + message: "Account reset has been initiated", + status: "queued", + job_id: job.job_id, + family_id: family.id, + status_url: api_v1_users_reset_status_path + } + end + + def reset_status + family = current_resource_owner.family + counts = reset_target_counts(family) + reset_complete = counts.values.sum.zero? + + render json: { + status: reset_complete ? "complete" : "data_remaining", + family_id: family.id, + reset_complete: reset_complete, + counts: counts + } end def destroy @@ -26,10 +58,26 @@ class Api::V1::UsersController < Api::V1::BaseController authorize_scope!(:write) end + def ensure_read_scope + authorize_scope!(:read) + end + def ensure_admin return true if current_resource_owner&.admin? - render_json({ error: "forbidden", message: I18n.t("users.reset.unauthorized") }, status: :forbidden) + render_json({ error: "forbidden", message: "You are not authorized to perform this action" }, status: :forbidden) false end + + def reset_target_counts(family) + { + accounts: family.accounts.count, + categories: family.categories.count, + tags: family.tags.count, + merchants: family.merchants.count, + plaid_items: family.plaid_items.count, + imports: family.imports.count, + budgets: family.budgets.count + } + end end diff --git a/app/controllers/api/v1/valuations_controller.rb b/app/controllers/api/v1/valuations_controller.rb index 633a90461..24c9cfd21 100644 --- a/app/controllers/api/v1/valuations_controller.rb +++ b/app/controllers/api/v1/valuations_controller.rb @@ -1,10 +1,48 @@ # frozen_string_literal: true class Api::V1::ValuationsController < Api::V1::BaseController - before_action :ensure_read_scope, only: [ :show ] + include Pagy::Backend + + InvalidFilterError = Class.new(StandardError) + BOOLEAN_PARAM = ActiveModel::Type::Boolean.new + + before_action :ensure_read_scope, only: [ :index, :show ] before_action :ensure_write_scope, only: [ :create, :update ] before_action :set_valuation, only: [ :show, :update ] + def index + family = current_resource_owner.family + accessible_account_ids = family.accounts.accessible_by(current_resource_owner).select(:id) + valuations_query = family.entries + .where(entryable_type: "Valuation", account_id: accessible_account_ids) + .includes(:account, :entryable) + + valuations_query = apply_filters(valuations_query).reverse_chronological + @per_page = safe_per_page_param + + @pagy, @entries = pagy( + valuations_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue InvalidFilterError => e + render json: { + error: "validation_failed", + message: e.message, + errors: [ e.message ] + }, status: :unprocessable_entity + rescue => e + Rails.logger.error "ValuationsController#index error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "An unexpected error occurred" + }, status: :internal_server_error + end + def show render :show rescue => e @@ -13,7 +51,7 @@ class Api::V1::ValuationsController < Api::V1::BaseController render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end @@ -46,11 +84,17 @@ class Api::V1::ValuationsController < Api::V1::BaseController end account = current_resource_owner.family.accounts.find(valuation_account_id) + requested_upsert = upsert_requested? + existing_write = false create_success = false error_payload = nil ActiveRecord::Base.transaction do + account.lock! if requested_upsert + existing_write = account.entries.valuations.exists?(date: valuation_params[:date]) if requested_upsert + + # upsert=true only affects response status; reconciliation owns write behavior. result = account.create_reconciliation( balance: valuation_params[:amount], date: valuation_params[:date] @@ -87,7 +131,7 @@ class Api::V1::ValuationsController < Api::V1::BaseController return end - render :show, status: :created + render :show, status: requested_upsert && existing_write ? :ok : :created rescue ActiveRecord::RecordNotFound render json: { @@ -100,7 +144,7 @@ class Api::V1::ValuationsController < Api::V1::BaseController render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end @@ -181,7 +225,7 @@ class Api::V1::ValuationsController < Api::V1::BaseController render json: { error: "internal_server_error", - message: "Error: #{e.message}" + message: "An unexpected error occurred" }, status: :internal_server_error end @@ -208,6 +252,39 @@ class Api::V1::ValuationsController < Api::V1::BaseController authorize_scope!(:write) end + def apply_filters(query) + if params[:account_id].present? + raise InvalidFilterError, "account_id must be a valid UUID" unless valid_uuid?(params[:account_id]) + + query = query.where(account_id: params[:account_id]) + end + query = query.where("entries.date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("entries.date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + query + end + + def parse_date_param(key) + Date.iso8601(params[key].to_s) + rescue ArgumentError + raise InvalidFilterError, "#{key} must be an ISO 8601 date" + end + + def safe_page_param + page = params[:page].to_i + page > 0 ? page : 1 + end + + def safe_per_page_param + per_page = params[:per_page].to_i + + case per_page + when 1..100 + per_page + else + 25 + end + end + def valuation_account_id params.dig(:valuation, :account_id) end @@ -215,4 +292,10 @@ class Api::V1::ValuationsController < Api::V1::BaseController def valuation_params params.require(:valuation).permit(:amount, :date, :notes) end + + def upsert_requested? + raw_value = params.key?(:upsert) ? params[:upsert] : params.dig(:valuation, :upsert) + + BOOLEAN_PARAM.cast(raw_value) + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7604ed654..7306d504f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,8 @@ class ApplicationController < ActionController::Base include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, - FeatureGuardable, Notifiable, SafePagination, AccountAuthorizable + FeatureGuardable, Notifiable, SafePagination, AccountAuthorizable, + PreviewGateable include Pundit::Authorization include Pagy::Backend diff --git a/app/controllers/brex_items/account_flows_controller.rb b/app/controllers/brex_items/account_flows_controller.rb new file mode 100644 index 000000000..1a240708b --- /dev/null +++ b/app/controllers/brex_items/account_flows_controller.rb @@ -0,0 +1,132 @@ +class BrexItems::AccountFlowsController < ApplicationController + before_action :require_admin! + + def preload_accounts + render json: brex_account_flow.preload_payload + end + + def select_accounts + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + result = brex_account_flow.select_accounts_result(accountable_type: @accountable_type) + + return handle_brex_selection_result(result, empty_path: new_account_path, api_return_path: @return_to) unless result.success? + + @brex_item = result.brex_item + @available_accounts = result.available_accounts + + render "brex_items/select_accounts", layout: false + end + + def link_accounts + result = brex_account_flow.link_new_accounts_result( + account_ids: params[:account_ids] || [], + accountable_type: params[:accountable_type] || "Depository" + ) + + redirect_with_navigation(result, return_to: safe_return_to_path) + end + + def select_existing_account + return redirect_to accounts_path, alert: t("brex_items.select_existing_account.no_account_specified") if params[:account_id].blank? + + @account = Current.family.accounts.find_by(id: params[:account_id]) + return redirect_to accounts_path, alert: t("brex_items.select_existing_account.no_account_specified") unless @account + + result = brex_account_flow.select_existing_account_result(account: @account) + + return handle_brex_selection_result(result, empty_path: accounts_path, api_return_path: accounts_path) unless result.success? + + @brex_item = result.brex_item + @available_accounts = result.available_accounts + @return_to = safe_return_to_path + + render "brex_items/select_existing_account", layout: false + end + + def link_existing_account + return redirect_to accounts_path, alert: t("brex_items.link_existing_account.no_account_specified") if params[:account_id].blank? + + account = Current.family.accounts.find_by(id: params[:account_id]) + return redirect_to accounts_path, alert: t("brex_items.link_existing_account.no_account_specified") unless account + + result = brex_account_flow.link_existing_account_result( + account: account, + brex_account_id: params[:brex_account_id] + ) + + redirect_with_navigation(result, return_to: safe_return_to_path) + end + + private + + def brex_account_flow + @brex_account_flow ||= BrexItem::AccountFlow.new(family: Current.family, brex_item_id: params[:brex_item_id]) + end + + def handle_brex_selection_result(result, empty_path:, api_return_path:) + case result.status + when :empty, :account_already_linked + redirect_to empty_path, alert: result.message + when :no_api_token, :select_connection + redirect_to settings_providers_path, alert: result.message + when :setup_required + if turbo_frame_request? + render partial: "brex_items/setup_required", layout: false + else + redirect_to settings_providers_path, alert: result.message + end + when :api_error, :unexpected_error + render_api_error_partial(result.message, api_return_path) + else + redirect_to settings_providers_path, alert: result.message + end + end + + def redirect_with_navigation(result, return_to:) + redirect_to navigation_path_for(result.target, return_to: return_to), result.flash_type => result.message + end + + def navigation_path_for(target, return_to:) + { + new_account: new_account_path, + settings_providers: settings_providers_path, + return_to_or_accounts: return_to || accounts_path + }.fetch(target, accounts_path) + end + + def render_api_error_partial(error_message, return_path) + render partial: "brex_items/api_error", locals: { error_message: error_message, return_path: return_path }, layout: false + end + + def safe_return_to_path + return nil if params[:return_to].blank? + + return_to = params[:return_to].to_s.strip + return nil unless return_to.start_with?("/") + + second_character = return_to[1] + return nil if second_character.blank? + return nil if second_character == "/" || second_character == "\\" + return nil if second_character.match?(/[[:space:][:cntrl:]]/) + return nil if encoded_path_separator?(return_to) + + uri = URI.parse(return_to) + + return nil if uri.scheme.present? || uri.host.present? + + return_to + rescue URI::InvalidURIError + nil + end + + def encoded_path_separator?(return_to) + encoded_second_character = return_to[1, 3] + return false unless encoded_second_character&.start_with?("%") + + decoded = URI.decode_www_form_component(encoded_second_character) + decoded == "/" || decoded == "\\" + rescue ArgumentError + false + end +end diff --git a/app/controllers/brex_items/account_setups_controller.rb b/app/controllers/brex_items/account_setups_controller.rb new file mode 100644 index 000000000..45678aef0 --- /dev/null +++ b/app/controllers/brex_items/account_setups_controller.rb @@ -0,0 +1,109 @@ +class BrexItems::AccountSetupsController < ApplicationController + before_action :require_admin! + before_action :set_brex_item + + def setup_accounts + flow = brex_account_flow + @api_error = flow.import_accounts_with_user_facing_error + @brex_accounts = flow.unlinked_brex_accounts + @account_type_options = flow.account_type_options + @displayable_account_type_options = flow.displayable_account_type_options + @subtype_options = flow.subtype_options + + render "brex_items/setup_accounts" + end + + def complete_account_setup + result = brex_account_flow.complete_setup_result( + account_types: sanitized_account_types, + account_subtypes: sanitized_account_subtypes + ) + + unless result.success? + redirect_to accounts_path, alert: result.message, status: :see_other + return + end + + flash[:notice] = result.message + + if turbo_frame_request? + render_accounts_update_after_setup + else + redirect_to accounts_path, status: :see_other + end + end + + private + + def set_brex_item + @brex_item = Current.family.brex_items.find(params[:id]) + end + + def brex_account_flow + @brex_account_flow ||= BrexItem::AccountFlow.new(family: Current.family, brex_item: @brex_item) + end + + def render_accounts_update_after_setup + @manual_accounts = Account.uncached { Current.family.accounts.visible_manual.order(:name).to_a } + @brex_items = Current.family.brex_items.ordered + + manual_accounts_stream = if @manual_accounts.any? + turbo_stream.update( + "manual-accounts", + partial: "accounts/index/manual_accounts", + locals: { accounts: @manual_accounts } + ) + else + turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts")) + end + + render turbo_stream: [ + manual_accounts_stream, + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(@brex_item), + partial: "brex_items/brex_item", + locals: { brex_item: @brex_item } + ) + ] + Array(flash_notification_stream_items) + end + + def sanitized_account_types + supported_types = Provider::BrexAdapter.supported_account_types + + setup_param_hash(:account_types, allowed_account_ids).each_with_object({}) do |(account_id, selected_type), sanitized| + next unless allowed_account_ids.include?(account_id.to_s) + + normalized_type = selected_type.to_s + sanitized[account_id.to_s] = supported_types.include?(normalized_type) ? normalized_type : "skip" + end + end + + def sanitized_account_subtypes + allowed_subtypes = (Depository::SUBTYPES.keys + CreditCard::SUBTYPES.keys).map(&:to_s) + + setup_param_hash(:account_subtypes, allowed_account_ids).each_with_object({}) do |(account_id, selected_subtype), sanitized| + next unless allowed_account_ids.include?(account_id.to_s) + next if selected_subtype.blank? + next unless allowed_subtypes.include?(selected_subtype.to_s) + + sanitized[account_id.to_s] = selected_subtype.to_s + end + end + + def setup_param_hash(key, allowed_keys) + raw_params = params.fetch(key, {}) + return {} if raw_params.blank? + + if raw_params.is_a?(ActionController::Parameters) + raw_params.permit(*allowed_keys).to_h + elsif raw_params.is_a?(Hash) + raw_params.slice(*allowed_keys) + else + {} + end + end + + def allowed_account_ids + @allowed_account_ids ||= @brex_item.brex_accounts.pluck(:id).map(&:to_s) + end +end diff --git a/app/controllers/brex_items_controller.rb b/app/controllers/brex_items_controller.rb new file mode 100644 index 000000000..551a36c47 --- /dev/null +++ b/app/controllers/brex_items_controller.rb @@ -0,0 +1,98 @@ +class BrexItemsController < ApplicationController + before_action :set_brex_item, only: [ :show, :edit, :update, :destroy, :sync ] + before_action :require_admin!, only: [ :new, :create, :edit, :update, :destroy, :sync ] + + def index + @brex_items = Current.family.brex_items.active.ordered + render layout: "settings" + end + + def show + end + + def new + @brex_item = Current.family.brex_items.build + end + + def create + @brex_item = Current.family.brex_items.build(brex_item_params) + @brex_item.name = t("brex_items.default_connection_name") if @brex_item.name.blank? + + if @brex_item.save + @brex_item.sync_later + render_provider_panel_success(t(".success")) + else + render_provider_panel_error + end + end + + def edit + end + + def update + if BrexItem::AccountFlow.update_item_with_cache_expiration(@brex_item, family: Current.family, attributes: brex_item_params) + render_provider_panel_success(t(".success")) + else + render_provider_panel_error + end + end + + def destroy + @brex_item.unlink_all!(dry_run: false) + @brex_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + @brex_item.sync_later unless @brex_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + private + + def render_provider_panel_success(message) + return redirect_to accounts_path, notice: message, status: :see_other unless turbo_frame_request? + + flash.now[:notice] = message + @brex_items = Current.family.brex_items.active.ordered.includes(:syncs, :brex_accounts) + render_brex_provider_panel(locals: { brex_items: @brex_items }, include_flash: true) + end + + def render_provider_panel_error + @error_message = @brex_item.errors.full_messages.join(", ") + return redirect_to settings_providers_path, alert: @error_message, status: :see_other unless turbo_frame_request? + + render_brex_provider_panel(locals: { error_message: @error_message }, status: :unprocessable_entity) + end + + def render_brex_provider_panel(locals:, status: :ok, include_flash: false) + streams = [ + turbo_stream.replace( + "brex-providers-panel", + partial: "settings/providers/brex_panel", + locals: locals + ) + ] + streams += flash_notification_stream_items if include_flash + render turbo_stream: streams, status: status + end + + def set_brex_item + @brex_item = Current.family.brex_items.find(params[:id]) + end + + def brex_item_params + permitted = params.require(:brex_item).permit(:name, :sync_start_date, :token, :base_url) + permitted.delete(:token) if @brex_item&.persisted? && permitted[:token].blank? + permitted[:token] = permitted[:token].to_s.strip if permitted[:token].present? + if permitted.key?(:base_url) + permitted[:base_url] = permitted[:base_url].to_s.strip + permitted[:base_url] = nil if permitted[:base_url].blank? + end + permitted + end +end diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb index 0490a5f61..65880ea05 100644 --- a/app/controllers/budget_categories_controller.rb +++ b/app/controllers/budget_categories_controller.rb @@ -7,7 +7,15 @@ class BudgetCategoriesController < ApplicationController end def show + # The aggregate `Budget#actual_spending` already excludes transactions + # whose kind is in BUDGET_EXCLUDED_KINDS (funds_movement, one_time, + # cc_payment) via IncomeStatement. The drilldown list must apply the + # same filter, otherwise a matched transfer (post-#874 the matcher + # correctly tags inflow as funds_movement and outflow per destination + # account) shows under the Uncategorized card -- or any retained + # category -- even though the aggregate ignores it. See issue #1059. @recent_transactions = @budget.transactions + .where.not(transactions: { kind: Transaction::BUDGET_EXCLUDED_KINDS }) if params[:id] == BudgetCategory.uncategorized.id @budget_category = @budget.uncategorized_budget_category diff --git a/app/controllers/budgets_controller.rb b/app/controllers/budgets_controller.rb index db04a4720..af4178411 100644 --- a/app/controllers/budgets_controller.rb +++ b/app/controllers/budgets_controller.rb @@ -7,6 +7,7 @@ class BudgetsController < ApplicationController def show @source_budget = @budget.most_recent_initialized_budget unless @budget.initialized? + @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.budgets"), nil ] ] end def edit diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 6d5e6b9fc..a38e5166c 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -58,7 +58,7 @@ class CategoriesController < ApplicationController def destroy_all Current.family.categories.destroy_all - redirect_back_or_to categories_path, notice: "All categories deleted" + redirect_back_or_to categories_path, notice: t(".success") end def bootstrap diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb index 6b8b494a7..aefb7daf4 100644 --- a/app/controllers/chats_controller.rb +++ b/app/controllers/chats_controller.rb @@ -29,7 +29,7 @@ class ChatsController < ApplicationController @chat.update!(chat_params) respond_to do |format| - format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" } + format.html { redirect_back_or_to chat_path(@chat), notice: t(".success") } format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) } end end @@ -38,12 +38,12 @@ class ChatsController < ApplicationController @chat.destroy clear_last_viewed_chat - redirect_to chats_path, notice: "Chat was successfully deleted" + redirect_to chats_path, notice: t(".notice") end def retry @chat.retry_last_message! - redirect_to chat_path(@chat, thinking: true) + redirect_to chat_path(@chat) end private diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 23fd76107..479ad9efc 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -62,8 +62,10 @@ module AccountableResource end end - # Update remaining account attributes - update_params = account_params.except(:return_to, :balance, :currency, :opening_balance_date) + # Update remaining account attributes. Note: currency is intentionally allowed + # here so all account types (depositories, credit cards, loans, etc.) can + # have their currency changed via this shared update path. + update_params = account_params.except(:return_to, :balance, :opening_balance_date) unless @account.update(update_params) @error_message = @account.errors.full_messages.join(", ") render :edit, status: :unprocessable_entity diff --git a/app/controllers/concerns/api/v1/security_resource_filtering.rb b/app/controllers/concerns/api/v1/security_resource_filtering.rb new file mode 100644 index 000000000..8f58391f1 --- /dev/null +++ b/app/controllers/concerns/api/v1/security_resource_filtering.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Api::V1::SecurityResourceFiltering + class InvalidFilterError < StandardError; end + + BOOLEAN_FILTERS = { + "true" => true, + "1" => true, + "false" => false, + "0" => false + }.freeze + + private + + def scoped_security_ids + Security + .where(id: holding_security_ids) + .or(Security.where(id: trade_security_ids)) + .distinct + .select(:id) + end + + def holding_security_ids + Holding.where(account_id: accessible_account_ids).select(:security_id) + end + + def trade_security_ids + Trade.joins(:entry).where(entries: { account_id: accessible_account_ids }).select(:security_id) + end + + def accessible_account_ids + @accessible_account_ids ||= current_resource_owner.family.accounts.visible.accessible_by(current_resource_owner).select(:id) + end + + def parse_boolean_filter_param(key) + normalized_value = params[key].to_s.strip.downcase + + invalid_filter!("#{key} must be true or false") if normalized_value.blank? + return BOOLEAN_FILTERS.fetch(normalized_value) if BOOLEAN_FILTERS.key?(normalized_value) + + invalid_filter!("#{key} must be true or false") + end + + def parse_date_param(key) + Date.iso8601(params[key].to_s) + rescue ArgumentError + invalid_filter!("#{key} must be an ISO 8601 date") + end + + def invalid_filter!(message) + raise InvalidFilterError, message + end +end diff --git a/app/controllers/concerns/api/v1/transfer_decision_filtering.rb b/app/controllers/concerns/api/v1/transfer_decision_filtering.rb new file mode 100644 index 000000000..fa2d0ac5c --- /dev/null +++ b/app/controllers/concerns/api/v1/transfer_decision_filtering.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Api::V1::TransferDecisionFiltering + extend ActiveSupport::Concern + + InvalidFilterError = Class.new(StandardError) + + private + + def transfer_decision_scope(model_class) + model_class + .where( + inflow_transaction_id: accessible_transaction_ids, + outflow_transaction_id: accessible_transaction_ids + ) + .includes( + inflow_transaction: { entry: :account }, + outflow_transaction: { entry: :account } + ) + end + + def apply_transfer_decision_filters(query, status_model: nil) + query = apply_transfer_status_filter(query, status_model) if status_model + query = apply_transfer_account_filter(query) if params[:account_id].present? + query = apply_transfer_date_filter(query) if params[:start_date].present? || params[:end_date].present? + query + end + + def accessible_transaction_ids + accessible_transactions.select(:id) + end + + def accessible_transactions + Transaction + .joins(:entry) + .where(entries: { account_id: accessible_account_ids }) + end + + def accessible_account_ids + @accessible_account_ids ||= Current.family.accounts.accessible_by(Current.user).select(:id) + end + + def apply_transfer_status_filter(query, status_model) + return query unless params[:status].present? + + unless status_model.statuses.key?(params[:status]) + invalid_filter!("status must be one of: #{status_model.statuses.keys.join(", ")}") + end + + query.where(status: params[:status]) + end + + def apply_transfer_account_filter(query) + invalid_filter!("account_id must be a valid UUID") unless valid_uuid?(params[:account_id]) + + account_transaction_ids = accessible_transaction_ids_for_account(params[:account_id]) + query + .where(inflow_transaction_id: account_transaction_ids) + .or(query.where(outflow_transaction_id: account_transaction_ids)) + end + + def apply_transfer_date_filter(query) + date_transaction_ids = transfer_date_transaction_ids + query + .where(inflow_transaction_id: date_transaction_ids) + .or(query.where(outflow_transaction_id: date_transaction_ids)) + end + + def accessible_transaction_ids_for_account(account_id) + Transaction + .joins(:entry) + .where(entries: { account_id: accessible_account_ids.where(id: account_id) }) + .select(:id) + end + + def transfer_date_transaction_ids + query = accessible_transactions + query = query.where("entries.date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("entries.date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + query.select(:id) + end + + def parse_date_param(key) + Date.iso8601(params[key].to_s) + rescue ArgumentError + invalid_filter!("#{key} must be an ISO 8601 date") + end + + def invalid_filter!(message) + raise InvalidFilterError, message + end +end diff --git a/app/controllers/concerns/breadcrumbable.rb b/app/controllers/concerns/breadcrumbable.rb index 38ebd8895..f2ff12282 100644 --- a/app/controllers/concerns/breadcrumbable.rb +++ b/app/controllers/concerns/breadcrumbable.rb @@ -2,12 +2,21 @@ module Breadcrumbable extend ActiveSupport::Concern included do - before_action :set_breadcrumbs + helper_method :breadcrumbs end private - # The default, unless specific controller or action explicitly overrides - def set_breadcrumbs - @breadcrumbs = [ [ "Home", root_path ], [ controller_name.titleize, nil ] ] + # Render-time helper so I18n.locale (set by Localize's around_action) is + # already in effect when the breadcrumb labels are translated. + # Controllers can still override by assigning @breadcrumbs in their action. + def breadcrumbs + @breadcrumbs || default_breadcrumbs + end + + def default_breadcrumbs + [ + [ I18n.t("breadcrumbs.home"), root_path ], + [ I18n.t("breadcrumbs.#{controller_name}", default: controller_name.titleize), nil ] + ] end end diff --git a/app/controllers/concerns/periodable.rb b/app/controllers/concerns/periodable.rb index 88be0f05c..bac475253 100644 --- a/app/controllers/concerns/periodable.rb +++ b/app/controllers/concerns/periodable.rb @@ -7,7 +7,12 @@ module Periodable private def set_period - period_key = params[:period] || Current.user&.default_period + if params[:period].present? + period_key = params[:period] + Current.user&.update!(default_period: period_key) if Period.valid_key?(period_key) + else + period_key = Current.user&.default_period + end @period = if period_key == "current_month" Period.current_month_for(Current.family) diff --git a/app/controllers/concerns/preview_gateable.rb b/app/controllers/concerns/preview_gateable.rb new file mode 100644 index 000000000..7ae25ce8f --- /dev/null +++ b/app/controllers/concerns/preview_gateable.rb @@ -0,0 +1,20 @@ +module PreviewGateable + extend ActiveSupport::Concern + + included do + helper_method :preview_features_enabled? + end + + def preview_features_enabled? + Current.user&.preview_features_enabled? == true + end + + # Use as a `before_action` on controllers that gate a preview feature. + # Redirects users without preview access to the dashboard with a flash + # explaining the feature is opt-in. Self-served via Settings → Preferences. + def require_preview_features! + return if preview_features_enabled? + + redirect_to root_path, alert: I18n.t("preview.not_enabled") + end +end diff --git a/app/controllers/concerns/self_hostable.rb b/app/controllers/concerns/self_hostable.rb index 3631571ae..83498269b 100644 --- a/app/controllers/concerns/self_hostable.rb +++ b/app/controllers/concerns/self_hostable.rb @@ -23,7 +23,7 @@ module SelfHostable if controller_name == "pages" && action_name == "redis_configuration_error" # If Redis is now working, redirect to home if redis_connected? - redirect_to root_path, notice: "Redis is now configured properly! You can now setup your Sure application." + redirect_to root_path, notice: t("concerns.self_hostable.redis_configured") end return diff --git a/app/controllers/concerns/webauthn_relying_party.rb b/app/controllers/concerns/webauthn_relying_party.rb new file mode 100644 index 000000000..a6dfb7905 --- /dev/null +++ b/app/controllers/concerns/webauthn_relying_party.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module WebauthnRelyingParty + extend ActiveSupport::Concern + + private + def webauthn_relying_party + webauthn_config = Rails.application.config.x.webauthn + + WebAuthn::RelyingParty.new( + name: "Sure", + id: webauthn_config.rp_id, + allowed_origins: webauthn_config.allowed_origins, + # Accept consumer passkeys/security keys without attesting device vendor + # identity; this keeps MFA registration broad for self-hosted users. + verify_attestation_statement: false + ) + end + + def webauthn_credential_payload + payload = params.require(:credential) + payload = JSON.parse(payload) if payload.is_a?(String) + + payload = payload.to_unsafe_h if payload.respond_to?(:to_unsafe_h) + raise ActionController::BadRequest, "credential must be an object" unless payload.is_a?(Hash) + + payload + rescue JSON::ParserError, TypeError, ArgumentError + raise ActionController::BadRequest, "invalid credential payload" + end +end diff --git a/app/controllers/enable_banking_items_controller.rb b/app/controllers/enable_banking_items_controller.rb index 6cb8b1fc8..0bfc0dabb 100644 --- a/app/controllers/enable_banking_items_controller.rb +++ b/app/controllers/enable_banking_items_controller.rb @@ -103,14 +103,15 @@ class EnableBankingItemsController < ApplicationController return end - # Track if this is for creating a new connection (vs re-authorizing existing) @new_connection = params[:new_connection] == "true" begin provider = @enable_banking_item.enable_banking_provider response = provider.get_aspsps(country: @enable_banking_item.country_code) - # API returns { aspsps: [...] }, extract the array - @aspsps = response[:aspsps] || response["aspsps"] || [] + raw_aspsps = response[:aspsps] || response["aspsps"] || [] + + # Sort: non-beta alphabetically, then beta alphabetically + @aspsps = raw_aspsps.map(&:with_indifferent_access).sort_by { |a| [ a[:beta] ? 1 : 0, a[:name].to_s.downcase ] } rescue Provider::EnableBanking::EnableBankingError => e Rails.logger.error "Enable Banking API error in select_bank: #{e.message}" @error_message = e.message @@ -123,14 +124,47 @@ class EnableBankingItemsController < ApplicationController # Initiate authorization for a selected bank def authorize aspsp_name = params[:aspsp_name] + psu_type = params[:psu_type].presence || "personal" unless aspsp_name.present? redirect_to settings_providers_path, alert: t(".bank_required", default: "Please select a bank.") return end + # Re-fetch ASPSP list from provider to avoid session cookie overflow. + # We do not store full ASPSP metadata in the session to stay within the 4KB limit; + # instead, we re-query the provider here for the final authorization parameters. + aspsp_data = nil + begin + provider_for_lookup = @enable_banking_item.enable_banking_provider + if provider_for_lookup + response = provider_for_lookup.get_aspsps(country: @enable_banking_item.country_code) + raw_aspsps = response[:aspsps] || response["aspsps"] || [] + found = raw_aspsps.find { |a| a[:name] == aspsp_name || a["name"] == aspsp_name } + aspsp_data = found&.with_indifferent_access + end + rescue Provider::EnableBanking::EnableBankingError => e + Rails.logger.warn "Enable Banking: could not fetch ASPSP metadata in authorize: #{e.message}" + end + + # Block DECOUPLED banks — our OAuth redirect flow doesn't support them + if aspsp_data.present? + # Adjust psu_type if the bank does not support the requested type + supported_types = Array(aspsp_data[:psu_types]).map(&:to_s) + if supported_types.any? && !supported_types.include?(psu_type) + psu_type = supported_types.first + end + + first_method = Array(aspsp_data[:auth_methods]).first + approach = first_method&.dig(:approach) || first_method&.dig("approach") + if approach == "DECOUPLED" + redirect_to settings_providers_path, alert: t(".decoupled_not_supported", + default: "This bank uses a separate device authentication method which is not yet supported. Please add this account manually.") + return + end + end + begin - # If this is a new connection request, create the item now (when user has selected a bank) target_item = if params[:new_connection] == "true" Current.family.enable_banking_items.create!( name: "Enable Banking Connection", @@ -142,10 +176,18 @@ class EnableBankingItemsController < ApplicationController @enable_banking_item end + # Capture PSU IP for use in background sync PSU headers + target_item.update(last_psu_ip: request.remote_ip) if request.remote_ip.present? + + language = I18n.locale.to_s.split("-").first + redirect_url = target_item.start_authorization( aspsp_name: aspsp_name, redirect_url: enable_banking_callback_url, - state: target_item.id + state: target_item.id, + psu_type: psu_type, + aspsp_data: aspsp_data, + language: language ) safe_redirect_to_enable_banking( @@ -156,10 +198,13 @@ class EnableBankingItemsController < ApplicationController rescue Provider::EnableBanking::EnableBankingError => e if e.message.include?("REDIRECT_URI_NOT_ALLOWED") Rails.logger.error "Enable Banking redirect URI not allowed: #{e.message}" - redirect_to settings_providers_path, alert: t(".redirect_uri_not_allowed", default: "Redirect not allowed. Configure `%{callback_url}` in your Enable Banking application settings.", callback_url: enable_banking_callback_url) + redirect_to settings_providers_path, alert: t(".redirect_uri_not_allowed", + default: "Redirect not allowed. Configure `%{callback_url}` in your Enable Banking application settings.", + callback_url: enable_banking_callback_url) else Rails.logger.error "Enable Banking authorization error: #{e.message}" - redirect_to settings_providers_path, alert: t(".authorization_failed", default: "Failed to start authorization: %{message}", message: e.message) + redirect_to settings_providers_path, alert: t(".authorization_failed", + default: "Failed to start authorization: %{message}", message: e.message) end rescue => e Rails.logger.error "Unexpected error in authorize: #{e.class}: #{e.message}" @@ -193,6 +238,9 @@ class EnableBankingItemsController < ApplicationController return end + # Refresh PSU IP on callback (user's browser is present here) + enable_banking_item.update(last_psu_ip: request.remote_ip) if request.remote_ip.present? + begin enable_banking_item.complete_authorization(code: code) @@ -219,10 +267,14 @@ class EnableBankingItemsController < ApplicationController # Re-authorize an expired session def reauthorize begin + language = I18n.locale.to_s.split("-").first + redirect_url = @enable_banking_item.start_authorization( aspsp_name: @enable_banking_item.aspsp_name, redirect_url: enable_banking_callback_url, - state: @enable_banking_item.id + state: @enable_banking_item.id, + psu_type: @enable_banking_item.psu_type || "personal", + language: language ) safe_redirect_to_enable_banking( @@ -232,7 +284,8 @@ class EnableBankingItemsController < ApplicationController ) rescue Provider::EnableBanking::EnableBankingError => e Rails.logger.error "Enable Banking reauthorization error: #{e.message}" - redirect_to settings_providers_path, alert: t(".reauthorization_failed", default: "Failed to re-authorize: %{message}", message: e.message) + redirect_to settings_providers_path, alert: t(".reauthorization_failed", + default: "Failed to re-authorize: %{message}", message: e.message) end end diff --git a/app/controllers/exchange_rates_controller.rb b/app/controllers/exchange_rates_controller.rb new file mode 100644 index 000000000..78be651b4 --- /dev/null +++ b/app/controllers/exchange_rates_controller.rb @@ -0,0 +1,36 @@ +class ExchangeRatesController < ApplicationController + def show + # Pure currency-to-currency exchange rate lookup + unless params[:from].present? && params[:to].present? + return render json: { error: "from and to currencies are required" }, status: :bad_request + end + + from_currency = params[:from].upcase + to_currency = params[:to].upcase + + # Same currency returns 1.0 + if from_currency == to_currency + return render json: { rate: 1.0, same_currency: true } + end + + # Parse date + begin + date = params[:date].present? ? Date.parse(params[:date]) : Date.current + rescue ArgumentError, TypeError + return render json: { error: "Invalid date format" }, status: :bad_request + end + + begin + rate_obj = ExchangeRate.find_or_fetch_rate(from: from_currency, to: to_currency, date: date) + rescue StandardError + return render json: { error: "Failed to fetch exchange rate" }, status: :bad_request + end + + if rate_obj.nil? + return render json: { error: "Exchange rate not found" }, status: :not_found + end + + rate_value = rate_obj.is_a?(Numeric) ? rate_obj : rate_obj.rate + render json: { rate: rate_value.to_f } + end +end diff --git a/app/controllers/family_merchants_controller.rb b/app/controllers/family_merchants_controller.rb index 04e8fc15d..db77ee413 100644 --- a/app/controllers/family_merchants_controller.rb +++ b/app/controllers/family_merchants_controller.rb @@ -2,7 +2,7 @@ class FamilyMerchantsController < ApplicationController before_action :set_merchant, only: %i[edit update destroy] def index - @breadcrumbs = [ [ "Home", root_path ], [ "Merchants", nil ] ] + @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.merchants"), nil ] ] # Show all merchants for this family @family_merchants = Current.family.merchants.alphabetically diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb index d51fbc411..45a89f938 100644 --- a/app/controllers/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -42,7 +42,7 @@ class HoldingsController < ApplicationController @holding.destroy_holding_and_entries! flash[:notice] = t(".success") else - flash[:alert] = "You cannot delete this holding" + flash[:alert] = t(".cannot_delete") end respond_to do |format| @@ -52,36 +52,44 @@ class HoldingsController < ApplicationController end def remap_security - # Combobox returns "TICKER|EXCHANGE" format - ticker, exchange = params[:security_id].to_s.split("|") + # Combobox returns "TICKER|EXCHANGE|PROVIDER" format + parsed = Security.parse_combobox_id(params[:security_id]) # Validate ticker is present (form has required: true, but can be bypassed) - if ticker.blank? - flash[:alert] = t(".security_not_found") - redirect_to account_path(@holding.account, tab: "holdings") - return - end - - new_security = Security::Resolver.new( - ticker, - exchange_operating_mic: exchange, - country_code: Current.family.country - ).resolve - - if new_security.nil? + if parsed[:ticker].blank? flash[:alert] = t(".security_not_found") redirect_to account_path(@holding.account, tab: "holdings") return end # The user explicitly selected this security from provider search results, - # so we know the provider can handle it. Bring it back online if it was - # previously marked offline (e.g. by a failed QIF import resolution). - if new_security.offline? - new_security.update!(offline: false, failed_fetch_count: 0, failed_fetch_at: nil) - end + # so we use the combobox data directly — no need to re-resolve via provider APIs. + new_security = Security.find_or_initialize_by( + ticker: parsed[:ticker], + exchange_operating_mic: parsed[:exchange_operating_mic] + ) + + # Honor the user's provider choice (validated by model inclusion check on save) + new_security.price_provider = parsed[:price_provider] if parsed[:price_provider].present? + + # Bring it online — user explicitly selected it from provider search results, + # so we know the provider can handle it. + new_security.offline = false + new_security.failed_fetch_count = 0 + new_security.failed_fetch_at = nil + + new_security.save! @holding.remap_security!(new_security) + + # Re-materialize holdings with the new security's prices. + # Reload account to avoid stale associations from remap_security!. + # The around_action :switch_timezone already sets the family timezone + # for this request, so Date.current is correct here. + account = Account.find(@holding.account_id) + strategy = account.linked? ? :reverse : :forward + Balance::Materializer.new(account, strategy: strategy, security_ids: [ new_security.id ]).materialize_balances + flash[:notice] = t(".success") respond_to do |format| diff --git a/app/controllers/ibkr_items_controller.rb b/app/controllers/ibkr_items_controller.rb new file mode 100644 index 000000000..34936995b --- /dev/null +++ b/app/controllers/ibkr_items_controller.rb @@ -0,0 +1,236 @@ +class IbkrItemsController < ApplicationController + before_action :set_ibkr_item, only: [ :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, only: [ :create, :select_accounts, :select_existing_account, :link_existing_account, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + + def create + @ibkr_item = Current.family.ibkr_items.build(ibkr_item_params) + @ibkr_item.name ||= t("ibkr_items.defaults.name") + + if @ibkr_item.save + @ibkr_item.sync_later + + if turbo_frame_request? + flash.now[:notice] = t(".success") + render turbo_stream: [ + turbo_stream.replace( + "ibkr-providers-panel", + partial: "settings/providers/ibkr_panel" + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end + else + @error_message = @ibkr_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "ibkr-providers-panel", + partial: "settings/providers/ibkr_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :see_other + end + end + end + + def update + attrs = ibkr_item_params.to_h + attrs["query_id"] = @ibkr_item.query_id if attrs["query_id"].blank? + attrs["token"] = @ibkr_item.token if attrs["token"].blank? + + if @ibkr_item.update(attrs.merge(status: :good)) + @ibkr_item.sync_later unless @ibkr_item.syncing? + + if turbo_frame_request? + flash.now[:notice] = t(".success") + render turbo_stream: [ + turbo_stream.replace( + "ibkr-providers-panel", + partial: "settings/providers/ibkr_panel" + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end + else + @error_message = @ibkr_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "ibkr-providers-panel", + partial: "settings/providers/ibkr_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :see_other + end + end + end + + def destroy + begin + @ibkr_item.unlink_all!(dry_run: false) + rescue => e + Rails.logger.warn("IBKR unlink during destroy failed: #{e.class} - #{e.message}") + end + + @ibkr_item.destroy_later + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + + def sync + @ibkr_item.sync_later unless @ibkr_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + def select_accounts + ibkr_item = current_ibkr_item + unless ibkr_item + redirect_to settings_providers_path, alert: t(".not_configured") + return + end + + redirect_to setup_accounts_ibkr_item_path(ibkr_item) + end + + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + @available_ibkr_accounts = Current.family.ibkr_items + .includes(ibkr_accounts: { account_provider: :account }) + .flat_map(&:ibkr_accounts) + .select { |ibkr_account| ibkr_account.account_provider.nil? } + .sort_by { |ibkr_account| ibkr_account.updated_at || ibkr_account.created_at } + .reverse + + render :select_existing_account, layout: false + end + + def link_existing_account + account = Current.family.accounts.find_by(id: params[:account_id]) + ibkr_account = Current.family.ibkr_items + .joins(:ibkr_accounts) + .where(ibkr_accounts: { id: params[:ibkr_account_id] }) + .first + &.ibkr_accounts + &.find_by(id: params[:ibkr_account_id]) + + if account.blank? || ibkr_account.blank? + redirect_to settings_providers_path, alert: t(".not_found") + return + end + + if account.accountable_type != "Investment" || account.account_providers.any? || account.plaid_account_id.present? || account.simplefin_account_id.present? + redirect_to account_path(account), alert: t(".only_manual_investment") + return + end + + provider = nil + + ibkr_account.with_lock do + if ibkr_account.current_account.present? + redirect_to account_path(account), alert: t(".already_linked") + return + end + + provider = ibkr_account.ensure_account_provider!(account) + end + + raise "Failed to create AccountProvider link" unless provider + + begin + IbkrAccount::Processor.new(ibkr_account.reload).process + rescue => e + Rails.logger.error("Failed to process linked IBKR account #{ibkr_account.id}: #{e.class} - #{e.message}") + end + + ibkr_account.ibkr_item.sync_later unless ibkr_account.ibkr_item.syncing? + redirect_to account_path(account), notice: t(".success"), status: :see_other + rescue => e + Rails.logger.error("Failed to link existing IBKR account: #{e.class} - #{e.message}") + redirect_to settings_providers_path, alert: t(".failed"), status: :see_other + end + + def setup_accounts + @ibkr_accounts = @ibkr_item.ibkr_accounts.includes(account_provider: :account) + @linked_accounts = @ibkr_accounts.select { |ibkr_account| ibkr_account.current_account.present? } + @unlinked_accounts = @ibkr_accounts.reject { |ibkr_account| ibkr_account.current_account.present? } + + no_accounts = @linked_accounts.blank? && @unlinked_accounts.blank? + latest_sync = @ibkr_item.syncs.ordered.first + should_sync = latest_sync.nil? || !latest_sync.completed? + + if no_accounts && !@ibkr_item.syncing? && should_sync + @ibkr_item.sync_later + end + + @linkable_accounts = Current.family.accounts + .visible + .where(accountable_type: "Investment") + .left_joins(:account_providers) + .where(account_providers: { id: nil }) + .order(:name) + + @syncing = @ibkr_item.syncing? + @waiting_for_sync = no_accounts && @syncing + @no_accounts_found = no_accounts && !@syncing && @ibkr_item.last_synced_at.present? + end + + def complete_account_setup + selected_accounts = Array(params[:account_ids]).reject(&:blank?) + created_accounts = [] + + selected_accounts.each do |ibkr_account_id| + ibkr_account = @ibkr_item.ibkr_accounts.find_by(id: ibkr_account_id) + next unless ibkr_account + + ibkr_account.with_lock do + next if ibkr_account.current_account.present? + + account = Account.create_from_ibkr_account(ibkr_account) + ibkr_account.ensure_account_provider!(account) + created_accounts << account + end + + begin + IbkrAccount::Processor.new(ibkr_account.reload).process + rescue => e + Rails.logger.error("Failed to process IBKR account #{ibkr_account.id} after setup: #{e.class} - #{e.message}") + end + end + + @ibkr_item.update!(pending_account_setup: @ibkr_item.unlinked_accounts_count.positive?) + @ibkr_item.sync_later if created_accounts.any? + + if created_accounts.any? + redirect_to accounts_path, notice: t(".success", count: created_accounts.count), status: :see_other + elsif selected_accounts.empty? + redirect_to setup_accounts_ibkr_item_path(@ibkr_item), alert: t(".none_selected"), status: :see_other + else + redirect_to setup_accounts_ibkr_item_path(@ibkr_item), alert: t(".none_created"), status: :see_other + end + end + + private + + def set_ibkr_item + @ibkr_item = Current.family.ibkr_items.find(params[:id]) + end + + def current_ibkr_item + active_items = Current.family.ibkr_items.active + + active_items.syncable.ordered.first || active_items.ordered.first + end + + def ibkr_item_params + params.require(:ibkr_item).permit(:name, :query_id, :token) + end +end diff --git a/app/controllers/import/cleans_controller.rb b/app/controllers/import/cleans_controller.rb index 7d91f2134..4b0b46c16 100644 --- a/app/controllers/import/cleans_controller.rb +++ b/app/controllers/import/cleans_controller.rb @@ -6,7 +6,7 @@ class Import::CleansController < ApplicationController def show unless @import.configured? redirect_path = @import.is_a?(PdfImport) ? import_path(@import) : import_configuration_path(@import) - return redirect_to redirect_path, alert: "Please configure your import before proceeding." + return redirect_to redirect_path, alert: t(".not_configured") end rows = @import.rows_ordered diff --git a/app/controllers/import/confirms_controller.rb b/app/controllers/import/confirms_controller.rb index 1a687d4bd..029a58469 100644 --- a/app/controllers/import/confirms_controller.rb +++ b/app/controllers/import/confirms_controller.rb @@ -8,7 +8,7 @@ class Import::ConfirmsController < ApplicationController return redirect_to import_path(@import) end - redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned? + redirect_to import_clean_path(@import), alert: t(".invalid_data") unless @import.cleaned? end private diff --git a/app/controllers/import/qif_category_selections_controller.rb b/app/controllers/import/qif_category_selections_controller.rb index 2ed7b195a..cf7538b9b 100644 --- a/app/controllers/import/qif_category_selections_controller.rb +++ b/app/controllers/import/qif_category_selections_controller.rb @@ -54,7 +54,7 @@ class Import::QifCategorySelectionsController < ApplicationController @import.sync_mappings unless format_changed end - redirect_to import_clean_path(@import), notice: "Categories and tags saved." + redirect_to import_clean_path(@import), notice: t(".success") end private diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index 0212bc850..bd08c3e2a 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -85,7 +85,7 @@ class Import::UploadsController < ApplicationController @import.sync_mappings end - redirect_to import_qif_category_selection_path(@import), notice: "QIF file uploaded successfully." + redirect_to import_qif_category_selection_path(@import), notice: t(".qif_uploaded") end def csv_str diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 4a3cc0492..94526c310 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -22,9 +22,9 @@ class ImportsController < ApplicationController def publish @import.publish_later - redirect_to import_path(@import), notice: "Your import has started in the background." + redirect_to import_path(@import), notice: t(".started") rescue Import::MaxRowCountExceededError - redirect_back_or_to import_path(@import), alert: "Your import exceeds the maximum row count of #{@import.max_row_count}." + redirect_back_or_to import_path(@import), alert: t(".max_rows_exceeded", max: @import.max_row_count) end def index @@ -33,7 +33,9 @@ class ImportsController < ApplicationController [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.imports"), imports_path ] ] - render layout: "settings" + respond_to do |format| + format.html { render layout: "settings" } + end end def new @@ -112,22 +114,22 @@ class ImportsController < ApplicationController def revert @import.revert_later - redirect_to imports_path, notice: "Import is reverting in the background." + redirect_to imports_path, notice: t(".started") end def apply_template if @import.suggested_template @import.apply_template!(@import.suggested_template) - redirect_to import_configuration_path(@import), notice: "Template applied." + redirect_to import_configuration_path(@import), notice: t(".template_applied") else - redirect_to import_configuration_path(@import), alert: "No template found, please manually configure your import." + redirect_to import_configuration_path(@import), alert: t(".no_template_found") end end def destroy @import.destroy - redirect_to imports_path, notice: "Your import has been deleted." + redirect_to imports_path, notice: t(".deleted") end private diff --git a/app/controllers/indexa_capital_items_controller.rb b/app/controllers/indexa_capital_items_controller.rb index 4b37eea8a..d5872528b 100644 --- a/app/controllers/indexa_capital_items_controller.rb +++ b/app/controllers/indexa_capital_items_controller.rb @@ -222,9 +222,10 @@ class IndexaCapitalItemsController < ApplicationController end def complete_account_setup - account_configs = params[:accounts] || {} + account_ids = Array(params[:account_ids]).reject(&:blank?) + sync_start_dates = params[:sync_start_dates] || {} - if account_configs.empty? + if account_ids.empty? redirect_to setup_accounts_indexa_capital_item_path(@indexa_capital_item), alert: t(".no_accounts") return end @@ -232,25 +233,31 @@ class IndexaCapitalItemsController < ApplicationController created_count = 0 skipped_count = 0 - account_configs.each do |indexa_capital_account_id, config| - next if config[:account_type] == "skip" - + account_ids.each do |indexa_capital_account_id| indexa_capital_account = @indexa_capital_item.indexa_capital_accounts.find_by(id: indexa_capital_account_id) next unless indexa_capital_account next if indexa_capital_account.account_provider.present? - accountable_type = infer_accountable_type(config[:account_type], config[:subtype]) - account = create_account_from_indexa_capital(indexa_capital_account, accountable_type, config) + # Parse the form-supplied date up front so a malformed value is silently + # dropped rather than aborting the loop body after the account is + # already persisted (which would mark a successfully-created account + # as "skipped"). + raw_sync_start_date = sync_start_dates[indexa_capital_account_id] + sync_start_date = (Date.parse(raw_sync_start_date.to_s) rescue nil) if raw_sync_start_date.present? - if account&.persisted? + # Wrap creation, provider link, and sync_start_date persistence in a + # single transaction so a failure in ensure_account_provider! (or the + # sync_start_date update) rolls back the Account row instead of leaving + # an orphan that is also wrongly counted as "created". + ActiveRecord::Base.transaction do + account = create_account_from_indexa_capital(indexa_capital_account, "Investment", {}) indexa_capital_account.ensure_account_provider!(account) - indexa_capital_account.update!(sync_start_date: config[:sync_start_date]) if config[:sync_start_date].present? - created_count += 1 - else - skipped_count += 1 + indexa_capital_account.update!(sync_start_date: sync_start_date) if sync_start_date end + + created_count += 1 rescue => e - Rails.logger.error "IndexaCapitalItemsController#complete_account_setup - Error: #{e.message}" + Rails.logger.error "IndexaCapitalItemsController#complete_account_setup - Error linking #{indexa_capital_account_id}: #{e.message}" skipped_count += 1 end diff --git a/app/controllers/invite_codes_controller.rb b/app/controllers/invite_codes_controller.rb index f9bcf6760..436e9a7d1 100644 --- a/app/controllers/invite_codes_controller.rb +++ b/app/controllers/invite_codes_controller.rb @@ -8,13 +8,13 @@ class InviteCodesController < ApplicationController def create InviteCode.generate! - redirect_back_or_to invite_codes_path, notice: "Code generated" + redirect_back_or_to invite_codes_path, notice: t(".success") end def destroy code = InviteCode.find(params[:id]) code.destroy - redirect_back_or_to invite_codes_path, notice: "Code deleted" + redirect_back_or_to invite_codes_path, notice: t(".success") end private diff --git a/app/controllers/kraken_items_controller.rb b/app/controllers/kraken_items_controller.rb new file mode 100644 index 000000000..6daba850f --- /dev/null +++ b/app/controllers/kraken_items_controller.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +class KrakenItemsController < ApplicationController + before_action :set_kraken_item, only: %i[update destroy sync setup_accounts complete_account_setup] + before_action :require_admin!, only: %i[create select_accounts link_accounts select_existing_account link_existing_account update destroy sync setup_accounts complete_account_setup] + + def create + @kraken_item = Current.family.kraken_items.build(kraken_item_params) + @kraken_item.name ||= t(".default_name") + + if @kraken_item.save + @kraken_item.set_kraken_institution_defaults! + @kraken_item.sync_later + render_panel_success(t(".success")) + else + render_panel_error(@kraken_item.errors.full_messages.join(", ")) + end + end + + def update + if @kraken_item.update(kraken_item_params) + render_panel_success(t(".success")) + else + render_panel_error(@kraken_item.errors.full_messages.join(", ")) + end + end + + def destroy + @kraken_item.unlink_all!(dry_run: false) + @kraken_item.destroy_later + redirect_to settings_providers_path, notice: t(".success") + end + + def sync + @kraken_item.sync_later unless @kraken_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to settings_providers_path } + format.json { head :ok } + end + end + + def select_accounts + account_flow = kraken_item_account_flow_context + kraken_item = account_flow[:kraken_item] + + unless kraken_item + redirect_to settings_providers_path, alert: kraken_item_selection_message(account_flow[:credentialed_items]) + return + end + + redirect_to setup_accounts_kraken_item_path(kraken_item, return_to: safe_return_to_path), status: :see_other + end + + def link_accounts + kraken_item = kraken_item_account_flow_context[:kraken_item] + unless kraken_item + redirect_to settings_providers_path, alert: t(".select_connection") + return + end + + redirect_to setup_accounts_kraken_item_path(kraken_item), status: :see_other + end + + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + account_flow = kraken_item_account_flow_context + @kraken_item = account_flow[:kraken_item] + + unless manual_crypto_exchange_account?(@account) + redirect_to accounts_path, alert: t("kraken_items.link_existing_account.errors.only_manual") + return + end + + unless @kraken_item + redirect_to settings_providers_path, alert: kraken_item_selection_message(account_flow[:credentialed_items]) + return + end + + @available_kraken_accounts = @kraken_item.kraken_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .order(:name) + + render :select_existing_account, layout: false + end + + def link_existing_account + @account = Current.family.accounts.find(params[:account_id]) + kraken_item = kraken_item_account_flow_context[:kraken_item] + + unless manual_crypto_exchange_account?(@account) + return redirect_or_flash_error(t(".errors.only_manual"), account_path(@account)) + end + + unless kraken_item + redirect_to settings_providers_path, alert: t(".select_connection") + return + end + + kraken_account = kraken_item.kraken_accounts.find_by(id: params[:kraken_account_id]) + unless kraken_account + return redirect_or_flash_error(t(".errors.invalid_kraken_account"), account_path(@account)) + end + if kraken_account.account_provider.present? + return redirect_or_flash_error(t(".errors.kraken_account_already_linked"), account_path(@account)) + end + + AccountProvider.create!(account: @account, provider: kraken_account) + kraken_item.sync_later + + redirect_to accounts_path, notice: t(".success") + end + + def setup_accounts + @kraken_accounts = unlinked_accounts_for(@kraken_item) + end + + def complete_account_setup + selected_accounts = Array(params[:selected_accounts]).reject(&:blank?) + created_accounts = [] + + selected_accounts.each do |kraken_account_id| + kraken_account = @kraken_item.kraken_accounts.find_by(id: kraken_account_id) + next unless kraken_account + + kraken_account.with_lock do + next if kraken_account.account_provider.present? + + account = Account.create_from_kraken_account(kraken_account) + provider_link = kraken_account.ensure_account_provider!(account) + provider_link ? created_accounts << account : account.destroy! + end + + KrakenAccount::Processor.new(kraken_account.reload).process + rescue StandardError => e + Rails.logger.error("Failed to setup account for KrakenAccount #{kraken_account_id}: #{e.message}") + end + + @kraken_item.update!(pending_account_setup: unlinked_accounts_for(@kraken_item).exists?) + @kraken_item.sync_later if created_accounts.any? + + notice = if created_accounts.any? + t(".success", count: created_accounts.count) + elsif selected_accounts.empty? + t(".none_selected") + else + t(".no_accounts") + end + + redirect_to accounts_path, notice: notice, status: :see_other + end + + private + + def set_kraken_item + @kraken_item = Current.family.kraken_items.find(params[:id]) + end + + def kraken_item_params + permitted = params.require(:kraken_item).permit(:name, :sync_start_date, :api_key, :api_secret) + if @kraken_item&.persisted? + permitted.delete(:api_key) if permitted[:api_key].blank? + permitted.delete(:api_secret) if permitted[:api_secret].blank? + end + permitted + end + + def render_panel_success(message) + if turbo_frame_request? + flash.now[:notice] = message + @kraken_items = Current.family.kraken_items.active.ordered + stream = turbo_stream.update("kraken-providers-panel", partial: "settings/providers/kraken_panel", locals: { kraken_items: @kraken_items }) + render turbo_stream: [ stream, *flash_notification_stream_items ] + else + redirect_to settings_providers_path, notice: message, status: :see_other + end + end + + def render_panel_error(message) + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "kraken-providers-panel", + partial: "settings/providers/kraken_panel", + locals: { error_message: message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: message, status: :see_other + end + end + + def kraken_item_account_flow_context + credentialed_items = Current.family.kraken_items.active.credentials_configured.ordered.select(&:credentials_configured?) + item = if params[:kraken_item_id].present? + credentialed_items.find { |candidate| candidate.id.to_s == params[:kraken_item_id].to_s } + elsif credentialed_items.one? + credentialed_items.first + end + + { kraken_item: item, credentialed_items: credentialed_items } + end + + def unlinked_accounts_for(kraken_item) + kraken_item.kraken_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).order(:name) + end + + def kraken_item_selection_message(credentialed_items) + if credentialed_items.count > 1 && params[:kraken_item_id].blank? + t("kraken_items.select_accounts.select_connection") + else + t("kraken_items.select_accounts.no_credentials_configured") + end + end + + def manual_crypto_exchange_account?(account) + account.manual_crypto_exchange? + end + + def redirect_or_flash_error(message, fallback_path) + if turbo_frame_request? + flash.now[:alert] = message + render turbo_stream: Array(flash_notification_stream_items) + else + redirect_to fallback_path, alert: message + end + end + + def safe_return_to_path + return nil if params[:return_to].blank? + + value = params[:return_to].to_s + uri = URI.parse(value) + return nil if uri.scheme.present? + return nil if uri.host.present? + return nil unless value.start_with?("/") + + value + rescue URI::InvalidURIError + nil + end +end diff --git a/app/controllers/loans_controller.rb b/app/controllers/loans_controller.rb index 961c5acf0..e6066f4ad 100644 --- a/app/controllers/loans_controller.rb +++ b/app/controllers/loans_controller.rb @@ -2,6 +2,6 @@ class LoansController < ApplicationController include AccountableResource permitted_accountable_attributes( - :id, :rate_type, :interest_rate, :term_months, :initial_balance + :id, :subtype, :rate_type, :interest_rate, :term_months, :initial_balance ) end diff --git a/app/controllers/mercury_items_controller.rb b/app/controllers/mercury_items_controller.rb index 14e34ca0f..f971caec5 100644 --- a/app/controllers/mercury_items_controller.rb +++ b/app/controllers/mercury_items_controller.rb @@ -13,13 +13,19 @@ class MercuryItemsController < ApplicationController # Preload Mercury accounts in background (async, non-blocking) def preload_accounts begin - # Check if family has credentials - unless Current.family.has_mercury_credentials? + account_flow = mercury_item_account_flow_context + mercury_item = account_flow[:mercury_item] + unless mercury_item + render json: mercury_item_selection_error_payload(account_flow[:credentialed_items]) + return + end + + unless mercury_item.credentials_configured? render json: { success: false, error: "no_credentials", has_accounts: false } return end - cache_key = "mercury_accounts_#{Current.family.id}" + cache_key = mercury_accounts_cache_key(mercury_item) # Check if already cached cached_accounts = Rails.cache.read(cache_key) @@ -29,8 +35,7 @@ class MercuryItemsController < ApplicationController return end - # Fetch from API - mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + mercury_provider = mercury_item.mercury_provider unless mercury_provider.present? render json: { success: false, error: "no_api_token", has_accounts: false } @@ -58,28 +63,21 @@ class MercuryItemsController < ApplicationController # Fetch available accounts from Mercury API and show selection UI def select_accounts begin - # Check if family has Mercury credentials configured - unless Current.family.has_mercury_credentials? - if turbo_frame_request? - # Render setup modal for turbo frame requests - render partial: "mercury_items/setup_required", layout: false - else - # Redirect for regular requests - redirect_to settings_providers_path, - alert: t(".no_credentials_configured", - default: "Please configure your Mercury API token first in Provider Settings.") - end + account_flow = mercury_item_account_flow_context + @mercury_item = account_flow[:mercury_item] + unless @mercury_item + render_mercury_item_selection_failure(credentialed_items: account_flow[:credentialed_items]) return end - cache_key = "mercury_accounts_#{Current.family.id}" + cache_key = mercury_accounts_cache_key(@mercury_item) # Try to get cached accounts first @available_accounts = Rails.cache.read(cache_key) # If not cached, fetch from API if @available_accounts.nil? - mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + mercury_provider = @mercury_item.mercury_provider unless mercury_provider.present? redirect_to settings_providers_path, alert: t(".no_api_token", @@ -95,12 +93,8 @@ class MercuryItemsController < ApplicationController Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes) end - # Filter out already linked accounts - mercury_item = Current.family.mercury_items.first - if mercury_item - linked_account_ids = mercury_item.mercury_accounts.joins(:account_provider).pluck(:account_id) - @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } - end + linked_account_ids = @mercury_item.mercury_accounts.joins(:account_provider).pluck(:account_id) + @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } @accountable_type = params[:accountable_type] || "Depository" @return_to = safe_return_to_path @@ -139,13 +133,16 @@ class MercuryItemsController < ApplicationController return end - # Create or find mercury_item for this family - mercury_item = Current.family.mercury_items.first_or_create!( - name: "Mercury Connection" - ) + account_flow = mercury_item_account_flow_context + mercury_item = account_flow[:mercury_item] + + unless mercury_item + redirect_to settings_providers_path, alert: t(".select_connection", default: "Choose a Mercury connection before linking accounts.") + return + end # Fetch account details from API - mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + mercury_provider = mercury_item.mercury_provider unless mercury_provider.present? redirect_to new_account_path, alert: t(".no_api_token") return @@ -259,29 +256,22 @@ class MercuryItemsController < ApplicationController return end - # Check if family has Mercury credentials configured - unless Current.family.has_mercury_credentials? - if turbo_frame_request? - # Render setup modal for turbo frame requests - render partial: "mercury_items/setup_required", layout: false - else - # Redirect for regular requests - redirect_to settings_providers_path, - alert: t(".no_credentials_configured", - default: "Please configure your Mercury API token first in Provider Settings.") - end + account_flow = mercury_item_account_flow_context + @mercury_item = account_flow[:mercury_item] + unless @mercury_item + render_mercury_item_selection_failure(credentialed_items: account_flow[:credentialed_items]) return end begin - cache_key = "mercury_accounts_#{Current.family.id}" + cache_key = mercury_accounts_cache_key(@mercury_item) # Try to get cached accounts first @available_accounts = Rails.cache.read(cache_key) # If not cached, fetch from API if @available_accounts.nil? - mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + mercury_provider = @mercury_item.mercury_provider unless mercury_provider.present? redirect_to settings_providers_path, alert: t(".no_api_token", @@ -302,12 +292,8 @@ class MercuryItemsController < ApplicationController return end - # Filter out already linked accounts - mercury_item = Current.family.mercury_items.first - if mercury_item - linked_account_ids = mercury_item.mercury_accounts.joins(:account_provider).pluck(:account_id) - @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } - end + linked_account_ids = @mercury_item.mercury_accounts.joins(:account_provider).pluck(:account_id) + @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } if @available_accounts.empty? redirect_to accounts_path, alert: t(".all_accounts_already_linked") @@ -343,6 +329,9 @@ class MercuryItemsController < ApplicationController return end + account_flow = mercury_item_account_flow_context + mercury_item = account_flow[:mercury_item] + @account = Current.family.accounts.find(account_id) # Check if account is already linked @@ -351,13 +340,13 @@ class MercuryItemsController < ApplicationController return end - # Create or find mercury_item for this family - mercury_item = Current.family.mercury_items.first_or_create!( - name: "Mercury Connection" - ) + unless mercury_item + redirect_to settings_providers_path, alert: t(".select_connection", default: "Choose a Mercury connection before linking accounts.") + return + end # Fetch account details from API - mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family) + mercury_provider = mercury_item.mercury_provider unless mercury_provider.present? redirect_to accounts_path, alert: t(".no_api_token") return @@ -423,7 +412,7 @@ class MercuryItemsController < ApplicationController if turbo_frame_request? flash.now[:notice] = t(".success") - @mercury_items = Current.family.mercury_items.ordered + @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) render turbo_stream: [ turbo_stream.replace( "mercury-providers-panel", @@ -454,10 +443,15 @@ class MercuryItemsController < ApplicationController end def update - if @mercury_item.update(mercury_item_params) + permitted_params = mercury_item_params + expire_accounts_cache = mercury_accounts_cache_sensitive_update?(permitted_params) + + if @mercury_item.update(permitted_params) + Rails.cache.delete(mercury_accounts_cache_key(@mercury_item)) if expire_accounts_cache + if turbo_frame_request? flash.now[:notice] = t(".success") - @mercury_items = Current.family.mercury_items.ordered + @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) render turbo_stream: [ turbo_stream.replace( "mercury-providers-panel", @@ -750,7 +744,67 @@ class MercuryItemsController < ApplicationController end def mercury_item_params - params.require(:mercury_item).permit(:name, :sync_start_date, :token, :base_url) + permitted = params.require(:mercury_item).permit(:name, :sync_start_date, :token, :base_url) + permitted.delete(:token) if @mercury_item&.persisted? && permitted[:token].blank? + permitted + end + + def mercury_items_with_credentials + Current.family.mercury_items.active.ordered.select(&:credentials_configured?) + end + + def mercury_item_account_flow_context + credentialed_items = mercury_items_with_credentials + mercury_item = nil + + if params[:mercury_item_id].present? + mercury_item = credentialed_items.find { |item| item.id.to_s == params[:mercury_item_id].to_s } + elsif credentialed_items.one? + mercury_item = credentialed_items.first + end + + { + mercury_item: mercury_item, + credentialed_items: credentialed_items + } + end + + def mercury_accounts_cache_key(mercury_item) + "mercury_accounts_#{Current.family.id}_#{mercury_item.id}" + end + + def mercury_accounts_cache_sensitive_update?(permitted_params) + permitted_params.key?(:token) || permitted_params.key?(:base_url) + end + + def mercury_item_selection_error_payload(credentialed_items) + if mercury_item_selection_required?(credentialed_items) + { + success: false, + error: "select_connection", + error_message: t(".select_connection", default: "Choose a Mercury connection before loading accounts."), + has_accounts: nil + } + else + { success: false, error: "no_credentials", has_accounts: false } + end + end + + def render_mercury_item_selection_failure(credentialed_items:) + if mercury_item_selection_required?(credentialed_items) + redirect_to settings_providers_path, + alert: t(".select_connection", default: "Choose a Mercury connection in Provider Settings.") + elsif turbo_frame_request? + render partial: "mercury_items/setup_required", layout: false + else + redirect_to settings_providers_path, + alert: t(".no_credentials_configured", + default: "Please configure your Mercury API token first in Provider Settings.") + end + end + + def mercury_item_selection_required?(credentialed_items) + credentialed_items.count > 1 && params[:mercury_item_id].blank? end # Sanitize return_to parameter to prevent XSS attacks diff --git a/app/controllers/mfa_controller.rb b/app/controllers/mfa_controller.rb index 987170a9c..51154ac51 100644 --- a/app/controllers/mfa_controller.rb +++ b/app/controllers/mfa_controller.rb @@ -1,6 +1,8 @@ class MfaController < ApplicationController + include WebauthnRelyingParty + layout :determine_layout - skip_authentication only: [ :verify, :verify_code ] + skip_authentication only: [ :verify, :verify_code, :webauthn_options, :verify_webauthn ] def new redirect_to root_path if Current.user.otp_required? @@ -9,8 +11,7 @@ class MfaController < ApplicationController def create if Current.user.verify_otp?(params[:code]) - Current.user.enable_mfa! - @backup_codes = Current.user.otp_backup_codes + @backup_codes = Current.user.enable_mfa! render :backup_codes else Current.user.disable_mfa! @@ -30,9 +31,7 @@ class MfaController < ApplicationController @user = User.find_by(id: session[:mfa_user_id]) if @user&.verify_otp?(params[:code]) - session.delete(:mfa_user_id) - @session = create_session_for(@user) - flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(@user) + complete_mfa_sign_in(@user) redirect_to root_path else flash.now[:alert] = t(".invalid_code") @@ -40,6 +39,60 @@ class MfaController < ApplicationController end end + def webauthn_options + @user = User.find_by(id: session[:mfa_user_id]) + + unless @user&.webauthn_enabled? + return render json: { error: t(".unavailable") }, status: :unprocessable_entity + end + + options = webauthn_relying_party.options_for_authentication( + allow: @user.webauthn_credentials.pluck(:credential_id), + user_verification: "preferred" + ) + session[:webauthn_authentication_challenge] = options.challenge + + render json: options + end + + def verify_webauthn + @user = User.find_by(id: session[:mfa_user_id]) + challenge = session.delete(:webauthn_authentication_challenge) + + unless @user&.webauthn_enabled? && challenge.present? + return render json: { error: t(".invalid_credential") }, status: :unprocessable_entity + end + + credential = WebAuthn::Credential.from_get( + webauthn_credential_payload, + relying_party: webauthn_relying_party + ) + stored_credential = @user.webauthn_credentials.find_by(credential_id: credential.id) + + unless stored_credential + return render json: { error: t(".invalid_credential") }, status: :unprocessable_entity + end + + stored_credential.with_lock do + credential.verify( + challenge, + public_key: stored_credential.public_key, + sign_count: stored_credential.sign_count, + user_presence: true + ) + + stored_credential.update!( + sign_count: credential.sign_count, + last_used_at: Time.current + ) + end + complete_mfa_sign_in(@user) + + render json: { redirect_url: root_path } + rescue WebAuthn::Error, ActionController::BadRequest, ActionController::ParameterMissing + render json: { error: t(".invalid_credential") }, status: :unprocessable_entity + end + def disable Current.user.disable_mfa! redirect_to settings_security_path, notice: t(".success") @@ -48,10 +101,18 @@ class MfaController < ApplicationController private def determine_layout - if action_name.in?(%w[verify verify_code]) + if action_name.in?(%w[webauthn_options verify_webauthn]) + false + elsif action_name.in?(%w[verify verify_code]) "auth" else "settings" end end + + def complete_mfa_sign_in(user) + session.delete(:mfa_user_id) + @session = create_session_for(user) + flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user) + end end diff --git a/app/controllers/oidc_accounts_controller.rb b/app/controllers/oidc_accounts_controller.rb index 25548995d..a0e31e7a4 100644 --- a/app/controllers/oidc_accounts_controller.rb +++ b/app/controllers/oidc_accounts_controller.rb @@ -7,7 +7,7 @@ class OidcAccountsController < ApplicationController @pending_auth = session[:pending_oidc_auth] if @pending_auth.nil? - redirect_to new_session_path, alert: "No pending OIDC authentication found" + redirect_to new_session_path, alert: t(".no_pending_oidc") return end @@ -26,7 +26,7 @@ class OidcAccountsController < ApplicationController @pending_auth = session[:pending_oidc_auth] if @pending_auth.nil? - redirect_to new_session_path, alert: "No pending OIDC authentication found" + redirect_to new_session_path, alert: t(".no_pending_oidc") return end @@ -75,7 +75,7 @@ class OidcAccountsController < ApplicationController @pending_auth = session[:pending_oidc_auth] if @pending_auth.nil? - redirect_to new_session_path, alert: "No pending OIDC authentication found" + redirect_to new_session_path, alert: t(".no_pending_oidc") return end @@ -91,7 +91,7 @@ class OidcAccountsController < ApplicationController @pending_auth = session[:pending_oidc_auth] if @pending_auth.nil? - redirect_to new_session_path, alert: "No pending OIDC authentication found" + redirect_to new_session_path, alert: t(".no_pending_oidc") return end @@ -104,7 +104,7 @@ class OidcAccountsController < ApplicationController # domain is not allowed, block JIT account creation—unless there's a # pending invitation for this user. unless invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email)) - redirect_to new_session_path, alert: "SSO account creation is disabled. Please contact an administrator." + redirect_to new_session_path, alert: t(".account_creation_disabled") return end @@ -164,7 +164,7 @@ class OidcAccountsController < ApplicationController elsif accept_pending_invitation_for(@user) t("invitations.accept_choice.joined_household") else - "Welcome! Your account has been created." + t(".account_created") end redirect_to root_path, notice: notice else diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index e0802c1c5..c52b7a883 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -26,11 +26,11 @@ class PagesController < ApplicationController @dashboard_sections = build_dashboard_sections - @breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ] + @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.dashboard"), nil ] ] end def intro - @breadcrumbs = [ [ "Home", chats_path ], [ "Intro", nil ] ] + @breadcrumbs = [ [ t("breadcrumbs.home"), chats_path ], [ t("breadcrumbs.intro"), nil ] ] end def update_preferences @@ -152,7 +152,7 @@ class PagesController < ApplicationController add_node = ->(unique_key, display_name, value, percentage, color) { node_indices[unique_key] ||= begin - nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color } + nodes << { id: unique_key, name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color } nodes.size - 1 end } diff --git a/app/controllers/pending_duplicate_merges_controller.rb b/app/controllers/pending_duplicate_merges_controller.rb index 80c592f3b..ec2914097 100644 --- a/app/controllers/pending_duplicate_merges_controller.rb +++ b/app/controllers/pending_duplicate_merges_controller.rb @@ -21,7 +21,7 @@ class PendingDuplicateMergesController < ApplicationController # Manually merge the pending transaction with the selected posted transaction unless merge_params[:posted_entry_id].present? - redirect_back_or_to transactions_path, alert: "Please select a posted transaction to merge with" + redirect_back_or_to transactions_path, alert: t(".no_posted_selected") return end @@ -29,7 +29,7 @@ class PendingDuplicateMergesController < ApplicationController posted_entry = find_eligible_posted_entry(merge_params[:posted_entry_id]) unless posted_entry - redirect_back_or_to transactions_path, alert: "Invalid transaction selected for merge" + redirect_back_or_to transactions_path, alert: t(".invalid_transaction") return end @@ -48,10 +48,14 @@ class PendingDuplicateMergesController < ApplicationController # Immediately merge if @transaction.merge_with_duplicate! - redirect_back_or_to transactions_path, notice: "Pending transaction merged with posted transaction" + redirect_back_or_to transactions_path, notice: t(".merge_success") else - redirect_back_or_to transactions_path, alert: "Could not merge transactions" + redirect_back_or_to transactions_path, alert: t(".merge_failed") end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed, + ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout => e + Rails.logger.error("Failed to manually merge pending transaction: #{e.message}") + redirect_back_or_to transactions_path, alert: t("transactions.merge_duplicate.failure") end private @@ -60,7 +64,7 @@ class PendingDuplicateMergesController < ApplicationController @transaction = entry.entryable unless @transaction.is_a?(Transaction) && @transaction.pending? - redirect_to transactions_path, alert: "This feature is only available for pending transactions" + redirect_to transactions_path, alert: t("pending_duplicate_merges.set_transaction.pending_only") end end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index bfe8c5bd5..08de7e0b4 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -62,7 +62,7 @@ class PlaidItemsController < ApplicationController .select { |pa| pa.account_provider.nil? && pa.account.nil? } # Not linked via new or legacy system if @available_plaid_accounts.empty? - redirect_to account_path(@account), alert: "No available Plaid accounts to link. Please connect a new Plaid account first." + redirect_to account_path(@account), alert: t(".no_available_accounts") end end @@ -72,13 +72,13 @@ class PlaidItemsController < ApplicationController # Verify the Plaid account belongs to this family's Plaid items unless Current.family.plaid_items.include?(plaid_account.plaid_item) - redirect_to account_path(@account), alert: "Invalid Plaid account selected" + redirect_to account_path(@account), alert: t(".invalid_account") return end # Verify the Plaid account is not already linked if plaid_account.account_provider.present? || plaid_account.account.present? - redirect_to account_path(@account), alert: "This Plaid account is already linked" + redirect_to account_path(@account), alert: t(".already_linked") return end @@ -88,7 +88,7 @@ class PlaidItemsController < ApplicationController provider: plaid_account ) - redirect_to accounts_path, notice: "Account successfully linked to Plaid" + redirect_to accounts_path, notice: t(".success") end private diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb index e937c4fb6..1de7a5f8c 100644 --- a/app/controllers/properties_controller.rb +++ b/app/controllers/properties_controller.rb @@ -10,7 +10,12 @@ class PropertiesController < ApplicationController def create @account = Current.family.accounts.create!( - property_params.merge(currency: Current.family.currency, balance: 0, status: "draft", owner: Current.user) + property_params.merge( + balance: 0, + status: "draft", + owner: Current.user, + currency: property_params[:currency].presence || Current.family.currency + ) ) @account.auto_share_with_family! if Current.family.share_all_by_default? @@ -39,9 +44,14 @@ class PropertiesController < ApplicationController end def update_balances - result = @account.set_current_balance(balance_params[:balance].to_d) + result = nil + Account.transaction do + @account.update!(currency: balance_params[:currency]) if balance_params[:currency].present? + result = @account.set_current_balance(balance_params[:balance].to_d) + raise ActiveRecord::Rollback unless result.success? + end - if result.success? + if result&.success? @success_message = "Balance updated successfully." if @account.active? @@ -50,7 +60,7 @@ class PropertiesController < ApplicationController redirect_to address_property_path(@account) end else - @error_message = result.error_message + @error_message = result&.error_message render :balances, status: :unprocessable_entity end end @@ -93,6 +103,7 @@ class PropertiesController < ApplicationController params.require(:account) .permit( :name, + :currency, :accountable_type, :institution_name, :institution_domain, diff --git a/app/controllers/pwa_controller.rb b/app/controllers/pwa_controller.rb new file mode 100644 index 000000000..dd3f1a65f --- /dev/null +++ b/app/controllers/pwa_controller.rb @@ -0,0 +1,15 @@ +class PwaController < ApplicationController + skip_authentication + + def manifest + # Force JSON format to avoid MissingTemplate errors when browsers request /manifest + # with HTML Accept headers (Safari Mobile does this for PWA manifest discovery) + render "pwa/manifest", content_type: "application/manifest+json" + end + + def service_worker + # Explicitly render JS template to avoid format negotiation issues + render "pwa/service-worker", content_type: "application/javascript" + end + # Renders app/views/pwa/service-worker.js with content type application/javascript +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 074f46cbd..5da04aaf3 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -6,7 +6,6 @@ class RegistrationsController < ApplicationController before_action :ensure_signup_open, if: :self_hosted? before_action :set_user, only: :create before_action :set_invitation - before_action :claim_invite_code, only: :create, if: :invite_code_required? before_action :validate_password_requirements, only: :create def new @@ -29,10 +28,10 @@ class RegistrationsController < ApplicationController @user.role = User.role_for_new_family_creator end - if @user.save - @invitation&.update!(accepted_at: Time.current) - @session = create_session_for(@user) + if signup_with_invite_claim! redirect_to root_path, notice: t(".success") + elsif @invite_code_invalid + redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code") else render :new, status: :unprocessable_entity, alert: t(".failure") end @@ -55,10 +54,30 @@ class RegistrationsController < ApplicationController specific_param ? params[specific_param] : params end - def claim_invite_code - unless InviteCode.claim! params[:user][:invite_code] - redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code") + # Keep save+claim atomic so failed signups never burn valid invite codes. + def signup_with_invite_claim! + invite_code = user_params[:invite_code] + @invite_code_invalid = invite_code_required? && invite_code.blank? + return false if @invite_code_invalid + + success = false + + ActiveRecord::Base.transaction do + unless @user.save + raise ActiveRecord::Rollback + end + + if invite_code_required? && !InviteCode.claim!(invite_code) + @invite_code_invalid = true + raise ActiveRecord::Rollback + end + + @invitation&.update!(accepted_at: Time.current) + @session = create_session_for(@user) + success = true end + + success end def validate_password_requirements diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 47202ec48..26a03f23d 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -12,7 +12,7 @@ class ReportsController < ApplicationController # Build reports sections for collapsible/reorderable UI @reports_sections = build_reports_sections - @breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ] + @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.reports"), nil ] ] end def print @@ -88,6 +88,15 @@ class ReportsController < ApplicationController # It will render *inside* the modal frame. end + def picker + @period_type = params[:period_type]&.to_sym || :monthly + @start_date = parse_date_param(:start_date) || Date.current.beginning_of_month + render partial: "reports/period_picker", locals: { + period_type: @period_type, + start_date: @start_date + } + end + private def setup_report_data(show_flash: false) @period_type = params[:period_type]&.to_sym || :monthly @@ -128,6 +137,9 @@ class ReportsController < ApplicationController # Flags for view rendering @has_accounts = accessible_accounts.any? + + # Build navigation links for period switching + @nav = build_period_navigation end def preferences_params @@ -388,7 +400,11 @@ class ReportsController < ApplicationController # Helper to process an entry (transaction or trade) process_entry = ->(category, entry, is_trade) do type = entry.amount > 0 ? "expense" : "income" - converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount + begin + converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency).amount + rescue Money::ConversionError + converted_amount = entry.amount.abs + end if category.nil? # Uncategorized or Other Investments (for trades) @@ -497,6 +513,32 @@ class ReportsController < ApplicationController trades_by_treatment = sell_trades.group_by { |t| t.entry.account.tax_treatment || :taxable } + # Unwrap helper: Trend#value / realized_gain_loss#value are Money objects, + # and this codebase's Money keeps the source currency through `*` and + # through `Money.new(money, _)`. Unwrapping to BigDecimal first keeps sums + # and the final Money.new(..., currency) correctly labeled in family currency. + to_numeric = ->(value) { value.is_a?(Money) ? value.amount : value } + + # Unrealized gains mark holdings to market, so convert at today's FX. + foreign_holding_currencies = current_holdings.map(&:currency).compact.uniq.reject { |c| c == currency } + holding_rates = ExchangeRate.rates_for(foreign_holding_currencies, to: currency, date: Date.current) + convert_current = ->(amount, from) { + numeric = to_numeric.call(amount) + from == currency ? numeric : numeric * (holding_rates[from] || 1) + } + + # Realized gains are locked at trade time, so convert each at its own + # entry-date FX. Mirrors InvestmentStatement::Totals, which also uses + # entry-date rates for contributions/withdrawals on this same card. + foreign_trade_currencies = sell_trades.map(&:currency).compact.uniq.reject { |c| c == currency } + rates_by_trade_date = sell_trades.map { |t| t.entry.date }.uniq.each_with_object({}) do |date, memo| + memo[date] = ExchangeRate.rates_for(foreign_trade_currencies, to: currency, date: date) + end + convert_trade = ->(amount, from, date) { + numeric = to_numeric.call(amount) + from == currency ? numeric : numeric * (rates_by_trade_date.dig(date, from) || 1) + } + # Build metrics per treatment %i[taxable tax_deferred tax_exempt tax_advantaged].each_with_object({}) do |treatment, hash| holdings = holdings_by_treatment[treatment] || [] @@ -505,13 +547,13 @@ class ReportsController < ApplicationController # Sum unrealized gains from holdings (only those with known cost basis) unrealized = holdings.sum do |h| trend = h.trend - trend ? trend.value : 0 + trend ? convert_current.call(trend.value, h.currency) : 0 end # Sum realized gains from sell trades realized = trades.sum do |t| gain = t.realized_gain_loss - gain ? gain.value : 0 + gain ? convert_trade.call(gain.value, t.currency, t.entry.date) : 0 end # Only include treatment groups that have some activity @@ -679,7 +721,11 @@ class ReportsController < ApplicationController month_key = entry.date.beginning_of_month # Convert to family currency - converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount + begin + converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency).amount + rescue Money::ConversionError + converted_amount = entry.amount.abs + end key = [ category_name, type ] breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 } @@ -1036,4 +1082,65 @@ class ReportsController < ApplicationController true end + + def build_period_navigation + # Called at the end of setup_report_data, so @start_date and @end_date are guaranteed to be set. + case @period_type + when :monthly + prev_start = @start_date.beginning_of_month - 1.month + prev_end = prev_start.end_of_month + next_start = @start_date.beginning_of_month + 1.month + next_end = next_start.end_of_month + at_latest = @start_date.beginning_of_month >= Date.current.beginning_of_month + when :quarterly + prev_start = (@start_date.beginning_of_quarter - 1.day).beginning_of_quarter + prev_end = prev_start.end_of_quarter + next_start = @end_date.end_of_quarter + 1.day + next_end = next_start.end_of_quarter + at_latest = @start_date.beginning_of_quarter >= Date.current.beginning_of_quarter + when :ytd + prev_year = @start_date.year - 1 + prev_start = Date.new(prev_year, 1, 1) + prev_end = Date.new(prev_year, 12, 31) + next_year = @start_date.year + 1 + next_start = Date.new(next_year, 1, 1) + next_end = next_year == Date.current.year ? Date.current : Date.new(next_year, 12, 31) + at_latest = @start_date.year >= Date.current.year + when :last_6_months + prev_start = @start_date.beginning_of_month - 6.months + prev_end = prev_start + 6.months - 1.day + candidate_start = @start_date.beginning_of_month + 6.months + if candidate_start + 6.months >= Date.current.beginning_of_month + next_end = Date.current.end_of_month + next_start = (next_end + 1.day - 6.months).beginning_of_month + else + next_start = candidate_start + next_end = next_start + 6.months - 1.day + end + at_latest = @end_date >= Date.current.end_of_month + else + return nil + end + + { prev_start: prev_start, prev_end: prev_end, next_start: next_start, next_end: next_end, at_latest: at_latest, label: period_label } + end + + def period_label + case @period_type + when :monthly + I18n.l(@start_date, format: :month_year) + when :quarterly + t("reports.index.period_label.quarterly", quarter: @start_date.quarter, year: @start_date.year) + when :ytd + if @start_date.year == Date.current.year + t("reports.index.period_label.ytd", year: @start_date.year) + else + t("reports.index.period_label.past_year", year: @start_date.year) + end + when :last_6_months + t("reports.index.period_label.last_6_months", + start: I18n.l(@start_date, format: :short_month_year), + end: I18n.l(@end_date, format: :short_month_year)) + end + end end diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 5e7496cb8..ae9e1ee1f 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -86,8 +86,8 @@ class RulesController < ApplicationController def update if @rule.update(rule_params) respond_to do |format| - format.html { redirect_back_or_to rules_path, notice: "Rule updated" } - format.turbo_stream { stream_redirect_back_or_to rules_path, notice: "Rule updated" } + format.html { redirect_back_or_to rules_path, notice: t(".success") } + format.turbo_stream { stream_redirect_back_or_to rules_path, notice: t(".success") } end else render :edit, status: :unprocessable_entity @@ -96,12 +96,12 @@ class RulesController < ApplicationController def destroy @rule.destroy - redirect_to rules_path, notice: "Rule deleted" + redirect_to rules_path, notice: t(".success") end def destroy_all Current.family.rules.destroy_all - redirect_to rules_path, notice: "All rules deleted" + redirect_to rules_path, notice: t(".success") end def confirm_all diff --git a/app/controllers/settings/ai_prompts_controller.rb b/app/controllers/settings/ai_prompts_controller.rb index ecd42b6f0..dddbc15df 100644 --- a/app/controllers/settings/ai_prompts_controller.rb +++ b/app/controllers/settings/ai_prompts_controller.rb @@ -3,8 +3,8 @@ class Settings::AiPromptsController < ApplicationController def show @breadcrumbs = [ - [ "Home", root_path ], - [ "AI Prompts", nil ] + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.ai_prompts"), nil ] ] @family = Current.family @assistant_config = Assistant.config_for(OpenStruct.new(user: Current.user)) diff --git a/app/controllers/settings/api_keys_controller.rb b/app/controllers/settings/api_keys_controller.rb index c509df41a..cb8b2c3db 100644 --- a/app/controllers/settings/api_keys_controller.rb +++ b/app/controllers/settings/api_keys_controller.rb @@ -7,8 +7,8 @@ class Settings::ApiKeysController < ApplicationController def show @breadcrumbs = [ - [ "Home", root_path ], - [ "API Key", nil ] + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.api_key"), nil ] ] @current_api_key = @api_key end @@ -31,7 +31,7 @@ class Settings::ApiKeysController < ApplicationController existing_keys.each { |key| key.update_column(:revoked_at, Time.current) } if @api_key.save - flash[:notice] = "Your API key has been created successfully" + flash[:notice] = t(".success") redirect_to settings_api_key_path else # Restore existing keys if new key creation failed @@ -42,13 +42,13 @@ class Settings::ApiKeysController < ApplicationController def destroy if @api_key.nil? - flash[:alert] = "API key not found" + flash[:alert] = t(".not_found") elsif @api_key.demo_monitoring_key? - flash[:alert] = "This API key cannot be revoked" + flash[:alert] = t(".cannot_revoke") elsif @api_key.revoke! - flash[:notice] = "API key has been revoked successfully" + flash[:notice] = t(".revoked_successfully") else - flash[:alert] = "Failed to revoke API key" + flash[:alert] = t(".revoke_failed") end redirect_to settings_api_key_path end diff --git a/app/controllers/settings/bank_sync_controller.rb b/app/controllers/settings/bank_sync_controller.rb deleted file mode 100644 index f954e5369..000000000 --- a/app/controllers/settings/bank_sync_controller.rb +++ /dev/null @@ -1,36 +0,0 @@ -class Settings::BankSyncController < ApplicationController - layout "settings" - - def show - @providers = [ - { - name: "Lunch Flow", - description: "US, Canada, UK, EU, Brazil and Asia through multiple open banking providers.", - path: "https://lunchflow.app/features/sure-integration?atp=BiDIYS", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Plaid", - description: "US & Canada bank connections with transactions, investments, and liabilities.", - path: "https://github.com/we-promise/sure/blob/main/docs/hosting/plaid.md", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "SimpleFIN", - description: "US & Canada connections via SimpleFIN protocol.", - path: "https://beta-bridge.simplefin.org", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Enable Banking (beta)", - description: "European bank connections via open banking APIs across multiple countries.", - path: "https://enablebanking.com", - target: "_blank", - rel: "noopener noreferrer" - } - ] - end -end diff --git a/app/controllers/settings/debugs_controller.rb b/app/controllers/settings/debugs_controller.rb new file mode 100644 index 000000000..aebed5616 --- /dev/null +++ b/app/controllers/settings/debugs_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class Settings::DebugsController < Admin::BaseController + FILTER_ID_PARAMS = %i[family_id account_id user_id account_provider_id].freeze + + def show + filter_params = debug_filters_params + + @start_date = safe_parse_date(filter_params[:start_date]) + @end_date = safe_parse_date(filter_params[:end_date]) + + @breadcrumbs = [ + [ t("breadcrumbs.home"), root_path ], + [ t("settings.debugs.show.page_title"), nil ] + ] + + scope = DebugLogEntry.includes(:family, :account, :user, :account_provider).recent + scope = scope.with_category(filter_params[:category]) + scope = scope.with_level(filter_params[:level]) + scope = scope.with_source(filter_params[:source]) + scope = scope.with_provider_key(filter_params[:provider_key]) + + FILTER_ID_PARAMS.each do |key| + value = safe_uuid(filter_params[key]) + scope = scope.where(key => value) if value.present? + end + + scope = scope.where("created_at >= ?", @start_date.beginning_of_day) if @start_date.present? + scope = scope.where("created_at < ?", @end_date.next_day.beginning_of_day) if @end_date.present? + + @pagy, @debug_log_entries = pagy(scope, limit: safe_per_page(50)) + @categories = DebugLogEntry.distinct.order(:category).pluck(:category) + @levels = DebugLogEntry::LEVELS + @sources = DebugLogEntry.distinct.order(:source).pluck(:source) + @provider_keys = DebugLogEntry.where.not(provider_key: [ nil, "" ]).distinct.order(:provider_key).pluck(:provider_key) + end + + private + def safe_parse_date(value) + Date.iso8601(value) + rescue ArgumentError, TypeError + nil + end + + def safe_uuid(value) + return if value.blank? + + uuid = value.to_s.strip + uuid.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i) ? uuid : nil + end + + def debug_filters_params + params.permit(:category, :level, :source, :provider_key, :start_date, :end_date, *FILTER_ID_PARAMS) + end +end diff --git a/app/controllers/settings/guides_controller.rb b/app/controllers/settings/guides_controller.rb index a21840a91..c078d15dd 100644 --- a/app/controllers/settings/guides_controller.rb +++ b/app/controllers/settings/guides_controller.rb @@ -3,8 +3,8 @@ class Settings::GuidesController < ApplicationController def show @breadcrumbs = [ - [ "Home", root_path ], - [ "Guides", nil ] + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.guides"), nil ] ] markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true, diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index f3a63e9a7..e2a577bfc 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -1,6 +1,15 @@ class Settings::HostingsController < ApplicationController layout "settings" + # Minimum accepted value for each configurable LLM budget field. Mirrors the + # `min:` attribute on the form inputs in `_openai_settings.html.erb` so the + # controller rejects what the browser-side validator would reject. + LLM_BUDGET_MINIMUMS = { + llm_context_window: 256, + llm_max_response_tokens: 64, + llm_max_items_per_call: 1 + }.freeze + guard_feature unless: -> { self_hosted? } before_action :ensure_admin, only: [ :update, :clear_cache, :disconnect_external_assistant ] @@ -8,19 +17,20 @@ class Settings::HostingsController < ApplicationController def show @breadcrumbs = [ - [ "Home", root_path ], - [ "Self-Hosting", nil ] + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.self_hosting"), nil ] ] # Determine which providers are currently selected exchange_rate_provider = ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider - securities_provider = ENV["SECURITIES_PROVIDER"].presence || Setting.securities_provider + enabled_securities = Setting.enabled_securities_providers - # Show Twelve Data settings if either provider is set to twelve_data - @show_twelve_data_settings = exchange_rate_provider == "twelve_data" || securities_provider == "twelve_data" - - # Show Yahoo Finance settings if either provider is set to yahoo_finance - @show_yahoo_finance_settings = exchange_rate_provider == "yahoo_finance" || securities_provider == "yahoo_finance" + # Show provider settings if used for FX or enabled for securities + @show_twelve_data_settings = exchange_rate_provider == "twelve_data" || enabled_securities.include?("twelve_data") + @show_yahoo_finance_settings = exchange_rate_provider == "yahoo_finance" || enabled_securities.include?("yahoo_finance") + @show_tiingo_settings = enabled_securities.include?("tiingo") + @show_eodhd_settings = enabled_securities.include?("eodhd") + @show_alpha_vantage_settings = enabled_securities.include?("alpha_vantage") # Only fetch provider data if we're showing the section if @show_twelve_data_settings @@ -57,9 +67,7 @@ class Settings::HostingsController < ApplicationController Setting.brand_fetch_high_res_logos = hosting_params[:brand_fetch_high_res_logos] == "1" end - if hosting_params.key?(:twelve_data_api_key) - Setting.twelve_data_api_key = hosting_params[:twelve_data_api_key] - end + update_encrypted_setting(:twelve_data_api_key) if hosting_params.key?(:exchange_rate_provider) Setting.exchange_rate_provider = hosting_params[:exchange_rate_provider] @@ -69,6 +77,40 @@ class Settings::HostingsController < ApplicationController Setting.securities_provider = hosting_params[:securities_provider] end + if hosting_params.key?(:securities_providers) + new_providers = Array(hosting_params[:securities_providers]).reject(&:blank?) & Security.valid_price_providers + old_providers = Setting.enabled_securities_providers + + Setting.securities_providers = new_providers.join(",") + + # Clear the legacy singular setting so the fallback in + # enabled_securities_providers doesn't re-enable a provider + # the user just unchecked. + Setting.securities_provider = nil if new_providers.empty? + + # Mark securities linked to removed providers as offline so they aren't + # silently queried against an incompatible fallback provider (e.g. MFAPI + # scheme codes sent to TwelveData). The price_provider is preserved so + # provider_status can report :provider_unavailable. + removed = old_providers - new_providers + removed.each do |removed_provider| + Security.where(price_provider: removed_provider, offline: false) + .in_batches.update_all(offline: true, offline_reason: "provider_disabled") + end + + # Bring securities back online when their provider is re-enabled — but only + # those that were taken offline by a provider toggle, not by health checks. + added = new_providers - old_providers + added.each do |added_provider| + Security.where(price_provider: added_provider, offline: true, offline_reason: "provider_disabled") + .in_batches.update_all(offline: false, offline_reason: nil, failed_fetch_count: 0, failed_fetch_at: nil) + end + end + + update_encrypted_setting(:tiingo_api_key) + update_encrypted_setting(:eodhd_api_key) + update_encrypted_setting(:alpha_vantage_api_key) + if hosting_params.key?(:syncs_include_pending) Setting.syncs_include_pending = hosting_params[:syncs_include_pending] == "1" end @@ -124,6 +166,21 @@ class Settings::HostingsController < ApplicationController Setting.openai_json_mode = hosting_params[:openai_json_mode].presence end + LLM_BUDGET_MINIMUMS.each do |key, minimum| + next unless hosting_params.key?(key) + raw = hosting_params[key].to_s.strip + if raw.blank? + Setting.public_send("#{key}=", nil) + next + end + parsed = Integer(raw, 10) rescue nil + if parsed.nil? || parsed < minimum + label = t("settings.hostings.openai_settings.#{key}_label") + raise Setting::ValidationError, t(".invalid_llm_budget", field: label, minimum: minimum) + end + Setting.public_send("#{key}=", parsed) + end + if hosting_params.key?(:external_assistant_url) Setting.external_assistant_url = hosting_params[:external_assistant_url] end @@ -166,7 +223,7 @@ class Settings::HostingsController < ApplicationController private def hosting_params return ActionController::Parameters.new unless params.key?(:setting) - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :tiingo_api_key, :eodhd_api_key, :alpha_vantage_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :llm_context_window, :llm_max_response_tokens, :llm_max_items_per_call, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id, securities_providers: []) end def update_assistant_type @@ -195,6 +252,12 @@ class Settings::HostingsController < ApplicationController flash[:alert] = t(".scheduler_sync_failed") end + def update_encrypted_setting(param_key) + return unless hosting_params.key?(param_key) + value = hosting_params[param_key].to_s.strip + Setting.public_send(:"#{param_key}=", value) unless value.blank? || value == "********" + end + def current_user_timezone Current.family&.timezone.presence || "UTC" end diff --git a/app/controllers/settings/llm_usages_controller.rb b/app/controllers/settings/llm_usages_controller.rb index 8013ecdff..73ec812b8 100644 --- a/app/controllers/settings/llm_usages_controller.rb +++ b/app/controllers/settings/llm_usages_controller.rb @@ -3,8 +3,8 @@ class Settings::LlmUsagesController < ApplicationController def show @breadcrumbs = [ - [ "Home", root_path ], - [ "LLM Usage", nil ] + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.llm_usage"), nil ] ] @family = Current.family diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 83f9e2e44..5798c573e 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -4,4 +4,24 @@ class Settings::PreferencesController < ApplicationController def show @user = Current.user end + + # Writes per-user boolean preferences stored in the JSONB `users.preferences` + # column. Mirrors Settings::AppearancesController#update so the toggle card on + # the Preferences page can submit directly without going through the broader + # UsersController#update flow (which expects a full user form payload). + def update + @user = Current.user + user_params = params.permit(user: [ :preview_features_enabled ]).fetch(:user, {}) + + @user.transaction do + @user.lock! + updated_prefs = (@user.preferences || {}).deep_dup + if user_params.key?(:preview_features_enabled) + updated_prefs["preview_features_enabled"] = + ActiveModel::Type::Boolean.new.cast(user_params[:preview_features_enabled]) + end + @user.update!(preferences: updated_prefs) + end + redirect_to settings_preferences_path + end end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 6e8dbbf0e..e1f3b5db4 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -6,8 +6,8 @@ class Settings::ProfilesController < ApplicationController @users = Current.family.users.order(:created_at) @pending_invitations = Current.family.invitations.pending @breadcrumbs = [ - [ "Home", root_path ], - [ "Profile Info", nil ] + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.profile"), nil ] ] end @@ -29,9 +29,9 @@ class Settings::ProfilesController < ApplicationController if @user.destroy # Also destroy the invitation associated with this user for this family Current.family.invitations.find_by(email: @user.email)&.destroy - flash[:notice] = "Member removed successfully." + flash[:notice] = t(".member_removed") else - flash[:alert] = "Failed to remove member." + flash[:alert] = t(".member_removal_failed") end redirect_to settings_profile_path diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index b8c07784b..2d123f2ab 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -1,12 +1,12 @@ class Settings::ProvidersController < ApplicationController - layout "settings" + layout -> { turbo_frame_request? ? "turbo_rails/frame" : "settings" } - before_action :ensure_admin, only: [ :show, :update ] + before_action :ensure_admin, only: [ :show, :update, :sync_all, :sync, :connect_form ] def show @breadcrumbs = [ - [ "Home", root_path ], - [ "Sync Providers", nil ] + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.bank_sync"), nil ] ] prepare_show_context @@ -66,17 +66,72 @@ class Settings::ProvidersController < ApplicationController # Reload provider configurations if needed reload_provider_configs(updated_fields) - redirect_to settings_providers_path, notice: "Provider settings updated successfully" + redirect_to settings_providers_path, notice: t(".updated_successfully") else - redirect_to settings_providers_path, notice: "No changes were made" + redirect_to settings_providers_path, notice: t(".no_changes") end rescue => error - Rails.logger.error("Failed to update provider settings: #{error.message}") - flash.now[:alert] = "Failed to update provider settings: #{error.message}" + Rails.logger.error("Failed to update provider settings: #{error.class} - #{error.message}") + flash.now[:alert] = "Failed to update provider settings. Please try again." prepare_show_context render :show, status: :unprocessable_entity end + def sync_all + family = Current.family + now = Time.current + + updated_count = Family + .where(id: family.id) + .where("last_sync_all_attempted_at IS NULL OR last_sync_all_attempted_at <= ?", 30.seconds.ago) + .update_all(last_sync_all_attempted_at: now, updated_at: now) + + if updated_count.zero? + return redirect_to settings_providers_path, notice: t("settings.providers.sync_all_recently") + end + + SyncAllProvidersJob.perform_later(family.id) + redirect_to settings_providers_path, notice: t("settings.providers.sync_all_in_progress") + end + + def sync + provider_key = params[:provider_key] + syncable_type = PANEL_SYNCABLE_TYPES[provider_key] + return redirect_to settings_providers_path unless syncable_type + + items = syncable_type.constantize.where(family: Current.family).syncable + scheduled = items.reject(&:syncing?) + scheduled.each(&:sync_later) + + notice_key = scheduled.any? ? "settings.providers.sync_provider_in_progress" : "settings.providers.sync_provider_no_items" + redirect_to settings_providers_path, notice: t(notice_key) + end + + def connect_form + provider_key = params[:provider_key] + + panel = FAMILY_PANELS.find { |p| p[:key] == provider_key } + if panel + @panel_key = panel[:key] + @panel_partial = panel[:partial] + @panel_title = panel[:title] + load_provider_items(provider_key) + return render :connect_form + end + + Provider::Factory.ensure_adapters_loaded + config = Provider::ConfigurationRegistry.all.find { |c| c.provider_key.to_s == provider_key } + if config + @panel_title = Provider::Metadata.for(provider_key)[:name] || provider_key.titleize + @provider_configuration = config + return render :connect_form + end + + redirect_to settings_providers_path, alert: t("settings.providers.not_found") + rescue ActiveRecord::Encryption::Errors::Configuration + redirect_to settings_providers_path, alert: t("settings.providers.encryption_error.title") + end + private def provider_params # Dynamically permit all provider configuration fields @@ -93,7 +148,9 @@ class Settings::ProvidersController < ApplicationController end def ensure_admin - redirect_to settings_providers_path, alert: "Not authorized" unless Current.user.admin? + return if Current.user.admin? + + redirect_to root_path, alert: t("settings.providers.not_authorized") end # Reload provider configurations after settings update @@ -119,28 +176,192 @@ class Settings::ProvidersController < ApplicationController end end + # Hardcoded family-scoped panels — provider connections are managed through + # their own models (SimplefinItem, LunchflowItem, etc.) rather than global + # settings, so they need custom UI per-provider for connection management, + # status display, and sync actions. The configuration registry excludes + # them (see prepare_show_context). + FAMILY_PANELS = [ + { key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" }, + { key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" }, + { key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" }, + { key: "coinstats", title: "CoinStats", turbo_id: "coinstats", partial: "coinstats_panel" }, + { key: "mercury", title: "Mercury", turbo_id: "mercury", partial: "mercury_panel" }, + { key: "brex", title: "Brex", turbo_id: "brex", partial: "brex_panel" }, + { key: "coinbase", title: "Coinbase", turbo_id: "coinbase", partial: "coinbase_panel" }, + { key: "binance", title: "Binance", turbo_id: "binance", partial: "binance_panel" }, + { key: "kraken", title: "Kraken", turbo_id: "kraken", partial: "kraken_panel" }, + { key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" }, + { key: "ibkr", title: "Interactive Brokers", turbo_id: "ibkr", partial: "ibkr_panel" }, + { key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" }, + { key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" } + ].freeze + + FAMILY_PANEL_KEYS = FAMILY_PANELS.map { |p| p[:key] }.freeze + + # Maps panel key → ActiveRecord model name for sync health queries + PANEL_SYNCABLE_TYPES = { + "simplefin" => "SimplefinItem", + "lunchflow" => "LunchflowItem", + "enable_banking" => "EnableBankingItem", + "coinstats" => "CoinstatsItem", + "mercury" => "MercuryItem", + "brex" => "BrexItem", + "coinbase" => "CoinbaseItem", + "binance" => "BinanceItem", + "kraken" => "KrakenItem", + "snaptrade" => "SnaptradeItem", + "ibkr" => "IbkrItem", + "indexa_capital" => "IndexaCapitalItem", + "sophtron" => "SophtronItem" + }.freeze + + def load_provider_items(provider_key) + case provider_key + when "simplefin" + @simplefin_items = Current.family.simplefin_items.ordered + when "lunchflow" + @lunchflow_items = Current.family.lunchflow_items.ordered + when "enable_banking" + @enable_banking_items = Current.family.enable_banking_items.ordered + when "coinstats" + @coinstats_items = Current.family.coinstats_items.ordered + when "mercury" + @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) + when "brex" + @brex_items = Current.family.brex_items.active.ordered.includes(:syncs, :brex_accounts) + when "coinbase" + @coinbase_items = Current.family.coinbase_items.ordered + when "binance" + @binance_items = Current.family.binance_items.active.ordered + when "kraken" + @kraken_items = Current.family.kraken_items.active.ordered + when "snaptrade" + @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered + when "ibkr" + @ibkr_items = Current.family.ibkr_items.ordered + when "indexa_capital" + @indexa_capital_items = Current.family.indexa_capital_items.ordered + when "sophtron" + @sophtron_items = Current.family.sophtron_items.ordered + end + end + # Prepares instance vars needed by the show view and partials def prepare_show_context - # Load all provider configurations (exclude SimpleFin and Lunchflow, which have their own family-specific panels below) + # Load all provider configurations (exclude family-scoped panels, which have their own UI below) Provider::Factory.ensure_adapters_loaded @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| - config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \ - config.provider_key.to_s.casecmp("enable_banking").zero? || \ - config.provider_key.to_s.casecmp("coinstats").zero? || \ - config.provider_key.to_s.casecmp("mercury").zero? || \ - config.provider_key.to_s.casecmp("coinbase").zero? || \ - config.provider_key.to_s.casecmp("snaptrade").zero? || \ - config.provider_key.to_s.casecmp("indexa_capital").zero? + FAMILY_PANEL_KEYS.any? { |key| config.provider_key.to_s.casecmp(key).zero? } end # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials @simplefin_items = Current.family.simplefin_items.where.not(access_url: [ nil, "" ]).ordered.select(:id) @lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id) @enable_banking_items = Current.family.enable_banking_items.ordered # Enable Banking panel needs session info for status display + # Providers page only needs to know whether any Sophtron connections exist with valid credentials + @sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id) @coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display - @mercury_items = Current.family.mercury_items.ordered.select(:id) + @mercury_items = Current.family.mercury_items.active.ordered + @brex_items = Current.family.brex_items.active.ordered @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display - @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered + @snaptrade_items = Current.family.snaptrade_items.ordered + @ibkr_items = Current.family.ibkr_items.ordered.select(:id) @indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id) + @binance_items = Current.family.binance_items.active.ordered + @kraken_items = Current.family.kraken_items.active.ordered + + @provider_sync_health = compute_provider_sync_health(family_panel_items) + + entries = build_provider_entries + + @connected = entries.select { |e| e[:summary][:status] == :ok } + @needs_attention = entries.select { |e| [ :warn, :err ].include?(e[:summary][:status]) } + @available = entries.select { |e| e[:summary][:status] == :off } + + @health = view_context.provider_health_strip(connected: @connected, needs_attention: @needs_attention) + end + + # Maps each family panel key to the loaded item collection. Used by + # compute_provider_sync_health and build_provider_entries to avoid relying + # on instance_variable_get for control flow. + def family_panel_items + { + "simplefin" => @simplefin_items, + "lunchflow" => @lunchflow_items, + "enable_banking" => @enable_banking_items, + "coinstats" => @coinstats_items, + "mercury" => @mercury_items, + "brex" => @brex_items, + "coinbase" => @coinbase_items, + "binance" => @binance_items, + "kraken" => @kraken_items, + "snaptrade" => @snaptrade_items, + "ibkr" => @ibkr_items, + "indexa_capital" => @indexa_capital_items, + "sophtron" => @sophtron_items + } + end + + # Returns a hash mapping provider key → { error:, last_synced_at:, stale: } + # by querying the latest sync per item for each family panel provider. + def compute_provider_sync_health(items_map) + PANEL_SYNCABLE_TYPES.each_with_object({}) do |(key, syncable_type), health| + ids = items_map[key]&.map(&:id)&.compact + next if ids.blank? + + health[key] = sync_health_for(syncable_type, ids) + end + end + + # Determines error/stale status and last successful sync time for a set of items. + def sync_health_for(syncable_type, item_ids) + # Use window function to get the single latest sync per item (same pattern as ProviderConnectionStatus) + ranked_subq = Sync + .where(syncable_type: syncable_type, syncable_id: item_ids) + .select("syncs.*, ROW_NUMBER() OVER (PARTITION BY syncable_id ORDER BY created_at DESC, id DESC) AS sync_rank") + + latest_per_item = Sync.from(ranked_subq, :syncs).where("sync_rank = 1").to_a + + has_error = latest_per_item.any? { |s| s.failed? || s.stale? } + + last_synced = Sync + .where(syncable_type: syncable_type, syncable_id: item_ids, status: "completed") + .maximum(:completed_at) + + stale = !has_error && last_synced.present? && last_synced < 24.hours.ago + + { error: has_error, last_synced_at: last_synced, stale: stale } + end + + # Builds a unified list of provider entries (registry-driven configurations + # and hardcoded family panels) with pre-computed status, sorted + # alphabetically by display title. Each entry carries enough data for the + # view to render either a provider_form or a family panel partial. + def build_provider_entries + configuration_entries = @provider_configurations.map do |config| + meta = Provider::Metadata.for(config.provider_key) + { + provider_key: config.provider_key.to_s, + title: meta[:name] || config.provider_key.to_s.titleize, + configuration: config, + maturity: meta[:maturity], + summary: view_context.provider_summary(config.provider_key) + } + end + + family_entries = FAMILY_PANELS.map do |panel| + { + provider_key: panel[:key], + title: panel[:title], + turbo_id: panel[:turbo_id], + partial: panel[:partial], + auto_open_param: panel[:auto_open], + maturity: Provider::Metadata.for(panel[:key])[:maturity], + summary: view_context.provider_summary(panel[:key]) + } + end + + (configuration_entries + family_entries).sort_by { |entry| entry[:title].downcase } end end diff --git a/app/controllers/settings/securities_controller.rb b/app/controllers/settings/securities_controller.rb index fd6791994..d53bddd77 100644 --- a/app/controllers/settings/securities_controller.rb +++ b/app/controllers/settings/securities_controller.rb @@ -3,9 +3,10 @@ class Settings::SecuritiesController < ApplicationController def show @breadcrumbs = [ - [ "Home", root_path ], - [ "Security", nil ] + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.security"), nil ] ] @oidc_identities = Current.user.oidc_identities.order(:provider) + @webauthn_credentials = Current.user.webauthn_credentials.order(created_at: :asc) end end diff --git a/app/controllers/settings/webauthn_credentials_controller.rb b/app/controllers/settings/webauthn_credentials_controller.rb new file mode 100644 index 000000000..943ec75ea --- /dev/null +++ b/app/controllers/settings/webauthn_credentials_controller.rb @@ -0,0 +1,83 @@ +class Settings::WebauthnCredentialsController < ApplicationController + include WebauthnRelyingParty + + layout "settings" + + before_action :ensure_mfa_enabled + + def options + Current.user.ensure_webauthn_id! + + registration_options = webauthn_relying_party.options_for_registration( + user: { + id: Current.user.webauthn_id, + name: Current.user.email, + display_name: Current.user.display_name + }, + exclude: Current.user.webauthn_credentials.pluck(:credential_id), + authenticator_selection: { user_verification: "preferred" }, + attestation: "none" + ) + + session[:webauthn_registration_challenge] = registration_options.challenge + + render json: registration_options + end + + def create + challenge = session.delete(:webauthn_registration_challenge) + + unless challenge.present? + return render json: { error: t("webauthn_credentials.failure") }, status: :unprocessable_entity + end + + credential = webauthn_relying_party.verify_registration( + webauthn_credential_payload, + challenge, + user_presence: true + ) + + Current.user.webauthn_credentials.create!( + nickname: webauthn_credential_name, + credential_id: credential.id, + public_key: credential.public_key, + sign_count: credential.sign_count, + transports: webauthn_credential_transports + ) + + render json: { redirect_url: settings_security_path } + rescue WebAuthn::Error, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique, ActionController::BadRequest, ActionController::ParameterMissing + render json: { error: t("webauthn_credentials.failure") }, status: :unprocessable_entity + end + + def destroy + Current.user.webauthn_credentials.find(params[:id]).destroy! + redirect_to settings_security_path, notice: t("webauthn_credentials.success") + end + + private + def ensure_mfa_enabled + return if Current.user.otp_required? + + respond_to do |format| + format.html { redirect_to settings_security_path, alert: t("webauthn_credentials.mfa_required") } + format.json { render json: { error: t("webauthn_credentials.mfa_required") }, status: :forbidden } + end + end + + def webauthn_credential_name + webauthn_credential_params[:nickname] + end + + def webauthn_credential_transports + Array(credential_response_params.dig(:response, :transports)).compact_blank + end + + def webauthn_credential_params + params.fetch(:webauthn_credential, ActionController::Parameters.new).permit(:nickname) + end + + def credential_response_params + params.fetch(:credential, ActionController::Parameters.new).permit(response: [ transports: [] ]) + end +end diff --git a/app/controllers/simplefin_items_controller.rb b/app/controllers/simplefin_items_controller.rb index 32ba1deb4..836f7e10f 100644 --- a/app/controllers/simplefin_items_controller.rb +++ b/app/controllers/simplefin_items_controller.rb @@ -1,7 +1,7 @@ class SimplefinItemsController < ApplicationController include SimplefinItems::MapsHelper - before_action :set_simplefin_item, only: [ :show, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup ] - before_action :require_admin!, only: [ :new, :create, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup ] + before_action :set_simplefin_item, only: [ :show, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup, :dismiss_replacement_suggestion ] + before_action :require_admin!, only: [ :new, :create, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup, :dismiss_replacement_suggestion ] def index @simplefin_items = Current.family.simplefin_items.active.ordered @@ -123,6 +123,28 @@ class SimplefinItemsController < ApplicationController end end + # Marks one replacement-suggestion as dismissed so the banner stops showing + # for that specific (dormant, active) pair. Composite key lets us suppress + # just one option if a dormant card has multiple candidates, without hiding + # the others. Dismissals are persisted on the latest sync's sync_stats; a + # fresh sync emits new suggestions with fresh dismissal state. + def dismiss_replacement_suggestion + dormant_sfa_id = params.require(:dormant_sfa_id) + active_sfa_id = params.require(:active_sfa_id) + dismissal_key = "#{dormant_sfa_id}:#{active_sfa_id}" + sync = @simplefin_item.syncs.order(created_at: :desc).first + + if sync + stats = sync.sync_stats.is_a?(Hash) ? sync.sync_stats.dup : {} + dismissed = Array(stats["dismissed_replacement_suggestions"]) + stats["dismissed_replacement_suggestions"] = (dismissed + [ dismissal_key ]).uniq + sync.update!(sync_stats: stats) + end + + redirect_back_or_to accounts_path, + notice: t(".dismissed") + end + # Starts a balances-only sync for this SimpleFin item def balances # Create a Sync and enqueue it to run asynchronously with a runtime-only flag @@ -365,9 +387,16 @@ class SimplefinItemsController < ApplicationController @account = Current.family.accounts.find(params[:account_id]) simplefin_account = SimplefinAccount.find(params[:simplefin_account_id]) - # Guard: only manual accounts can be linked (no existing provider links or legacy IDs) - if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present? - flash[:alert] = t("simplefin_items.link_existing_account.errors.only_manual") + # Cross-provider guard: we only support swapping SimpleFIN-to-SimpleFIN links + # here. If @account is linked to a different provider type (Plaid, Binance, + # etc.), require explicit unlink first so users don't silently lose + # cross-provider data. Same-provider relinks (e.g., Citi fraud replacement + # swapping to a new card sfa) are now allowed below. + has_foreign_provider = @account.account_providers + .where.not(provider_type: "SimplefinAccount").exists? || + @account.plaid_account_id.present? + if has_foreign_provider + flash[:alert] = t("simplefin_items.link_existing_account.errors.different_provider") if turbo_frame_request? return render turbo_stream: Array(flash_notification_stream_items) else @@ -390,6 +419,18 @@ class SimplefinItemsController < ApplicationController Account.transaction do simplefin_account.lock! + # Detach @account's EXISTING SimpleFIN link (if any) before attaching the + # new one. This is the fraud-replacement path: user is swapping from + # sfa_old (dead card) to sfa_new (replacement). Without this, @account + # would end up with two AccountProviders and the old sfa's data would + # still flow in on every sync. + @account.account_providers.where(provider_type: "SimplefinAccount").find_each do |existing_ap| + # Skip the one we're about to (re)assign — avoid deleting what we then recreate. + next if existing_ap.provider_id == simplefin_account.id + existing_ap.destroy + end + @account.update!(simplefin_account_id: nil) if @account.simplefin_account_id.present? + # Clear legacy association if present (Account.simplefin_account_id) if (legacy_account = simplefin_account.account) legacy_account.update!(simplefin_account_id: nil) diff --git a/app/controllers/snaptrade_items_controller.rb b/app/controllers/snaptrade_items_controller.rb index f02d5a155..6062c4dfb 100644 --- a/app/controllers/snaptrade_items_controller.rb +++ b/app/controllers/snaptrade_items_controller.rb @@ -132,12 +132,19 @@ class SnaptradeItemsController < ApplicationController snaptrade_item = Current.family.snaptrade_items.find_by(id: params[:item_id]) if snaptrade_item - # Trigger a sync to fetch the newly connected accounts snaptrade_item.sync_later unless snaptrade_item.syncing? - # Redirect to accounts page - user can click "accounts need setup" badge - # when sync completes. This avoids the auto-refresh loop issues. - redirect_to accounts_path, notice: t(".success") + + stored_return_to, stored_accountable_type = clear_snaptrade_resume_context + return_to = params[:return_to].presence || stored_return_to + accountable_type = params[:accountable_type].presence || stored_accountable_type + + if return_to == "setup_accounts" + redirect_to setup_accounts_snaptrade_item_path(snaptrade_item, accountable_type: accountable_type.presence), notice: t(".success") + else + redirect_to accounts_path, notice: t(".success") + end else + clear_snaptrade_resume_context redirect_to settings_providers_path, alert: t(".no_item") end end @@ -340,35 +347,46 @@ class SnaptradeItemsController < ApplicationController # Collection actions for account linking flow def preload_accounts - snaptrade_item = Current.family.snaptrade_items.first - if snaptrade_item + snaptrade_item = current_snaptrade_item + unless snaptrade_item + redirect_to settings_providers_path, alert: t(".not_configured", default: "SnapTrade is not configured.") + return + end + + if snaptrade_item.user_registered? snaptrade_item.sync_later unless snaptrade_item.syncing? redirect_to setup_accounts_snaptrade_item_path(snaptrade_item) else - redirect_to settings_providers_path, alert: t(".not_configured", default: "SnapTrade is not configured.") + redirect_to connect_snaptrade_item_path(snaptrade_item) end end def select_accounts @accountable_type = params[:accountable_type] @return_to = params[:return_to] - snaptrade_item = Current.family.snaptrade_items.first + snaptrade_item = current_snaptrade_item - if snaptrade_item + unless snaptrade_item + redirect_to settings_providers_path, alert: t(".not_configured", default: "SnapTrade is not configured.") + return + end + + if snaptrade_item.user_registered? redirect_to setup_accounts_snaptrade_item_path(snaptrade_item, accountable_type: @accountable_type, return_to: @return_to) else - redirect_to settings_providers_path, alert: t(".not_configured", default: "SnapTrade is not configured.") + store_snaptrade_resume_context(return_to: @return_to, accountable_type: @accountable_type) + redirect_to connect_snaptrade_item_path(snaptrade_item) end end def link_accounts - redirect_to settings_providers_path, alert: "Use the account setup flow instead" + redirect_to settings_providers_path, alert: t(".use_setup_flow") end def select_existing_account @account_id = params[:account_id] @account = Current.family.accounts.find_by(id: @account_id) - snaptrade_item = Current.family.snaptrade_items.first + snaptrade_item = current_snaptrade_item if snaptrade_item && @account @snaptrade_accounts = snaptrade_item.snaptrade_accounts @@ -417,6 +435,26 @@ class SnaptradeItemsController < ApplicationController @snaptrade_item = Current.family.snaptrade_items.find(params[:id]) end + def current_snaptrade_item + active_items = Current.family.snaptrade_items.active + + active_items.syncable.ordered.first || + active_items.credentials_configured.ordered.first || + active_items.ordered.first + end + + def store_snaptrade_resume_context(return_to:, accountable_type:) + session[:snaptrade_resume] = { + return_to: return_to, + accountable_type: accountable_type + } + end + + def clear_snaptrade_resume_context + resume = (session.delete(:snaptrade_resume) || {}).with_indifferent_access + [ resume[:return_to], resume[:accountable_type] ] + end + def snaptrade_item_params params.require(:snaptrade_item).permit( :name, diff --git a/app/controllers/sophtron_items_controller.rb b/app/controllers/sophtron_items_controller.rb new file mode 100644 index 000000000..6222954ef --- /dev/null +++ b/app/controllers/sophtron_items_controller.rb @@ -0,0 +1,1250 @@ +class SophtronItemsController < ApplicationController + include SyncStats::Collector + + CONNECTION_STATUS_MAX_POLLS = 6 + LOGIN_PROGRESS_CONNECTION_STATUS_MAX_POLLS = 15 + POST_MFA_CONNECTION_STATUS_MAX_POLLS = 15 + CONNECTION_STATUS_POLL_INTERVAL_MS = 4_000 + MAX_SECURITY_ANSWERS = 10 + MAX_SECURITY_ANSWER_LENGTH = 256 + MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY = "manual_sync_processed_sophtron_account_ids" + + before_action :set_sophtron_item, only: [ + :show, :edit, :update, :destroy, :connect_institution, :sync, + :connection_status, :submit_mfa, :toggle_manual_sync, + :setup_accounts, :complete_account_setup + ] + before_action :require_admin!, only: [ + :new, :create, :preload_accounts, :select_accounts, :link_accounts, + :select_existing_account, :link_existing_account, :connect_institution, + :edit, :update, :destroy, :sync, :connection_status, :submit_mfa, :toggle_manual_sync, + :setup_accounts, :complete_account_setup + ] + + def index + @sophtron_items = Current.family.sophtron_items.active.ordered + render layout: "settings" + end + + def show + end + + def preload_accounts + item = configured_sophtron_item + unless item + render json: { success: false, error: "no_credentials_configured", has_accounts: false } + return + end + + item.ensure_customer! + + unless item.connected_to_institution? + render json: { success: false, error: "no_institution_connected", has_accounts: nil } + return + end + + accounts = item.fetch_remote_accounts + render json: { success: true, has_accounts: accounts.any?, cached: true } + rescue Provider::Sophtron::Error => e + Rails.logger.error("Sophtron preload error: #{e.message}") + render json: { success: false, error: "api_error", error_message: t(".api_error"), has_accounts: nil } + rescue StandardError => e + Rails.logger.error("Unexpected error preloading Sophtron accounts: #{e.class}: #{e.message}") + render json: { success: false, error: "unexpected_error", error_message: t(".unexpected_error"), has_accounts: nil } + end + + def select_accounts + item = configured_sophtron_item + unless item + render_or_redirect_setup_required + return + end + + item.ensure_customer! + + if connect_new_institution_flow? || !item.connected_to_institution? + prepare_connection_form(item) + render :connect, layout: false + return + end + + @available_accounts = item.reject_already_linked(item.fetch_remote_accounts) + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + + if @available_accounts.empty? + redirect_to new_account_path, alert: t(".no_accounts_found") + return + end + + render layout: false + rescue Provider::Sophtron::Error => e + Rails.logger.error("Sophtron API error in select_accounts: #{e.message}") + render_api_error(t(".api_error"), safe_return_to_path) + rescue StandardError => e + Rails.logger.error("Unexpected error in select_accounts: #{e.class}: #{e.message}") + render_api_error(t(".unexpected_error"), safe_return_to_path) + end + + def connect_institution + if params[:institution_id].blank? || params[:bank_username].blank? || params[:bank_password].blank? + redirect_to select_accounts_sophtron_items_path(connection_context_params), alert: t(".missing_parameters") + return + end + + item = item_for_institution_connection(@sophtron_item) + item.ensure_customer! + response = sophtron_response_data!( + item.sophtron_provider.create_user_institution( + institution_id: params[:institution_id], + username: params[:bank_username], + password: params[:bank_password], + pin: "" + ) + ).with_indifferent_access + + job_id = response[:JobID] || response[:job_id] + user_institution_id = response[:UserInstitutionID] || response[:user_institution_id] + + if job_id.blank? || user_institution_id.blank? + raise Provider::Sophtron::Error.new("Sophtron did not return JobID and UserInstitutionID", :invalid_response) + end + + item.update!( + name: item.name.presence || t("sophtron_items.defaults.name"), + institution_id: params[:institution_id], + institution_name: params[:institution_name], + user_institution_id: user_institution_id, + current_job_id: job_id, + raw_job_payload: response, + job_status: nil, + last_connection_error: nil, + status: :good + ) + + redirect_to connection_status_sophtron_item_path(item, connection_context_params) + rescue Provider::Sophtron::Error => e + Rails.logger.error("Sophtron connect institution error: #{e.message}") + redirect_to select_accounts_sophtron_items_path(connection_context_params), alert: t(".api_error", message: e.message) + end + + def connection_status + if prefetch_request? + head :no_content + return + end + + if @sophtron_item.current_job_id.blank? + redirect_to select_accounts_sophtron_items_path(connection_context_params) + return + end + + @poll_attempt = requested_poll_attempt + if @poll_attempt > connection_status_max_polls + render_connection_timeout + return + end + + job = sophtron_response_data!(@sophtron_item.sophtron_provider.get_job_information(@sophtron_item.current_job_id)) + @sophtron_item.upsert_job_snapshot!(job) + + if Provider::Sophtron.job_success?(job) + if manual_sync_flow? + complete_manual_sync_from_job(job) + return + end + + @sophtron_item.update!( + current_job_id: nil, + last_connection_error: nil, + pending_account_setup: true, + status: :good + ) + render_account_selection(@sophtron_item, force_refresh: true) + elsif Provider::Sophtron.job_requires_input?(job) + @challenge = @sophtron_item.build_mfa_challenge(job) + prepare_connection_status_context + render :mfa, layout: false + elsif Provider::Sophtron.job_completed?(job) + if manual_sync_flow? + complete_manual_sync_from_job(job) + return + end + + if post_mfa_polling? + return if render_account_selection_if_accounts_available(@sophtron_item) + end + + render_pending_connection_status + elsif Provider::Sophtron.job_failed?(job) + failure_message = sophtron_connection_failure_message(job) + @sophtron_item.update!( + current_job_id: nil, + current_job_sophtron_account_id: nil, + user_institution_id: (manual_sync_flow? ? @sophtron_item.user_institution_id : nil), + last_connection_error: failure_message, + status: :requires_update + ) + fail_manual_sync!(manual_sync_record, failure_message) if manual_sync_flow? + render_institution_connection_error(failure_message) + else + render_pending_connection_status + end + rescue Provider::Sophtron::Error => e + Rails.logger.error("Sophtron job polling error: #{e.message}") + render_api_error(t(".api_error", message: e.message), accounts_path) + end + + def submit_mfa + provider = @sophtron_item.sophtron_provider + job_id = @sophtron_item.current_job_id + + case params[:mfa_type] + when "security_answer" + security_answers = normalized_security_answers + unless security_answers + redirect_to connection_status_sophtron_item_path(@sophtron_item, connection_context_params), alert: t(".invalid_security_answers") + return + end + + sophtron_response_data!(provider.update_job_security_answer(job_id, security_answers)) + when "token_choice" + sophtron_response_data!(provider.update_job_token_input(job_id, token_choice: params[:token_choice])) + when "token_input" + sophtron_response_data!(provider.update_job_token_input(job_id, token_input: params[:token_input])) + when "verify_phone" + sophtron_response_data!(provider.update_job_token_input(job_id, verify_phone_flag: true)) + when "captcha" + sophtron_response_data!(provider.update_job_captcha(job_id, params[:captcha_input])) + else + redirect_to connection_status_sophtron_item_path(@sophtron_item, connection_context_params), alert: t(".unknown_challenge") + return + end + + redirect_to connection_status_sophtron_item_path(@sophtron_item, connection_context_params.merge(post_mfa: true)) + rescue Provider::Sophtron::Error => e + Rails.logger.error("Sophtron MFA submission error: #{e.message}") + redirect_to connection_status_sophtron_item_path(@sophtron_item, connection_context_params), alert: t(".api_error", message: e.message) + end + + def link_accounts + selected_account_ids = params[:account_ids] || [] + accountable_type = params[:accountable_type] || "Depository" + return_to = safe_return_to_path + + if selected_account_ids.empty? + redirect_to new_account_path, alert: t(".no_accounts_selected") + return + end + + item = configured_sophtron_item + unless item&.connected_to_institution? + redirect_to select_accounts_sophtron_items_path(accountable_type: accountable_type, return_to: return_to), alert: t(".no_institution_connected") + return + end + + accounts_data = item.fetch_remote_accounts(force: true) + + created_accounts = [] + already_linked_accounts = [] + invalid_accounts = [] + + selected_account_ids.each do |account_id| + account_data = accounts_data.find { |account| SophtronItem.external_account_id(account).to_s == account_id.to_s } + next unless account_data + + if account_data[:account_name].blank? + invalid_accounts << account_id + Rails.logger.warn "SophtronItemsController - Skipping account #{account_id} with blank name" + next + end + + sophtron_account = item.upsert_sophtron_account(account_data) + + if sophtron_account.account_provider.present? + already_linked_accounts << account_data[:account_name] + next + end + + ActiveRecord::Base.transaction do + account = Account.create_and_sync( + { + family: Current.family, + name: account_data[:account_name], + balance: 0, + currency: account_data[:currency] || "USD", + accountable_type: accountable_type, + accountable_attributes: {} + }, + skip_initial_sync: true + ) + + AccountProvider.create!(account: account, provider: sophtron_account) + created_accounts << account + end + end + + item.start_initial_load_later if created_accounts.any? + redirect_after_account_link(return_to, created_accounts, already_linked_accounts, invalid_accounts) + rescue Provider::Sophtron::Error => e + redirect_to new_account_path, alert: t(".api_error", message: e.message) + end + + def select_existing_account + unless params[:account_id].present? + redirect_to accounts_path, alert: t(".no_account_specified") + return + end + + @account = Current.family.accounts.find(params[:account_id]) + + if @account.account_providers.exists? + redirect_to accounts_path, alert: t(".account_already_linked") + return + end + + item = configured_sophtron_item + unless item + render_or_redirect_setup_required + return + end + + item.ensure_customer! + + unless item.connected_to_institution? + prepare_connection_form(item, account: @account) + render :connect, layout: false + return + end + + @available_accounts = item.reject_already_linked(item.fetch_remote_accounts) + @return_to = safe_return_to_path + + if @available_accounts.empty? + redirect_to accounts_path, alert: t(".all_accounts_already_linked") + return + end + + render layout: false + rescue Provider::Sophtron::Error => e + Rails.logger.error("Sophtron API error in select_existing_account: #{e.message}") + render_api_error(t(".api_error", message: e.message), accounts_path) + rescue StandardError => e + Rails.logger.error("Unexpected error in select_existing_account: #{e.class}: #{e.message}") + render_api_error(t(".unexpected_error"), accounts_path) + end + + def link_existing_account + account_id = params[:account_id] + sophtron_account_id = params[:sophtron_account_id] + return_to = safe_return_to_path + + unless account_id.present? && sophtron_account_id.present? + redirect_to accounts_path, alert: t(".missing_parameters") + return + end + + account = Current.family.accounts.find(account_id) + + if account.account_providers.exists? + redirect_to accounts_path, alert: t(".account_already_linked") + return + end + + item = configured_sophtron_item + unless item&.connected_to_institution? + redirect_to accounts_path, alert: t(".no_institution_connected") + return + end + + account_data = item.fetch_remote_accounts(force: true).find { |remote_account| SophtronItem.external_account_id(remote_account).to_s == sophtron_account_id.to_s } + unless account_data + redirect_to accounts_path, alert: t(".sophtron_account_not_found") + return + end + + if account_data[:account_name].blank? + redirect_to accounts_path, alert: t(".invalid_account_name") + return + end + + sophtron_account = item.upsert_sophtron_account(account_data) + + if sophtron_account.account_provider.present? + redirect_to accounts_path, alert: t(".sophtron_account_already_linked") + return + end + + AccountProvider.create!(account: account, provider: sophtron_account) + item.start_initial_load_later + + redirect_to return_to || accounts_path, notice: t(".success", account_name: account.name) + rescue Provider::Sophtron::Error => e + Rails.logger.error("Sophtron API error in link_existing_account: #{e.message}") + redirect_to accounts_path, alert: t(".api_error", message: e.message) + end + + def new + @sophtron_item = Current.family.sophtron_items.build + end + + def create + @sophtron_item = Current.family.sophtron_items.build(sophtron_params) + @sophtron_item.name ||= t("sophtron_items.defaults.name") + + if @sophtron_item.save + unless verify_and_provision_customer(@sophtron_item) + render_sophtron_panel_error(:new, @sophtron_item.last_connection_error) + return + end + + render_sophtron_panel_success(:create) + else + render_sophtron_panel_error(:new, @sophtron_item.errors.full_messages.join(", ")) + end + end + + def edit + end + + def update + if @sophtron_item.update(sophtron_params) + unless verify_and_provision_customer(@sophtron_item) + render_sophtron_panel_error(:edit, @sophtron_item.last_connection_error) + return + end + + render_sophtron_panel_success(:update) + else + render_sophtron_panel_error(:edit, @sophtron_item.errors.full_messages.join(", ")) + end + end + + def destroy + begin + @sophtron_item.unlink_all!(dry_run: false) + rescue => e + Rails.logger.warn("Sophtron unlink during destroy failed: #{e.class} - #{e.message}") + end + + @sophtron_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + if @sophtron_item.manual_sync_required? + start_manual_sync + return + end + + @sophtron_item.sync_later unless @sophtron_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + def toggle_manual_sync + toggle_accounts = manual_sync_toggle_sophtron_accounts + + if toggle_accounts.exists? + enabled = if @sophtron_item.manual_sync? + @sophtron_item.sophtron_accounts.where.not(id: toggle_accounts.select(:id)).update_all(manual_sync: true, updated_at: Time.current) + false + else + !toggle_accounts.requires_manual_sync.exists? + end + toggle_accounts.update_all(manual_sync: enabled, updated_at: Time.current) + @sophtron_item.update!(manual_sync: false) unless enabled + elsif params[:institution_key].present? || params[:user_institution_id].present? + redirect_back_or_to accounts_path, alert: t("sophtron_items.sync.no_linked_accounts") + return + else + @sophtron_item.update!(manual_sync: !@sophtron_item.manual_sync?) + enabled = @sophtron_item.manual_sync? + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path, notice: t(".success_#{enabled ? 'enabled' : 'disabled'}") } + format.turbo_stream do + flash.now[:notice] = t(".success_#{enabled ? 'enabled' : 'disabled'}") + render turbo_stream: [ + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(@sophtron_item), + partial: "sophtron_items/sophtron_item", + locals: { sophtron_item: @sophtron_item.reload } + ), + *flash_notification_stream_items + ] + end + end + end + + def setup_accounts + @api_error = fetch_sophtron_accounts_from_api + + @sophtron_accounts = @sophtron_item.sophtron_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + + supported_types = Provider::SophtronAdapter.supported_account_types + account_type_keys = { + "depository" => "Depository", + "credit_card" => "CreditCard", + "investment" => "Investment", + "loan" => "Loan", + "other_asset" => "OtherAsset" + } + + all_account_type_options = account_type_keys.filter_map do |key, type| + next unless supported_types.include?(type) + [ t(".account_types.#{key}"), type ] + end + + @account_type_options = [ [ t(".account_types.skip"), "skip" ] ] + all_account_type_options + @subtype_options = { + "Depository" => { + label: "Account Subtype:", + options: Depository::SUBTYPES.map { |k, v| [ v[:long], k ] } + }, + "CreditCard" => { + label: "", + options: [], + message: "Credit cards will be automatically set up as credit card accounts." + }, + "Investment" => { + label: "Investment Type:", + options: Investment::SUBTYPES.map { |k, v| [ v[:long], k ] } + }, + "Loan" => { + label: "Loan Type:", + options: Loan::SUBTYPES.map { |k, v| [ v[:long], k ] } + }, + "Crypto" => { + label: nil, + options: [], + message: "Crypto accounts track cryptocurrency holdings." + }, + "OtherAsset" => { + label: nil, + options: [], + message: "No additional options needed for Other Assets." + } + } + end + + def complete_account_setup + account_types = params[:account_types] || {} + account_subtypes = params[:account_subtypes] || {} + valid_types = Provider::SophtronAdapter.supported_account_types + created_accounts = [] + skipped_count = 0 + + begin + ActiveRecord::Base.transaction do + account_types.each do |sophtron_account_id, selected_type| + if selected_type == "skip" || selected_type.blank? + skipped_count += 1 + next + end + + unless valid_types.include?(selected_type) + Rails.logger.warn("Invalid account type '#{selected_type}' submitted for Sophtron account #{sophtron_account_id}") + next + end + + sophtron_account = @sophtron_item.sophtron_accounts.find_by(id: sophtron_account_id) + unless sophtron_account + Rails.logger.warn("Sophtron account #{sophtron_account_id} not found for item #{@sophtron_item.id}") + next + end + + if sophtron_account.account_provider.present? + Rails.logger.info("Sophtron account #{sophtron_account_id} already linked, skipping") + next + end + + selected_subtype = account_subtypes[sophtron_account_id] + selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank? + + account = Account.create_and_sync( + { + family: Current.family, + name: sophtron_account.name, + balance: sophtron_account.balance || 0, + currency: sophtron_account.currency || "USD", + accountable_type: selected_type, + accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {} + }, + skip_initial_sync: true + ) + + AccountProvider.create!(account: account, provider: sophtron_account) + created_accounts << account + end + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error("Sophtron account setup failed: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + flash[:alert] = t(".creation_failed") + redirect_to accounts_path, status: :see_other + return + rescue StandardError => e + Rails.logger.error("Sophtron account setup failed unexpectedly: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + flash[:alert] = t(".unexpected_error") + redirect_to accounts_path, status: :see_other + return + end + + @sophtron_item.start_initial_load_later if created_accounts.any? + + flash[:notice] = if created_accounts.any? + t(".success", count: created_accounts.count) + elsif skipped_count > 0 + t(".all_skipped") + else + t(".no_accounts") + end + + if turbo_frame_request? + @manual_accounts = Account.uncached { + Current.family.accounts.visible_manual.order(:name).to_a + } + @sophtron_items = Current.family.sophtron_items.ordered + manual_accounts_stream = if @manual_accounts.any? + turbo_stream.update( + "manual-accounts", + partial: "accounts/index/manual_accounts", + locals: { accounts: @manual_accounts } + ) + else + turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts")) + end + + render turbo_stream: [ + manual_accounts_stream, + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(@sophtron_item), + partial: "sophtron_items/sophtron_item", + locals: { sophtron_item: @sophtron_item } + ) + ] + Array(flash_notification_stream_items) + else + redirect_to accounts_path, status: :see_other + end + end + + private + + def start_manual_sync + if @sophtron_item.current_job_sophtron_account_id.present? + redirect_to active_manual_sync_path, alert: t(".already_running") + return + end + + unless linked_manual_sync_sophtron_accounts.exists? + redirect_back_or_to accounts_path, alert: t(".no_linked_accounts") + return + end + + sync = @sophtron_item.syncs.create! + sync.start! if sync.may_start? + @manual_sync = sync + + provider = @sophtron_item.sophtron_provider + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + reset_manual_sync_progress!(sync) + start_next_manual_sync_account(sync, provider) + rescue Provider::Sophtron::Error => e + clear_or_fail_manual_sync_after_error!(sync, e.message) if defined?(sync) && sync.present? + Rails.logger.error("Sophtron manual sync error: #{e.message}") + redirect_back_or_to accounts_path, alert: t(".api_error", message: e.message) + end + + def active_manual_sync_path + return accounts_path if @sophtron_item.current_job_id.blank? + + connection_status_sophtron_item_path( + @sophtron_item, + connection_context_params.merge( + manual_sync: true, + sync_id: manual_sync_record&.id, + sophtron_account_id: @sophtron_item.current_job_sophtron_account_id + ) + ) + end + + def start_next_manual_sync_account(sync, provider) + sophtron_account = next_manual_sync_sophtron_account(sync) + + unless sophtron_account + @sophtron_item.update!( + current_job_id: nil, + current_job_sophtron_account_id: nil, + last_connection_error: nil, + status: :good + ) + sync.finalize_if_all_children_finalized + flash.discard(:alert) + @manual_sync = sync + render :manual_sync_complete, layout: false + return + end + + start_manual_sync_for_account(sophtron_account, provider, sync) + end + + def start_manual_sync_for_account(sophtron_account, provider, sync) + refresh_response = sophtron_response_data!(provider.refresh_account(sophtron_account.account_id)).with_indifferent_access + job_id = refresh_response[:JobID] || refresh_response[:job_id] + + if job_id.blank? + complete_manual_sync!(sophtron_account, provider, sync) + start_next_manual_sync_account(sync, provider) + return + end + + @sophtron_item.update!( + current_job_id: job_id, + current_job_sophtron_account_id: sophtron_account.id, + raw_job_payload: refresh_response, + job_status: nil, + last_connection_error: nil, + status: :good + ) + + job = sophtron_response_data!(provider.get_job_information(job_id)) + @sophtron_item.upsert_job_snapshot!(job) + + if Provider::Sophtron.job_requires_input?(job) + @challenge = @sophtron_item.build_mfa_challenge(job) + prepare_connection_status_context + render :mfa, layout: false + elsif Provider::Sophtron.job_failed?(job) + failure_message = t(".failed") + fail_manual_sync_and_clear_job!(sync, failure_message) + redirect_back_or_to accounts_path, alert: failure_message + elsif Provider::Sophtron.job_success?(job) || Provider::Sophtron.job_completed?(job) + complete_manual_sync!(sophtron_account, provider, sync) + start_next_manual_sync_account(sync, provider) + else + @poll_attempt = 1 + render_pending_connection_status + end + end + + def complete_manual_sync_from_job(job) + sophtron_account = @sophtron_item.current_job_sophtron_account + sophtron_account ||= linked_manual_sync_sophtron_accounts.find_by(id: params[:sophtron_account_id]) if params[:sophtron_account_id].present? + sync = manual_sync_record + + unless sophtron_account && sync + @sophtron_item.update!(current_job_id: nil, current_job_sophtron_account_id: nil) + render_api_error(t("sophtron_items.sync.no_linked_accounts"), accounts_path) + return + end + + provider = @sophtron_item.sophtron_provider + complete_manual_sync!(sophtron_account, provider, sync) + start_next_manual_sync_account(sync, provider) + rescue Provider::Sophtron::Error => e + fail_manual_sync_and_clear_job!(sync, e.message) if defined?(sync) && sync.present? + render_api_error(t("sophtron_items.sync.api_error", message: e.message), accounts_path) + end + + def complete_manual_sync!(sophtron_account, provider, sync) + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + result = SophtronItem::Importer.new(@sophtron_item, sophtron_provider: provider, sync: sync) + .import_transactions_after_refresh(sophtron_account) + + unless result[:success] + error_message = result[:error] || t("sophtron_items.sync.failed") + fail_manual_sync_and_clear_job!(sync, error_message) + raise Provider::Sophtron::Error.new(error_message, :api_error) + end + + processing_result = process_manual_sync_account!(sync, sophtron_account) + mark_manual_sync_account_processed!(sync, sophtron_account) + collect_manual_sync_stats!(sync, processing_result) + @sophtron_item.update!( + current_job_id: nil, + current_job_sophtron_account_id: nil, + last_connection_error: nil, + status: :good + ) + + if (account = sophtron_account.current_account) + account.sync_later( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + else + sync.finalize_if_all_children_finalized + end + + @manual_sync_account = sophtron_account + @manual_sync = sync + end + + def process_manual_sync_account!(sync, sophtron_account) + SophtronAccount::Processor.new(sophtron_account.reload).process + rescue StandardError => e + Rails.logger.error("Sophtron manual sync processing error: #{e.class} - #{e.message}") + fail_manual_sync_and_clear_job!(sync, e.message) + raise Provider::Sophtron::Error.new(t("sophtron_items.sync.processing_failed"), :api_error) + end + + def fail_manual_sync_and_clear_job!(sync, message) + clear_manual_sync_job!(message, status: :requires_update) + fail_manual_sync!(sync, message) + end + + def clear_or_fail_manual_sync_after_error!(sync, message) + if sync.failed? + clear_manual_sync_job!(@sophtron_item.last_connection_error, status: :requires_update) + else + fail_manual_sync_and_clear_job!(sync, message) + end + end + + def clear_manual_sync_job!(message = nil, status: nil) + attributes = { + current_job_id: nil, + current_job_sophtron_account_id: nil + } + attributes[:last_connection_error] = message if message.present? + attributes[:status] = status if status.present? + + @sophtron_item.update!(attributes) + end + + def fail_manual_sync!(sync, message) + return unless sync + + sync.start! if sync.may_start? + sync.fail! if sync.may_fail? + sync.update!(error: message) + end + + def manual_sync_record + return @manual_sync if defined?(@manual_sync) && @manual_sync.present? + + sync = @sophtron_item.syncs.find_by(id: params[:sync_id]) if params[:sync_id].present? + sync || visible_manual_sync_record + end + + def visible_manual_sync_record + @sophtron_item.syncs.visible.ordered.detect do |sync| + sync.sync_stats.to_h.key?(MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY) + end + end + + def linked_manual_sync_sophtron_accounts + @sophtron_item.manual_sync_sophtron_accounts + end + + def manual_sync_toggle_sophtron_accounts + accounts = @sophtron_item.sophtron_accounts.order(:created_at, :id) + institution_key = params[:institution_key].presence || params[:user_institution_id] + return accounts if institution_key.blank? + + account_ids = accounts.select do |sophtron_account| + sophtron_account.institution_key.to_s == institution_key.to_s + end.map(&:id) + + accounts.where(id: account_ids) + end + + def next_manual_sync_sophtron_account(sync) + processed_ids = manual_sync_processed_sophtron_account_ids(sync) + linked_manual_sync_sophtron_accounts.detect { |sophtron_account| processed_ids.exclude?(sophtron_account.id.to_s) } + end + + def reset_manual_sync_progress!(sync) + sync.update!(sync_stats: { MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => [] }) + end + + def mark_manual_sync_account_processed!(sync, sophtron_account) + processed_ids = manual_sync_processed_sophtron_account_ids(sync) + processed_ids << sophtron_account.id.to_s + stats = sync.sync_stats.to_h + sync.update!(sync_stats: stats.merge(MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => processed_ids.uniq)) + end + + def collect_manual_sync_stats!(sync, processing_result) + mark_import_started(sync) + collect_setup_stats(sync, provider_accounts: @sophtron_item.sophtron_accounts.includes(:account_provider, :account)) + + account_ids = @sophtron_item.sophtron_accounts + .where(id: manual_sync_processed_sophtron_account_ids(sync)) + .includes(:account_provider) + .filter_map { |sophtron_account| sophtron_account.current_account&.id } + + collect_transaction_stats( + sync, + account_ids: account_ids, + source: "sophtron", + window_start: sync.syncing_at || sync.created_at, + window_end: Time.current + ) + + collect_manual_sync_health_stats!(sync, processing_result) + end + + def collect_manual_sync_health_stats!(sync, processing_result) + if processing_result.is_a?(Hash) && processing_result[:success] == false + errors = Array(processing_result[:errors]).presence || [ { message: t("sophtron_items.sync.failed"), category: "transaction_import" } ] + collect_health_stats(sync, errors: errors) + elsif sync.sync_stats.to_h["total_errors"].to_i.zero? + collect_health_stats(sync, errors: nil) + end + end + + def manual_sync_processed_sophtron_account_ids(sync) + Array(sync.sync_stats.to_h[MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY]).map(&:to_s) + end + + def configured_sophtron_item + Current.family.configured_sophtron_item + end + + def normalized_security_answers + raw_answers = Array(params[:security_answers]).flatten + return if raw_answers.size > MAX_SECURITY_ANSWERS + return if raw_answers.any? { |answer| answer.to_s.length > MAX_SECURITY_ANSWER_LENGTH } + + answers = raw_answers.filter_map do |answer| + answer.to_s.strip.presence + end + + return if answers.empty? + + answers + end + + def sophtron_response_data!(response) + Provider::Sophtron.response_data!(response) + end + + def verify_and_provision_customer(item) + provider = item.sophtron_provider + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + sophtron_response_data!(provider.health_check_auth) + item.ensure_customer!(provider: provider) + true + rescue Provider::Sophtron::Error => e + item.update(status: :requires_update, last_connection_error: e.message) + Rails.logger.error("Sophtron customer provisioning failed: #{e.message}") + false + end + + def render_sophtron_panel_success(action_name) + if turbo_frame_request? + flash.now[:notice] = t("sophtron_items.#{action_name}.success") + @sophtron_items = Current.family.sophtron_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "sophtron-providers-panel", + partial: "settings/providers/sophtron_panel", + locals: { sophtron_items: @sophtron_items } + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t("sophtron_items.#{action_name}.success"), status: :see_other + end + end + + def render_sophtron_panel_error(view_name, message) + @error_message = message + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "sophtron-providers-panel", + partial: "settings/providers/sophtron_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + render view_name, status: :unprocessable_entity + end + end + + def render_or_redirect_setup_required + if turbo_frame_request? + render partial: "sophtron_items/setup_required", layout: false + else + redirect_to settings_providers_path, alert: t("sophtron_items.select_accounts.no_credentials_configured") + end + end + + def item_for_institution_connection(item) + return item unless connect_new_institution_flow? && should_create_sophtron_item_for_new_institution?(item) + + Current.family.sophtron_items.create!( + name: item.name.presence || t("sophtron_items.defaults.name"), + user_id: item.user_id, + access_key: item.access_key, + base_url: item.base_url, + customer_id: item.customer_id, + customer_name: item.customer_name, + raw_customer_payload: item.raw_customer_payload, + sync_start_date: item.sync_start_date + ) + end + + def should_create_sophtron_item_for_new_institution?(item) + item.user_institution_id.present? || + item.current_job_id.present? || + item.institution_id.present? || + item.institution_name.present? || + item.sophtron_accounts.exists? + end + + def prepare_connection_form(item, account: nil) + @sophtron_item = item + @account = account + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + @connect_new_institution = connect_new_institution_flow? + @institution_search = params[:institution_name].to_s.strip + @institutions = [] + + if @institution_search.length >= 2 + @institutions = sophtron_response_data!(item.sophtron_provider.search_institutions(@institution_search)) + end + end + + def render_account_selection(item, force_refresh: false) + @available_accounts = item.reject_already_linked(item.fetch_remote_accounts(force: force_refresh)) + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + + if params[:account_id].present? + @account = Current.family.accounts.find(params[:account_id]) + render :select_existing_account, layout: false + else + render :select_accounts, layout: false + end + end + + def render_account_selection_if_accounts_available(item) + accounts = item.fetch_remote_accounts(force: true) + return false if accounts.empty? + + item.update!( + current_job_id: nil, + last_connection_error: nil, + pending_account_setup: true, + status: :good + ) + + @available_accounts = item.reject_already_linked(accounts) + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + + if params[:account_id].present? + @account = Current.family.accounts.find(params[:account_id]) + render :select_existing_account, layout: false + else + render :select_accounts, layout: false + end + + true + rescue Provider::Sophtron::Error => e + Rails.logger.info("Sophtron accounts are not available after completed job #{item.current_job_id}: #{e.message}") + false + end + + def render_pending_connection_status + if @poll_attempt >= connection_status_max_polls + render_connection_timeout + return + end + + prepare_connection_status_context + @next_poll_attempt = @poll_attempt + 1 + render :connection_status, layout: false + end + + def prepare_connection_status_context + @accountable_type = params[:accountable_type] || "Depository" + @account_id = params[:account_id] + @return_to = safe_return_to_path + @manual_sync_flow = manual_sync_flow? + @manual_sync_id = manual_sync_record&.id if @manual_sync_flow + @manual_sync_sophtron_account_id = params[:sophtron_account_id] || @sophtron_item.current_job_sophtron_account_id + @poll_interval_ms = CONNECTION_STATUS_POLL_INTERVAL_MS + @post_mfa_polling = post_mfa_polling? + @max_poll_attempts = connection_status_max_polls + end + + def requested_poll_attempt + poll_attempt = params[:poll_attempt].to_i + poll_attempt.positive? ? poll_attempt : 1 + end + + def render_connection_timeout + @poll_attempt = connection_status_max_polls if @poll_attempt.to_i > connection_status_max_polls + @poll_attempt = 1 if @poll_attempt.to_i < 1 + @sophtron_item.update!( + last_connection_error: t(".timeout"), + status: :requires_update + ) + prepare_connection_status_context + @timed_out = true + render :connection_status, layout: false + end + + def connection_status_max_polls + if post_mfa_polling? + POST_MFA_CONNECTION_STATUS_MAX_POLLS + elsif login_progress_polling? + LOGIN_PROGRESS_CONNECTION_STATUS_MAX_POLLS + else + CONNECTION_STATUS_MAX_POLLS + end + end + + def post_mfa_polling? + ActiveModel::Type::Boolean.new.cast(params[:post_mfa]) || post_mfa_job_payload?(@sophtron_item.raw_job_payload) + end + + def manual_sync_flow? + ActiveModel::Type::Boolean.new.cast(params[:manual_sync]) || @sophtron_item.current_job_sophtron_account_id.present? + end + + def post_mfa_job_payload?(job_payload) + job = (job_payload || {}).with_indifferent_access + job[:TokenInput].present? || %w[TokenInput TransactionTable].include?(job[:LastStep].to_s) + end + + def login_progress_polling? + login_progress_job_payload?(@sophtron_item.raw_job_payload) + end + + def login_progress_job_payload?(job_payload) + job = (job_payload || {}).with_indifferent_access + last_status = job[:LastStatus] || job[:last_status] + return false if Provider::Sophtron.failure_job_status?(last_status) + + job[:LastStep].present? || job[:last_step].present? || last_status.present? + end + + def prefetch_request? + [ + request.headers["X-Sec-Purpose"], + request.headers["Sec-Purpose"], + request.headers["Purpose"] + ].any? { |value| value.to_s.include?("prefetch") } + end + + def render_institution_connection_error(message) + render_api_error( + message, + select_accounts_sophtron_items_path(connection_context_params.except(:post_mfa, "post_mfa")), + heading: t("sophtron_items.api_error.institution_unable_to_connect"), + issue_keys: %w[bad_credentials verification_code institution_timeout unsupported_mfa], + action_label: t("sophtron_items.api_error.try_again") + ) + end + + def sophtron_connection_failure_message(job) + last_status = job.with_indifferent_access[:LastStatus].to_s + return t("sophtron_items.connection_status.failed_timeout") if last_status.match?(/timeout/i) + + t("sophtron_items.connection_status.failed") + end + + def render_api_error(message, return_path, heading: nil, issue_keys: nil, action_label: nil) + render partial: "sophtron_items/api_error", + locals: { + error_message: message, + return_path: return_path, + heading: heading, + issue_keys: issue_keys, + action_label: action_label + }, + layout: false + end + + def redirect_after_account_link(return_to, created_accounts, already_linked_accounts, invalid_accounts) + if invalid_accounts.any? && created_accounts.empty? && already_linked_accounts.empty? + redirect_to new_account_path, alert: t(".invalid_account_names", count: invalid_accounts.count) + elsif invalid_accounts.any? && (created_accounts.any? || already_linked_accounts.any?) + redirect_to return_to || accounts_path, + alert: t(".partial_invalid", + created_count: created_accounts.count, + already_linked_count: already_linked_accounts.count, + invalid_count: invalid_accounts.count) + elsif created_accounts.any? && already_linked_accounts.any? + redirect_to return_to || accounts_path, + notice: t(".partial_success", + created_count: created_accounts.count, + already_linked_count: already_linked_accounts.count, + already_linked_names: already_linked_accounts.join(", ")) + elsif created_accounts.any? + redirect_to return_to || accounts_path, notice: t(".success", count: created_accounts.count) + elsif already_linked_accounts.any? + redirect_to return_to || accounts_path, + alert: t(".all_already_linked", + count: already_linked_accounts.count, + names: already_linked_accounts.join(", ")) + else + redirect_to new_account_path, alert: t(".link_failed") + end + end + + def fetch_sophtron_accounts_from_api + return nil unless @sophtron_item.sophtron_accounts.empty? + return t("sophtron_items.setup_accounts.no_access_key") unless @sophtron_item.credentials_configured? + return t("sophtron_items.setup_accounts.no_institution_connected") unless @sophtron_item.connected_to_institution? + + @sophtron_item.fetch_remote_accounts(force: true) + nil + rescue Provider::Sophtron::Error => e + Rails.logger.error("Sophtron API error: #{e.message}") + t("sophtron_items.setup_accounts.api_error") + rescue StandardError => e + Rails.logger.error("Unexpected error fetching Sophtron accounts: #{e.class}: #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + t("sophtron_items.setup_accounts.api_error") + end + + def set_sophtron_item + @sophtron_item = Current.family.sophtron_items.find(params[:id]) + end + + def sophtron_params + params.require(:sophtron_item).permit(:name, :user_id, :access_key, :base_url, :sync_start_date) + end + + def connection_context_params + params.permit(:accountable_type, :account_id, :return_to, :post_mfa, :connect_new_institution, :manual_sync, :sync_id, :sophtron_account_id, :institution_key, :user_institution_id).to_h.compact + end + + def connect_new_institution_flow? + ActiveModel::Type::Boolean.new.cast(params[:connect_new_institution]) + end + + def safe_return_to_path + return nil if params[:return_to].blank? + + return_to = params[:return_to].to_s + + begin + uri = URI.parse(return_to) + return nil if uri.scheme.present? || uri.host.present? + return nil if return_to.start_with?("//") + return nil unless return_to.start_with?("/") + + return_to + rescue URI::InvalidURIError + nil + end + end +end diff --git a/app/controllers/splits_controller.rb b/app/controllers/splits_controller.rb index a62540e9d..e31b64596 100644 --- a/app/controllers/splits_controller.rb +++ b/app/controllers/splits_controller.rb @@ -16,7 +16,7 @@ class SplitsController < ApplicationController raw_splits = raw_splits.values if raw_splits.respond_to?(:values) splits = raw_splits.map do |s| - { name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence } + { name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence, excluded: s[:excluded] } end @entry.split!(splits) @@ -51,7 +51,7 @@ class SplitsController < ApplicationController raw_splits = raw_splits.values if raw_splits.respond_to?(:values) splits = raw_splits.map do |s| - { name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence } + { name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence, excluded: s[:excluded] } end Entry.transaction do @@ -95,6 +95,6 @@ class SplitsController < ApplicationController end def split_params - params.require(:split).permit(splits: [ :name, :amount, :category_id ]) + params.require(:split).permit(splits: [ :name, :amount, :category_id, :excluded ]) end end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index da02534bd..d6d5ba7d7 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -8,7 +8,7 @@ class SubscriptionsController < ApplicationController # Upgrade page for unsubscribed users def upgrade if Current.family.subscription&.active? - redirect_to root_path, notice: "You are already contributing. Thank you!" + redirect_to root_path, notice: t(".already_contributing") else @plan = params[:plan] || "annual" render layout: "onboardings" @@ -33,9 +33,9 @@ class SubscriptionsController < ApplicationController def create if Current.family.can_start_trial? Current.family.start_trial_subscription! - redirect_to root_path, notice: "Welcome to Sure!" + redirect_to root_path, notice: t(".welcome") else - redirect_to root_path, alert: "You have already started or completed a trial. Please upgrade to continue." + redirect_to root_path, alert: t(".trial_already_used") end end @@ -54,9 +54,9 @@ class SubscriptionsController < ApplicationController if checkout_result.success? Current.family.start_subscription!(checkout_result.subscription_id) - redirect_to root_path, notice: "Welcome to Sure! Your contribution is appreciated." + redirect_to root_path, notice: t(".welcome_with_contribution") else - redirect_to root_path, alert: "Something went wrong processing your contribution. Please try again." + redirect_to root_path, alert: t(".contribution_failed") end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 1c2862907..219971529 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -36,7 +36,7 @@ class TagsController < ApplicationController def destroy_all Current.family.tags.destroy_all - redirect_back_or_to tags_path, notice: "All tags deleted" + redirect_back_or_to tags_path, notice: t(".all_deleted") end private diff --git a/app/controllers/transaction_categories_controller.rb b/app/controllers/transaction_categories_controller.rb index 6a8ff353a..d9daf436a 100644 --- a/app/controllers/transaction_categories_controller.rb +++ b/app/controllers/transaction_categories_controller.rb @@ -21,6 +21,7 @@ class TransactionCategoriesController < ApplicationController transaction.lock_saved_attributes! @entry.lock_saved_attributes! + in_split_group = helpers.in_split_group?(@entry, params[:grouped]) respond_to do |format| format.html { redirect_back_or_to transaction_path(@entry) } format.turbo_stream do @@ -28,12 +29,12 @@ class TransactionCategoriesController < ApplicationController turbo_stream.replace( dom_id(transaction, "category_menu_mobile"), partial: "transactions/transaction_category", - locals: { transaction: transaction, variant: "mobile" } + locals: { transaction: transaction, variant: "mobile", in_split_group: in_split_group } ), turbo_stream.replace( dom_id(transaction, "category_menu_desktop"), partial: "transactions/transaction_category", - locals: { transaction: transaction, variant: "desktop" } + locals: { transaction: transaction, variant: "desktop", in_split_group: in_split_group } ), turbo_stream.replace( "category_name_mobile_#{transaction.id}", diff --git a/app/controllers/transactions/bulk_updates_controller.rb b/app/controllers/transactions/bulk_updates_controller.rb index 82d4c6ddf..133452f21 100644 --- a/app/controllers/transactions/bulk_updates_controller.rb +++ b/app/controllers/transactions/bulk_updates_controller.rb @@ -16,7 +16,7 @@ class Transactions::BulkUpdatesController < ApplicationController private def bulk_update_params params.require(:bulk_update) - .permit(:date, :notes, :category_id, :merchant_id, entry_ids: [], tag_ids: []) + .permit(:date, :notes, :name, :category_id, :merchant_id, entry_ids: [], tag_ids: []) end # Check if tag_ids was explicitly provided in the request. diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 6548d26ed..fbcd89c18 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -27,6 +27,7 @@ class TransactionsController < ApplicationController ) @pagy, @transactions = pagy(base_scope, limit: safe_per_page) + Transaction::ActivitySecurityPreloader.new(@transactions).preload # Preload split parent data entry_ids = @transactions.map { |t| t.entry.id } @@ -62,6 +63,8 @@ class TransactionsController < ApplicationController 10.days.from_now.to_date, Date.current) .includes(:merchant) + + @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.transactions"), nil ] ] end def clear_filter @@ -106,7 +109,7 @@ class TransactionsController < ApplicationController @entry.mark_user_modified! @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any? - flash[:notice] = "Transaction created" + flash[:notice] = t(".created") respond_to do |format| format.html { redirect_back_or_to account_path(@entry.account) } @@ -134,12 +137,15 @@ class TransactionsController < ApplicationController @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any? @entry.sync_account_later + notes_changed = @entry.saved_change_to_notes? + # Reload to ensure fresh state for turbo stream rendering @entry.reload respond_to do |format| - format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" } + format.html { redirect_back_or_to account_path(@entry.account), notice: t(".updated") } format.turbo_stream do + in_split_group = helpers.in_split_group?(@entry, params[:grouped]) render turbo_stream: [ turbo_stream.replace( dom_id(@entry, :header), @@ -151,9 +157,18 @@ class TransactionsController < ApplicationController partial: "entries/protection_indicator", locals: { entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) } ), - turbo_stream.replace(@entry), + (turbo_stream.replace( + dom_id(@entry, :notes), + partial: "transactions/notes", + locals: { entry: @entry, can_annotate: can_annotate_entry? } + ) if params[:entry]&.key?(:notes) && notes_changed), + turbo_stream.replace( + dom_id(@entry), + partial: "entries/entry", + locals: { entry: @entry, in_split_group: in_split_group } + ), *flash_notification_stream_items - ] + ].compact end end else @@ -173,7 +188,9 @@ class TransactionsController < ApplicationController end redirect_to transactions_path - rescue ActiveRecord::RecordNotDestroyed, ActiveRecord::RecordInvalid => e + rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid, + ActiveRecord::RecordNotDestroyed, ActiveRecord::Deadlocked, + ActiveRecord::LockWaitTimeout => e Rails.logger.error("Failed to merge duplicate transaction #{params[:id]}: #{e.message}") flash[:alert] = t("transactions.merge_duplicate.failure") redirect_to transactions_path @@ -353,6 +370,30 @@ class TransactionsController < ApplicationController head :unprocessable_entity end + def exchange_rate + account = Current.family.accounts.find(params[:account_id]) + currency_from = params[:currency] + date = params[:date]&.to_date || Date.current + + if account.currency == currency_from + render json: { same_currency: true, rate: 1.0 } + else + rate_obj = ExchangeRate.find_or_fetch_rate( + from: currency_from, + to: account.currency, + date: date + ) + + if rate_obj.nil? + return render json: { error: "Exchange rate not found" }, status: :not_found + end + + rate_value = rate_obj.is_a?(Numeric) ? rate_obj : rate_obj.rate + + render json: { rate: rate_value.to_f, account_currency: account.currency } + end + end + private def accessible_transactions Current.family.transactions @@ -409,12 +450,13 @@ class TransactionsController < ApplicationController def entry_params entry_params = params.require(:entry).permit( :name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type, - entryable_attributes: [ :id, :category_id, :merchant_id, :kind, :investment_activity_label, { tag_ids: [] } ] + entryable_attributes: [ :id, :category_id, :merchant_id, :kind, :investment_activity_label, :exchange_rate, { tag_ids: [] } ] ) nature = entry_params.delete(:nature) entry_params.delete(:amount) if entry_params[:amount].blank? + entry_params.delete(:date) if entry_params[:date].blank? if nature.present? && entry_params[:amount].present? signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d @@ -500,12 +542,18 @@ class TransactionsController < ApplicationController if params[:security_id] == "__custom__" # User selected "Enter custom ticker" - check for combobox selection or manual entry if params[:ticker].present? - # Combobox selection: format is "SYMBOL|EXCHANGE" - ticker_symbol, exchange_operating_mic = params[:ticker].split("|") + # Combobox selection: format is "SYMBOL|EXCHANGE|PROVIDER" + parsed = Security.parse_combobox_id(params[:ticker]) + if parsed[:ticker].blank? + flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker") + redirect_back_or_to transactions_path + return nil + end Security::Resolver.new( - ticker_symbol.strip, - exchange_operating_mic: exchange_operating_mic.presence || params[:exchange_operating_mic].presence, - country_code: user_country + parsed[:ticker].strip, + exchange_operating_mic: parsed[:exchange_operating_mic] || params[:exchange_operating_mic].presence, + country_code: user_country, + price_provider: parsed[:price_provider] ).resolve elsif params[:custom_ticker].present? # Manual entry from combobox's name_when_new or fallback text field @@ -528,12 +576,18 @@ class TransactionsController < ApplicationController end found elsif params[:ticker].present? - # Direct combobox (no existing holdings) - format is "SYMBOL|EXCHANGE" - ticker_symbol, exchange_operating_mic = params[:ticker].split("|") + # Direct combobox (no existing holdings) - format is "SYMBOL|EXCHANGE|PROVIDER" + parsed = Security.parse_combobox_id(params[:ticker]) + if parsed[:ticker].blank? + flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker") + redirect_back_or_to transactions_path + return nil + end Security::Resolver.new( - ticker_symbol.strip, - exchange_operating_mic: exchange_operating_mic.presence || params[:exchange_operating_mic].presence, - country_code: user_country + parsed[:ticker].strip, + exchange_operating_mic: parsed[:exchange_operating_mic] || params[:exchange_operating_mic].presence, + country_code: user_country, + price_provider: parsed[:price_provider] ).resolve elsif params[:custom_ticker].present? # Manual entry from combobox's name_when_new (no existing holdings path) diff --git a/app/controllers/transfer_matches_controller.rb b/app/controllers/transfer_matches_controller.rb index 341688d5d..4e67653ba 100644 --- a/app/controllers/transfer_matches_controller.rb +++ b/app/controllers/transfer_matches_controller.rb @@ -32,7 +32,7 @@ class TransferMatchesController < ApplicationController @transfer.sync_account_later - redirect_back_or_to transactions_path, notice: "Transfer created" + redirect_back_or_to transactions_path, notice: t(".success") end private diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 0a000fad6..465891f2f 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -1,22 +1,27 @@ class TransfersController < ApplicationController include StreamExtensions - before_action :set_transfer, only: %i[show destroy update] + before_action :set_transfer, only: %i[show destroy update mark_as_recurring] + before_action :set_accounts, only: %i[new create] def new @transfer = Transfer.new @from_account_id = params[:from_account_id] - - @accounts = accessible_accounts - .alphabetically - .includes( - :account_providers, - logo_attachment: :blob - ) end def show @categories = Current.family.categories.alphabetically + + # Whether the current user can hit `mark_as_recurring`: feature flag on, + # AND they have write access to BOTH transfer endpoints. Gating the + # view button on this avoids showing a CTA that the controller would + # reject via `require_account_permission!` for read-only sharers. + endpoint_ids = [ @transfer.from_account&.id, @transfer.to_account&.id ].compact + writable_endpoint_count = Account.writable_by(Current.user).where(id: endpoint_ids).distinct.count + @can_mark_as_recurring_transfer = + !Current.family.recurring_transactions_disabled? && + endpoint_ids.size == 2 && + writable_endpoint_count == 2 end def create @@ -31,8 +36,9 @@ class TransfersController < ApplicationController family: Current.family, source_account_id: source_account.id, destination_account_id: destination_account.id, - date: Date.parse(transfer_params[:date]), - amount: transfer_params[:amount].to_d + date: transfer_params[:date].present? ? Date.parse(transfer_params[:date]) : Date.current, + amount: transfer_params[:amount].to_d, + exchange_rate: transfer_params[:exchange_rate].presence&.to_d ).create if @transfer.persisted? @@ -45,6 +51,16 @@ class TransfersController < ApplicationController @from_account_id = transfer_params[:from_account_id] render :new, status: :unprocessable_entity end + rescue Money::ConversionError + @transfer ||= Transfer.new + @transfer.errors.add(:base, "Exchange rate unavailable for selected currencies and date") + set_accounts + render :new, status: :unprocessable_entity + rescue ArgumentError + @transfer ||= Transfer.new + @transfer.errors.add(:date, "is invalid") + set_accounts + render :new, status: :unprocessable_entity end def update @@ -70,6 +86,65 @@ class TransfersController < ApplicationController redirect_back_or_to transactions_url, notice: t(".success") end + def mark_as_recurring + if Current.family.recurring_transactions_disabled? + flash[:alert] = t("recurring_transactions.transfer_feature_disabled") + redirect_back_or_to transactions_path + return + end + + source_account = @transfer.from_account + destination_account = @transfer.to_account + + if source_account.nil? || destination_account.nil? + flash[:alert] = t("recurring_transactions.unexpected_error") + redirect_back_or_to transactions_path + return + end + + return unless require_account_permission!(source_account) + return unless require_account_permission!(destination_account) + + existing = Current.family.recurring_transactions.find_by( + account_id: source_account.id, + destination_account_id: destination_account.id, + amount: @transfer.outflow_transaction.entry.amount, + currency: @transfer.outflow_transaction.entry.currency + ) + + if existing + flash[:alert] = t("recurring_transactions.transfer_already_exists") + respond_to do |format| + format.html { redirect_back_or_to transactions_path } + end + return + end + + begin + RecurringTransaction.create_from_transfer(@transfer) + flash[:notice] = t("recurring_transactions.transfer_marked_as_recurring") + respond_to do |format| + format.html { redirect_back_or_to transactions_path } + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique + # RecordNotUnique covers the race window between `find_by` and `create!` + # (the partial unique index protects us at the DB level). + flash[:alert] = t("recurring_transactions.transfer_creation_failed") + respond_to do |format| + format.html { redirect_back_or_to transactions_path } + end + rescue StandardError => e + Rails.logger.error( + "transfers#mark_as_recurring failed: #{e.class} #{e.message} " \ + "(transfer=#{@transfer&.id} family=#{Current.family&.id} user=#{Current.user&.id})" + ) + flash[:alert] = t("recurring_transactions.unexpected_error") + respond_to do |format| + format.html { redirect_back_or_to transactions_path } + end + end + end + private def set_transfer # Finds the transfer and ensures the user has access to it @@ -85,7 +160,16 @@ class TransfersController < ApplicationController end def transfer_params - params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded) + params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded, :exchange_rate) + end + + def set_accounts + @accounts = accessible_accounts + .alphabetically + .includes( + :account_providers, + logo_attachment: :blob + ) end def transfer_update_params diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ebf250d03..d265734c6 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -6,7 +6,7 @@ class UsersController < ApplicationController if @user.resend_confirmation_email redirect_to settings_profile_path, notice: t(".success") else - redirect_to settings_profile_path, alert: t("no_pending_change") + redirect_to settings_profile_path, alert: t(".no_pending_change") end end @@ -107,7 +107,10 @@ class UsersController < ApplicationController def user_params family_attrs = [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :id ] - family_attrs.push(:moniker, :default_account_sharing) if Current.user.admin? + if Current.user.admin? + family_attrs.push(:moniker, :default_account_sharing) + family_attrs << { enabled_currencies: [] } + end params.require(:user).permit( :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, @@ -127,8 +130,9 @@ class UsersController < ApplicationController moniker_changed = family_attrs[:moniker].present? && family_attrs[:moniker] != Current.family.moniker sharing_changed = family_attrs[:default_account_sharing].present? && family_attrs[:default_account_sharing] != Current.family.default_account_sharing + enabled_currencies_changed = family_attrs.key?(:enabled_currencies) - moniker_changed || sharing_changed + moniker_changed || sharing_changed || enabled_currencies_changed end def ensure_admin diff --git a/app/controllers/valuations_controller.rb b/app/controllers/valuations_controller.rb index 32ec2df74..9dbfdd597 100644 --- a/app/controllers/valuations_controller.rb +++ b/app/controllers/valuations_controller.rb @@ -7,6 +7,12 @@ class ValuationsController < ApplicationController @entry = @account.entries.build(entry_params.merge(currency: @account.currency)) + if entry_params[:amount].blank? + @error_message = t("valuations.errors.amount_required") + render :new, status: :unprocessable_entity + return + end + @reconciliation_dry_run = @entry.account.create_reconciliation( balance: entry_params[:amount], date: entry_params[:date], @@ -21,6 +27,13 @@ class ValuationsController < ApplicationController return unless require_account_permission!(@entry.account) @account = @entry.account + + if entry_params[:amount].blank? + @error_message = t("valuations.errors.amount_required") + render :show, status: :unprocessable_entity + return + end + @entry.assign_attributes(entry_params.merge(currency: @account.currency)) @reconciliation_dry_run = @entry.account.update_reconciliation( @@ -44,8 +57,8 @@ class ValuationsController < ApplicationController if result.success? respond_to do |format| - format.html { redirect_back_or_to account_path(account), notice: "Account updated" } - format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: "Account updated") } + format.html { redirect_back_or_to account_path(account), notice: t(".account_updated") } + format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: t(".account_updated")) } end else @error_message = result.error_message @@ -71,7 +84,7 @@ class ValuationsController < ApplicationController @entry.reload respond_to do |format| - format.html { redirect_back_or_to account_path(@entry.account), notice: "Entry updated" } + format.html { redirect_back_or_to account_path(@entry.account), notice: t(".entry_updated") } format.turbo_stream do render turbo_stream: [ turbo_stream.replace( diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 54cf8331f..e50325dcd 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -15,7 +15,8 @@ class WebhooksController < ApplicationController render json: { received: true }, status: :ok rescue => error Sentry.capture_exception(error) - render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request + Rails.logger.error("Webhook error: #{error.class} - #{error.message}") + render json: { error: "Invalid webhook" }, status: :bad_request end def plaid_eu @@ -31,7 +32,8 @@ class WebhooksController < ApplicationController render json: { received: true }, status: :ok rescue => error Sentry.capture_exception(error) - render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request + Rails.logger.error("Webhook error: #{error.class} - #{error.message}") + render json: { error: "Invalid webhook" }, status: :bad_request end def stripe diff --git a/app/helpers/account_statements_helper.rb b/app/helpers/account_statements_helper.rb new file mode 100644 index 000000000..fa736b5ae --- /dev/null +++ b/app/helpers/account_statements_helper.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module AccountStatementsHelper + ACCOUNT_STATEMENT_BALANCE_FIELDS = %w[opening_balance closing_balance].freeze + + def account_statement_status_badge(statement) + case statement.review_status + when "linked" + render("shared/badge", color: "success") { t("account_statements.status.linked") } + when "rejected" + render("shared/badge", color: "warning") { t("account_statements.status.rejected") } + else + render("shared/badge") { t("account_statements.status.unmatched") } + end + end + + def account_statement_coverage_classes(status) + case status.to_s + when "not_expected" + "bg-container-inset text-subdued ring-alpha-black-25" + when "covered" + "bg-success/10 text-success ring-success/20" + when "duplicate", "ambiguous" + "bg-warning/10 text-warning ring-warning/20" + when "mismatched" + "bg-destructive/10 text-destructive ring-destructive/20" + else + "bg-gray-tint-5 text-secondary ring-alpha-black-50" + end + end + + def account_statement_period(statement) + if statement.period_start_on.present? && statement.period_end_on.present? + "#{format_date(statement.period_start_on)} - #{format_date(statement.period_end_on)}" + else + t("account_statements.period.unknown") + end + end + + def account_statement_coverage_label(month) + account_statement_month_label(month.date) + end + + def account_statement_month_label(date) + l(date, format: "%b %Y") + end + + def account_statement_coverage_range(coverage) + t( + "account_statements.account_tab.coverage_range", + start: account_statement_month_label(coverage.expected_start_month), + end: account_statement_month_label(coverage.expected_end_month) + ) + end + + def account_statement_reconciliation_label(check) + key = check[:key] if check.respond_to?(:key?) && check.key?(:key) + key ||= check["key"] if check.respond_to?(:key?) && check.key?("key") + fallback = t("account_statements.reconciliation.checks.unknown_check") + return fallback if key.blank? + + t( + "account_statements.reconciliation.checks.#{key}", + default: fallback + ) + end + + def account_statement_balance_label(statement, field) + return t("account_statements.balance.unknown") unless field.to_s.in?(ACCOUNT_STATEMENT_BALANCE_FIELDS) + + money = statement.public_send("#{field}_money") + money ? money.format : t("account_statements.balance.unknown") + end + + def account_statement_currency_options(statement) + currency_picker_options_for_family(Current.family, extra: [ statement.currency ]).map do |code| + currency = Money::Currency.new(code) + [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] + end + end + + def account_statement_file_icon(statement) + if statement.pdf? + "file-text" + elsif statement.xlsx? + "sheet" + else + "file-spreadsheet" + end + end +end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 99ef891ce..a119246fd 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -8,4 +8,41 @@ module AccountsHelper # Always use the account sync path, which handles syncing all providers sync_account_path(account) end + + # Returns the account id segment from `/accounts/(/...)?`, or nil. + # Used as a cache-key component so the sidebar's active-link styling is + # correct without busting the cache for every unrelated path change. + def sidebar_active_account_id + match = request.path.match(%r{\A/accounts/([\w-]+)}) + match && match[1] + end + + # Cache key for `accounts/_account_sidebar_tabs.html.erb`. + # Kept here (not in the ERB) so the partial stays render-only. + # + # `shares_version` includes both row count and `max(updated_at)` because + # deleting a non-most-recent share would not move `max(updated_at)` and + # could otherwise serve stale fragments to a user who lost access. + # Both are pulled in a single SQL round-trip via `pick`. Note: Rails + # returns the values as Strings for raw SQL fragments — that's fine + # since they only feed into a cache key (concat-stable, never coerced). + def account_sidebar_tabs_cache_key(family:, active_tab:, mobile:) + shares_version = + if Current.user + count, max_at = AccountShare + .where(user_id: Current.user.id) + .pick(Arel.sql("count(*)"), Arel.sql("max(updated_at)")) + "#{count}-#{max_at}" + end + + [ + family.build_cache_key("account_sidebar_tabs_v1", invalidate_on_data_updates: true), + Current.user&.id, + shares_version, + active_tab, + mobile, + I18n.locale, + sidebar_active_account_id + ] + end end diff --git a/app/helpers/api/v1/money_helper.rb b/app/helpers/api/v1/money_helper.rb new file mode 100644 index 000000000..2556a1941 --- /dev/null +++ b/app/helpers/api/v1/money_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Api::V1::MoneyHelper + def money_to_minor_units(money) + (money.amount * money.currency.minor_unit_conversion).round(0).to_i if money + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7f0aad0bd..9f2c0a647 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -14,10 +14,28 @@ module ApplicationHelper form_with(**options, &block) end + # Locale-aware ordinal label for integers. + # English falls through to Ruby's ordinalize ("1st"); Catalan returns "1r"/"2n"/... + def localized_ordinal(number) + case I18n.locale + when :ca + n = number.to_i + suffix = case n + when 1, 3 then "r" + when 2 then "n" + when 4 then "t" + else "è" + end + "#{n}#{suffix}" + else + number.to_i.ordinalize + end + end + def icon(key, size: "md", color: "default", custom: false, as_button: false, **opts) extra_classes = opts.delete(:class) sizes = { xs: "w-3 h-3", sm: "w-4 h-4", md: "w-5 h-5", lg: "w-6 h-6", xl: "w-7 h-7", "2xl": "w-8 h-8" } - colors = { default: "fg-gray", white: "fg-inverse", success: "text-success", warning: "text-warning", destructive: "text-destructive", current: "text-current" } + colors = { default: "text-secondary", white: "text-inverse", success: "text-success", warning: "text-warning", destructive: "text-destructive", info: "text-info", current: "text-current" } icon_classes = class_names( "shrink-0", @@ -26,12 +44,14 @@ module ApplicationHelper extra_classes ) + resolved_key = normalize_icon_key(key) + if custom - inline_svg_tag("#{key}.svg", class: icon_classes, **opts) + inline_svg_tag("#{resolved_key}.svg", class: icon_classes, **opts) elsif as_button - render DS::Button.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts) + render DS::Button.new(variant: "icon", class: extra_classes, icon: resolved_key, size: size, type: "button", **opts) else - lucide_icon(key, class: icon_classes, **opts) + safe_lucide_icon(resolved_key, class: icon_classes, **opts) end end @@ -72,7 +92,7 @@ module ApplicationHelper def family_moniker - Current.family&.moniker_label || "Family" + Current.family&.moniker_label || I18n.t("shared.family_moniker.singular") end def family_moniker_downcase @@ -80,7 +100,7 @@ module ApplicationHelper end def family_moniker_plural - Current.family&.moniker_label_plural || "Families" + Current.family&.moniker_label_plural || I18n.t("shared.family_moniker.plural") end def family_moniker_plural_downcase @@ -100,6 +120,17 @@ module ApplicationHelper .join(separator) end + def currency_picker_options_for_family(family = Current.family, extra: []) + return Money::Currency.as_options.map(&:iso_code) unless family + + family.enabled_currency_codes(extra:) + end + + def currency_label(currency_or_code) + currency = currency_or_code.is_a?(Money::Currency) ? currency_or_code : Money::Currency.new(currency_or_code) + "#{currency.name} (#{currency.iso_code})" + end + def show_super_admin_bar? if params[:admin].present? cookies.permanent[:admin] = params[:admin] @@ -179,6 +210,20 @@ module ApplicationHelper end private + def safe_lucide_icon(key, **opts) + lucide_icon(key, **opts) + rescue StandardError => e + Rails.logger.warn("[ApplicationHelper] Falling back to key for unknown icon #{key.inspect}: #{e.message}") + lucide_icon("key", **opts) + end + + def normalize_icon_key(key) + normalized = key.to_s.strip + return normalized if normalized.blank? + + normalized.downcase + end + def calculate_total(item, money_method, negate) # Filter out transfer-type transactions from entries # Only Entry objects have entryable transactions, Account objects don't diff --git a/app/helpers/brex_items_helper.rb b/app/helpers/brex_items_helper.rb new file mode 100644 index 000000000..be30ecb40 --- /dev/null +++ b/app/helpers/brex_items_helper.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module BrexItemsHelper + BrexAccountDisplay = Struct.new( + :id, + :name, + :kind, + :currency, + :status, + :blank_name, + keyword_init: true + ) do + alias_method :blank_name?, :blank_name + end + + def brex_account_display(account) + data = account.with_indifferent_access + kind = BrexAccount.kind_for(data) + name = BrexAccount.name_for(data) + + BrexAccountDisplay.new( + id: data[:id], + name: name, + kind: kind, + currency: BrexAccount.currency_code_from_money(data[:current_balance] || data[:available_balance] || data[:account_limit]), + status: data[:status], + blank_name: name.blank? + ) + end + + def brex_account_metadata(display) + parts = [ + t("brex_items.account_metadata.provider"), + display.currency, + translated_brex_metadata_value("kinds", display.kind), + translated_brex_metadata_value("statuses", display.status) + ].compact + + parts.join(t("brex_items.account_metadata.separator")) + end + + def brex_item_render_locals(brex_item, sync_stats_map: nil, account_counts_map: nil, institutions_count_map: nil) + counts = (account_counts_map || {})[brex_item.id] || {} + + { + brex_item: brex_item, + stats: (sync_stats_map || {})[brex_item.id] || brex_item.syncs.ordered.first&.sync_stats || {}, + unlinked_count: counts[:unlinked] || brex_item.unlinked_accounts_count, + linked_count: counts[:linked] || brex_item.linked_accounts_count, + total_count: counts[:total] || brex_item.total_accounts_count, + institutions_count: (institutions_count_map || {})[brex_item.id] || brex_item.connected_institutions.size + } + end + + def default_brex_depository_subtype(account_name) + normalized_name = account_name.to_s.downcase + + if normalized_name.match?(/\bchecking\b|\bchequing\b|\bck\b|demand\s+deposit/) + "checking" + elsif normalized_name.match?(/\bsavings\b|\bsv\b/) + "savings" + elsif normalized_name.match?(/money\s+market|\bmm\b/) + "money_market" + else + "checking" + end + end + + private + def translated_brex_metadata_value(scope, value) + key = value.to_s + return nil if key.blank? + + t("brex_items.#{scope}.#{key}", default: key.titleize) + end +end diff --git a/app/helpers/budgets_helper.rb b/app/helpers/budgets_helper.rb new file mode 100644 index 000000000..8ffb27ad8 --- /dev/null +++ b/app/helpers/budgets_helper.rb @@ -0,0 +1,70 @@ +module BudgetsHelper + def budget_has_over_budget?(budget) + return false unless budget.initialized? + + budget.budget_categories.any?(&:any_over_budget?) + end + + def budget_categories_view_state(budget) + @budget_categories_view_state ||= {} + @budget_categories_view_state[budget.object_id] ||= build_budget_categories_view_state(budget) + end + + private + + def build_budget_categories_view_state(budget) + uncategorized_budget_category = budget.uncategorized_budget_category + all_category_groups = BudgetCategory::Group.for(budget.budget_categories) + + over_budget_groups = if budget.initialized? + filtered_groups_for(all_category_groups) { |budget_category| budget_category.any_over_budget? } + else + [] + end + + show_over_budget_uncategorized = budget.initialized? && uncategorized_budget_category.any_over_budget? + over_budget_count = visible_count_for(over_budget_groups) { |budget_category| budget_category.any_over_budget? } + over_budget_count += 1 if show_over_budget_uncategorized + + on_track_groups = if budget.initialized? + filtered_groups_for(all_category_groups) { |budget_category| budget_category.visible_on_track? } + else + all_category_groups + end + + show_on_track_uncategorized = all_category_groups.any? && (!budget.initialized? || uncategorized_budget_category.on_track?) + on_track_count = visible_count_for(on_track_groups) { |budget_category| parent_visible_for_on_track?(budget, budget_category) } + on_track_count += 1 if show_on_track_uncategorized + visible_expenses_empty = on_track_count.zero? + + { + uncategorized_budget_category: uncategorized_budget_category, + visible_expenses_empty: visible_expenses_empty, + over_budget_groups: over_budget_groups, + show_over_budget_uncategorized: show_over_budget_uncategorized, + over_budget_count: over_budget_count, + on_track_groups: on_track_groups, + show_on_track_uncategorized: show_on_track_uncategorized, + on_track_count: on_track_count + } + end + + def parent_visible_for_on_track?(budget, budget_category) + budget.initialized? ? budget_category.visible_on_track? : true + end + + def filtered_groups_for(groups) + groups.each_with_object([]) do |group, filtered_groups| + visible_subcategories = group.budget_subcategories.select { |budget_category| yield(budget_category) } + next unless yield(group.budget_category) || visible_subcategories.any? + + filtered_groups << BudgetCategory::Group.new(group.budget_category, visible_subcategories) + end + end + + def visible_count_for(groups) + groups.sum do |group| + (yield(group.budget_category) ? 1 : 0) + group.budget_subcategories.count + end + end +end diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb index 2d586dee7..458dae432 100644 --- a/app/helpers/categories_helper.rb +++ b/app/helpers/categories_helper.rb @@ -1,21 +1,21 @@ module CategoriesHelper def transfer_category Category.new \ - name: "Transfer", + name: I18n.t("categories.virtual.transfer"), color: Category::TRANSFER_COLOR, lucide_icon: "arrow-right-left" end def payment_category Category.new \ - name: "Payment", + name: I18n.t("categories.virtual.payment"), color: Category::PAYMENT_COLOR, lucide_icon: "arrow-right" end def trade_category Category.new \ - name: "Trade", + name: I18n.t("categories.virtual.trade"), color: Category::TRADE_COLOR end diff --git a/app/helpers/custom_confirm.rb b/app/helpers/custom_confirm.rb index cdf245853..f2d59a009 100644 --- a/app/helpers/custom_confirm.rb +++ b/app/helpers/custom_confirm.rb @@ -38,14 +38,14 @@ class CustomConfirm end def default_title - "Are you sure?" + I18n.t("shared.custom_confirm.default_title") end def default_body - "This is not reversible." + I18n.t("shared.custom_confirm.default_body") end def default_btn_text - "Confirm" + I18n.t("shared.custom_confirm.default_btn_text") end end diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index 6cf680452..30f986bce 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -1,51 +1,60 @@ module ImportsHelper def mapping_label(mapping_class) { - "Import::AccountTypeMapping" => "Account Type", - "Import::AccountMapping" => "Account", - "Import::CategoryMapping" => "Category", - "Import::TagMapping" => "Tag" + "Import::AccountTypeMapping" => I18n.t("imports.mapping_labels.account_type"), + "Import::AccountMapping" => I18n.t("imports.mapping_labels.account"), + "Import::CategoryMapping" => I18n.t("imports.mapping_labels.category"), + "Import::TagMapping" => I18n.t("imports.mapping_labels.tag") }.fetch(mapping_class.name) end def import_col_label(key) { - date: "Date", - amount: "Amount", - name: "Name", - currency: "Currency", - category: "Category", - tags: "Tags", - account: "Account", - notes: "Notes", - qty: "Quantity", - ticker: "Ticker", - exchange: "Exchange", - price: "Price", - entity_type: "Type", - category_parent: "Parent category", - category_color: "Color", - category_icon: "Lucide icon" + date: I18n.t("imports.column_labels.date"), + amount: I18n.t("imports.column_labels.amount"), + name: I18n.t("imports.column_labels.name"), + currency: I18n.t("imports.column_labels.currency"), + category: I18n.t("imports.column_labels.category"), + tags: I18n.t("imports.column_labels.tags"), + account: I18n.t("imports.column_labels.account"), + notes: I18n.t("imports.column_labels.notes"), + qty: I18n.t("imports.column_labels.qty"), + ticker: I18n.t("imports.column_labels.ticker"), + exchange: I18n.t("imports.column_labels.exchange"), + price: I18n.t("imports.column_labels.price"), + entity_type: I18n.t("imports.column_labels.entity_type"), + category_parent: I18n.t("imports.column_labels.category_parent"), + category_color: I18n.t("imports.column_labels.category_color"), + category_icon: I18n.t("imports.column_labels.category_icon") }[key] end def dry_run_resource(key) map = { - transactions: DryRunResource.new(label: "Transactions", icon: "credit-card", text_class: "text-cyan-500", bg_class: "bg-cyan-500/5"), - accounts: DryRunResource.new(label: "Accounts", icon: "layers", text_class: "text-orange-500", bg_class: "bg-orange-500/5"), - categories: DryRunResource.new(label: "Categories", icon: "shapes", text_class: "text-blue-500", bg_class: "bg-blue-500/5"), - tags: DryRunResource.new(label: "Tags", icon: "tags", text_class: "text-violet-500", bg_class: "bg-violet-500/5"), - rules: DryRunResource.new(label: "Rules", icon: "workflow", text_class: "text-green-500", bg_class: "bg-green-500/5"), - merchants: DryRunResource.new(label: "Merchants", icon: "store", text_class: "text-amber-500", bg_class: "bg-amber-500/5"), - trades: DryRunResource.new(label: "Trades", icon: "arrow-left-right", text_class: "text-emerald-500", bg_class: "bg-emerald-500/5"), - valuations: DryRunResource.new(label: "Valuations", icon: "trending-up", text_class: "text-pink-500", bg_class: "bg-pink-500/5"), - budgets: DryRunResource.new(label: "Budgets", icon: "wallet", text_class: "text-indigo-500", bg_class: "bg-indigo-500/5"), - budget_categories: DryRunResource.new(label: "Budget Categories", icon: "pie-chart", text_class: "text-teal-500", bg_class: "bg-teal-500/5") + transactions: DryRunResource.new(label: t("imports.dry_run_resources.transactions"), icon: "credit-card", text_class: "text-info", bg_class: "bg-info/10"), + balances: DryRunResource.new(label: t("imports.dry_run_resources.balances"), icon: "line-chart", text_class: "text-secondary", bg_class: "bg-container-inset"), + accounts: DryRunResource.new(label: t("imports.dry_run_resources.accounts"), icon: "layers", text_class: "text-warning", bg_class: "bg-warning/10"), + categories: DryRunResource.new(label: t("imports.dry_run_resources.categories"), icon: "shapes", text_class: "text-info", bg_class: "bg-info/10"), + tags: DryRunResource.new(label: t("imports.dry_run_resources.tags"), icon: "tags", text_class: "text-info", bg_class: "bg-info/10"), + rules: DryRunResource.new(label: t("imports.dry_run_resources.rules"), icon: "workflow", text_class: "text-success", bg_class: "bg-success/10"), + merchants: DryRunResource.new(label: t("imports.dry_run_resources.merchants"), icon: "store", text_class: "text-warning", bg_class: "bg-warning/10"), + recurring_transactions: DryRunResource.new(label: t("imports.dry_run_resources.recurring_transactions"), icon: "repeat-2", text_class: "text-secondary", bg_class: "bg-container-inset"), + transfers: DryRunResource.new(label: t("imports.dry_run_resources.transfers"), icon: "repeat", text_class: "text-secondary", bg_class: "bg-container-inset"), + rejected_transfers: DryRunResource.new(label: t("imports.dry_run_resources.rejected_transfers"), icon: "ban", text_class: "text-destructive", bg_class: "bg-destructive/10"), + trades: DryRunResource.new(label: t("imports.dry_run_resources.trades"), icon: "arrow-left-right", text_class: "text-success", bg_class: "bg-success/10"), + holdings: DryRunResource.new(label: t("imports.dry_run_resources.holdings"), icon: "briefcase-business", text_class: "text-secondary", bg_class: "bg-container-inset"), + valuations: DryRunResource.new(label: t("imports.dry_run_resources.valuations"), icon: "trending-up", text_class: "text-info", bg_class: "bg-info/10"), + budgets: DryRunResource.new(label: t("imports.dry_run_resources.budgets"), icon: "wallet", text_class: "text-info", bg_class: "bg-info/10"), + budget_categories: DryRunResource.new(label: t("imports.dry_run_resources.budget_categories"), icon: "pie-chart", text_class: "text-success", bg_class: "bg-success/10") } map[key] end + def import_verification_view(import) + ImportVerificationView.new(import.verification_payload) + end + def permitted_import_configuration_path(import) if permitted_import_types.include?(import.type.underscore) "import/configurations/#{import.type.underscore}" @@ -71,8 +80,53 @@ module ImportsHelper private def permitted_import_types - %w[transaction_import trade_import account_import mint_import category_import rule_import] + %w[transaction_import trade_import account_import mint_import actual_import category_import rule_import] end DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true) + + ImportVerificationView = Struct.new(:payload) do + def status + readback.fetch("status", "not_verified").to_s + end + + def checked_total + checked_counts.values.sum(&:to_i) + end + + def checked_counts + hash_value(readback["checked_counts"]) + end + + def mismatches + hash_value(readback["mismatches"]) + end + + def mismatches_count + mismatches.size + end + + def mismatches_preview + mismatches.first(3) + end + + def mismatches? + mismatches.any? + end + + private + def readback + hash_value(payload_hash["readback"]) + end + + def payload_hash + hash_value(payload) + end + + def hash_value(value) + return {} unless value.respond_to?(:to_h) + + value.to_h.deep_stringify_keys + end + end end diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 6f451d4a4..66677a6c1 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -167,7 +167,8 @@ module LanguagesHelper "pt-BR", # Brazilian Portuguese "zh-CN", # Chinese (Simplified) "zh-TW", # Chinese (Traditional) - "nl" # Dutch + "nl", # Dutch + "hu" # Hungarian ].freeze COUNTRY_MAPPING = { @@ -370,7 +371,12 @@ module LanguagesHelper }.freeze def country_options - COUNTRY_MAPPING.keys.map { |key| [ COUNTRY_MAPPING[key], key ] } + COUNTRY_MAPPING.keys.map do |key| + english = COUNTRY_MAPPING[key] + emoji, name = english.split(" ", 2) + label = I18n.t("countries.#{key}", default: name) + [ "#{emoji} #{label}", key ] + end end def language_options diff --git a/app/helpers/reports_helper.rb b/app/helpers/reports_helper.rb index d79535146..adc964f7b 100644 --- a/app/helpers/reports_helper.rb +++ b/app/helpers/reports_helper.rb @@ -1,15 +1,13 @@ module ReportsHelper - # Returns CSS classes for tax treatment badge styling - def tax_treatment_badge_classes(treatment) + # Returns the DS::Pill tone for a given tax treatment. Mirrors the + # mapping used by `accounts/_tax_treatment_badge.html.erb` so the + # report and the per-account badge stay visually aligned. + def tax_treatment_pill_tone(treatment) case treatment.to_sym - when :tax_exempt - "bg-green-500/10 text-green-600 theme-dark:text-green-400" - when :tax_deferred - "bg-blue-500/10 text-blue-600 theme-dark:text-blue-400" - when :tax_advantaged - "bg-purple-500/10 text-purple-600 theme-dark:text-purple-400" - else - "bg-gray-500/10 text-secondary" + when :tax_exempt then :green + when :tax_deferred then :indigo # was raw blue-500/10 → closest DS tone + when :tax_advantaged then :violet # was raw purple-500/10 → closest DS tone + else :neutral end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 79a3c248a..d9eb42a0e 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -1,31 +1,31 @@ module SettingsHelper SETTINGS_ORDER = [ # General section - { name: "Accounts", path: :accounts_path }, - { name: "Bank Sync", path: :settings_bank_sync_path }, - { name: "Preferences", path: :settings_preferences_path }, - { name: "Appearance", path: :settings_appearance_path }, - { name: "Profile Info", path: :settings_profile_path }, - { name: "Security", path: :settings_security_path }, - { name: "Payment", path: :settings_payment_path, condition: :not_self_hosted? }, + { name: -> { t("settings.settings_nav.accounts_label") }, path: :accounts_path }, + { name: -> { t("settings.settings_nav.bank_sync_label") }, path: :settings_providers_path, condition: :admin_user? }, + { name: -> { t("settings.settings_nav.preferences_label") }, path: :settings_preferences_path }, + { name: -> { t("settings.settings_nav.appearance_label") }, path: :settings_appearance_path }, + { name: -> { t("settings.settings_nav.profile_label") }, path: :settings_profile_path }, + { name: -> { t("settings.settings_nav.security_label") }, path: :settings_security_path }, + { name: -> { t("settings.settings_nav.payment_label") }, path: :settings_payment_path, condition: :not_self_hosted? }, # Transactions section - { name: "Categories", path: :categories_path }, - { name: "Tags", path: :tags_path }, - { name: "Rules", path: :rules_path }, - { name: "Merchants", path: :family_merchants_path }, - { name: "Recurring", path: :recurring_transactions_path }, + { name: -> { t("settings.settings_nav.categories_label") }, path: :categories_path }, + { name: -> { t("settings.settings_nav.tags_label") }, path: :tags_path }, + { name: -> { t("settings.settings_nav.rules_label") }, path: :rules_path }, + { name: -> { t("settings.settings_nav.merchants_label") }, path: :family_merchants_path }, + { name: -> { t("settings.settings_nav.recurring_transactions_label") }, path: :recurring_transactions_path }, + { name: -> { t("settings.settings_nav.statement_vault_label") }, path: :account_statements_path, condition: :admin_user? }, # Advanced section - { name: "AI Prompts", path: :settings_ai_prompts_path, condition: :admin_user? }, - { name: "LLM Usage", path: :settings_llm_usage_path, condition: :admin_user? }, - { name: "API Key", path: :settings_api_key_path, condition: :admin_user? }, - { name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted_and_admin? }, - { name: "Providers", path: :settings_providers_path, condition: :admin_user? }, - { name: "Imports", path: :imports_path, condition: :admin_user? }, - { name: "Exports", path: :family_exports_path, condition: :admin_user? }, + { name: -> { t("settings.settings_nav.ai_prompts_label") }, path: :settings_ai_prompts_path, condition: :admin_user? }, + { name: -> { t("settings.settings_nav.llm_usage_label") }, path: :settings_llm_usage_path, condition: :admin_user? }, + { name: -> { t("settings.settings_nav.api_key_label") }, path: :settings_api_key_path, condition: :admin_user? }, + { name: -> { t("settings.settings_nav.self_hosting_label") }, path: :settings_hosting_path, condition: :self_hosted_and_admin? }, + { name: -> { t("settings.settings_nav.imports_label") }, path: :imports_path, condition: :admin_user? }, + { name: -> { t("settings.settings_nav.exports_label") }, path: :family_exports_path, condition: :admin_user? }, # More section - { name: "Guides", path: :settings_guides_path }, - { name: "What's new", path: :changelog_path }, - { name: "Feedback", path: :feedback_path } + { name: -> { t("settings.settings_nav.guides_label") }, path: :settings_guides_path }, + { name: -> { t("settings.settings_nav.whats_new_label") }, path: :changelog_path }, + { name: -> { t("settings.settings_nav.feedback_label") }, path: :feedback_path } ] def adjacent_setting(current_path, offset) @@ -41,13 +41,68 @@ module SettingsHelper render partial: "settings/settings_nav_link_large", locals: { path: send(adjacent[:path]), direction: offset > 0 ? "next" : "previous", - title: adjacent[:name] + title: setting_name(adjacent) } end - def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, &block) + def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil, &block) content = capture(&block) - render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param } + render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param, status: status, meta: meta, actions: actions, badge: badge } + end + + def provider_summary(provider_key) + key = provider_key.to_s.downcase + + case key + when "plaid", "plaid_eu" + configured = @provider_configurations&.find { |c| c.provider_key.to_s.casecmp(key).zero? }&.configured? + configured ? { status: :ok } : { status: :off } + when "simplefin" + return { status: :off } unless @simplefin_items&.any? + sync_based_summary(key) + when "lunchflow" + return { status: :off } unless @lunchflow_items&.any? + sync_based_summary(key) + when "enable_banking" + return { status: :off } unless @enable_banking_items&.any? + enable_banking_summary + when "coinstats" + return { status: :off } unless @coinstats_items&.any? + sync_based_summary(key) + when "mercury" + return { status: :off } unless @mercury_items&.any? + sync_based_summary(key) + when "brex" + return { status: :off } unless @brex_items&.any? + sync_based_summary(key) + when "coinbase" + return { status: :off } unless @coinbase_items&.any? + sync_based_summary(key) + when "binance" + return { status: :off } unless @binance_items&.any? + sync_based_summary(key) + when "kraken" + return { status: :off } unless @kraken_items&.any? + sync_based_summary(key) + when "snaptrade" + configured_item = @snaptrade_items&.find(&:credentials_configured?) + return { status: :off } unless configured_item + unless configured_item.user_registered? + return { status: :warn, meta: t("settings.providers.meta.registration_needed") } + end + sync_based_summary(key) + when "ibkr" + return { status: :off } unless @ibkr_items&.any? + sync_based_summary(key) + when "indexa_capital" + return { status: :off } unless @indexa_capital_items&.any? + sync_based_summary(key) + when "sophtron" + return { status: :off } unless @sophtron_items&.any? + sync_based_summary(key) + else + { status: :off } + end end def settings_nav_footer @@ -70,11 +125,94 @@ module SettingsHelper end end + # Below this many synced accounts, the per-row pills already give the user + # enough at-a-glance signal and the strip is redundant chrome. + HEALTH_STRIP_MIN_ACCOUNTS = 10 + + # Slim health-strip data for the providers index. Pulls counts from the + # already-resolved entry summaries plus the family's distinct synced-account + # count for the trailing stat. Returns a hash consumed by the + # `settings/providers/_health_strip` partial, or nil when the family has + # fewer than HEALTH_STRIP_MIN_ACCOUNTS connected accounts. + def provider_health_strip(connected:, needs_attention:) + accounts_count = Current.family.accounts.joins(:account_providers).distinct.count + return nil if accounts_count < HEALTH_STRIP_MIN_ACCOUNTS + + active_entries = connected + needs_attention + last_synced_at = active_entries.map { |e| e[:summary][:last_synced_at] }.compact.max + + { + connected: active_entries.size, + needs_attention: needs_attention.size, + accounts_syncing: accounts_count, + last_synced_at: last_synced_at + } + end + + # Strips the leading "about " from `time_ago_in_words` so copy reads as + # "Synced 6 hours ago" instead of "Synced about 6 hours ago". + def concise_time_ago(time) + time_ago_in_words(time).sub(/\Aabout /, "") + end + private + def sync_based_summary(provider_key) + health = @provider_sync_health&.dig(provider_key) || {} + last_synced_at = health[:last_synced_at] + + base = if health[:error] + { status: :err, meta: t("settings.providers.meta.sync_error") } + elsif health[:stale] + { status: :warn, meta: t("settings.providers.meta.no_recent_sync") } + elsif last_synced_at.present? + { status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)) } + else + { status: :ok } + end + + base.merge(last_synced_at: last_synced_at) + end + + def enable_banking_summary + health = @provider_sync_health&.dig("enable_banking") || {} + last_synced_at = health[:last_synced_at] + + return { status: :err, meta: t("settings.providers.meta.sync_error"), last_synced_at: nil } if health[:error] + + valid_items = @enable_banking_items&.select(&:session_valid?) || [] + + # All items have expired/missing sessions — need re-authorization + if valid_items.empty? + return { status: :warn, meta: t("settings.providers.meta.reconsent_required"), last_synced_at: last_synced_at } + end + + expiring = valid_items.find do |item| + item.session_expires_at.present? && item.session_expires_at < 7.days.from_now + end + + if expiring + days = [ ((expiring.session_expires_at - Time.current) / 1.day).ceil, 1 ].max + return { status: :warn, meta: t("settings.providers.meta.reconsent_needed", count: days), last_synced_at: last_synced_at } + end + + return { status: :warn, meta: t("settings.providers.meta.no_recent_sync"), last_synced_at: last_synced_at } if health[:stale] + + if last_synced_at.present? + { status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)), last_synced_at: last_synced_at } + else + { status: :ok, last_synced_at: nil } + end + end + def not_self_hosted? !self_hosted? end + def setting_name(setting) + name = setting[:name] + name.respond_to?(:call) ? instance_exec(&name) : name + end + # Helper used by SETTINGS_ORDER conditions def admin_user? Current.user&.admin? diff --git a/app/helpers/simplefin_items_helper.rb b/app/helpers/simplefin_items_helper.rb index d141bdf17..7f704279c 100644 --- a/app/helpers/simplefin_items_helper.rb +++ b/app/helpers/simplefin_items_helper.rb @@ -38,4 +38,16 @@ module SimplefinItemsHelper parts << " — #{sample}" if sample.present? parts.join end + + # Human-friendly relative-time phrase for an activity badge. Returns nil for + # a nil input so callers can fall through to "no activity" copy. + def activity_when(time, now: Time.current) + return nil if time.blank? + days = ((now.to_i - time.to_i) / 86_400).floor + case days + when ..0 then t("simplefin_items.setup_accounts.activity.today") + when 1 then t("simplefin_items.setup_accounts.activity.yesterday") + else t("simplefin_items.setup_accounts.activity.days_ago", count: days) + end + end end diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb index bb51ab2ff..277d34aa3 100644 --- a/app/helpers/styled_form_builder.rb +++ b/app/helpers/styled_form_builder.rb @@ -44,6 +44,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder selected: selected_value, placeholder: placeholder, searchable: options.fetch(:searchable, false), + menu_placement: options[:menu_placement], variant: options.fetch(:variant, :simple), include_blank: options[:include_blank], label: options[:label], @@ -88,6 +89,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder @template.render( DS::Button.new( text: value, + type: "submit", data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }), full_width: true ) diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb index 92ce26c81..b9ea06d35 100644 --- a/app/helpers/transactions_helper.rb +++ b/app/helpers/transactions_helper.rb @@ -20,6 +20,10 @@ module TransactionsHelper transaction_search_filters[0] end + def in_split_group?(entry, params_grouped) + entry.split_child? && Current.user.show_split_grouped? && params_grouped == "true" + end + # ---- Transaction extra details helpers ---- # Returns a structured hash describing extra details for a transaction. # Input can be a Transaction or an Entry (responds_to :transaction). diff --git a/app/javascript/application.js b/app/javascript/application.js index 3f3b9c3a1..353017fa9 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,6 +1,49 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails"; import "controllers"; +import HwComboboxController from "controllers/hw_combobox_controller"; + +// Fix hotwire_combobox race condition: when typing quickly, a slow response for +// an early query (e.g. "A") can overwrite the correct results for the final query +// (e.g. "AAPL"). We abort the previous in-flight request whenever a new one fires, +// so stale Turbo Stream responses never reach the DOM. +const originalFilterAsync = HwComboboxController.prototype._filterAsync; +HwComboboxController.prototype._filterAsync = async function(inputType) { + if (this._searchAbortController) { + this._searchAbortController.abort(); + } + this._searchAbortController = new AbortController(); + + const query = { + q: this._fullQuery, + input_type: inputType, + for_id: this.element.dataset.asyncId, + callback_id: this._enqueueCallback() + }; + + const url = new URL(this.asyncSrcValue, window.location.origin); + Object.entries(query).forEach(([k, v]) => { + if (v != null) url.searchParams.set(k, v); + }); + + try { + const response = await fetch(url.toString(), { + headers: { + "Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content + }, + signal: this._searchAbortController.signal, + credentials: "same-origin" + }); + + if (response.ok) { + await Turbo.renderStreamMessage(await response.text()); + } + } catch (e) { + if (e.name !== "AbortError") throw e; + } +}; Turbo.StreamActions.redirect = function () { // Use "replace" to avoid adding form submission to browser history diff --git a/app/javascript/controllers/account_type_selector_controller.js b/app/javascript/controllers/account_type_selector_controller.js index 4504c41b7..ef9ced189 100644 --- a/app/javascript/controllers/account_type_selector_controller.js +++ b/app/javascript/controllers/account_type_selector_controller.js @@ -18,7 +18,8 @@ export default class extends Controller { // Hide all subtype selects const subtypeSelects = container.querySelectorAll('.subtype-select') subtypeSelects.forEach(select => { - select.style.display = 'none' + select.classList.add('hidden') + select.style.removeProperty('display') // Clear the name attribute so it doesn't get submitted const selectElement = select.querySelector('select') if (selectElement) { @@ -34,7 +35,8 @@ export default class extends Controller { // Show the relevant subtype select const relevantSubtype = container.querySelector(`[data-type="${selectedType}"]`) if (relevantSubtype) { - relevantSubtype.style.display = 'block' + relevantSubtype.classList.remove('hidden') + relevantSubtype.style.removeProperty('display') // Re-add the name attribute so it gets submitted const selectElement = relevantSubtype.querySelector('select') if (selectElement) { @@ -65,4 +67,4 @@ export default class extends Controller { } } } -} \ No newline at end of file +} diff --git a/app/javascript/controllers/admin_sso_form_controller.js b/app/javascript/controllers/admin_sso_form_controller.js index 2344b8b63..2ade19da5 100644 --- a/app/javascript/controllers/admin_sso_form_controller.js +++ b/app/javascript/controllers/admin_sso_form_controller.js @@ -111,10 +111,21 @@ export default class extends Controller { if (response.ok) { const data = await response.json() if (data.issuer) { - // Valid OIDC discovery endpoint - issuerInput.classList.remove('border-yellow-300', 'border-red-300') - issuerInput.classList.add('border-green-300') - this.showValidationMessage(issuerInput, 'Valid OIDC issuer', 'success') + if (data.issuer === issuer) { + issuerInput.classList.remove('border-yellow-300', 'border-red-300', 'border-amber-300') + issuerInput.classList.add('border-green-300') + this.showValidationMessage(issuerInput, 'Valid OIDC issuer', 'success') + } else { + issuerInput.classList.remove('border-yellow-300', 'border-green-300') + issuerInput.classList.add('border-amber-300') + + const trailingSlashOnly = data.issuer.replace(/\/$/, '') === issuer.replace(/\/$/, '') + const message = trailingSlashOnly + ? `Issuer mismatch: discovery returned ${data.issuer}. This is usually a trailing slash mismatch, so copy the issuer exactly as returned.` + : `Issuer mismatch: discovery returned ${data.issuer}. Copy the issuer exactly as returned by the provider.` + + this.showValidationMessage(issuerInput, message, 'warning') + } } else { throw new Error('Invalid discovery response') } diff --git a/app/javascript/controllers/app_layout_controller.js b/app/javascript/controllers/app_layout_controller.js index cc15c78c2..612114735 100644 --- a/app/javascript/controllers/app_layout_controller.js +++ b/app/javascript/controllers/app_layout_controller.js @@ -21,22 +21,27 @@ export default class extends Controller { toggleLeftSidebar() { const isOpen = this.leftSidebarTarget.classList.contains("w-full"); this.#updateUserPreference("show_sidebar", !isOpen); - this.#toggleSidebarWidth(this.leftSidebarTarget, isOpen); + this.#toggleSidebarWidth(this.leftSidebarTarget, isOpen, "left"); } toggleRightSidebar() { const isOpen = this.rightSidebarTarget.classList.contains("w-full"); this.#updateUserPreference("show_ai_sidebar", !isOpen); - this.#toggleSidebarWidth(this.rightSidebarTarget, isOpen); + this.#toggleSidebarWidth(this.rightSidebarTarget, isOpen, "right"); } - #toggleSidebarWidth(el, isCurrentlyOpen) { + #toggleSidebarWidth(el, isCurrentlyOpen, side) { + const expandedClasses = side === "left" ? [...this.expandedSidebarClasses, "border-r"] : [...this.expandedSidebarClasses, "border-l"]; + const collapsedClasses = side === "left" ? [...this.collapsedSidebarClasses, "border-r-0"] : [...this.collapsedSidebarClasses, "border-l-0"]; + if (isCurrentlyOpen) { - el.classList.remove(...this.expandedSidebarClasses); - el.classList.add(...this.collapsedSidebarClasses); + el.classList.remove(...expandedClasses); + el.classList.add(...collapsedClasses); + el.inert = true; } else { - el.classList.add(...this.expandedSidebarClasses); - el.classList.remove(...this.collapsedSidebarClasses); + el.classList.add(...expandedClasses); + el.classList.remove(...collapsedClasses); + el.inert = false; } } diff --git a/app/javascript/controllers/bank_search_controller.js b/app/javascript/controllers/bank_search_controller.js new file mode 100644 index 000000000..a2c045cd9 --- /dev/null +++ b/app/javascript/controllers/bank_search_controller.js @@ -0,0 +1,19 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["input", "item", "emptyState"]; + + filter() { + const query = this.inputTarget.value.toLocaleLowerCase().trim(); + let visibleCount = 0; + + this.itemTargets.forEach(item => { + const name = item.dataset.bankName?.toLocaleLowerCase() ?? ""; + const match = name.includes(query); + item.style.display = match ? "" : "none"; + if (match) visibleCount++; + }); + + this.emptyStateTarget.classList.toggle("hidden", visibleCount > 0); + } +} diff --git a/app/javascript/controllers/budget_filter_controller.js b/app/javascript/controllers/budget_filter_controller.js new file mode 100644 index 000000000..2187a0f65 --- /dev/null +++ b/app/javascript/controllers/budget_filter_controller.js @@ -0,0 +1,58 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["onTrack", "overBudget", "tab"]; + static values = { filter: { type: String, default: "all" } }; + + connect() { + const filterParam = new URLSearchParams(window.location.search).get("filter"); + + if (this.#isValidFilter(filterParam) && filterParam !== this.filterValue) { + this.filterValue = filterParam; + } else if (filterParam && !this.#isValidFilter(filterParam)) { + this.#syncFilterParam(); + } + } + + setFilter(event) { + this.filterValue = event.params.filter; + this.#syncFilterParam(); + } + + filterValueChanged() { + const filter = this.filterValue; + + if (this.hasOnTrackTarget) { + this.onTrackTarget.hidden = filter === "over_budget"; + } + + if (this.hasOverBudgetTarget) { + this.overBudgetTarget.hidden = filter === "on_track"; + } + + this.tabTargets.forEach((tab) => { + const isActive = tab.dataset.budgetFilterFilterParam === filter; + tab.classList.toggle("bg-white", isActive); + tab.classList.toggle("theme-dark:bg-gray-700", isActive); + tab.classList.toggle("text-primary", isActive); + tab.classList.toggle("shadow-sm", isActive); + tab.classList.toggle("text-secondary", !isActive); + }); + } + + #isValidFilter(filter) { + return ["all", "over_budget", "on_track"].includes(filter); + } + + #syncFilterParam() { + const url = new URL(window.location.href); + + if (this.filterValue === "all") { + url.searchParams.delete("filter"); + } else { + url.searchParams.set("filter", this.filterValue); + } + + window.history.replaceState({}, "", url); + } +} diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index 271b6a0f5..cac1867f5 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -13,6 +13,8 @@ export default class extends Controller { static values = { singularLabel: String, pluralLabel: String, + selectedLabel: { type: String, default: "selected" }, + editLabel: { type: String, default: "Edit" }, selectedIds: { type: Array, default: [] }, }; @@ -28,7 +30,7 @@ export default class extends Controller { bulkEditDrawerHeaderTargetConnected(element) { const headingTextEl = element.querySelector("h2"); - headingTextEl.innerText = `Edit ${ + headingTextEl.innerText = `${this.editLabelValue} ${ this.selectedIdsValue.length } ${this._pluralizedResourceName()}`; } @@ -132,14 +134,19 @@ export default class extends Controller { _updateSelectionBar() { const count = this.selectedIdsValue.length; - this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected`; + this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} ${this.selectedLabelValue}`; this.selectionBarTarget.classList.toggle("hidden", count === 0); this.selectionBarTarget.querySelector("input[type='checkbox']").checked = count > 0; if (this.hasDuplicateLinkTarget) { - this.duplicateLinkTarget.classList.toggle("hidden", count !== 1); - if (count === 1) { + const selectedRow = this._selectedRow(); + const canDuplicate = + count === 1 && selectedRow?.dataset.entryType === "Transaction"; + + this.duplicateLinkTarget.classList.toggle("hidden", !canDuplicate); + + if (canDuplicate) { const url = new URL( this.duplicateLinkTarget.href, window.location.origin, @@ -158,6 +165,14 @@ export default class extends Controller { return this.pluralLabelValue; } + _selectedRow() { + if (this.selectedIdsValue.length !== 1) return null; + + return this.rowTargets.find( + (row) => row.dataset.id === this.selectedIdsValue[0], + ); + } + _updateGroups() { this.groupTargets.forEach((group) => { const rows = this.rowTargets.filter( diff --git a/app/javascript/controllers/currency_preferences_controller.js b/app/javascript/controllers/currency_preferences_controller.js new file mode 100644 index 000000000..4908d2f9e --- /dev/null +++ b/app/javascript/controllers/currency_preferences_controller.js @@ -0,0 +1,57 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["dialog", "checkbox", "selectedCount"]; + static values = { + baseCurrency: String, + locale: String, + selectedCountTranslations: Object, + }; + + connect() { + this.updateSelectedCount(); + } + + open() { + this.updateSelectedCount(); + this.dialogTarget.showModal(); + } + + selectAll() { + this.checkboxTargets.forEach((checkbox) => { + checkbox.checked = true; + }); + + this.updateSelectedCount(); + } + + selectBaseOnly() { + this.checkboxTargets.forEach((checkbox) => { + checkbox.checked = checkbox.value === this.baseCurrencyValue; + }); + + this.updateSelectedCount(); + } + + updateSelectedCount() { + if (!this.hasSelectedCountTarget) return; + + const selectedCount = this.checkboxTargets.filter((checkbox) => checkbox.checked).length; + const pluralRules = new Intl.PluralRules(this.localeValue || undefined); + const pluralCategory = pluralRules.select(selectedCount); + const labelTemplate = + this.selectedCountTranslationsValue[pluralCategory] || + this.selectedCountTranslationsValue.other || + "%{count}"; + const label = labelTemplate.replace("%{count}", selectedCount); + + this.selectedCountTarget.textContent = label; + } + + handleSubmitEnd(event) { + if (!event.detail.success) return; + if (!this.dialogTarget.open) return; + + this.dialogTarget.close(); + } +} diff --git a/app/javascript/controllers/donut_chart_controller.js b/app/javascript/controllers/donut_chart_controller.js index 216e7d9d6..933c7f2b0 100644 --- a/app/javascript/controllers/donut_chart_controller.js +++ b/app/javascript/controllers/donut_chart_controller.js @@ -26,12 +26,14 @@ export default class extends Controller { this.#draw(); document.addEventListener("turbo:load", this.#redraw); this.element.addEventListener("mouseleave", this.#clearSegmentHover); + this.contentContainerTarget.addEventListener("mouseleave", this.#clearSegmentHover); } disconnect() { this.#teardown(); document.removeEventListener("turbo:load", this.#redraw); this.element.removeEventListener("mouseleave", this.#clearSegmentHover); + this.contentContainerTarget.removeEventListener("mouseleave", this.#clearSegmentHover); } get #data() { @@ -151,8 +153,12 @@ export default class extends Controller { this.#handleSegmentHover(event); }, 10); }) - .on("mouseleave", () => { + .on("mouseleave", (event, d) => { clearTimeout(hoverTimeout); + const leavingUnused = d.data.id === this.unusedSegmentIdValue; + if (leavingUnused || !this.contentContainerTarget.contains(event.relatedTarget)) { + this.#clearSegmentHover(); + } }) .on("click", (event, d) => { if (this.enableClickValue) { diff --git a/app/javascript/controllers/exchange_rate_form_controller.js b/app/javascript/controllers/exchange_rate_form_controller.js new file mode 100644 index 000000000..80126bb20 --- /dev/null +++ b/app/javascript/controllers/exchange_rate_form_controller.js @@ -0,0 +1,298 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = [ + "amount", + "destinationAmount", + "date", + "exchangeRateContainer", + "exchangeRateField", + "convertDestinationDisplay", + "calculateRateDisplay" + ]; + + static values = { + exchangeRateUrl: String, + accountCurrencies: Object + }; + + connect() { + this.sourceCurrency = null; + this.destinationCurrency = null; + this.activeTab = "convert"; + + if (!this.hasRequiredExchangeRateTargets()) { + return; + } + + this.checkCurrencyDifference(); + } + + hasRequiredExchangeRateTargets() { + return this.hasDateTarget; + } + + checkCurrencyDifference() { + const context = this.getExchangeRateContext(); + + if (!context) { + this.hideExchangeRateField(); + return; + } + + const { fromCurrency, toCurrency, date } = context; + + if (!fromCurrency || !toCurrency) { + this.hideExchangeRateField(); + return; + } + + this.sourceCurrency = fromCurrency; + this.destinationCurrency = toCurrency; + + if (fromCurrency === toCurrency) { + this.hideExchangeRateField(); + return; + } + + this.fetchExchangeRate(fromCurrency, toCurrency, date); + } + + onExchangeRateTabClick(event) { + const btn = event.target.closest("button[data-id]"); + if (!btn) { + return; + } + + const nextTab = btn.dataset.id; + + if (nextTab === this.activeTab) { + return; + } + + this.activeTab = nextTab; + + if (this.activeTab === "convert") { + this.clearCalculateRateFields(); + } else if (this.activeTab === "calculateRate") { + this.clearConvertFields(); + } + } + + onAmountChange() { + this.onAmountInputChange(); + } + + onSourceAmountChange() { + this.onAmountInputChange(); + } + + onAmountInputChange() { + if (!this.hasAmountTarget) { + return; + } + + if (this.activeTab === "convert") { + this.calculateConvertDestination(); + } else { + this.calculateRateFromAmounts(); + } + } + + onConvertSourceAmountChange() { + this.calculateConvertDestination(); + } + + onConvertExchangeRateChange() { + this.calculateConvertDestination(); + } + + calculateConvertDestination() { + if (!this.hasAmountTarget || !this.hasExchangeRateFieldTarget || !this.hasConvertDestinationDisplayTarget) { + return; + } + + const amount = Number.parseFloat(this.amountTarget.value); + const rate = Number.parseFloat(this.exchangeRateFieldTarget.value); + + if (amount && rate && rate !== 0) { + const destAmount = (amount * rate).toFixed(2); + this.convertDestinationDisplayTarget.textContent = this.destinationCurrency ? `${destAmount} ${this.destinationCurrency}` : destAmount; + } else { + this.convertDestinationDisplayTarget.textContent = "-"; + } + } + + onCalculateRateSourceAmountChange() { + this.calculateRateFromAmounts(); + } + + onCalculateRateDestinationAmountChange() { + this.calculateRateFromAmounts(); + } + + calculateRateFromAmounts() { + if (!this.hasAmountTarget || !this.hasDestinationAmountTarget || !this.hasCalculateRateDisplayTarget || !this.hasExchangeRateFieldTarget) { + return; + } + + const amount = Number.parseFloat(this.amountTarget.value); + const destAmount = Number.parseFloat(this.destinationAmountTarget.value); + + if (amount && destAmount && amount !== 0) { + const rate = destAmount / amount; + const formattedRate = this.formatExchangeRate(rate); + this.calculateRateDisplayTarget.textContent = formattedRate; + this.exchangeRateFieldTarget.value = rate.toFixed(14); + } else { + this.calculateRateDisplayTarget.textContent = "-"; + this.exchangeRateFieldTarget.value = ""; + } + } + + formatExchangeRate(rate) { + let formattedRate = rate.toFixed(14); + formattedRate = formattedRate.replace(/(\.\d{2}\d*?)0+$/, "$1"); + + if (!formattedRate.includes(".")) { + formattedRate += ".00"; + } else if (formattedRate.match(/\.\d$/)) { + formattedRate += "0"; + } + + return formattedRate; + } + + clearConvertFields() { + if (this.hasExchangeRateFieldTarget) { + this.exchangeRateFieldTarget.value = ""; + } + if (this.hasConvertDestinationDisplayTarget) { + this.convertDestinationDisplayTarget.textContent = "-"; + } + } + + clearCalculateRateFields() { + if (this.hasDestinationAmountTarget) { + this.destinationAmountTarget.value = ""; + } + if (this.hasCalculateRateDisplayTarget) { + this.calculateRateDisplayTarget.textContent = "-"; + } + if (this.hasExchangeRateFieldTarget) { + this.exchangeRateFieldTarget.value = ""; + } + } + + async fetchExchangeRate(fromCurrency, toCurrency, date) { + if (this.exchangeRateAbortController) { + this.exchangeRateAbortController.abort(); + } + + this.exchangeRateAbortController = new AbortController(); + const signal = this.exchangeRateAbortController.signal; + + try { + const url = new URL(this.exchangeRateUrlValue, window.location.origin); + url.searchParams.set("from", fromCurrency); + url.searchParams.set("to", toCurrency); + if (date) { + url.searchParams.set("date", date); + } + + const response = await fetch(url, { signal }); + const data = await response.json(); + + if (!this.isCurrentExchangeRateState(fromCurrency, toCurrency, date)) { + return; + } + + if (!response.ok) { + if (this.shouldShowManualExchangeRate(data)) { + this.showManualExchangeRateField(); + } else { + this.hideExchangeRateField(); + } + return; + } + + if (data.same_currency) { + this.hideExchangeRateField(); + } else { + this.sourceCurrency = fromCurrency; + this.destinationCurrency = toCurrency; + this.showExchangeRateField(data.rate); + } + } catch (error) { + if (error.name === "AbortError") { + return; + } + + console.error("Error fetching exchange rate:", error); + this.hideExchangeRateField(); + } + } + + showExchangeRateField(rate) { + if (this.hasExchangeRateFieldTarget) { + this.exchangeRateFieldTarget.value = this.formatExchangeRate(rate); + } + if (this.hasExchangeRateContainerTarget) { + this.exchangeRateContainerTarget.classList.remove("hidden"); + } + + this.calculateConvertDestination(); + } + + showManualExchangeRateField() { + const context = this.getExchangeRateContext(); + this.sourceCurrency = context?.fromCurrency || null; + this.destinationCurrency = context?.toCurrency || null; + + if (this.hasExchangeRateFieldTarget) { + this.exchangeRateFieldTarget.value = ""; + } + if (this.hasExchangeRateContainerTarget) { + this.exchangeRateContainerTarget.classList.remove("hidden"); + } + + this.calculateConvertDestination(); + } + + shouldShowManualExchangeRate(data) { + if (!data || typeof data.error !== "string") { + return false; + } + + return data.error === "Exchange rate not found" || data.error === "Exchange rate unavailable"; + } + + hideExchangeRateField() { + if (this.hasExchangeRateContainerTarget) { + this.exchangeRateContainerTarget.classList.add("hidden"); + } + if (this.hasExchangeRateFieldTarget) { + this.exchangeRateFieldTarget.value = ""; + } + if (this.hasConvertDestinationDisplayTarget) { + this.convertDestinationDisplayTarget.textContent = "-"; + } + if (this.hasCalculateRateDisplayTarget) { + this.calculateRateDisplayTarget.textContent = "-"; + } + if (this.hasDestinationAmountTarget) { + this.destinationAmountTarget.value = ""; + } + + this.sourceCurrency = null; + this.destinationCurrency = null; + } + + getExchangeRateContext() { + throw new Error("Subclasses must implement getExchangeRateContext()"); + } + + isCurrentExchangeRateState(_fromCurrency, _toCurrency, _date) { + throw new Error("Subclasses must implement isCurrentExchangeRateState()"); + } +} diff --git a/app/javascript/controllers/form_dropdown_controller.js b/app/javascript/controllers/form_dropdown_controller.js index d191106f8..a13f22eef 100644 --- a/app/javascript/controllers/form_dropdown_controller.js +++ b/app/javascript/controllers/form_dropdown_controller.js @@ -9,6 +9,9 @@ export default class extends Controller { const inputEvent = new Event("input", { bubbles: true }) this.inputTarget.dispatchEvent(inputEvent) + const changeEvent = new Event("change", { bubbles: true }) + this.inputTarget.dispatchEvent(changeEvent) + const form = this.element.closest("form") const controllers = (form?.dataset.controller || "").split(/\s+/) if (form && controllers.includes("auto-submit-form")) { diff --git a/app/javascript/controllers/password_validator_controller.js b/app/javascript/controllers/password_validator_controller.js index 4de9c6fd1..76cca812e 100644 --- a/app/javascript/controllers/password_validator_controller.js +++ b/app/javascript/controllers/password_validator_controller.js @@ -52,11 +52,11 @@ export default class extends Controller { // Update block lines sequentially based on total requirements met this.blockLineTargets.forEach((line, index) => { if (index < requirementsMet) { - line.classList.remove("bg-gray-200"); + line.classList.remove("bg-surface-inset"); line.classList.add("bg-green-600"); } else { line.classList.remove("bg-green-600"); - line.classList.add("bg-gray-200"); + line.classList.add("bg-surface-inset"); } }); } diff --git a/app/javascript/controllers/polling_controller.js b/app/javascript/controllers/polling_controller.js index 0e9e743f8..8478b2f69 100644 --- a/app/javascript/controllers/polling_controller.js +++ b/app/javascript/controllers/polling_controller.js @@ -6,6 +6,7 @@ export default class extends Controller { static values = { url: String, interval: { type: Number, default: 3000 }, + frameId: String, }; connect() { @@ -33,10 +34,16 @@ export default class extends Controller { async refresh() { try { + const frame = this.frameElement(); + if (!frame) { + this.stopPolling(); + return; + } + const response = await fetch(this.urlValue, { headers: { Accept: "text/html", - "Turbo-Frame": this.element.id, + "Turbo-Frame": frame.id, }, }); @@ -46,13 +53,19 @@ export default class extends Controller { template.innerHTML = html; const newFrame = template.content.querySelector( - `turbo-frame#${this.element.id}`, + `turbo-frame#${this.cssEscape(frame.id)}`, ); if (newFrame) { - this.element.innerHTML = newFrame.innerHTML; + if (frame === this.element) { + this.syncPollingAttributes(newFrame); + } + frame.innerHTML = newFrame.innerHTML; // Check if we should stop polling (no more pending/processing exports) - if (!newFrame.hasAttribute("data-polling-url-value")) { + if ( + frame === this.element && + !newFrame.hasAttribute("data-polling-url-value") + ) { this.stopPolling(); } } @@ -61,4 +74,41 @@ export default class extends Controller { console.error("Polling error:", error); } } + + frameElement() { + if (this.hasFrameIdValue) { + return document.getElementById(this.frameIdValue); + } + + if (this.element.tagName.toLowerCase() === "turbo-frame") { + return this.element; + } + + return this.element.closest("turbo-frame"); + } + + cssEscape(value) { + if (window.CSS?.escape) return CSS.escape(value); + + return value.replaceAll('"', '\\"'); + } + + syncPollingAttributes(newFrame) { + const pollingUrl = newFrame.getAttribute("data-polling-url-value"); + const pollingInterval = newFrame.getAttribute( + "data-polling-interval-value", + ); + + if (pollingUrl) { + this.element.setAttribute("data-polling-url-value", pollingUrl); + } else { + this.element.removeAttribute("data-polling-url-value"); + } + + if (pollingInterval) { + this.element.setAttribute("data-polling-interval-value", pollingInterval); + } else { + this.element.removeAttribute("data-polling-interval-value"); + } + } } diff --git a/app/javascript/controllers/providers_filter_controller.js b/app/javascript/controllers/providers_filter_controller.js new file mode 100644 index 000000000..54004b2f6 --- /dev/null +++ b/app/javascript/controllers/providers_filter_controller.js @@ -0,0 +1,68 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="providers-filter" +// Filters provider cards by free-text query and a chip-selected kind. +// Updates the visible-count target on the section heading and toggles +// an empty-state target when no card matches. +export default class extends Controller { + static targets = ["input", "chip", "card", "empty", "count"]; + static values = { kind: { type: String, default: "all" } }; + + connect() { + this.syncChipState(); + } + + filter() { + const query = this.hasInputTarget + ? this.inputTarget.value.toLocaleLowerCase().trim() + : ""; + const activeKind = this.kindValue; + let visibleCount = 0; + + this.cardTargets.forEach((card) => { + const name = card.dataset.providerName ?? ""; + const region = card.dataset.providerRegion ?? ""; + const kind = card.dataset.providerKind ?? ""; + const haystack = `${name} ${region} ${kind}`; + const matchesQuery = !query || haystack.includes(query); + const matchesKind = activeKind === "all" || kind === activeKind; + const visible = matchesQuery && matchesKind; + card.classList.toggle("hidden", !visible); + if (visible) visibleCount++; + }); + + if (this.hasCountTarget) { + this.countTarget.textContent = visibleCount; + } + + if (this.hasEmptyTarget) { + this.emptyTarget.classList.toggle("hidden", visibleCount > 0); + } + } + + selectChip(event) { + this.kindValue = event.currentTarget.dataset.kind ?? "all"; + this.syncChipState(); + this.filter(); + } + + clear() { + if (this.hasInputTarget) this.inputTarget.value = ""; + this.kindValue = "all"; + this.syncChipState(); + this.filter(); + if (this.hasInputTarget) this.inputTarget.focus(); + } + + syncChipState() { + if (!this.hasChipTarget) return; + this.chipTargets.forEach((chip) => { + const active = chip.dataset.kind === this.kindValue; + chip.classList.toggle("bg-container", active); + chip.classList.toggle("shadow-border-xs", active); + chip.classList.toggle("text-primary", active); + chip.classList.toggle("text-secondary", !active); + chip.setAttribute("aria-pressed", active ? "true" : "false"); + }); + } +} diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js index 15cbf374b..1af8a25a8 100644 --- a/app/javascript/controllers/sankey_chart_controller.js +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -1,14 +1,17 @@ import { Controller } from "@hotwired/stimulus"; import * as d3 from "d3"; import { sankey } from "d3-sankey"; +import { sankeyNodeHasChildren, zoomSankeyData } from "utils/sankey_zoom"; // Connects to data-controller="sankey-chart" export default class extends Controller { + static targets = ["chart", "zoomOutButton"]; + static values = { data: Object, nodeWidth: { type: Number, default: 15 }, nodePadding: { type: Number, default: 20 }, - currencySymbol: { type: String, default: "$" } + currencySymbol: { type: String, default: "$" }, }; // Visual constants @@ -18,64 +21,158 @@ export default class extends Controller { static MIN_NODE_PADDING = 4; static MAX_PADDING_RATIO = 0.4; static CORNER_RADIUS = 8; + static ZOOM_TRANSITION_MS = 220; static DEFAULT_COLOR = "var(--color-gray-400)"; static CSS_VAR_MAP = { "var(--color-success)": "#10A861", "var(--color-destructive)": "#EC2222", "var(--color-gray-400)": "#9E9E9E", - "var(--color-gray-500)": "#737373" + "var(--color-gray-500)": "#737373", }; static MIN_LABEL_SPACING = 28; // Minimum vertical space needed for labels (2 lines) connect() { + this.connected = true; + this.zoomRootId = null; this.resizeObserver = new ResizeObserver(() => this.#draw()); - this.resizeObserver.observe(this.element); + this.resizeObserver.observe(this.#chartElement()); this.tooltip = null; this.#createTooltip(); + this.#syncZoomControls(); + this.#draw(); + } + + dataValueChanged() { + if (!this.connected) return; + + this.zoomRootId = null; + this.#syncZoomControls(); this.#draw(); } disconnect() { + this.connected = false; this.resizeObserver?.disconnect(); + clearTimeout(this.drawTimeout); this.tooltip?.remove(); this.tooltip = null; } - #draw() { - const { nodes = [], links = [] } = this.dataValue || {}; + zoomOut() { + if (!this.zoomRootId) return; + + this.zoomRootId = null; + this.#syncZoomControls(); + this.#draw({ animate: true }); + } + + #draw({ animate = false } = {}) { + const { nodes = [], links = [] } = this.#visibleData(); if (!nodes.length || !links.length) return; // Hide tooltip and reset any hover states before redrawing this.#hideTooltip(); - d3.select(this.element).selectAll("svg").remove(); + const chartElement = this.#chartElement(); + const chart = d3.select(chartElement); - const width = this.element.clientWidth || 600; - const height = this.element.clientHeight || 400; + clearTimeout(this.drawTimeout); + chart.selectAll("svg").interrupt(); - const svg = d3.select(this.element) + if (animate) { + chart + .selectAll("svg") + .transition() + .duration(this.constructor.ZOOM_TRANSITION_MS / 2) + .style("opacity", 0) + .remove(); + + this.drawTimeout = setTimeout(() => { + this.#render(nodes, links, true); + }, this.constructor.ZOOM_TRANSITION_MS / 2); + } else { + chart.selectAll("svg").remove(); + this.#render(nodes, links, false); + } + } + + #render(nodes, links, animate) { + const chartElement = this.#chartElement(); + const chart = d3.select(chartElement); + + const width = chartElement.clientWidth || 600; + const height = chartElement.clientHeight || 400; + + const svg = chart .append("svg") .attr("width", width) - .attr("height", height); + .attr("height", height) + .style("opacity", animate ? 0 : 1); const effectivePadding = this.#calculateNodePadding(nodes.length, height); - const sankeyData = this.#generateSankeyData(nodes, links, width, height, effectivePadding); + const sankeyData = this.#generateSankeyData( + nodes, + links, + width, + height, + effectivePadding, + ); this.#createGradients(svg, sankeyData.links); const linkPaths = this.#drawLinks(svg, sankeyData.links); - const { nodeGroups, hiddenLabels } = this.#drawNodes(svg, sankeyData.nodes, width); + const { nodeGroups, hiddenLabels } = this.#drawNodes( + svg, + sankeyData.nodes, + width, + ); this.#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels); + + if (animate) { + svg + .transition() + .duration(this.constructor.ZOOM_TRANSITION_MS / 2) + .style("opacity", 1); + } + } + + #chartElement() { + return this.hasChartTarget ? this.chartTarget : this.element; + } + + #visibleData() { + if (!this.zoomRootId) return this.dataValue || {}; + + return zoomSankeyData(this.dataValue, this.zoomRootId); + } + + #syncZoomControls() { + this.zoomOutButtonTargets.forEach((button) => { + button.hidden = !this.zoomRootId; + }); + } + + #zoomIn(node) { + if (!node.id || !sankeyNodeHasChildren(this.#visibleData(), node.id)) + return; + + this.zoomRootId = node.id; + this.#syncZoomControls(); + this.#draw({ animate: true }); } // Dynamic padding prevents padding from dominating when there are many nodes #calculateNodePadding(nodeCount, height) { const margin = this.constructor.EXTENT_MARGIN; - const availableHeight = height - (margin * 2); - const maxPaddingTotal = availableHeight * this.constructor.MAX_PADDING_RATIO; + const availableHeight = height - margin * 2; + const maxPaddingTotal = + availableHeight * this.constructor.MAX_PADDING_RATIO; const gaps = Math.max(nodeCount - 1, 1); - const dynamicPadding = Math.min(this.nodePaddingValue, Math.floor(maxPaddingTotal / gaps)); + const dynamicPadding = Math.min( + this.nodePaddingValue, + Math.floor(maxPaddingTotal / gaps), + ); return Math.max(this.constructor.MIN_NODE_PADDING, dynamicPadding); } @@ -84,11 +181,14 @@ export default class extends Controller { const sankeyGenerator = sankey() .nodeWidth(this.nodeWidthValue) .nodePadding(nodePadding) - .extent([[margin, margin], [width - margin, height - margin]]); + .extent([ + [margin, margin], + [width - margin, height - margin], + ]); return sankeyGenerator({ - nodes: nodes.map(d => ({ ...d })), - links: links.map(d => ({ ...d })), + nodes: nodes.map((d) => ({ ...d })), + links: links.map((d) => ({ ...d })), }); } @@ -97,17 +197,20 @@ export default class extends Controller { links.forEach((link, i) => { const gradientId = this.#gradientId(link, i); - const gradient = defs.append("linearGradient") + const gradient = defs + .append("linearGradient") .attr("id", gradientId) .attr("gradientUnits", "userSpaceOnUse") .attr("x1", link.source.x1) .attr("x2", link.target.x0); - gradient.append("stop") + gradient + .append("stop") .attr("offset", "0%") .attr("stop-color", this.#colorWithOpacity(link.source.color)); - gradient.append("stop") + gradient + .append("stop") .attr("offset", "100%") .attr("stop-color", this.#colorWithOpacity(link.target.color)); }); @@ -132,32 +235,37 @@ export default class extends Controller { } #drawLinks(svg, links) { - return svg.append("g") + return svg + .append("g") .attr("fill", "none") .selectAll("path") .data(links) .join("path") .attr("class", "sankey-link") - .attr("d", d => d3.linkHorizontal()({ - source: [d.source.x1, d.y0], - target: [d.target.x0, d.y1] - })) + .attr("d", (d) => + d3.linkHorizontal()({ + source: [d.source.x1, d.y0], + target: [d.target.x0, d.y1], + }), + ) .attr("stroke", (d, i) => `url(#${this.#gradientId(d, i)})`) - .attr("stroke-width", d => Math.max(1, d.width)) + .attr("stroke-width", (d) => Math.max(1, d.width)) .style("transition", "opacity 0.3s ease"); } #drawNodes(svg, nodes, width) { - const nodeGroups = svg.append("g") + const nodeGroups = svg + .append("g") .selectAll("g") .data(nodes) .join("g") .style("transition", "opacity 0.3s ease"); - nodeGroups.append("path") - .attr("d", d => this.#nodePath(d)) - .attr("fill", d => d.color || this.constructor.DEFAULT_COLOR) - .attr("stroke", d => d.color ? "none" : "var(--color-gray-500)"); + nodeGroups + .append("path") + .attr("d", (d) => this.#nodePath(d)) + .attr("fill", (d) => d.color || this.constructor.DEFAULT_COLOR) + .attr("stroke", (d) => (d.color ? "none" : "var(--color-gray-500)")); const hiddenLabels = this.#addNodeLabels(nodeGroups, width, nodes); @@ -167,10 +275,15 @@ export default class extends Controller { #nodePath(node) { const { x0, y0, x1, y1 } = node; const height = y1 - y0; - const radius = Math.max(0, Math.min(this.constructor.CORNER_RADIUS, height / 2)); + const radius = Math.max( + 0, + Math.min(this.constructor.CORNER_RADIUS, height / 2), + ); - const isSourceNode = node.sourceLinks?.length > 0 && !node.targetLinks?.length; - const isTargetNode = node.targetLinks?.length > 0 && !node.sourceLinks?.length; + const isSourceNode = + node.sourceLinks?.length > 0 && !node.targetLinks?.length; + const isTargetNode = + node.targetLinks?.length > 0 && !node.sourceLinks?.length; // Too small for rounded corners if (height < radius * 2) { @@ -215,14 +328,18 @@ export default class extends Controller { const controller = this; const hiddenLabels = this.#calculateHiddenLabels(nodes); - nodeGroups.append("text") - .attr("x", d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6) - .attr("y", d => (d.y1 + d.y0) / 2) + nodeGroups + .append("text") + .attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)) + .attr("y", (d) => (d.y1 + d.y0) / 2) .attr("dy", "-0.2em") - .attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end") - .attr("class", "text-xs font-medium text-primary fill-current select-none") + .attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end")) + .attr( + "class", + "text-xs font-medium text-primary fill-current select-none", + ) .style("cursor", "default") - .style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1) + .style("opacity", (d) => (hiddenLabels.has(d.index) ? 0 : 1)) .style("transition", "opacity 0.2s ease") .each(function (d) { const textEl = d3.select(this); @@ -230,7 +347,8 @@ export default class extends Controller { textEl.append("tspan").text(d.name); - textEl.append("tspan") + textEl + .append("tspan") .attr("x", textEl.attr("x")) .attr("dy", "1.2em") .attr("class", "font-mono text-secondary") @@ -244,26 +362,28 @@ export default class extends Controller { // Calculate which labels should be hidden to prevent overlap #calculateHiddenLabels(nodes) { const hiddenLabels = new Set(); - const height = this.element.clientHeight || 400; + const height = this.#chartElement().clientHeight || 400; const isLargeGraph = height > 600; - const minSpacing = isLargeGraph ? this.constructor.MIN_LABEL_SPACING * 0.7 : this.constructor.MIN_LABEL_SPACING; + const minSpacing = isLargeGraph + ? this.constructor.MIN_LABEL_SPACING * 0.7 + : this.constructor.MIN_LABEL_SPACING; // Group nodes by column (using depth which d3-sankey assigns) const columns = new Map(); - nodes.forEach(node => { + nodes.forEach((node) => { const depth = node.depth; if (!columns.has(depth)) columns.set(depth, []); columns.get(depth).push(node); }); // For each column, check for overlapping labels - columns.forEach(columnNodes => { + columns.forEach((columnNodes) => { // Sort by vertical position - columnNodes.sort((a, b) => ((a.y0 + a.y1) / 2) - ((b.y0 + b.y1) / 2)); + columnNodes.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2); let lastVisibleY = Number.NEGATIVE_INFINITY; - columnNodes.forEach(node => { + columnNodes.forEach((node) => { const nodeY = (node.y0 + node.y1) / 2; const nodeHeight = node.y1 - node.y0; @@ -284,25 +404,41 @@ export default class extends Controller { #attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels) { const applyHover = (targetLinks) => { const targetSet = new Set(targetLinks); - const connectedNodes = new Set(targetLinks.flatMap(l => [l.source, l.target])); + const connectedNodes = new Set( + targetLinks.flatMap((l) => [l.source, l.target]), + ); linkPaths - .style("opacity", d => targetSet.has(d) ? 1 : this.constructor.HOVER_OPACITY) - .style("filter", d => targetSet.has(d) ? this.constructor.HOVER_FILTER : "none"); + .style("opacity", (d) => + targetSet.has(d) ? 1 : this.constructor.HOVER_OPACITY, + ) + .style("filter", (d) => + targetSet.has(d) ? this.constructor.HOVER_FILTER : "none", + ); - nodeGroups.style("opacity", d => connectedNodes.has(d) ? 1 : this.constructor.HOVER_OPACITY); + nodeGroups.style("opacity", (d) => + connectedNodes.has(d) ? 1 : this.constructor.HOVER_OPACITY, + ); // Show labels for connected nodes (even if normally hidden) - nodeGroups.selectAll("text") - .style("opacity", d => connectedNodes.has(d) ? 1 : (hiddenLabels.has(d.index) ? 0 : this.constructor.HOVER_OPACITY)); + nodeGroups + .selectAll("text") + .style("opacity", (d) => + connectedNodes.has(d) + ? 1 + : hiddenLabels.has(d.index) + ? 0 + : this.constructor.HOVER_OPACITY, + ); }; const resetHover = () => { linkPaths.style("opacity", 1).style("filter", "none"); nodeGroups.style("opacity", 1); // Restore hidden labels to hidden state - nodeGroups.selectAll("text") - .style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1); + nodeGroups + .selectAll("text") + .style("opacity", (d) => (hiddenLabels.has(d.index) ? 0 : 1)); }; linkPaths @@ -310,33 +446,56 @@ export default class extends Controller { applyHover([d]); this.#showTooltip(event, d.value, d.percentage); }) - .on("mousemove", event => this.#updateTooltipPosition(event)) + .on("mousemove", (event) => this.#updateTooltipPosition(event)) .on("mouseleave", () => { resetHover(); this.#hideTooltip(); }); // Hover on node rectangles (not just text) - nodeGroups.selectAll("path") - .style("cursor", "default") + nodeGroups + .selectAll("path") + .style("cursor", (d) => + sankeyNodeHasChildren(this.#visibleData(), d.id) + ? "pointer" + : "default", + ) .on("mouseenter", (event, d) => { - const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d); + const connectedLinks = sankeyData.links.filter( + (l) => l.source === d || l.target === d, + ); applyHover(connectedLinks); this.#showTooltip(event, d.value, d.percentage, d.name); }) - .on("mousemove", event => this.#updateTooltipPosition(event)) + .on("mousemove", (event) => this.#updateTooltipPosition(event)) + .on("click", (event, d) => { + event.stopPropagation(); + this.#zoomIn(d); + }) .on("mouseleave", () => { resetHover(); this.#hideTooltip(); }); - nodeGroups.selectAll("text") + nodeGroups + .selectAll("text") + .style("cursor", (d) => + sankeyNodeHasChildren(this.#visibleData(), d.id) + ? "pointer" + : "default", + ) .on("mouseenter", (event, d) => { - const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d); + const connectedLinks = sankeyData.links.filter( + (l) => l.source === d || l.target === d, + ); applyHover(connectedLinks); this.#showTooltip(event, d.value, d.percentage, d.name); }) - .on("mousemove", event => this.#updateTooltipPosition(event)) + .on("mousemove", (event) => this.#updateTooltipPosition(event)) + .on("click", (event, d) => { + event.stopPropagation(); + this.#zoomIn(d); + }) .on("mouseleave", () => { resetHover(); this.#hideTooltip(); @@ -347,9 +506,13 @@ export default class extends Controller { #createTooltip() { const dialog = this.element.closest("dialog"); - this.tooltip = d3.select(dialog || document.body) + this.tooltip = d3 + .select(dialog || document.body) .append("div") - .attr("class", "bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50 top-0") + .attr( + "class", + "bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50 top-0", + ) .style("opacity", 0) .style("pointer-events", "none"); } @@ -381,9 +544,7 @@ export default class extends Controller { const x = isInDialog ? event.clientX : event.pageX; const y = isInDialog ? event.clientY : event.pageY; - this.tooltip - ?.style("left", `${x + 10}px`) - .style("top", `${y - 10}px`); + this.tooltip?.style("left", `${x + 10}px`).style("top", `${y - 10}px`); } } @@ -400,7 +561,7 @@ export default class extends Controller { #formatCurrency(value) { const formatted = Number.parseFloat(value).toLocaleString(undefined, { minimumFractionDigits: 2, - maximumFractionDigits: 2 + maximumFractionDigits: 2, }); return this.currencySymbolValue + formatted; } diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js index 23b56051d..19b44bb64 100644 --- a/app/javascript/controllers/select_controller.js +++ b/app/javascript/controllers/select_controller.js @@ -2,9 +2,9 @@ import { Controller } from "@hotwired/stimulus" import { autoUpdate } from "@floating-ui/dom" export default class extends Controller { - static targets = ["button", "menu", "input"] + static targets = ["button", "menu", "input", "content", "option"] static values = { - placement: { type: String, default: "bottom-start" }, + menuPlacement: { type: String, default: "auto" }, offset: { type: Number, default: 6 } } @@ -70,12 +70,14 @@ export default class extends Controller { const previousSelected = this.menuTarget.querySelector("[aria-selected='true']") if (previousSelected) { previousSelected.setAttribute("aria-selected", "false") + previousSelected.setAttribute("tabindex", "-1") previousSelected.classList.remove("bg-container-inset") const prevIcon = previousSelected.querySelector(".check-icon") if (prevIcon) prevIcon.classList.add("hidden") } selectedElement.setAttribute("aria-selected", "true") + selectedElement.setAttribute("tabindex", "0") selectedElement.classList.add("bg-container-inset") const selectedIcon = selectedElement.querySelector(".check-icon") if (selectedIcon) selectedIcon.classList.remove("hidden") @@ -103,7 +105,25 @@ export default class extends Controller { scrollToSelected() { const selected = this.menuTarget.querySelector(".bg-container-inset") - if (selected) selected.scrollIntoView({ block: "center" }) + if (!selected) return + + const container = this.hasContentTarget ? this.contentTarget : this.menuTarget + const containerRect = container.getBoundingClientRect() + const selectedRect = selected.getBoundingClientRect() + const delta = selectedRect.top - containerRect.top - (container.clientHeight - selectedRect.height) / 2 + + const nextScrollTop = container.scrollTop + delta + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight) + container.scrollTop = this.clamp(nextScrollTop, 0, maxScrollTop) + } + + clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) + } + + placementMode() { + const mode = (this.menuPlacementValue || "auto").toLowerCase() + return ["auto", "down", "up"].includes(mode) ? mode : "auto" } handleOutsideClick(event) { @@ -112,8 +132,66 @@ export default class extends Controller { handleKeydown(event) { if (!this.isOpen) return - if (event.key === "Escape") { this.close(); this.buttonTarget.focus() } - if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click() } + if (event.key === "Escape") { this.close(); this.buttonTarget.focus(); return } + if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click(); return } + + // WAI-ARIA APG listbox keyboard pattern: ArrowUp/Down moves focus + // between options (roving tabindex), Home/End jump to first/last. + // From the search input, ArrowDown/Up bridge into the visible + // options so users can reach the filtered matches; other keys + // (typing, caret movement) stay with the input. + const fromSearch = event.target.matches('input[type="search"]') + const visibleOptions = this.visibleOptions() + if (fromSearch) { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return + if (visibleOptions.length === 0) return + event.preventDefault() + const targetIndex = event.key === "ArrowDown" ? 0 : visibleOptions.length - 1 + this.rovingFocus(visibleOptions, targetIndex) + return + } + + if (visibleOptions.length === 0) return + const currentIndex = visibleOptions.indexOf(event.target) + let nextIndex = null + switch (event.key) { + case "ArrowDown": nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % visibleOptions.length; break + case "ArrowUp": nextIndex = currentIndex < 0 ? visibleOptions.length - 1 : (currentIndex - 1 + visibleOptions.length) % visibleOptions.length; break + case "Home": nextIndex = 0; break + case "End": nextIndex = visibleOptions.length - 1; break + default: return + } + event.preventDefault() + this.rovingFocus(visibleOptions, nextIndex) + } + + // Roving tabindex helper: makes the target option tabbable (and + // focuses it), clears tabindex on every other option in the listbox. + rovingFocus(visibleOptions, index) { + const all = this.hasOptionTarget ? this.optionTargets : [] + const target = visibleOptions[index] + all.forEach(opt => opt.setAttribute("tabindex", opt === target ? "0" : "-1")) + target.focus() + } + + // Options the user can currently see — list-filter hides non-matches + // by setting `style.display = "none"`. Inline check keeps it cheap. + visibleOptions() { + const options = this.hasOptionTarget ? this.optionTargets : [] + return options.filter(opt => opt.style.display !== "none") + } + + // After list-filter#filter runs, the option holding tabindex="0" may + // be hidden. Promote the first visible option so Tab from the search + // input still lands somewhere reachable; if none match, no-op. + syncTabindex() { + const visible = this.visibleOptions() + if (visible.length === 0) return + const tabbable = visible.find(opt => opt.getAttribute("tabindex") === "0") + if (tabbable) return + const all = this.hasOptionTarget ? this.optionTargets : [] + all.forEach(opt => opt.setAttribute("tabindex", "-1")) + visible[0].setAttribute("tabindex", "0") } handleTurboLoad() { if (this.isOpen) this.close() } @@ -163,7 +241,8 @@ export default class extends Controller { const spaceBelow = containerRect.bottom - buttonRect.bottom const spaceAbove = buttonRect.top - containerRect.top - const shouldOpenUp = spaceBelow < menuHeight && spaceAbove > spaceBelow + const placement = this.placementMode() + const shouldOpenUp = placement === "up" || (placement === "auto" && spaceBelow < menuHeight && spaceAbove > spaceBelow) this.menuTarget.style.left = "0" this.menuTarget.style.width = "100%" diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index 361ae95ed..ff64fd41c 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -39,15 +39,15 @@ export default class extends Controller { } } - // Sets or removes the data-theme attribute + // Sets the data-theme attribute and broadcasts a `theme:change` event so + // imperative consumers (D3/SVG/canvas) can repaint without polling. setTheme(isDark) { - if (isDark) { - localStorage.theme = "dark"; - document.documentElement.setAttribute("data-theme", "dark"); - } else { - localStorage.theme = "light"; - document.documentElement.setAttribute("data-theme", "light"); - } + const theme = isDark ? "dark" : "light"; + localStorage.theme = theme; + document.documentElement.setAttribute("data-theme", theme); + document.documentElement.dispatchEvent( + new CustomEvent("theme:change", { detail: { theme } }), + ); } systemPrefersDark() { diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 4344bf59a..d4f4988af 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -110,7 +110,7 @@ export default class extends Controller { .attr("cx", this._d3InitialContainerWidth / 2) .attr("cy", this._d3InitialContainerHeight / 2) .attr("r", 4) - .attr("class", "fg-subdued") + .attr("class", "text-subdued") .style("fill", "currentColor"); } @@ -220,7 +220,7 @@ export default class extends Controller { // Style ticks this._d3Group .selectAll(".tick text") - .attr("class", "fg-gray") + .attr("class", "text-secondary") .style("font-size", "12px") .style("font-weight", "500") .attr("text-anchor", "middle") @@ -289,7 +289,7 @@ export default class extends Controller { .append("div") .attr( "class", - "bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0 top-0", + "bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0 top-0 privacy-sensitive", ); } @@ -334,7 +334,7 @@ export default class extends Controller { // Guideline this._d3Group .append("line") - .attr("class", "guideline fg-subdued") + .attr("class", "guideline text-subdued") .attr("x1", this._d3XScale(d.date)) .attr("y1", 0) .attr("x2", this._d3XScale(d.date)) diff --git a/app/javascript/controllers/transaction_form_controller.js b/app/javascript/controllers/transaction_form_controller.js new file mode 100644 index 000000000..739b923da --- /dev/null +++ b/app/javascript/controllers/transaction_form_controller.js @@ -0,0 +1,60 @@ +import ExchangeRateFormController from "controllers/exchange_rate_form_controller"; + +// Connects to data-controller="transaction-form" +export default class extends ExchangeRateFormController { + static targets = [ + ...ExchangeRateFormController.targets, + "account", + "currency" + ]; + + hasRequiredExchangeRateTargets() { + if (!this.hasAccountTarget || !this.hasCurrencyTarget || !this.hasDateTarget) { + return false; + } + + return true; + } + + getExchangeRateContext() { + if (!this.hasRequiredExchangeRateTargets()) { + return null; + } + + const accountId = this.accountTarget.value; + const currency = this.currencyTarget.value; + const date = this.dateTarget.value; + + if (!accountId || !currency) { + return null; + } + + const accountCurrency = this.accountCurrenciesValue[accountId]; + if (!accountCurrency) { + return null; + } + + return { + fromCurrency: currency, + toCurrency: accountCurrency, + date + }; + } + + isCurrentExchangeRateState(fromCurrency, toCurrency, date) { + if (!this.hasRequiredExchangeRateTargets()) { + return false; + } + + const currentAccountId = this.accountTarget.value; + const currentCurrency = this.currencyTarget.value; + const currentDate = this.dateTarget.value; + const currentAccountCurrency = this.accountCurrenciesValue[currentAccountId]; + + return fromCurrency === currentCurrency && toCurrency === currentAccountCurrency && date === currentDate; + } + + onCurrencyChange() { + this.checkCurrencyDifference(); + } +} diff --git a/app/javascript/controllers/transaction_type_tabs_controller.js b/app/javascript/controllers/transaction_type_tabs_controller.js new file mode 100644 index 000000000..e347121de --- /dev/null +++ b/app/javascript/controllers/transaction_type_tabs_controller.js @@ -0,0 +1,21 @@ +import { Controller } from "@hotwired/stimulus" + +const ACTIVE_CLASSES = ["bg-container", "text-primary", "shadow-sm"] +const INACTIVE_CLASSES = ["hover:bg-container", "text-subdued", "hover:text-primary", "hover:shadow-sm"] + +export default class extends Controller { + static targets = ["tab", "natureField"] + + selectTab(event) { + event.preventDefault() + + const selectedTab = event.currentTarget + this.natureFieldTarget.value = selectedTab.dataset.nature + + this.tabTargets.forEach(tab => { + const isActive = tab === selectedTab + tab.classList.remove(...(isActive ? INACTIVE_CLASSES : ACTIVE_CLASSES)) + tab.classList.add(...(isActive ? ACTIVE_CLASSES : INACTIVE_CLASSES)) + }) + } +} diff --git a/app/javascript/controllers/transfer_form_controller.js b/app/javascript/controllers/transfer_form_controller.js new file mode 100644 index 000000000..e8d17437c --- /dev/null +++ b/app/javascript/controllers/transfer_form_controller.js @@ -0,0 +1,59 @@ +import ExchangeRateFormController from "controllers/exchange_rate_form_controller"; + +// Connects to data-controller="transfer-form" +export default class extends ExchangeRateFormController { + static targets = [ + ...ExchangeRateFormController.targets, + "fromAccount", + "toAccount" + ]; + + hasRequiredExchangeRateTargets() { + if (!this.hasFromAccountTarget || !this.hasToAccountTarget || !this.hasDateTarget) { + return false; + } + + return true; + } + + getExchangeRateContext() { + if (!this.hasRequiredExchangeRateTargets()) { + return null; + } + + const fromAccountId = this.fromAccountTarget.value; + const toAccountId = this.toAccountTarget.value; + const date = this.dateTarget.value; + + if (!fromAccountId || !toAccountId) { + return null; + } + + const fromCurrency = this.accountCurrenciesValue[fromAccountId]; + const toCurrency = this.accountCurrenciesValue[toAccountId]; + + if (!fromCurrency || !toCurrency) { + return null; + } + + return { + fromCurrency, + toCurrency, + date + }; + } + + isCurrentExchangeRateState(fromCurrency, toCurrency, date) { + if (!this.hasRequiredExchangeRateTargets()) { + return false; + } + + const currentFromAccountId = this.fromAccountTarget.value; + const currentToAccountId = this.toAccountTarget.value; + const currentFromCurrency = this.accountCurrenciesValue[currentFromAccountId]; + const currentToCurrency = this.accountCurrenciesValue[currentToAccountId]; + const currentDate = this.dateTarget.value; + + return fromCurrency === currentFromCurrency && toCurrency === currentToCurrency && date === currentDate; + } +} diff --git a/app/javascript/controllers/webauthn_authentication_controller.js b/app/javascript/controllers/webauthn_authentication_controller.js new file mode 100644 index 000000000..d4c141307 --- /dev/null +++ b/app/javascript/controllers/webauthn_authentication_controller.js @@ -0,0 +1,62 @@ +import WebauthnController from "controllers/webauthn_controller"; +import { + prepareCredentialRequestOptions, + serializePublicKeyCredential, +} from "utils/webauthn"; + +export default class extends WebauthnController { + static targets = ["error"]; + static values = { + optionsUrl: String, + verifyUrl: String, + unsupportedMessage: String, + errorFallback: String, + }; + + async authenticate(event) { + event.preventDefault(); + this.clearError(); + + if (!window.PublicKeyCredential) { + this.showError(this.unsupportedMessageValue); + return; + } + + try { + const options = await this.fetchOptions(); + const credential = await navigator.credentials.get({ + publicKey: prepareCredentialRequestOptions(options), + }); + + await this.verifyCredential(serializePublicKeyCredential(credential)); + } catch (error) { + this.showError(error.message); + } + } + + async fetchOptions() { + const response = await fetch(this.optionsUrlValue, { + method: "POST", + headers: this.headers, + credentials: "same-origin", + }); + + if (!response.ok) throw new Error(await this.errorMessage(response)); + + return response.json(); + } + + async verifyCredential(credential) { + const response = await fetch(this.verifyUrlValue, { + method: "POST", + headers: this.headers, + credentials: "same-origin", + body: JSON.stringify({ credential }), + }); + + if (!response.ok) throw new Error(await this.errorMessage(response)); + + const result = await response.json(); + window.location.href = result.redirect_url; + } +} diff --git a/app/javascript/controllers/webauthn_controller.js b/app/javascript/controllers/webauthn_controller.js new file mode 100644 index 000000000..f421451ec --- /dev/null +++ b/app/javascript/controllers/webauthn_controller.js @@ -0,0 +1,39 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + get headers() { + return { + Accept: "application/json", + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector("meta[name='csrf-token']") + ?.content, + }; + } + + async errorMessage(response) { + try { + const result = await response.clone().json(); + if (result.error) return result.error; + } catch (_error) { + return this.errorFallbackValue; + } + + return this.errorFallbackValue; + } + + showError(message) { + if (this.hasErrorTarget) { + this.errorTarget.textContent = message; + this.errorTarget.hidden = false; + this.errorTarget.setAttribute("aria-hidden", "false"); + } + } + + clearError() { + if (this.hasErrorTarget) { + this.errorTarget.textContent = ""; + this.errorTarget.hidden = true; + this.errorTarget.setAttribute("aria-hidden", "true"); + } + } +} diff --git a/app/javascript/controllers/webauthn_registration_controller.js b/app/javascript/controllers/webauthn_registration_controller.js new file mode 100644 index 000000000..2e0325a6f --- /dev/null +++ b/app/javascript/controllers/webauthn_registration_controller.js @@ -0,0 +1,67 @@ +import WebauthnController from "controllers/webauthn_controller"; +import { + prepareCredentialCreationOptions, + serializePublicKeyCredential, +} from "utils/webauthn"; + +export default class extends WebauthnController { + static targets = ["error", "nickname"]; + static values = { + optionsUrl: String, + createUrl: String, + unsupportedMessage: String, + errorFallback: String, + }; + + async register(event) { + event.preventDefault(); + this.clearError(); + + if (!window.PublicKeyCredential) { + this.showError(this.unsupportedMessageValue); + return; + } + + try { + const options = await this.fetchOptions(); + const credential = await navigator.credentials.create({ + publicKey: prepareCredentialCreationOptions(options), + }); + + await this.createCredential(serializePublicKeyCredential(credential)); + } catch (error) { + this.showError(error.message); + } + } + + async fetchOptions() { + const response = await fetch(this.optionsUrlValue, { + method: "POST", + headers: this.headers, + credentials: "same-origin", + }); + + if (!response.ok) throw new Error(await this.errorMessage(response)); + + return response.json(); + } + + async createCredential(credential) { + const response = await fetch(this.createUrlValue, { + method: "POST", + headers: this.headers, + credentials: "same-origin", + body: JSON.stringify({ + credential, + webauthn_credential: { + nickname: this.hasNicknameTarget ? this.nicknameTarget.value : "", + }, + }), + }); + + if (!response.ok) throw new Error(await this.errorMessage(response)); + + const result = await response.json(); + window.location.href = result.redirect_url; + } +} diff --git a/app/javascript/utils/sankey_zoom.mjs b/app/javascript/utils/sankey_zoom.mjs new file mode 100644 index 000000000..15e27b9ae --- /dev/null +++ b/app/javascript/utils/sankey_zoom.mjs @@ -0,0 +1,149 @@ +const CASH_FLOW_NODE_ID = "cash_flow_node"; +const CASH_FLOW_NODE_NAME = "Cash Flow"; + +export function sankeyNodeHasChildren(data, nodeId) { + const graph = buildGraph(data); + const nodeIndex = graph.indexById.get(nodeId); + if (nodeIndex === undefined || graph.cashFlowIndex < 0) return false; + + return childIndexesFor(graph, nodeIndex).length > 0; +} + +export function zoomSankeyData(data, rootNodeId) { + const graph = buildGraph(data); + const rootIndex = graph.indexById.get(rootNodeId); + if (rootIndex === undefined || graph.cashFlowIndex < 0) return data; + + const includedIndexes = descendantIndexesFor(graph, rootIndex); + if (includedIndexes.size <= 1) return data; + + const orderedIndexes = graph.nodes + .map((_, index) => index) + .filter((index) => includedIndexes.has(index)); + const reindexed = new Map( + orderedIndexes.map((index, newIndex) => [index, newIndex]), + ); + + return { + ...data, + nodes: orderedIndexes.map((index) => ({ ...graph.nodes[index] })), + links: graph.links + .filter( + (link) => + includedIndexes.has(link.sourceIndex) && + includedIndexes.has(link.targetIndex), + ) + .map((link) => ({ + ...link.original, + source: reindexed.get(link.sourceIndex), + target: reindexed.get(link.targetIndex), + })), + }; +} + +function buildGraph(data) { + const nodes = data?.nodes || []; + const links = (data?.links || []).map((link) => { + const sourceIndex = linkIndex(link.source); + const targetIndex = linkIndex(link.target); + + return { + original: link, + sourceIndex, + targetIndex, + }; + }); + + const indexById = new Map( + nodes.map((node, index) => [nodeId(node, index), index]), + ); + const cashFlowIndex = nodes.findIndex( + (node) => + nodeId(node, -1) === CASH_FLOW_NODE_ID || + node.name === CASH_FLOW_NODE_NAME, + ); + + return { + nodes, + links, + indexById, + cashFlowIndex, + outbound: groupLinksBy(links, "sourceIndex"), + inbound: groupLinksBy(links, "targetIndex"), + }; +} + +function nodeId(node, index) { + return node?.id ?? index; +} + +function linkIndex(endpoint) { + return typeof endpoint === "object" ? endpoint.index : endpoint; +} + +function groupLinksBy(links, key) { + const groups = new Map(); + + links.forEach((link) => { + const index = link[key]; + if (!groups.has(index)) groups.set(index, []); + groups.get(index).push(link); + }); + + return groups; +} + +function descendantIndexesFor(graph, rootIndex) { + const included = new Set([rootIndex]); + const queue = [rootIndex]; + + while (queue.length) { + const currentIndex = queue.shift(); + + childIndexesFor(graph, currentIndex).forEach((childIndex) => { + if (included.has(childIndex)) return; + + included.add(childIndex); + queue.push(childIndex); + }); + } + + return included; +} + +function childIndexesFor(graph, nodeIndex) { + if (nodeIndex === graph.cashFlowIndex) { + return (graph.outbound.get(nodeIndex) || []).map((link) => link.targetIndex); + } + + if (canReach(graph, graph.cashFlowIndex, nodeIndex)) { + return (graph.outbound.get(nodeIndex) || []).map((link) => link.targetIndex); + } + + if (canReach(graph, nodeIndex, graph.cashFlowIndex)) { + return (graph.inbound.get(nodeIndex) || []).map((link) => link.sourceIndex); + } + + return []; +} + +function canReach(graph, startIndex, targetIndex) { + if (startIndex === targetIndex) return true; + + const visited = new Set([startIndex]); + const queue = [startIndex]; + + while (queue.length) { + const currentIndex = queue.shift(); + + for (const link of graph.outbound.get(currentIndex) || []) { + if (link.targetIndex === targetIndex) return true; + if (visited.has(link.targetIndex)) continue; + + visited.add(link.targetIndex); + queue.push(link.targetIndex); + } + } + + return false; +} diff --git a/app/javascript/utils/webauthn.js b/app/javascript/utils/webauthn.js new file mode 100644 index 000000000..f7799e64d --- /dev/null +++ b/app/javascript/utils/webauthn.js @@ -0,0 +1,85 @@ +function bufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer); + const binary = String.fromCharCode(...bytes); + + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function base64urlToBuffer(value) { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd( + base64.length + ((4 - (base64.length % 4)) % 4), + "=", + ); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes.buffer; +} + +export function prepareCredentialCreationOptions(options) { + options.challenge = base64urlToBuffer(options.challenge); + options.user.id = base64urlToBuffer(options.user.id); + options.excludeCredentials = (options.excludeCredentials || []).map( + (credential) => ({ + ...credential, + id: base64urlToBuffer(credential.id), + }), + ); + + return options; +} + +export function prepareCredentialRequestOptions(options) { + options.challenge = base64urlToBuffer(options.challenge); + options.allowCredentials = (options.allowCredentials || []).map( + (credential) => ({ + ...credential, + id: base64urlToBuffer(credential.id), + }), + ); + + return options; +} + +export function serializePublicKeyCredential(credential) { + const serialized = { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment, + clientExtensionResults: credential.getClientExtensionResults(), + }; + + if (credential.response.attestationObject) { + serialized.response = { + attestationObject: bufferToBase64url( + credential.response.attestationObject, + ), + clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), + transports: credential.response.getTransports + ? credential.response.getTransports() + : [], + }; + } else { + serialized.response = { + authenticatorData: bufferToBase64url( + credential.response.authenticatorData, + ), + clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), + signature: bufferToBase64url(credential.response.signature), + userHandle: credential.response.userHandle + ? bufferToBase64url(credential.response.userHandle) + : null, + }; + } + + return serialized; +} diff --git a/app/jobs/assistant_response_job.rb b/app/jobs/assistant_response_job.rb index 70664f02b..36a6c0e84 100644 --- a/app/jobs/assistant_response_job.rb +++ b/app/jobs/assistant_response_job.rb @@ -1,7 +1,7 @@ class AssistantResponseJob < ApplicationJob queue_as :high_priority - def perform(message) - message.request_response + def perform(message, assistant_message = nil) + message.request_response(assistant_message: assistant_message) end end diff --git a/app/jobs/debug_log_cleanup_job.rb b/app/jobs/debug_log_cleanup_job.rb new file mode 100644 index 000000000..3edf46f9f --- /dev/null +++ b/app/jobs/debug_log_cleanup_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class DebugLogCleanupJob < ApplicationJob + queue_as :scheduled + + def perform + deleted_count = DebugLogEntry.where(created_at: ...retention_period.ago).delete_all + Rails.logger.info("DebugLogCleanupJob: Deleted #{deleted_count} debug log entries older than #{retention_period.inspect}") if deleted_count > 0 + end + + private + def retention_period + Rails.application.config.x.debug_log.retention_days.days + end +end diff --git a/app/jobs/demo_family_refresh_job.rb b/app/jobs/demo_family_refresh_job.rb index 65f6a8c9b..b30f6cab2 100644 --- a/app/jobs/demo_family_refresh_job.rb +++ b/app/jobs/demo_family_refresh_job.rb @@ -2,10 +2,12 @@ class DemoFamilyRefreshJob < ApplicationJob queue_as :scheduled def perform + return unless Rails.application.config.app_mode.managed? + period_end = Time.current period_start = period_end - 24.hours - demo_email = Rails.application.config_for(:demo).fetch("email") + demo_email = Rails.application.config_for(:demo).with_indifferent_access.fetch(:email) demo_user = User.find_by(email: demo_email) old_family = demo_user&.family diff --git a/app/jobs/destroy_job.rb b/app/jobs/destroy_job.rb index 8b5622423..ba937e61f 100644 --- a/app/jobs/destroy_job.rb +++ b/app/jobs/destroy_job.rb @@ -5,6 +5,6 @@ class DestroyJob < ApplicationJob def perform(model) model.destroy rescue => e - model.update!(scheduled_for_deletion: false) # Let's the user try again by resetting the state + model.update!(scheduled_for_deletion: false) if model.respond_to?(:scheduled_for_deletion) # Let's the user try again by resetting the state end end diff --git a/app/jobs/identify_recurring_transactions_job.rb b/app/jobs/identify_recurring_transactions_job.rb index 92e1e9151..9bb2c4156 100644 --- a/app/jobs/identify_recurring_transactions_job.rb +++ b/app/jobs/identify_recurring_transactions_job.rb @@ -51,6 +51,7 @@ class IdentifyRecurringTransactionsJob < ApplicationJob return true if family.simplefin_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:simplefin_items) return true if family.lunchflow_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:lunchflow_items) return true if family.enable_banking_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:enable_banking_items) + return true if family.sophtron_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:sophtron_items) # Check accounts' syncs return true if family.accounts.joins(:syncs).merge(Sync.incomplete).exists? diff --git a/app/jobs/rule_job.rb b/app/jobs/rule_job.rb index 92a23c086..c70c79fa9 100644 --- a/app/jobs/rule_job.rb +++ b/app/jobs/rule_job.rb @@ -40,7 +40,7 @@ class RuleJob < ApplicationJob status = "pending" elsif result.is_a?(Integer) # Only synchronous actions were executed - transactions_processed = result + transactions_processed = transactions_queued transactions_modified = result status = "success" else diff --git a/app/jobs/sophtron_initial_load_job.rb b/app/jobs/sophtron_initial_load_job.rb new file mode 100644 index 000000000..ca91a9c64 --- /dev/null +++ b/app/jobs/sophtron_initial_load_job.rb @@ -0,0 +1,20 @@ +class SophtronInitialLoadJob < ApplicationJob + queue_as :high_priority + + RETRY_DELAY = 10.seconds + MAX_ATTEMPTS = 30 + + def perform(sophtron_item, attempts_remaining: MAX_ATTEMPTS) + if sophtron_item.syncing? + if attempts_remaining.positive? + self.class.set(wait: RETRY_DELAY).perform_later(sophtron_item, attempts_remaining: attempts_remaining - 1) + else + Rails.logger.warn("SophtronInitialLoadJob - gave up waiting for SophtronItem #{sophtron_item.id} to finish syncing") + end + + return + end + + sophtron_item.sync_later + end +end diff --git a/app/jobs/sophtron_refresh_poll_job.rb b/app/jobs/sophtron_refresh_poll_job.rb new file mode 100644 index 000000000..1f5afb08a --- /dev/null +++ b/app/jobs/sophtron_refresh_poll_job.rb @@ -0,0 +1,76 @@ +class SophtronRefreshPollJob < ApplicationJob + queue_as :high_priority + + POLL_INTERVAL = 4.seconds + MAX_ATTEMPTS = 60 + + def perform(sophtron_account, job_id:, attempts_remaining: MAX_ATTEMPTS, sync: nil) + sophtron_item = sophtron_account.sophtron_item + provider = sophtron_item.sophtron_provider + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + job = Provider::Sophtron.response_data!(provider.get_job_information(job_id)) + sophtron_item.upsert_job_snapshot!(job) + + if Provider::Sophtron.job_requires_input?(job) + mark_requires_update!(sophtron_item, job_id) + elsif Provider::Sophtron.job_failed?(job) + sophtron_item.update!(last_connection_error: "Sophtron refresh failed") + elsif Provider::Sophtron.job_success?(job) || Provider::Sophtron.job_completed?(job) + import_transactions!(sophtron_account, provider, sync) + elsif attempts_remaining.to_i > 1 + self.class.set(wait: POLL_INTERVAL).perform_later( + sophtron_account, + job_id: job_id, + attempts_remaining: attempts_remaining.to_i - 1, + sync: sync + ) + else + sophtron_item.update!(last_connection_error: "Sophtron refresh did not finish before the polling timeout") + end + rescue Provider::Sophtron::Error => e + handle_provider_error!(sophtron_account.sophtron_item, e) + end + + private + + def import_transactions!(sophtron_account, provider, sync) + sophtron_item = sophtron_account.sophtron_item + result = SophtronItem::Importer.new(sophtron_item, sophtron_provider: provider, sync: sync) + .import_transactions_after_refresh(sophtron_account) + + unless result[:success] + attributes = { last_connection_error: result[:error] } + attributes[:status] = :requires_update if result[:requires_update] + sophtron_item.update!(attributes) + return + end + + SophtronAccount::Processor.new(sophtron_account.reload).process + + account = sophtron_account.current_account + return unless account + + account.sync_later( + parent_sync: sync, + window_start_date: sync&.window_start_date, + window_end_date: sync&.window_end_date + ) + end + + def mark_requires_update!(sophtron_item, job_id) + sophtron_item.update!( + status: :requires_update, + current_job_id: job_id, + last_connection_error: "Sophtron refresh requires MFA" + ) + end + + def handle_provider_error!(sophtron_item, error) + requires_update = error.error_type.in?([ :unauthorized, :access_forbidden ]) + attributes = { last_connection_error: error.message } + attributes[:status] = :requires_update if requires_update + sophtron_item.update!(attributes) + Rails.logger.error "SophtronRefreshPollJob - Sophtron API error for item #{sophtron_item.id}: #{error.message}" + end +end diff --git a/app/jobs/sync_all_providers_job.rb b/app/jobs/sync_all_providers_job.rb new file mode 100644 index 000000000..431b77cad --- /dev/null +++ b/app/jobs/sync_all_providers_job.rb @@ -0,0 +1,9 @@ +class SyncAllProvidersJob < ApplicationJob + queue_as :high_priority + sidekiq_options lock: :until_executed, lock_args: ->(args) { [ args.first ] }, on_conflict: :log + + def perform(family_id) + family = Family.find_by(id: family_id) + family&.sync_later + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 8a31a8d5e..b0595d308 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -2,6 +2,8 @@ class Account < ApplicationRecord include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable, TaxTreatable before_validation :assign_default_owner, if: -> { owner_id.blank? } + before_destroy :capture_account_statement_ids_to_move + after_destroy_commit :move_account_statements_to_inbox validates :name, :balance, :currency, presence: true validate :owner_belongs_to_family, if: -> { owner_id.present? && family_id.present? } @@ -20,6 +22,14 @@ class Account < ApplicationRecord has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy has_many :recurring_transactions, dependent: :destroy + # Inverse for recurring transfers where this account is the destination. + # Account#recurring_transactions only matches account_id; without this + # association, destroying the destination account would hit the FK + # cascade silently and the AR cache wouldn't reflect the deletion. + has_many :inbound_recurring_transfers, + class_name: "RecurringTransaction", + foreign_key: :destination_account_id, + dependent: :destroy monetize :balance, :cash_balance @@ -69,6 +79,8 @@ class Account < ApplicationRecord } has_one_attached :logo, dependent: :purge_later + # No dependent: option; before_destroy captures IDs, after_destroy_commit moves statements back to inbox. + has_many :account_statements delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy delegate :subtype, to: :accountable, allow_nil: true @@ -248,27 +260,55 @@ class Account < ApplicationRecord end def create_from_binance_account(binance_account) - family = binance_account.binance_item.family + create_from_crypto_exchange_account(binance_account, family: binance_account.binance_item.family) + end + + def create_from_ibkr_account(ibkr_account) + family = ibkr_account.ibkr_item.family + default_name = if ibkr_account.ibkr_account_id.present? + "Interactive Brokers (#{ibkr_account.ibkr_account_id})" + else + "Interactive Brokers" + end attributes = { family: family, - name: binance_account.name, - balance: (binance_account.current_balance || 0).to_d, + name: default_name, + balance: 0, cash_balance: 0, - currency: binance_account.currency.presence || family.currency, - accountable_type: "Crypto", + currency: ibkr_account.currency.presence || family.currency, + accountable_type: "Investment", accountable_attributes: { - subtype: "exchange", - tax_treatment: "taxable" + subtype: "brokerage" } } create_and_sync(attributes, skip_initial_sync: true) end + def create_from_kraken_account(kraken_account) + create_from_crypto_exchange_account(kraken_account, family: kraken_account.kraken_item.family) + end private + def create_from_crypto_exchange_account(provider_account, family:) + attributes = { + family: family, + name: provider_account.name, + balance: (provider_account.current_balance || 0).to_d, + cash_balance: 0, + currency: provider_account.currency.presence || family.currency, + accountable_type: "Crypto", + accountable_attributes: { + subtype: "exchange", + tax_treatment: "taxable" + } + } + + create_and_sync(attributes, skip_initial_sync: true) + end + def build_simplefin_accountable_attributes(simplefin_account, account_type, subtype) attributes = {} attributes[:subtype] = subtype if subtype.present? @@ -298,6 +338,14 @@ class Account < ApplicationRecord read_attribute(:institution_domain).presence || provider&.institution_domain end + def manual_crypto_exchange? + accountable_type == "Crypto" && + accountable&.subtype == "exchange" && + account_providers.none? && + plaid_account_id.blank? && + simplefin_account_id.blank? + end + def logo_url if institution_domain.present? && Setting.brand_fetch_client_id.present? logo_size = Setting.brand_fetch_logo_size @@ -328,15 +376,23 @@ class Account < ApplicationRecord end def current_holdings - holdings - .where(currency: currency) - .where.not(qty: 0) - .where( - id: holdings.select("DISTINCT ON (security_id) id") - .where(currency: currency) - .order(:security_id, date: :desc) - ) - .order(amount: :desc) + if (provider_snapshot_date = latest_provider_holdings_snapshot_date) + holdings + .where.not(account_provider_id: nil) + .where(date: provider_snapshot_date) + .where.not(qty: 0) + .order(amount: :desc) + else + holdings + .where(currency: currency) + .where.not(qty: 0) + .where( + id: holdings.select("DISTINCT ON (security_id) id") + .where(currency: currency) + .order(:security_id, date: :desc) + ) + .order(amount: :desc) + end end def latest_provider_holdings_snapshot_date @@ -470,4 +526,21 @@ class Account < ApplicationRecord return if User.where(id: owner_id, family_id: family_id).exists? errors.add(:owner, :invalid, message: "must belong to the same family as the account") end + + def capture_account_statement_ids_to_move + @statement_ids_to_move = account_statements.ids + end + + def move_account_statements_to_inbox + statement_ids = Array(@statement_ids_to_move).compact + return if statement_ids.empty? + + # Bypass callbacks deliberately: the account was destroyed, so linked statements need a direct inbox move. + AccountStatement.where(id: statement_ids).update_all( + account_id: nil, + review_status: "unmatched", + match_confidence: nil, + updated_at: Time.current + ) + end end diff --git a/app/models/account/current_balance_manager.rb b/app/models/account/current_balance_manager.rb index f1c5928a5..b01c2f26a 100644 --- a/app/models/account/current_balance_manager.rb +++ b/app/models/account/current_balance_manager.rb @@ -92,22 +92,55 @@ class Account::CurrentBalanceManager # Linked accounts manage "current balance" via the special `current_anchor` valuation. # This is NOT a user-facing feature, and is primarily used in "processors" while syncing # linked account data (e.g. via Plaid) + # + # Before overwriting a stale (previous-day) current_anchor, we convert it to a + # reconciliation valuation. This preserves the API-reported balance as a historical + # waypoint that the ReverseCalculator uses for more accurate balance history. def set_current_balance_for_linked_account(balance) - if current_anchor_valuation - changes_made = update_current_anchor(balance) - Result.new(success?: true, changes_made?: changes_made, error: nil) - else - create_current_anchor(balance) - Result.new(success?: true, changes_made?: true, error: nil) + changes_made = false + + ActiveRecord::Base.transaction do + # If an anchor exists from a previous day, preserve it as a reconciliation + # before replacing it with today's fresh anchor. + preserve_anchor_as_reconciliation_if_stale if current_anchor_valuation + + # Re-check: the memoized value was cleared if the anchor was converted + if current_anchor_valuation + changes_made = update_current_anchor(balance) + else + create_current_anchor(balance) + changes_made = true + end end + + Result.new(success?: true, changes_made?: changes_made, error: nil) end def current_anchor_valuation @current_anchor_valuation ||= account.valuations.current_anchor.includes(:entry).first end + # If the existing current_anchor is from a previous day, convert it to a + # reconciliation before overwriting. This accumulates a chain of API-reported + # balance waypoints over time without creating extra entries per sync. + # + # Same-day updates are left in place (no extra reconciliations on repeated syncs). + def preserve_anchor_as_reconciliation_if_stale + entry = current_anchor_valuation.entry + return if entry.date == Date.current # Same-day update — nothing to preserve + + current_anchor_valuation.update!(kind: "reconciliation") + entry.update!(name: Valuation.build_reconciliation_name(account.accountable_type)) + Rails.logger.info("[AnchorRotation] Converted current_anchor to reconciliation for account #{account.id}, date=#{entry.date}, entry_id=#{entry.id}") + + # Clear memoized value so the next check creates a fresh current_anchor. + # The chained scope (.current_anchor.first) always issues a fresh SQL query, + # so we don't need to reload the full association. + @current_anchor_valuation = nil + end + def create_current_anchor(balance) - entry = account.entries.create!( + account.entries.create!( date: Date.current, name: Valuation.build_current_anchor_name(account.accountable_type), amount: balance, @@ -115,31 +148,28 @@ class Account::CurrentBalanceManager entryable: Valuation.new(kind: "current_anchor") ) - # Reload associations and clear memoized value so it gets the new anchor - account.valuations.reload + # Clear memoized value so it picks up the new anchor on next access. @current_anchor_valuation = nil end def update_current_anchor(balance) changes_made = false - ActiveRecord::Base.transaction do - # Update associated entry attributes - entry = current_anchor_valuation.entry + # Update associated entry attributes + entry = current_anchor_valuation.entry - if entry.amount != balance - entry.amount = balance - changes_made = true - end - - if entry.date != Date.current - entry.date = Date.current - changes_made = true - end - - entry.save! if entry.changed? + if entry.amount != balance + entry.amount = balance + changes_made = true end + if entry.date != Date.current + entry.date = Date.current + changes_made = true + end + + entry.save! if entry.changed? + changes_made end end diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index d3830ddda..b91d4ed83 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -8,6 +8,17 @@ module Account::Linkable # Legacy provider associations - kept for backward compatibility during migration belongs_to :plaid_account, optional: true belongs_to :simplefin_account, optional: true + + # SQL-level mirror of `linked?`. Use this for set-based checks (e.g. bulk + # `EXISTS`) so both definitions stay in sync. If `linked?` adds a new + # provider source, update this scope too. + scope :linked, -> { + left_outer_joins(:account_providers) + .where( + "account_providers.id IS NOT NULL OR accounts.plaid_account_id IS NOT NULL OR accounts.simplefin_account_id IS NOT NULL" + ) + .distinct + } end # A "linked" account gets transaction and balance data from a third party like Plaid or SimpleFin diff --git a/app/models/account/market_data_importer.rb b/app/models/account/market_data_importer.rb index c6caf7980..5e7ae6182 100644 --- a/app/models/account/market_data_importer.rb +++ b/app/models/account/market_data_importer.rb @@ -24,18 +24,12 @@ class Account::MarketDataImporter .each do |source_currency, date| key = [ source_currency, account.currency ] pair_dates[key] = [ pair_dates[key], date ].compact.min - - inverse_key = [ account.currency, source_currency ] - pair_dates[inverse_key] = [ pair_dates[inverse_key], date ].compact.min end # 2. ACCOUNT-BASED PAIR – convert the account currency to the family currency (if different) if foreign_account? key = [ account.currency, account.family.currency ] pair_dates[key] = [ pair_dates[key], account.start_date ].compact.min - - inverse_key = [ account.family.currency, account.currency ] - pair_dates[inverse_key] = [ pair_dates[inverse_key], account.start_date ].compact.min end pair_dates.each do |(source, target), start_date| @@ -51,34 +45,71 @@ class Account::MarketDataImporter def import_security_prices return unless Security.provider - account_securities = (account.trades.map(&:security) + account.current_holdings.map(&:security)).uniq + current_security_ids = account.current_holdings.pluck(:security_id).to_set + traded_security_ids = account.trades.pluck(:security_id).uniq - return if account_securities.empty? + all_security_ids = (current_security_ids | traded_security_ids) + return if all_security_ids.empty? - account_securities.each do |security| - security.import_provider_prices( - start_date: first_required_price_date(security), - end_date: Date.current - ) + securities = Security.online.where(id: all_security_ids).index_by(&:id) + start_dates = batch_first_required_price_dates(all_security_ids) + historical_ids = traded_security_ids - current_security_ids.to_a + + # For securities no longer held, cap end_date at the last holding date so + # all_prices_exist? stays stable and we don't call the provider every sync. + last_holding_date = account.holdings + .where(security_id: historical_ids) + .group(:security_id) + .maximum(:date) + + # import_market_data runs before materialize_balances in Account::Syncer, so + # current_holdings can reflect a stale pre-trade snapshot. If a historical + # security has a trade newer than its last holding date the position was + # reopened this sync; fetch prices through today so the forthcoming + # materialization has a price available. + latest_trade_date = account.trades + .where(security_id: historical_ids) + .group(:security_id) + .maximum("entries.date") + + all_security_ids.each do |security_id| + security = securities[security_id] + next unless security + + end_date = if current_security_ids.include?(security_id) + Date.current + else + holding_date = last_holding_date[security_id] + trade_date = latest_trade_date[security_id] + reopened = trade_date && holding_date && trade_date > holding_date + reopened ? Date.current : (holding_date || Date.current) + end + + security.import_provider_prices(start_date: start_dates[security_id], end_date: end_date) security.import_provider_details end end private - # Calculates the first date we require a price for the given security scoped to this account - def first_required_price_date(security) - trade_start_date = account.trades.with_entry - .where(security: security) - .where(entries: { account_id: account.id }) - .minimum("entries.date") + # Replaces 2-queries-per-security with 3 queries total. + def batch_first_required_price_dates(security_ids) + # account.trades is a has_many :through :entries, so entries is already joined + trade_start_dates = account.trades.group(:security_id).minimum("entries.date") - holding_start_date = - if account.holdings.where(security: security).where.not(account_provider_id: nil).exists? - account.start_date - end + provider_holding_security_ids = account.holdings + .where(security_id: security_ids) + .where.not(account_provider_id: nil) + .pluck(:security_id) + .to_set - [ trade_start_date, holding_start_date ].compact.min + account_start_date = account.start_date + + security_ids.each_with_object({}) do |security_id, hash| + trade_date = trade_start_dates[security_id] + holding_date = provider_holding_security_ids.include?(security_id) ? account_start_date : nil + hash[security_id] = [ trade_date, holding_date ].compact.min || account_start_date + end end def needs_exchange_rates? diff --git a/app/models/account/opening_balance_manager.rb b/app/models/account/opening_balance_manager.rb index 95597cdaa..3ad6818e0 100644 --- a/app/models/account/opening_balance_manager.rb +++ b/app/models/account/opening_balance_manager.rb @@ -51,7 +51,11 @@ class Account::OpeningBalanceManager end def oldest_entry_date - @oldest_entry_date ||= account.entries.minimum(:date) + if opening_anchor_valuation&.entry + account.entries.where.not(id: opening_anchor_valuation.entry.id).minimum(:date) + else + account.entries.minimum(:date) + end end def default_date diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 2d814e9e8..549423f10 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -83,7 +83,8 @@ class Account::ProviderImportAdapter incoming_pending = ActiveModel::Type::Boolean.new.cast(pending_extra.dig("simplefin", "pending")) || ActiveModel::Type::Boolean.new.cast(pending_extra.dig("plaid", "pending")) || - ActiveModel::Type::Boolean.new.cast(pending_extra.dig("lunchflow", "pending")) + ActiveModel::Type::Boolean.new.cast(pending_extra.dig("lunchflow", "pending")) || + ActiveModel::Type::Boolean.new.cast(pending_extra.dig("enable_banking", "pending")) end if entry.new_record? && !incoming_pending @@ -108,18 +109,53 @@ class Account::ProviderImportAdapter end if pending_match + old_pending_external_id = pending_match.external_id + pending_entry_date = pending_match.date entry = pending_match entry.assign_attributes(external_id: external_id) + + # Clear the pending flag so this entry no longer shows as pending after being claimed + # by a booked transaction. Also record the old external_id so the sync engine can + # exclude it from re-import (preventing the old pending from being recreated on the + # next sync when the stored raw payload still contains the pending transaction data). + if entry.entryable.is_a?(Transaction) + ex = (entry.transaction.extra || {}).deep_dup + Transaction::PENDING_PROVIDERS.each do |provider| + next unless ex.key?(provider) + ex[provider].delete("pending") + ex.delete(provider) if ex[provider].empty? + end + if old_pending_external_id.present? + existing_claims = Array.wrap(ex["auto_claimed_pending_ids"]) + ex["auto_claimed_pending_ids"] = (existing_claims + [ old_pending_external_id ]).uniq + end + entry.transaction.extra = ex + end end end # Track if this is a new posted transaction (for fuzzy suggestion after save) is_new_posted = entry.new_record? && !incoming_pending + # Preserve the original pending date across all syncs: + # - First claim: pending_entry_date is captured from the pending match above + # - Subsequent syncs: entry already exists (no pending_match found), so check + # auto_claimed_pending_ids which signals it was previously auto-claimed and + # keep entry.date (the pending date stored on first claim) unchanged + effective_date = if pending_entry_date + pending_entry_date + elsif !entry.new_record? && + entry.entryable.is_a?(Transaction) && + entry.transaction.extra&.key?("auto_claimed_pending_ids") + entry.date + else + date + end + entry.assign_attributes( amount: amount, currency: currency, - date: date + date: effective_date ) # Use enrichment pattern to respect user overrides @@ -160,6 +196,10 @@ class Account::ProviderImportAdapter elsif detected_label == "Contribution" auto_kind = "investment_contribution" auto_category = account.family.investment_contributions_category + elsif account.accountable_type == "Loan" && amount.negative? + auto_kind = "loan_payment" + elsif account.accountable_type == "CreditCard" && amount.negative? + auto_kind = "cc_payment" end # Set investment activity label, kind, and category if detected @@ -546,8 +586,9 @@ class Account::ProviderImportAdapter # @param external_id [String, nil] Provider's unique ID (optional, for deduplication) # @param source [String] Provider name # @param activity_label [String, nil] Investment activity label (e.g., "Buy", "Sell", "Reinvestment") + # @param exchange_rate [BigDecimal, Numeric, nil] Optional provider-supplied FX rate into the account currency # @return [Entry] The created entry with trade - def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil) + def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil, exchange_rate: nil) raise ArgumentError, "security is required" if security.nil? raise ArgumentError, "source is required" if source.blank? @@ -580,13 +621,16 @@ class Account::ProviderImportAdapter end # Always update Trade attributes (works for both new and existing records) - entry.entryable.assign_attributes( + trade_attributes = { security: security, qty: quantity, price: price, currency: currency, investment_activity_label: activity_label || (quantity > 0 ? "Buy" : "Sell") - ) + } + trade_attributes[:exchange_rate] = exchange_rate unless exchange_rate.nil? + + entry.entryable.assign_attributes(trade_attributes) entry.assign_attributes( date: date, @@ -691,6 +735,7 @@ class Account::ProviderImportAdapter (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true + OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true SQL .order(date: :desc) # Prefer most recent pending transaction @@ -737,6 +782,7 @@ class Account::ProviderImportAdapter (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true + OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true SQL # If merchant_id is provided, prioritize matching by merchant @@ -806,6 +852,7 @@ class Account::ProviderImportAdapter (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true + OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true SQL # For low confidence, require BOTH merchant AND name match (stronger signal needed) diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 3b6a4c9b0..874091f81 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -9,6 +9,7 @@ class Account::Syncer Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})") import_market_data materialize_balances(window_start_date: sync.window_start_date) + apply_provider_balance_overrides end def perform_post_sync @@ -34,4 +35,16 @@ class Account::Syncer Rails.logger.error("Error syncing market data for account #{account.id}: #{e.message}") Sentry.capture_exception(e) end + + def apply_provider_balance_overrides + return unless account.linked_to?("IbkrAccount") + + ibkr_account = account.account_providers.find_by(provider_type: "IbkrAccount")&.provider + return unless ibkr_account + + IbkrAccount::HistoricalBalancesSync.new(ibkr_account).sync! + rescue => e + Rails.logger.error("Error syncing IBKR historical balances for account #{account.id}: #{e.class} - #{e.message}") + Sentry.capture_exception(e) + end end diff --git a/app/models/account_import.rb b/app/models/account_import.rb index 02f19c05b..53ee51f1f 100644 --- a/app/models/account_import.rb +++ b/app/models/account_import.rb @@ -59,11 +59,11 @@ class AccountImport < Import end def csv_template - template = <<-CSV + template = <<~CSV Account type*,Name*,Balance*,Currency,Balance Date - Checking,Main Checking Account,1000.00,USD,01/01/2024 - Savings,Emergency Fund,5000.00,USD,01/15/2024 - Credit Card,Rewards Card,-500.00,USD,02/01/2024 + Checking,Main Checking Account,1000.00,USD,2024-01-01 + Savings,Emergency Fund,5000.00,USD,2024-01-15 + Credit Card,Rewards Card,-500.00,USD,2024-02-01 CSV CSV.parse(template, headers: true) diff --git a/app/models/account_order.rb b/app/models/account_order.rb index 8a26c0fc4..bd7367c0b 100644 --- a/app/models/account_order.rb +++ b/app/models/account_order.rb @@ -33,11 +33,11 @@ class AccountOrder end def label - ORDERS.dig(key, :label) + I18n.t("account_order.#{key}.label", default: ORDERS.dig(key, :label)) end def label_short - ORDERS.dig(key, :label_short) + I18n.t("account_order.#{key}.label_short", default: ORDERS.dig(key, :label_short)) end def sql_order diff --git a/app/models/account_statement.rb b/app/models/account_statement.rb new file mode 100644 index 000000000..07cc80c86 --- /dev/null +++ b/app/models/account_statement.rb @@ -0,0 +1,462 @@ +# frozen_string_literal: true + +require "digest/md5" +require "digest/sha2" +require "stringio" + +class AccountStatement < ApplicationRecord + include Monetizable + + DuplicateUploadError = Class.new(StandardError) do + attr_reader :statement + + def initialize(statement) + @statement = statement + super("Statement file has already been uploaded") + end + end + InvalidUploadError = Class.new(StandardError) + + PreparedUpload = Data.define(:content, :filename, :content_type, :byte_size, :checksum, :content_sha256) + + MAX_FILE_SIZE = 25.megabytes + READ_CHUNK_SIZE = 1.megabyte + ALLOWED_EXTENSION_CONTENT_TYPES = { + ".pdf" => %w[application/pdf], + ".csv" => %w[text/csv text/plain application/csv application/vnd.ms-excel], + ".xlsx" => %w[application/vnd.openxmlformats-officedocument.spreadsheetml.sheet] + }.freeze + ALLOWED_CONTENT_TYPES = ALLOWED_EXTENSION_CONTENT_TYPES.values.flatten.uniq.freeze + ACCEPTED_FILE_EXTENSIONS = ALLOWED_EXTENSION_CONTENT_TYPES.keys.freeze + + belongs_to :family + belongs_to :account, optional: true + belongs_to :suggested_account, class_name: "Account", optional: true + + has_one_attached :original_file, dependent: :purge_later + + enum :source, { manual_upload: "manual_upload" }, validate: true, default: "manual_upload" + enum :upload_status, { stored: "stored", failed: "failed" }, validate: true, default: "stored" + enum :review_status, { unmatched: "unmatched", linked: "linked", rejected: "rejected" }, validate: true, default: "unmatched", scopes: false + + monetize :opening_balance, :closing_balance + + before_validation :sync_file_metadata, if: -> { original_file.attached? } + before_validation :normalize_currency + before_validation :sync_review_status + + validates :filename, :content_type, :checksum, presence: true + validates :byte_size, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: MAX_FILE_SIZE } + validates :content_type, inclusion: { in: ALLOWED_CONTENT_TYPES } + validates :content_sha256, + format: { with: /\A[0-9a-f]{64}\z/ }, + uniqueness: { scope: :family_id, allow_nil: true, message: :duplicate_statement_file }, + allow_nil: true + validates :parser_confidence, :match_confidence, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true + validate :account_belongs_to_family + validate :suggested_account_belongs_to_family + validate :period_order + validate :currency_is_valid + validate :filename_extension_matches_content_type + validate :original_file_attached + validate :original_file_constraints, if: -> { original_file.attached? } + + scope :ordered, -> { order(created_at: :desc) } + scope :with_account, -> { where.not(account_id: nil) } + scope :unmatched, -> { where(account_id: nil).where(review_status: "unmatched") } + scope :for_month, ->(month) { + month_start = month.to_date.beginning_of_month + month_end = month_start.end_of_month + where("period_start_on <= ? AND period_end_on >= ?", month_end, month_start) + } + + class << self + def statement_manager?(user) + user&.admin? || user&.member? + end + + def create_from_upload!(family:, account:, file:) + prepared_upload = prepare_upload!(file) + create_from_prepared_upload!(family: family, account: account, prepared_upload: prepared_upload) + end + + def create_from_prepared_upload!(family:, account:, prepared_upload:) + statement = nil + duplicate = duplicate_for(family, prepared_upload) + raise DuplicateUploadError, duplicate if duplicate + + statement = family.account_statements.build( + account: account, + filename: prepared_upload.filename, + content_type: prepared_upload.content_type, + byte_size: prepared_upload.byte_size, + checksum: prepared_upload.checksum, + content_sha256: prepared_upload.content_sha256, + source: :manual_upload, + upload_status: :stored, + review_status: account.present? ? :linked : :unmatched, + currency: account&.currency || family.currency + ) + + statement.original_file.attach( + io: StringIO.new(prepared_upload.content), + filename: prepared_upload.filename, + content_type: prepared_upload.content_type + ) + + MetadataDetector.new(statement, content: prepared_upload.content).apply + statement.assign_account_match unless account.present? + statement.save! + statement + rescue ActiveRecord::RecordNotUnique + duplicate = duplicate_for(family, prepared_upload) + purge_original_file(statement) + + if duplicate + raise DuplicateUploadError, duplicate + end + + raise + rescue StandardError + purge_original_file(statement) + raise + end + + def reconciliation_statuses_for(statements, account:) + statement_list = statements.to_a + balance_lookup = balance_lookup_for(account, statement_list) + + statement_list.to_h do |statement| + [ statement.id, statement.reconciliation_status(balance_lookup: balance_lookup) ] + end + end + + def prepare_upload!(file) + filename = file.original_filename.to_s + content = read_upload_content!(file) + byte_size = content.bytesize + raise InvalidUploadError if byte_size.zero? + + content_type = detected_content_type(content:, filename:, declared_content_type: file.content_type) + raise InvalidUploadError unless allowed_upload?(filename:, content_type:) + raise InvalidUploadError if content_type == "application/pdf" && !valid_pdf_content?(content) + + PreparedUpload.new( + content: content, + filename: filename, + content_type: content_type, + byte_size: byte_size, + checksum: Digest::MD5.base64digest(content), + content_sha256: Digest::SHA256.hexdigest(content) + ) + end + + def detected_content_type(content:, filename:, declared_content_type:) + Marcel::MimeType.for( + StringIO.new(content), + name: filename, + declared_type: declared_content_type.presence + ) + end + + def allowed_upload?(filename:, content_type:) + allowed_content_types_for_filename(filename).include?(content_type) + end + + def allowed_content_types_for_filename(filename) + ALLOWED_EXTENSION_CONTENT_TYPES.fetch(File.extname(filename.to_s).downcase, []) + end + + def valid_pdf_content?(content) + content.start_with?("%PDF-") + end + + def purge_original_file(statement) + return unless statement&.original_file&.attached? + + statement.original_file.purge + rescue StandardError => e + Rails.logger.warn("AccountStatement staged blob cleanup failed: #{e.class}: #{e.message}") + end + + def balance_lookup_for(account, statements) + currencies = statements.map(&:statement_currency).compact.uniq + dates = statements.flat_map { |statement| [ statement.period_start_on, statement.period_end_on ] }.compact.uniq + balances = if currencies.any? && dates.any? + account.balances.where(currency: currencies, date: dates).to_a + else + [] + end + balances_by_key = balances.index_by { |balance| [ balance.date, balance.currency ] } + + ->(date, currency) { balances_by_key[[ date, currency ]] } + end + + def read_upload_content!(file) + declared_size = declared_upload_size(file) + raise InvalidUploadError if declared_size.present? && declared_size > MAX_FILE_SIZE + + content = +"".b + loop do + chunk = file.read(READ_CHUNK_SIZE) + break if chunk.nil? || chunk.empty? + + content << chunk + raise InvalidUploadError if content.bytesize > MAX_FILE_SIZE + end + + file.rewind if file.respond_to?(:rewind) + content + end + + def declared_upload_size(file) + if file.respond_to?(:size) + file.size + elsif file.respond_to?(:length) + file.length + end + end + + def duplicate_for(family, prepared_upload) + scope = family.account_statements + sha_duplicate = scope.find_by(content_sha256: prepared_upload.content_sha256) if prepared_upload.content_sha256.present? + return sha_duplicate if sha_duplicate + + # Active Storage's MD5 checksum is retained only to catch legacy rows that predate content_sha256. + legacy_scope = prepared_upload.content_sha256.present? ? scope.where(content_sha256: nil) : scope + legacy_scope.find_by(checksum: prepared_upload.checksum) + end + end + + def viewable_by?(user) + return false unless user&.family_id == family_id + + account.present? ? account.shared_with?(user) : self.class.statement_manager?(user) + end + + def manageable_by?(user) + return false unless user&.family_id == family_id + + return self.class.statement_manager?(user) if account.blank? + + account.permission_for(user).in?([ :owner, :full_control ]) && self.class.statement_manager?(user) + end + + def link_to_account!(target_account, confidence: 1.0) + update!( + account: target_account, + suggested_account: nil, + match_confidence: confidence, + review_status: :linked, + currency: currency.presence || target_account.currency + ) + end + + def unlink! + transaction do + update!( + account: nil, + review_status: :unmatched, + match_confidence: nil + ) + assign_account_match + save! + end + end + + def reject_match! + update!( + suggested_account: nil, + match_confidence: nil, + review_status: :rejected + ) + end + + def assign_account_match + match = AccountMatcher.new(self).best_match + + self.suggested_account = match&.account + self.match_confidence = match&.confidence + clear_invalid_suggested_account + end + + def covered_months + return [] unless period_start_on.present? && period_end_on.present? + + current = period_start_on.beginning_of_month + last = period_end_on.beginning_of_month + months = [] + + while current <= last + months << current + current = current.next_month + end + + months + end + + def covers_month?(month) + covered_months.include?(month.to_date.beginning_of_month) + end + + def reconciliation_status(balance_lookup: nil) + checks = reconciliation_checks(balance_lookup: balance_lookup) + return "unavailable" if checks.empty? + + checks.any? { |check| check[:status] == "mismatched" } ? "mismatched" : "matched" + end + + def reconciliation_mismatched?(balance_lookup: nil) + reconciliation_status(balance_lookup: balance_lookup) == "mismatched" + end + + def reconciliation_checks(balance_lookup: nil) + return [] unless account.present? && period_start_on.present? && period_end_on.present? + + checks = [] + opening_balance_record = balance_record_for(period_start_on, statement_currency, balance_lookup) + closing_balance_record = balance_record_for(period_end_on, statement_currency, balance_lookup) + + if opening_balance.present? && opening_balance_record.present? + checks << reconciliation_check( + key: "opening_balance", + statement_amount: opening_balance, + ledger_amount: opening_balance_record.start_balance + ) + end + + if closing_balance.present? && closing_balance_record.present? + checks << reconciliation_check( + key: "closing_balance", + statement_amount: closing_balance, + ledger_amount: closing_balance_record.end_balance + ) + end + + if opening_balance.present? && closing_balance.present? && opening_balance_record.present? && closing_balance_record.present? + checks << reconciliation_check( + key: "period_movement", + statement_amount: closing_balance - opening_balance, + ledger_amount: closing_balance_record.end_balance - opening_balance_record.start_balance + ) + end + + checks + end + + def statement_currency + currency.presence || account&.currency || family.currency + end + + def pdf? + content_type.in?(ALLOWED_EXTENSION_CONTENT_TYPES[".pdf"]) + end + + def csv? + content_type.in?(ALLOWED_EXTENSION_CONTENT_TYPES[".csv"]) + end + + def xlsx? + content_type.in?(ALLOWED_EXTENSION_CONTENT_TYPES[".xlsx"]) + end + + private + + def reconciliation_check(key:, statement_amount:, ledger_amount:) + difference = statement_amount.to_d - ledger_amount.to_d + { + key: key, + statement_amount: statement_amount.to_d, + ledger_amount: ledger_amount.to_d, + difference: difference, + status: difference.abs <= 0.01.to_d ? "matched" : "mismatched" + } + end + + def balance_record_for(date, currency, balance_lookup) + return balance_lookup.call(date, currency) if balance_lookup + + account.balances.find_by(date: date, currency: currency) + end + + def sync_file_metadata + blob = original_file.blob + self.filename ||= blob.filename.to_s + self.content_type ||= blob.content_type + self.byte_size ||= blob.byte_size + self.checksum ||= blob.checksum + end + + def normalize_currency + self.currency = currency.to_s.upcase.presence if currency.present? + end + + def sync_review_status + return if rejected? + + self.review_status = "linked" if account.present? && !linked? + self.review_status = "unmatched" if account.blank? && linked? + end + + def account_belongs_to_family + return if account.nil? + return if account.family_id == family_id + + errors.add(:account, :invalid) + end + + def suggested_account_belongs_to_family + return if suggested_account_valid_for_family? + + errors.add(:suggested_account, :invalid) + end + + def clear_invalid_suggested_account + return if suggested_account_valid_for_family? + + self.suggested_account = nil + self.match_confidence = nil + end + + def suggested_account_valid_for_family? + suggested_account.nil? || suggested_account.family_id == family_id + end + + def period_order + return if period_start_on.blank? || period_end_on.blank? + return if period_start_on <= period_end_on + + errors.add(:period_end_on, :on_or_after_start) + end + + def currency_is_valid + return if currency.blank? + + Money::Currency.new(currency) + rescue Money::Currency::UnknownCurrencyError, ArgumentError + errors.add(:currency, :invalid) + end + + def filename_extension_matches_content_type + return if filename.blank? || content_type.blank? + return if self.class.allowed_upload?(filename: filename, content_type: content_type) + + errors.add(:content_type, :invalid) + end + + def original_file_constraints + if original_file.byte_size.zero? + errors.add(:original_file, :blank) + elsif original_file.byte_size > MAX_FILE_SIZE + errors.add(:original_file, :too_large, max_mb: MAX_FILE_SIZE / 1.megabyte) + end + + unless self.class.allowed_upload?(filename: original_file.filename.to_s, content_type: original_file.content_type) + errors.add(:original_file, :invalid_format, file_format: original_file.content_type) + end + end + + def original_file_attached + errors.add(:original_file, :blank) unless original_file.attached? + end +end diff --git a/app/models/account_statement/account_matcher.rb b/app/models/account_statement/account_matcher.rb new file mode 100644 index 000000000..6e8c5c1c2 --- /dev/null +++ b/app/models/account_statement/account_matcher.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +class AccountStatement::AccountMatcher + Match = Struct.new(:account, :confidence, keyword_init: true) + + attr_reader :statement + + def initialize(statement) + @statement = statement + end + + def best_match + candidates = statement.family.accounts.visible.to_a.filter_map do |account| + confidence = confidence_for(account) + next if confidence < 0.35 + + Match.new(account: account, confidence: confidence.round(4)) + end + + candidates.max_by(&:confidence) + end + + private + + def confidence_for(account) + score = 0.to_d + + if institution_hint.present? + score += 0.45.to_d if account_text(account).include?(institution_hint) + end + + if account_name_hint.present? + score += 0.25.to_d if account.name.to_s.downcase.include?(account_name_hint) + end + + if account_last4_hint.present? + score += 0.25.to_d if account_sensitive_match_text(account).include?(account_last4_hint) + end + + score += 0.05.to_d if statement.statement_currency == account.currency + [ score, 1.to_d ].min + end + + def institution_hint + @institution_hint ||= statement.institution_name_hint.to_s.downcase.squish.presence + end + + def account_name_hint + @account_name_hint ||= statement.account_name_hint.to_s.downcase.squish.presence + end + + def account_last4_hint + @account_last4_hint ||= statement.account_last4_hint.to_s.downcase.squish.presence + end + + def account_text(account) + [ + account.name, + account.institution_name, + account.institution_domain + ].compact.join(" ").downcase + end + + def account_sensitive_match_text(account) + # Exclude user-controlled account notes from matching hints. Statement + # matching should use conservative account metadata, not free-form prose + # that can accidentally manufacture a last-four match. + [ + account.name, + account.institution_name + ].compact.join(" ").downcase + end +end diff --git a/app/models/account_statement/coverage.rb b/app/models/account_statement/coverage.rb new file mode 100644 index 000000000..225df183c --- /dev/null +++ b/app/models/account_statement/coverage.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +class AccountStatement::Coverage + Month = Struct.new(:date, :status, :statements, :ambiguous_statements, keyword_init: true) do + def expected? + status != "not_expected" + end + + def covered? + status == "covered" + end + + def missing? + status == "missing" + end + + def duplicate? + status == "duplicate" + end + + def ambiguous? + status == "ambiguous" + end + + def mismatched? + status == "mismatched" + end + + def not_expected? + status == "not_expected" + end + end + + attr_reader :account, :start_month, :end_month, :expected_start_month, :expected_end_month, :selected_year, :available_years + + class << self + def for_year(account, year) + expected_end_month = default_expected_end_month + expected_start_month = default_expected_start_month(account, fallback_end_month: expected_end_month) + available_years = years_between(expected_start_month, expected_end_month) + selected_year = resolve_year_value(year, available_years) + + new( + account, + start_month: Date.new(selected_year, 1, 1), + end_month: Date.new(selected_year, 12, 1), + expected_start_month: expected_start_month, + expected_end_month: expected_end_month, + selected_year: selected_year, + available_years: available_years + ) + end + + def years_for(account) + expected_end_month = default_expected_end_month + expected_start_month = default_expected_start_month(account, fallback_end_month: expected_end_month) + + years_between(expected_start_month, expected_end_month) + end + + def resolve_year(account, year) + resolve_year_value(year, years_for(account)) + end + + def default_expected_end_month + Date.current.prev_month.beginning_of_month + end + + def default_expected_start_month(account, fallback_end_month: default_expected_end_month) + candidates = [ + account.entries.minimum(:date), + account.balances.minimum(:date), + account.account_statements.where.not(period_start_on: nil).minimum(:period_start_on), + account.family.account_statements.unmatched.where(suggested_account: account).where.not(period_start_on: nil).minimum(:period_start_on) + ].compact + + start_month = (candidates.min || fallback_end_month.advance(months: -11)).to_date.beginning_of_month + start_month > fallback_end_month ? fallback_end_month : start_month + end + + private + + def years_between(start_month, end_month) + (start_month.year..end_month.year).to_a.reverse + end + + def resolve_year_value(year, available_years) + requested_year = year.to_i if year.present? + + available_years.include?(requested_year) ? requested_year : available_years.first + end + end + + def initialize(account, start_month: nil, end_month: nil, expected_start_month: nil, expected_end_month: nil, selected_year: nil, available_years: nil) + raise ArgumentError, "account is required" if account.nil? + + @account = account + @expected_end_month = (expected_end_month || end_month || self.class.default_expected_end_month).to_date.beginning_of_month + resolved_expected_start_month = (expected_start_month || start_month || self.class.default_expected_start_month(account, fallback_end_month: @expected_end_month)).to_date.beginning_of_month + @expected_start_month = resolved_expected_start_month > @expected_end_month ? @expected_end_month : resolved_expected_start_month + @start_month = (start_month || @expected_start_month).to_date.beginning_of_month + @end_month = (end_month || @expected_end_month).to_date.beginning_of_month + @selected_year = selected_year + @available_years = available_years || self.class.years_for(account) + end + + def months + @months ||= begin + current = start_month + result = [] + + while current <= end_month + result << build_month(current) + current = current.next_month + end + + result + end + end + + def summary_counts + months.group_by(&:status).transform_values(&:count) + end + + private + + def build_month(month) + return Month.new(date: month, status: "not_expected", statements: [], ambiguous_statements: []) unless expected_month?(month) + + linked_statements = statements_covering(linked_statement_scope, month) + ambiguous_statements = statements_covering(ambiguous_statement_scope, month) + + status = if linked_statements.size > 1 + "duplicate" + elsif linked_statements.any? { |statement| statement.reconciliation_mismatched?(balance_lookup: balance_lookup) } + "mismatched" + elsif linked_statements.one? + "covered" + elsif ambiguous_statements.any? + "ambiguous" + else + "missing" + end + + Month.new(date: month, status: status, statements: linked_statements, ambiguous_statements: ambiguous_statements) + end + + def expected_month?(month) + month >= expected_start_month && month <= expected_end_month + end + + def linked_statement_scope + @linked_statement_scope ||= account.account_statements + .where("period_start_on <= ? AND period_end_on >= ?", end_month.end_of_month, start_month) + .ordered + .to_a + end + + def ambiguous_statement_scope + @ambiguous_statement_scope ||= account.family.account_statements + .unmatched + .where(suggested_account: account) + .where("period_start_on <= ? AND period_end_on >= ?", end_month.end_of_month, start_month) + .ordered + .to_a + end + + def statements_covering(statements, month) + month_start = month.to_date.beginning_of_month + month_end = month_start.end_of_month + + statements.select do |statement| + statement.period_start_on.present? && + statement.period_end_on.present? && + statement.period_start_on <= month_end && + statement.period_end_on >= month_start + end + end + + def balance_lookup + @balance_lookup ||= begin + currencies = linked_statement_scope.map(&:statement_currency).compact.uniq + dates = linked_statement_scope.flat_map { |statement| [ statement.period_start_on, statement.period_end_on ] }.compact.uniq + balances = if currencies.any? && dates.any? + account.balances.where(currency: currencies, date: dates).to_a + else + [] + end + by_key = balances.index_by { |balance| [ balance.date, balance.currency ] } + + ->(date, currency) { by_key[[ date, currency ]] } + end + end +end diff --git a/app/models/account_statement/metadata_detector.rb b/app/models/account_statement/metadata_detector.rb new file mode 100644 index 000000000..e8079d5b8 --- /dev/null +++ b/app/models/account_statement/metadata_detector.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require "csv" +require "stringio" + +class AccountStatement::MetadataDetector + DATE_PATTERNS = [ + /(?\d{4})[-_\.](?0?[1-9]|1[0-2]) + | + (?jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?) + [-_\s\.]+(?\d{4}) + ) + (?![a-z0-9]) + /ix.freeze + + LAST4_PATTERN = /(?:^|[^a-z0-9])(?:x{2,}|ending|last\s*4|acct|account|card)[^\d]*(\d{4})(?=\D|$)/i.freeze + GENERIC_FILENAME_HINTS = [ + "statement", + "statements", + "bank statement", + "bank statements", + "account statement", + "account statements", + "credit card statement", + "card statement" + ].freeze + MAX_CSV_COLUMNS = 100 + MAX_CSV_DATE_SAMPLES = 250 + MAX_CSV_SAMPLE_BYTES = 256 + + attr_reader :statement, :content + + def initialize(statement, content:) + @statement = statement + @content = content + end + + def apply + output = statement.sanitized_parser_output || {} + metadata_sources = [] + + if detect_from_filename + metadata_sources << "filename" + end + + if statement.csv? && detect_from_csv(output) + metadata_sources << "csv_dates" + elsif statement.xlsx? + output["spreadsheet_detection"] = "filename_only" + elsif statement.pdf? + output["pdf_detection"] = "filename_only" + end + + output["metadata_sources"] = metadata_sources + statement.sanitized_parser_output = output + statement.parser_confidence ||= if metadata_sources.include?("csv_dates") + 0.65 + elsif metadata_sources.any? + 0.45 + else + 0.1 + end + end + + private + + def detect_from_filename + basename = File.basename(statement.filename.to_s, ".*") + return false if basename.blank? + + detected = false + + if (last4 = basename.match(LAST4_PATTERN)&.captures&.first) + statement.account_last4_hint ||= last4 + detected = true + end + + dates = DATE_PATTERNS.flat_map { |pattern| basename.scan(pattern) } + .map { |match| Array(match).first } + .filter_map { |value| parse_date(value) } + .uniq + .sort + + if dates.size >= 2 + statement.period_start_on ||= dates.first + statement.period_end_on ||= dates.last + detected = true + elsif dates.size == 1 + statement.period_start_on ||= dates.first.beginning_of_month + statement.period_end_on ||= dates.first.end_of_month + detected = true + elsif (month_date = parse_month_from_filename(basename)) + statement.period_start_on ||= month_date.beginning_of_month + statement.period_end_on ||= month_date.end_of_month + detected = true + end + + hint = basename + .gsub(LAST4_PATTERN, "") + .gsub(/\b\d{4}[-_\.]\d{1,2}(?:[-_\.]\d{1,2})?\b/, "") + .gsub(/\b\d{8}\b/, "") + .tr("_-", " ") + .gsub(/\b(?:19|20)\d{2}\b/, "") + .gsub(/\b(?:0?[1-9]|1[0-2])\b/, "") + .squish + .presence + + if (meaningful_hint = meaningful_filename_hint(hint)) + statement.institution_name_hint ||= meaningful_hint + statement.account_name_hint ||= meaningful_hint + detected = true + end + + detected + end + + def detect_from_csv(output) + csv = CSV.new(StringIO.new(content.to_s), headers: true, liberal_parsing: true) + first_row = csv.shift + return false if first_row.blank? + + headers = first_row.headers.compact.map(&:to_s) + return false if headers.size > MAX_CSV_COLUMNS + + date_header = headers.find { |header| csv_sample_text(header).to_s.match?(/date|posted|transaction/i) } + return false if date_header.blank? + + samples = [ csv_sample_text(first_row[date_header]) ].compact_blank + csv.each do |row| + break if samples.size >= MAX_CSV_DATE_SAMPLES + + sample = csv_sample_text(row[date_header]) + samples << sample if sample.present? + end + return false if samples.blank? + + date_format = Import.detect_date_format(samples) + dates = samples.filter_map { |sample| parse_date_with_format(sample, date_format) }.uniq.sort + return false if dates.blank? + + statement.period_start_on ||= dates.first + statement.period_end_on ||= dates.last + output["csv"] = { + "date_header" => date_header.to_s, + "date_format" => date_format, + "rows_sampled" => samples.size + } + true + rescue CSV::MalformedCSVError + false + end + + def csv_sample_text(value) + text = value.to_s + return nil if text.bytesize > MAX_CSV_SAMPLE_BYTES + + text + end + + def meaningful_filename_hint(hint) + return nil if hint.blank? + + normalized = hint.downcase.gsub(/[^a-z0-9]+/, " ").squish + without_generic_words = normalized + .gsub(/\b(?:bank|account|card|credit|debit|statement|statements)\b/, "") + .squish + + return nil if GENERIC_FILENAME_HINTS.include?(normalized) || without_generic_words.blank? + + hint + end + + def parse_date(value) + text = value.to_s.tr("_", "-") + date = if text.match?(/\A\d{8}\z/) + Date.strptime(text, "%Y%m%d") + else + Date.parse(text) + end + + AccountStatement::MetadataDetector.reasonable_date?(date) ? date : nil + rescue Date::Error, ArgumentError + nil + end + + def parse_date_with_format(value, format) + date = Date.strptime(value.to_s, format) + AccountStatement::MetadataDetector.reasonable_date?(date) ? date : nil + rescue Date::Error, ArgumentError + nil + end + + def parse_month_from_filename(basename) + match = basename.match(MONTH_PATTERN) + return nil unless match + + year = (match[:year_first] || match[:year_second]).to_i + month = if match[:month_first] + match[:month_first].to_i + else + Date::ABBR_MONTHNAMES.index(match[:month_name][0, 3].capitalize) + end + + date = Date.new(year, month, 1) + AccountStatement::MetadataDetector.reasonable_date?(date) ? date : nil + rescue Date::Error, NoMethodError + nil + end + + def self.reasonable_date?(date) + Import.reasonable_date_range.cover?(date) + end +end diff --git a/app/models/actual_import.rb b/app/models/actual_import.rb new file mode 100644 index 000000000..32628f4aa --- /dev/null +++ b/app/models/actual_import.rb @@ -0,0 +1,104 @@ +class ActualImport < Import + after_create :set_mappings + + DEFAULT_COLUMN_MAPPINGS = { + signage_convention: "inflows_positive", + date_col_label: "Date", + date_format: "%Y-%m-%d", + name_col_label: "Payee", + amount_col_label: "Amount", + account_col_label: "Account", + category_col_label: "Category", + notes_col_label: "Notes" + }.freeze + + CATEGORY_GROUP_COLUMN = "Category_Group".freeze + + def self.default_column_mappings + DEFAULT_COLUMN_MAPPINGS + end + + def generate_rows_from_csv + rows.destroy_all + + mapped_rows = csv_rows.map.with_index(1) do |row, index| + { + source_row_number: index, + account: row[account_col_label].to_s, + date: row[date_col_label].to_s, + amount: signed_csv_amount(row).to_s, + currency: default_currency.to_s, + name: row[name_col_label].to_s, + category: combined_category(row), + notes: row[notes_col_label].to_s + } + end + + rows.insert_all!(mapped_rows) + update_column(:rows_count, rows.count) + end + + def import! + transaction do + mappings.each(&:create_mappable!) + + rows.each do |row| + account = mappings.accounts.mappable_for(row.account) + category = mappings.categories.mappable_for(row.category) + + entry = account.entries.build \ + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: account.currency.presence || family.currency, + notes: row.notes, + entryable: Transaction.new(category: category), + import: self + + entry.save! + end + end + end + + def mapping_steps + [ Import::CategoryMapping, Import::AccountMapping ] + end + + def required_column_keys + %i[date amount] + end + + def column_keys + %i[date amount name category account notes] + end + + def csv_template + template = <<~CSV + Account,Date,Payee,Notes,Category_Group,Category,Amount,Split_Amount,Cleared + Checking Account,2024-01-01,Employer,Monthly salary,Income,Paycheck,2500.00,0,Reconciled + Credit Card,2024-01-03,Coffee Shop,Morning coffee,Food,Coffee,-4.25,0,Cleared + CSV + + CSV.parse(template, headers: true) + end + + def signed_csv_amount(csv_row) + csv_row[amount_col_label].to_d + end + + private + def set_mappings + assign_attributes(self.class.default_column_mappings) + save! + end + + def combined_category(row) + category = row[category_col_label].to_s.strip + category_group = row[CATEGORY_GROUP_COLUMN].to_s.strip + + return category if category_group.blank? + return category_group if category.blank? + + "#{category_group}: #{category}" + end +end diff --git a/app/models/api_key.rb b/app/models/api_key.rb index ae24ccec3..4c12b4668 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -112,7 +112,7 @@ class ApiKey < ApplicationRecord def prevent_demo_monitoring_key_destroy! return unless demo_monitoring_key? - errors.add(:base, "Cannot destroy demo monitoring API key") + errors.add(:base, :cannot_destroy_demo_key) throw(:abort) end end diff --git a/app/models/assistant/base.rb b/app/models/assistant/base.rb index 2b77671af..42bd69397 100644 --- a/app/models/assistant/base.rb +++ b/app/models/assistant/base.rb @@ -1,13 +1,11 @@ class Assistant::Base - include Assistant::Broadcastable - attr_reader :chat def initialize(chat) @chat = chat end - def respond_to(message) + def respond_to(message, assistant_message: nil) raise NotImplementedError, "#{self.class}#respond_to must be implemented" end end diff --git a/app/models/assistant/broadcastable.rb b/app/models/assistant/broadcastable.rb deleted file mode 100644 index 7fd2507b5..000000000 --- a/app/models/assistant/broadcastable.rb +++ /dev/null @@ -1,12 +0,0 @@ -module Assistant::Broadcastable - extend ActiveSupport::Concern - - private - def update_thinking(thought) - chat.broadcast_update target: "thinking-indicator", partial: "chats/thinking_indicator", locals: { chat: chat, message: thought } - end - - def stop_thinking - chat.broadcast_remove target: "thinking-indicator" - end -end diff --git a/app/models/assistant/builtin.rb b/app/models/assistant/builtin.rb index 1d615eb5a..6a1ae93c9 100644 --- a/app/models/assistant/builtin.rb +++ b/app/models/assistant/builtin.rb @@ -17,12 +17,8 @@ class Assistant::Builtin < Assistant::Base @functions = functions end - def respond_to(message) - assistant_message = AssistantMessage.new( - chat: chat, - content: "", - ai_model: message.ai_model - ) + def respond_to(message, assistant_message: nil) + assistant_message ||= AssistantMessage.new(chat: chat, content: "", ai_model: message.ai_model) llm_provider = get_model_provider(message.ai_model) unless llm_provider @@ -40,7 +36,6 @@ class Assistant::Builtin < Assistant::Base responder.on(:output_text) do |text| if assistant_message.content.blank? - stop_thinking Chat.transaction do assistant_message.append_text!(text) chat.update_latest_response!(latest_response_id) @@ -51,7 +46,6 @@ class Assistant::Builtin < Assistant::Base end responder.on(:response) do |data| - update_thinking("Analyzing your data...") if data[:function_tool_calls].present? assistant_message.tool_calls = data[:function_tool_calls] latest_response_id = data[:id] @@ -62,7 +56,14 @@ class Assistant::Builtin < Assistant::Base responder.respond(previous_response_id: latest_response_id) rescue => e - stop_thinking + if assistant_message&.persisted? + if assistant_message.content.blank? + assistant_message.destroy + else + # Demote partially-streamed turns to `failed` so `Responder#conversation_history` excludes them. + assistant_message.update_columns(status: "failed") + end + end chat.add_error(e) end diff --git a/app/models/assistant/external.rb b/app/models/assistant/external.rb index a64888a6e..f2e200641 100644 --- a/app/models/assistant/external.rb +++ b/app/models/assistant/external.rb @@ -33,8 +33,9 @@ class Assistant::External < Assistant::Base end end - def respond_to(message) + def respond_to(message, assistant_message: nil) response_completed = false + assistant_message ||= AssistantMessage.new(chat: chat, content: "", ai_model: "external-agent") unless self.class.configured? raise Assistant::Error, @@ -45,12 +46,6 @@ class Assistant::External < Assistant::Base raise Assistant::Error, "Your account is not authorized to use the external assistant." end - assistant_message = AssistantMessage.new( - chat: chat, - content: "", - ai_model: "external-agent" - ) - client = build_client messages = build_conversation_messages @@ -58,17 +53,10 @@ class Assistant::External < Assistant::Base messages: messages, user: "sure-family-#{chat.user.family_id}" ) do |text| - if assistant_message.content.blank? - stop_thinking - assistant_message.content = text - assistant_message.save! - else - assistant_message.append_text!(text) - end + assistant_message.append_text!(text) end - if assistant_message.new_record? - stop_thinking + if assistant_message.content.blank? raise Assistant::Error, "External assistant returned an empty response." end @@ -76,12 +64,10 @@ class Assistant::External < Assistant::Base assistant_message.update!(ai_model: model) if model.present? rescue Assistant::Error, ActiveRecord::ActiveRecordError => e cleanup_partial_response(assistant_message) unless response_completed - stop_thinking chat.add_error(e) rescue => e Rails.logger.error("[Assistant::External] Unexpected error: #{e.class} - #{e.message}") cleanup_partial_response(assistant_message) unless response_completed - stop_thinking chat.add_error(Assistant::Error.new("Something went wrong with the external assistant. Check server logs for details.")) end @@ -103,7 +89,7 @@ class Assistant::External < Assistant::Base end def build_conversation_messages - chat.conversation_messages.ordered.last(MAX_CONVERSATION_MESSAGES).map do |msg| + chat.conversation_messages.where(status: "complete").ordered.last(MAX_CONVERSATION_MESSAGES).map do |msg| { role: msg.role, content: msg.content } end end diff --git a/app/models/assistant/function/import_bank_statement.rb b/app/models/assistant/function/import_bank_statement.rb index b0cd02906..dee54602f 100644 --- a/app/models/assistant/function/import_bank_statement.rb +++ b/app/models/assistant/function/import_bank_statement.rb @@ -155,7 +155,7 @@ class Assistant::Function::ImportBankStatement < Assistant::Function account_holder: result[:account_holder], message: "Successfully extracted #{result[:transactions].size} transactions. Import created with ID: #{import.id}. Review and publish when ready." } - rescue Provider::ProviderError, Faraday::Error, Timeout::Error, RuntimeError => e + rescue Provider::Error, Faraday::Error, Timeout::Error, RuntimeError => e Rails.logger.error("ImportBankStatement error: #{e.class.name} - #{e.message}") Rails.logger.error(e.backtrace.first(10).join("\n")) { diff --git a/app/models/assistant/history_trimmer.rb b/app/models/assistant/history_trimmer.rb new file mode 100644 index 000000000..02e64f8b9 --- /dev/null +++ b/app/models/assistant/history_trimmer.rb @@ -0,0 +1,53 @@ +class Assistant::HistoryTrimmer + def initialize(messages, max_tokens:) + @messages = messages || [] + @max_tokens = max_tokens.to_i + end + + def call + return [] if @messages.empty? || @max_tokens <= 0 + + kept = [] + tokens = 0 + + group_tool_pairs(@messages).reverse_each do |group| + group_tokens = Assistant::TokenEstimator.estimate(group) + break if tokens + group_tokens > @max_tokens + + kept.unshift(*group) + tokens += group_tokens + end + + kept + end + + private + + # Bundles each assistant message that has `tool_calls` with the + # consecutive `role: "tool"` results that follow it, so the trimmer + # never splits a call/result pair when dropping from the oldest end. + def group_tool_pairs(messages) + groups = [] + current_group = nil + + messages.each do |msg| + if assistant_with_tool_calls?(msg) + groups << current_group if current_group + current_group = [ msg ] + elsif msg[:role].to_s == "tool" && current_group + current_group << msg + else + groups << current_group if current_group + current_group = nil + groups << [ msg ] + end + end + + groups << current_group if current_group + groups + end + + def assistant_with_tool_calls?(msg) + msg[:role].to_s == "assistant" && msg[:tool_calls].is_a?(Array) && msg[:tool_calls].any? + end +end diff --git a/app/models/assistant/responder.rb b/app/models/assistant/responder.rb index f2b6d121a..480c69c22 100644 --- a/app/models/assistant/responder.rb +++ b/app/models/assistant/responder.rb @@ -79,6 +79,7 @@ class Assistant::Responder instructions: instructions, functions: function_tool_caller.function_definitions, function_results: function_results, + messages: conversation_history, streamer: streamer, previous_response_id: previous_response_id, session_id: chat_session_id, @@ -114,4 +115,46 @@ class Assistant::Responder def chat @chat ||= message.chat end + + def conversation_history + messages = [] + return messages unless chat&.messages + + chat.messages + .where(type: [ "UserMessage", "AssistantMessage" ], status: "complete") + .includes(:tool_calls) + .ordered + .each do |chat_message| + if chat_message.tool_calls.any? + messages << { + role: chat_message.role, + content: chat_message.content || "", + tool_calls: chat_message.tool_calls.map(&:to_tool_call) + } + + chat_message.tool_calls.map(&:to_result).each do |fn_result| + # Handle nil explicitly to avoid serializing to "null" + output = fn_result[:output] + content = if output.nil? + "" + elsif output.is_a?(String) + output + else + output.to_json + end + + messages << { + role: "tool", + tool_call_id: fn_result[:call_id], + name: fn_result[:name], + content: content + } + end + + elsif !chat_message.content.blank? + messages << { role: chat_message.role, content: chat_message.content || "" } + end + end + messages + end end diff --git a/app/models/assistant/token_estimator.rb b/app/models/assistant/token_estimator.rb new file mode 100644 index 000000000..4f5e3fd60 --- /dev/null +++ b/app/models/assistant/token_estimator.rb @@ -0,0 +1,19 @@ +module Assistant::TokenEstimator + CHARS_PER_TOKEN = 4 + SAFETY_FACTOR = 1.25 + + def self.estimate(value) + chars = char_length(value) + ((chars / CHARS_PER_TOKEN.to_f) * SAFETY_FACTOR).ceil + end + + def self.char_length(value) + case value + when nil then 0 + when String then value.length + when Array then value.sum { |v| char_length(v) } + when Hash then value.to_json.length + else value.to_s.length + end + end +end diff --git a/app/models/assistant_message.rb b/app/models/assistant_message.rb index 4b1a1404a..a40304d2c 100644 --- a/app/models/assistant_message.rb +++ b/app/models/assistant_message.rb @@ -7,6 +7,7 @@ class AssistantMessage < Message def append_text!(text) self.content += text + self.status = :complete if pending? save! end end diff --git a/app/models/auth_config.rb b/app/models/auth_config.rb index 7cb630966..7b4c9c7a4 100644 --- a/app/models/auth_config.rb +++ b/app/models/auth_config.rb @@ -74,7 +74,17 @@ class AuthConfig end def sso_providers - Rails.configuration.x.auth.sso_providers || [] + if FeatureFlags.db_sso_providers? + # After boot, OmniAuth registers successfully configured providers into + # Rails.configuration.x.auth.sso_providers. Prefer that filtered list + # so we never render login buttons for providers that couldn't be + # registered (e.g., missing required fields in YAML fallback). + # Fall back to ProviderLoader for pre-boot contexts. + registered = Rails.configuration.x.auth.sso_providers + registered&.any? ? registered : ProviderLoader.load_providers + else + Rails.configuration.x.auth.sso_providers || [] + end end end end diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb index a1e43d99e..c8b090547 100644 --- a/app/models/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -15,8 +15,7 @@ class Balance::BaseCalculator end def holdings_value_for_date(date) - @holdings_value_for_date ||= {} - @holdings_value_for_date[date] ||= sync_cache.get_holdings(date).sum(&:amount) + sync_cache.get_holdings_value(date) end def derive_cash_balance_on_date_from_total(total_balance:, date:) diff --git a/app/models/balance/chart_series_builder.rb b/app/models/balance/chart_series_builder.rb index 034f54c67..c8c733579 100644 --- a/app/models/balance/chart_series_builder.rb +++ b/app/models/balance/chart_series_builder.rb @@ -1,5 +1,5 @@ class Balance::ChartSeriesBuilder - def initialize(account_ids:, currency:, period: Period.last_30_days, interval: "1 day", favorable_direction: "up") + def initialize(account_ids:, currency:, period: Period.last_30_days, interval: nil, favorable_direction: "up") @account_ids = account_ids @currency = currency @period = period diff --git a/app/models/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb index 35a774452..39b530178 100644 --- a/app/models/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -12,6 +12,7 @@ class Balance::ReverseCalculator < Balance::BaseCalculator # Calculates in reverse-chronological order (End of day -> Start of day) account.current_anchor_date.downto(account.opening_anchor_date).map do |date| flows = flows_for_date(date) + valuation = sync_cache.get_valuation(date) if use_opening_anchor_for_date?(date) end_cash_balance = derive_cash_balance_on_date_from_total( @@ -20,6 +21,21 @@ class Balance::ReverseCalculator < Balance::BaseCalculator ) end_non_cash_balance = account.opening_anchor_balance - end_cash_balance + start_cash_balance = end_cash_balance + start_non_cash_balance = end_non_cash_balance + market_value_change = 0 + elsif valuation && valuation.entryable.reconciliation? + # Reconciliation waypoint: reset to the known API-reported balance. + # These waypoints are created by CurrentBalanceManager when it preserves + # a stale current_anchor as a reconciliation before replacing it. + # We derive both cash and non-cash from the total to ensure the split + # reflects the account's cash ratio on that date. + end_cash_balance = derive_cash_balance_on_date_from_total( + total_balance: valuation.amount, + date: date + ) + end_non_cash_balance = valuation.amount - end_cash_balance + start_cash_balance = end_cash_balance start_non_cash_balance = end_non_cash_balance market_value_change = 0 @@ -73,9 +89,9 @@ class Balance::ReverseCalculator < Balance::BaseCalculator derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse) end - # Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations - # to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed - # explanation, see the test suite. + # Checks if this date should use the opening anchor balance instead of deriving it. + # Only the opening_anchor_date itself gets this treatment — reconciliation waypoints + # are handled separately in the calculate loop above. def use_opening_anchor_for_date?(date) account.has_opening_anchor? && date == account.opening_anchor_date end diff --git a/app/models/balance/sync_cache.rb b/app/models/balance/sync_cache.rb index a6e12c9ee..58caf70ad 100644 --- a/app/models/balance/sync_cache.rb +++ b/app/models/balance/sync_cache.rb @@ -7,8 +7,8 @@ class Balance::SyncCache entries_by_date[date]&.find { |e| e.valuation? } end - def get_holdings(date) - holdings_by_date[date] || [] + def get_holdings_value(date) + holdings_value_by_date[date] || 0 end def get_entries(date) @@ -22,33 +22,32 @@ class Balance::SyncCache @entries_by_date ||= converted_entries.group_by(&:date) end - def holdings_by_date - @holdings_by_date ||= converted_holdings.group_by(&:date) + def holdings_value_by_date + @holdings_value_by_date ||= account.holdings.each_with_object(Hash.new(0)) do |h, totals| + begin + converted = Money.new(h.amount, h.currency).exchange_to(account.currency, date: h.date).amount + rescue Money::ConversionError + converted = h.amount # fallback to 1:1 conversion rate if exchange rate unavailable + end + totals[h.date] += converted + end end def converted_entries - @converted_entries ||= account.entries.excluding_split_parents.order(:date).to_a.map do |e| + @converted_entries ||= account.entries.excluding_split_parents.includes(:entryable).order(:date).to_a.map do |e| converted_entry = e.dup + + custom_rate = e.entryable.exchange_rate if e.entryable.respond_to?(:exchange_rate) + + # Use Money#exchange_to with custom rate if available, standard lookup otherwise converted_entry.amount = converted_entry.amount_money.exchange_to( account.currency, date: e.date, - fallback_rate: 1 + custom_rate: custom_rate ).amount + converted_entry.currency = account.currency converted_entry end end - - def converted_holdings - @converted_holdings ||= account.holdings.map do |h| - converted_holding = h.dup - converted_holding.amount = converted_holding.amount_money.exchange_to( - account.currency, - date: h.date, - fallback_rate: 1 - ).amount - converted_holding.currency = account.currency - converted_holding - end - end end diff --git a/app/models/balance_sheet/account_totals.rb b/app/models/balance_sheet/account_totals.rb index d03340423..5c767687f 100644 --- a/app/models/balance_sheet/account_totals.rb +++ b/app/models/balance_sheet/account_totals.rb @@ -27,7 +27,14 @@ class BalanceSheet::AccountTotals def visible_accounts @visible_accounts ||= begin - scope = family.accounts.visible.with_attached_logo.includes(:account_shares) + scope = family.accounts.visible.with_attached_logo + .includes( + :account_shares, + :accountable, + :plaid_account, + :simplefin_account, + account_providers: :provider + ) scope = scope.accessible_by(user) if user scope end diff --git a/app/models/balance_sheet/classification_group.rb b/app/models/balance_sheet/classification_group.rb index 968d4b6c0..2efced22d 100644 --- a/app/models/balance_sheet/classification_group.rb +++ b/app/models/balance_sheet/classification_group.rb @@ -13,7 +13,7 @@ class BalanceSheet::ClassificationGroup end def name - classification.titleize.pluralize + I18n.t("pages.dashboard.balance_sheet.classifications.#{classification}", default: classification.titleize.pluralize) end def icon @@ -34,7 +34,7 @@ class BalanceSheet::ClassificationGroup .transform_keys { |at| Accountable.from_type(at) } .map do |accountable, account_rows| BalanceSheet::AccountGroup.new( - name: I18n.t("accounts.types.#{accountable.name.underscore}", default: accountable.display_name), + name: accountable.display_name, color: accountable.color, accountable_type: accountable, accounts: account_rows, diff --git a/app/models/binance_account/usd_converter.rb b/app/models/binance_account/usd_converter.rb index 405a94775..96c582460 100644 --- a/app/models/binance_account/usd_converter.rb +++ b/app/models/binance_account/usd_converter.rb @@ -21,7 +21,7 @@ module BinanceAccount::UsdConverter return [ amount.to_d, true, nil ] end - converted = Money.new(amount, "USD").exchange_to(target_currency, fallback_rate: rate.rate).amount + converted = Money.new(amount, "USD").exchange_to(target_currency, custom_rate: rate.rate).amount stale = rate.date != date rate_date = stale ? rate.date : nil diff --git a/app/models/brex_account.rb b/app/models/brex_account.rb new file mode 100644 index 000000000..743fcc01a --- /dev/null +++ b/app/models/brex_account.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +class BrexAccount < ApplicationRecord + include CurrencyNormalizable, Encryptable + + CARD_PRIMARY_ACCOUNT_ID = "card_primary" + + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end + + belongs_to :brex_item + + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + validates :account_id, uniqueness: { scope: :brex_item_id } + validates :account_kind, inclusion: { in: %w[cash card] } + + def self.card_account_id + CARD_PRIMARY_ACCOUNT_ID + end + + def self.kind_for(account_data) + return account_data.account_kind if account_data.respond_to?(:account_kind) + + data = account_data.with_indifferent_access + kind = data[:account_kind].presence || data[:kind].presence || "cash" + kind.to_s == "credit_card" ? "card" : kind.to_s + end + + def self.name_for(account_data) + data = account_data.with_indifferent_access + kind = kind_for(data) + + if kind == "card" + data[:name].presence || I18n.t("brex_items.default_card_name", default: "Brex Card") + else + data[:name].presence || data[:display_name].presence || I18n.t("brex_items.default_cash_name", id: data[:id], default: "Brex Cash #{data[:id]}") + end + end + + def self.currency_for(account_data) + data = account_data.with_indifferent_access + currency_code_from_money(data[:current_balance] || data[:available_balance] || data[:account_limit]) + end + + def self.default_account_type_for(account_data) + kind_for(account_data) == "card" ? "CreditCard" : "Depository" + end + + def self.default_accountable_attributes(accountable_type) + case accountable_type + when "CreditCard" + { subtype: CreditCard::DEFAULT_SUBTYPE } + when "Depository" + { subtype: Depository::DEFAULT_SUBTYPE } + else + {} + end + end + + def self.money_to_decimal(money_payload) + return nil if money_payload.blank? + + payload = money_payload.is_a?(Hash) ? money_payload.with_indifferent_access : { amount: money_payload, currency: "USD" } + amount = payload[:amount] + return nil if amount.nil? + + currency = currency_code_from_money(payload) + divisor = Money::Currency.new(currency).minor_unit_conversion + BigDecimal(amount.to_s) / BigDecimal(divisor.to_s) + rescue Money::Currency::UnknownCurrencyError, ArgumentError + Rails.logger.warn("Invalid Brex money payload #{money_payload.inspect}, defaulting conversion to USD") + begin + safe_amount = BigDecimal(payload[:amount].to_s) + safe_amount / BigDecimal(Money::Currency.new("USD").minor_unit_conversion.to_s) + rescue ArgumentError, TypeError + BigDecimal("0") + end + end + + def self.currency_code_from_money(money_payload) + payload = money_payload.is_a?(Hash) ? money_payload.with_indifferent_access : {} + currency = payload[:currency].presence || "USD" + Money::Currency.new(currency).iso_code + rescue Money::Currency::UnknownCurrencyError + "USD" + end + + def self.sanitize_payload(payload) + case payload + when Array + payload.map { |value| sanitize_payload(value) } + when Hash + payload.each_with_object({}) do |(key, value), sanitized| + key_string = key.to_s + normalized_key = key_string.downcase + + if sensitive_number_key?(normalized_key) + sanitized["#{key_string}_last4"] = last_four(value) + elsif normalized_key == "card_metadata" + sanitized[key_string] = sanitize_card_metadata(value) + elsif sensitive_secret_key?(normalized_key) + sanitized[key_string] = "[FILTERED]" + else + sanitized[key_string] = sanitize_payload(value) + end + end + else + payload + end + end + + def self.last_four(value) + digits = value.to_s.gsub(/\D/, "") + digits.last(4) if digits.present? + end + + def self.sanitize_card_metadata(value) + return nil unless value.is_a?(Hash) + + metadata = value.with_indifferent_access + { + "card_id" => metadata[:card_id].presence || metadata[:id].presence, + "card_name" => metadata[:card_name].presence || metadata[:name].presence, + "card_type" => metadata[:card_type].presence || metadata[:type].presence, + "last_four" => last_four(metadata[:last_four].presence || metadata[:last4].presence || metadata[:card_last_four].presence) + }.compact + end + + def current_account + account + end + + def linked_account + account + end + + def cash? + account_kind == "cash" + end + + def card? + account_kind == "card" + end + + def upsert_brex_snapshot!(account_snapshot) + snapshot = account_snapshot.with_indifferent_access + kind = snapshot[:account_kind].presence || snapshot[:kind].presence || "cash" + kind = "card" if kind.to_s == "credit_card" + + update!( + current_balance: self.class.money_to_decimal(snapshot[:current_balance]), + available_balance: self.class.money_to_decimal(snapshot[:available_balance]), + account_limit: self.class.money_to_decimal(snapshot[:account_limit]), + currency: self.class.currency_code_from_money(snapshot[:current_balance] || snapshot[:available_balance] || snapshot[:account_limit]), + name: self.class.name_for(snapshot.merge(account_kind: kind)), + account_id: snapshot[:id]&.to_s, + account_kind: kind, + account_status: snapshot[:status], + account_type: snapshot[:type], + provider: "brex", + institution_metadata: build_institution_metadata(snapshot, kind), + raw_payload: self.class.sanitize_payload(account_snapshot) + ) + end + + def upsert_brex_transactions_snapshot!(transactions_snapshot) + update!( + raw_transactions_payload: self.class.sanitize_payload(transactions_snapshot) + ) + end + + private + + def self.sensitive_number_key?(normalized_key) + normalized_key.in?(%w[account_number routing_number pan primary_account_number card_number]) + end + + def self.sensitive_secret_key?(normalized_key) + normalized_key.include?("token") || + normalized_key.include?("secret") || + normalized_key.in?(%w[api_key access_key authorization cvc cvv security_code]) + end + private_class_method :sensitive_number_key?, :sensitive_secret_key? + + def build_institution_metadata(snapshot, kind) + { + name: "Brex", + domain: "brex.com", + url: "https://brex.com", + account_kind: kind, + account_type: snapshot[:type], + primary: snapshot[:primary], + account_number_last4: self.class.last_four(snapshot[:account_number]), + routing_number_last4: self.class.last_four(snapshot[:routing_number]), + status: snapshot[:status], + current_statement_period: self.class.sanitize_payload(snapshot[:current_statement_period]) + }.compact + end +end diff --git a/app/models/brex_account/processor.rb b/app/models/brex_account/processor.rb new file mode 100644 index 000000000..67c8a4a7b --- /dev/null +++ b/app/models/brex_account/processor.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class BrexAccount::Processor + include CurrencyNormalizable + + attr_reader :brex_account + + def initialize(brex_account) + @brex_account = brex_account + end + + def process + unless brex_account.current_account.present? + Rails.logger.info "BrexAccount::Processor - No linked account for brex_account #{brex_account.id}, skipping processing" + return + end + + process_account! + process_transactions + rescue StandardError => e + Rails.logger.error "BrexAccount::Processor - Failed to process account #{brex_account.id}: #{e.message}" + report_exception(e, "account") + raise + end + + private + + def process_account! + account = brex_account.current_account + balance = brex_account.current_balance + currency = parse_currency(brex_account.currency) + + if balance.nil? + Rails.logger.warn "BrexAccount::Processor - current_balance is nil for brex_account #{brex_account.id}, defaulting to 0" + balance = 0 + end + + if currency.nil? + Rails.logger.warn "BrexAccount::Processor - currency parse failed for brex_account #{brex_account.id}: #{brex_account.currency.inspect}, defaulting to USD" + Sentry.capture_message("BrexAccount currency parse failed", level: :warning) do |scope| + scope.set_tags(brex_account_id: brex_account.id) + scope.set_context("brex_account", { + id: brex_account.id, + currency: brex_account.currency + }) + end + currency = "USD" + end + + account.update!( + balance: balance, + cash_balance: balance, + currency: currency + ) + + if account.accountable_type == "CreditCard" && brex_account.available_balance.present? + account.accountable.update!(available_credit: brex_account.available_balance) + end + end + + # Transaction import errors are logged and swallowed so balance sync can continue. + def process_transactions + BrexAccount::Transactions::Processor.new(brex_account).process + rescue StandardError => e + Rails.logger.error "BrexAccount::Processor - Failed to process transactions for brex_account #{brex_account.id}: #{e.message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + report_exception(e, "transactions") + end + + def report_exception(error, context) + Sentry.capture_exception(error) do |scope| + scope.set_tags( + brex_account_id: brex_account.id, + context: context + ) + end + end +end diff --git a/app/models/brex_account/transactions/processor.rb b/app/models/brex_account/transactions/processor.rb new file mode 100644 index 000000000..da0a81e17 --- /dev/null +++ b/app/models/brex_account/transactions/processor.rb @@ -0,0 +1,83 @@ +class BrexAccount::Transactions::Processor + attr_reader :brex_account + + def initialize(brex_account) + @brex_account = brex_account + end + + def process + unless brex_account.raw_transactions_payload.present? + Rails.logger.info "BrexAccount::Transactions::Processor - No transactions in raw_transactions_payload for brex_account #{brex_account.id}" + return { success: true, total: 0, imported: 0, skipped: 0, failed: 0, errors: [], skipped_transactions: [] } + end + + total_count = brex_account.raw_transactions_payload.count + Rails.logger.info "BrexAccount::Transactions::Processor - Processing #{total_count} transactions for brex_account #{brex_account.id}" + + imported_count = 0 + failed_count = 0 + skipped_count = 0 + errors = [] + skipped = [] + + # Each entry is processed inside a transaction, but to avoid locking up the DB when + # there are hundreds or thousands of transactions, we process them individually. + brex_account.raw_transactions_payload.each_with_index do |transaction_data, index| + begin + result = BrexEntry::Processor.new( + transaction_data, + brex_account: brex_account + ).process + + if result == :skipped + skipped_count += 1 + skipped << { index: index, transaction_id: transaction_id_for(transaction_data), reason: "No linked account" } + elsif result.nil? + failed_count += 1 + errors << { index: index, transaction_id: transaction_id_for(transaction_data), error: "No transaction imported" } + else + imported_count += 1 + end + rescue ArgumentError => e + # Validation error - log and continue + failed_count += 1 + transaction_id = transaction_id_for(transaction_data) + error_message = "Validation error: #{e.message}" + Rails.logger.error "BrexAccount::Transactions::Processor - #{error_message} (transaction #{transaction_id})" + errors << { index: index, transaction_id: transaction_id, error: error_message } + rescue => e + # Unexpected error - log with full context and continue + failed_count += 1 + transaction_id = transaction_id_for(transaction_data) + error_message = "#{e.class}: #{e.message}" + Rails.logger.error "BrexAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + errors << { index: index, transaction_id: transaction_id, error: error_message } + end + end + + result = { + success: failed_count == 0, + total: total_count, + imported: imported_count, + skipped: skipped_count, + failed: failed_count, + errors: errors, + skipped_transactions: skipped + } + + if failed_count > 0 + Rails.logger.warn "BrexAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions" + else + Rails.logger.info "BrexAccount::Transactions::Processor - Successfully processed #{imported_count} transactions" + end + + result + end + + private + + def transaction_id_for(transaction_data) + transaction_data&.dig(:id) || transaction_data&.dig("id") || "unknown" + end +end diff --git a/app/models/brex_entry/processor.rb b/app/models/brex_entry/processor.rb new file mode 100644 index 000000000..03bbb8689 --- /dev/null +++ b/app/models/brex_entry/processor.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "digest/md5" + +class BrexEntry::Processor + include CurrencyNormalizable + + def initialize(brex_transaction, brex_account:) + @brex_transaction = brex_transaction + @brex_account = brex_account + end + + def process + cached_external_id = nil + cached_external_id = external_id + + unless account.present? + Rails.logger.warn "BrexEntry::Processor - No linked account for brex_account #{brex_account.id}, skipping transaction #{cached_external_id}" + return :skipped + end + + import_adapter.import_transaction( + external_id: cached_external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "brex", + merchant: merchant, + notes: notes, + extra: extra + ) + rescue ArgumentError => e + Rails.logger.error "BrexEntry::Processor - Validation error for transaction #{cached_external_id || safe_external_id}: #{e.message}" + raise + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error "BrexEntry::Processor - Failed to save transaction #{cached_external_id || safe_external_id}: #{e.message}" + raise StandardError.new("Failed to import transaction: #{e.message}") + rescue => e + Rails.logger.error "BrexEntry::Processor - Unexpected error processing transaction #{cached_external_id || safe_external_id}: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).join("\n") + raise StandardError.new("Unexpected error importing transaction: #{e.message}") + end + + private + attr_reader :brex_transaction, :brex_account + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= brex_account.current_account + end + + def data + @data ||= brex_transaction.with_indifferent_access + end + + def external_id + id = data[:id].presence + raise ArgumentError, "Brex transaction missing required field 'id'" unless id + + "brex_#{id}" + end + + def safe_external_id + external_id + rescue ArgumentError + "brex_unknown" + end + + def name + data[:description].presence || + merchant_payload[:raw_descriptor].presence || + merchant_payload[:name].presence || + I18n.t("brex_items.entries.default_name") + end + + def notes + note_parts = [] + note_parts << data[:type] if data[:type].present? + note_parts << data[:expense_id] if data[:expense_id].present? + note_parts.any? ? note_parts.join(" - ") : nil + end + + def merchant + merchant_name = merchant_payload[:raw_descriptor].presence || merchant_payload[:name].presence + return @merchant if instance_variable_defined?(:@merchant) + return @merchant = nil if merchant_name.blank? + + merchant_name = merchant_name.to_s.strip + return @merchant = nil if merchant_name.blank? + + merchant_id = Digest::MD5.hexdigest(merchant_name.downcase) + + @merchant = import_adapter.find_or_create_merchant( + provider_merchant_id: "brex_merchant_#{merchant_id}", + name: merchant_name, + source: "brex" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "BrexEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + @merchant = nil + end + + def merchant_payload + @merchant_payload ||= begin + payload = data[:merchant] + payload.is_a?(Hash) ? payload.with_indifferent_access : {} + end + end + + def amount + BrexAccount.money_to_decimal(data[:amount]) || BigDecimal("0") + rescue ArgumentError => e + Rails.logger.error "Failed to parse Brex transaction amount: #{data[:amount].inspect} - #{e.message}" + raise + end + + def currency + amount_currency = transaction_amount_currency + log_invalid_currency(amount_currency) if amount_currency.blank? && data[:amount].present? + + parse_currency(amount_currency) || + parse_currency(brex_account.currency) || + "USD" + end + + def transaction_amount_currency + amount_payload = data[:amount] + return nil unless amount_payload.is_a?(Hash) + + amount_payload.with_indifferent_access[:currency] + end + + def log_invalid_currency(currency_value) + Rails.logger.warn( + "Invalid Brex currency #{currency_value.inspect} for transaction #{data[:id].presence || 'unknown'} " \ + "on brex_account #{brex_account.id} amount=#{data[:amount].inspect} account_currency=#{brex_account.currency.inspect}; defaulting to fallback" + ) + end + + def date + date_value = data[:posted_at_date].presence || data[:initiated_at_date].presence + + case date_value + when String + Date.parse(date_value) + when Integer, Float + Time.at(date_value).to_date + when Time, DateTime + date_value.to_date + when Date + date_value + else + raise ArgumentError, "Invalid date format: #{date_value.inspect}" + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Brex transaction date '#{date_value}': #{e.message}") + raise ArgumentError, "Unable to parse transaction date: #{date_value.inspect}" + end + + def extra + { + brex: { + transaction_id: data[:id], + account_kind: brex_account.account_kind, + type: data[:type], + card_id: data[:card_id], + transfer_id: data[:transfer_id], + expense_id: data[:expense_id], + card_transaction_operation_reference_id: data[:card_transaction_operation_reference_id], + initiated_at_date: data[:initiated_at_date], + posted_at_date: data[:posted_at_date], + merchant: BrexAccount.sanitize_payload(data[:merchant]) + }.compact + } + end +end diff --git a/app/models/brex_item.rb b/app/models/brex_item.rb new file mode 100644 index 000000000..865797e65 --- /dev/null +++ b/app/models/brex_item.rb @@ -0,0 +1,197 @@ +class BrexItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + BLANK_TOKEN_SENTINELS = [ "", " ", " ", " ", "\t", "\n", "\r" ].freeze + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + if encryption_ready? + encrypts :token, deterministic: true + encrypts :raw_payload + end + + validates :name, presence: true + validates :token, presence: true, on: :create + validate :base_url_must_be_official_brex_url + validate :token_cannot_be_blank_when_changed + before_validation :normalize_token + before_validation :normalize_base_url + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + + has_many :brex_accounts, dependent: :destroy + has_many :accounts, through: :brex_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + scope :with_credentials, -> { where.not(token: [ nil, *BLANK_TOKEN_SENTINELS ]).where("BTRIM(token) <> ''") } + + def self.resolve_for(family:, brex_item_id: nil) + normalized_id = brex_item_id.to_s.strip.presence + + if normalized_id.present? + return family.brex_items.active.with_credentials.find_by(id: normalized_id) + end + + credentialed_items = family.brex_items.active.with_credentials.ordered + credentialed_items.first if credentialed_items.one? + end + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def import_latest_brex_data(sync_start_date: nil) + provider = brex_provider + unless provider + Rails.logger.error "BrexItem #{id} - Cannot import: provider is not configured" + raise Provider::Brex::BrexError.new("Brex provider is not configured", :not_configured) + end + + BrexItem::Importer.new(self, brex_provider: provider, sync_start_date: sync_start_date).import + rescue => e + Rails.logger.error "BrexItem #{id} - Failed to import data: #{e.message}" + raise + end + + def process_accounts + return [] if brex_accounts.empty? + + results = [] + brex_accounts.joins(:account).includes(:account).merge(Account.visible).each do |brex_account| + begin + result = BrexAccount::Processor.new(brex_account).process + results << { brex_account_id: brex_account.id, success: true, result: result } + rescue => e + Rails.logger.error "BrexItem #{id} - Failed to process account #{brex_account.id}: #{e.message}" + results << { brex_account_id: brex_account.id, success: false, error: e.message } + end + end + + results + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + accounts.visible.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error "BrexItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + end + end + + results + end + + def upsert_brex_snapshot!(accounts_snapshot) + update!(raw_payload: BrexAccount.sanitize_payload(accounts_snapshot)) + end + + def has_completed_initial_setup? + # Setup is complete if we have any linked accounts + accounts.any? + end + + def sync_status_summary + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count + + if total_accounts == 0 + I18n.t("brex_items.sync_status.no_accounts") + elsif unlinked_count == 0 + I18n.t("brex_items.sync_status.all_synced", count: linked_count) + else + I18n.t("brex_items.sync_status.partial_setup", synced: linked_count, pending: unlinked_count) + end + end + + def linked_accounts_count + brex_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + brex_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + brex_accounts.count + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + def connected_institutions + brex_accounts.where.not(institution_metadata: nil) + .pluck(:institution_metadata) + .compact + .uniq { |inst| inst["name"] || inst["institution_name"] } + end + + def institution_summary + institutions = connected_institutions + case institutions.count + when 0 + I18n.t("brex_items.institution_summary.none") + when 1 + name = institutions.first["name"] || + institutions.first["institution_name"] || + I18n.t("brex_items.institution_summary.count", count: 1) + I18n.t("brex_items.institution_summary.one", name: name) + else + I18n.t("brex_items.institution_summary.count", count: institutions.count) + end + end + + def credentials_configured? + token.to_s.strip.present? + end + + def effective_base_url + return Provider::Brex::DEFAULT_BASE_URL if base_url.blank? + + Provider::Brex.normalize_base_url(base_url) + end + + private + def normalize_token + self.token = token&.strip + end + + def token_cannot_be_blank_when_changed + return unless persisted? && will_save_change_to_token? && token.blank? + + errors.add(:token, :blank) + end + + def normalize_base_url + stripped = base_url.to_s.strip + if stripped.blank? + self.base_url = nil + return + end + + normalized = Provider::Brex.normalize_base_url(stripped) + self.base_url = normalized if normalized.present? + end + + def base_url_must_be_official_brex_url + return if base_url.blank? || Provider::Brex.allowed_base_url?(base_url) + + errors.add(:base_url, :official_hosts_only) + end +end diff --git a/app/models/brex_item/account_flow.rb b/app/models/brex_item/account_flow.rb new file mode 100644 index 000000000..7fc08ed0d --- /dev/null +++ b/app/models/brex_item/account_flow.rb @@ -0,0 +1,425 @@ +# frozen_string_literal: true + +class BrexItem::AccountFlow + require_dependency "brex_item/account_flow/setup" + + include Setup + + CACHE_TTL = 5.minutes + + class NoApiTokenError < StandardError; end + class AccountNotFoundError < StandardError; end + class InvalidAccountNameError < StandardError; end + class AccountAlreadyLinkedError < StandardError; end + + NavigationResult = Data.define(:target, :flash_type, :message) + + SelectionResult = Data.define(:status, :brex_item, :available_accounts, :accountable_type, :message) do + def success? = status == :success + def setup_required? = status == :setup_required + def provider_error? = status.in?([ :api_error, :unexpected_error ]) + end + + LinkAccountsResult = Data.define(:created_accounts, :already_linked_names, :invalid_account_ids) do + def created_count = created_accounts.count + def already_linked_count = already_linked_names.count + def invalid_count = invalid_account_ids.count + end + + SetupResult = Data.define(:created_accounts, :skipped_count, :failed_count) do + def created_count = created_accounts.count + end + + SetupCompletion = Data.define(:success, :message) do + def success? = success + end + + attr_reader :family, :brex_item_id, :brex_item, :credentialed_items + + def initialize(family:, brex_item_id: nil, brex_item: nil) + @family = family + @brex_item_id = brex_item_id.to_s.strip.presence + @credentialed_items = family.brex_items.active.with_credentials.ordered + @brex_item = brex_item || BrexItem.resolve_for(family: family, brex_item_id: @brex_item_id) + end + + def self.cache_key(family, brex_item) + "brex_accounts_#{family.id}_#{brex_item.id}" + end + + def self.cache_sensitive_update?(permitted_params) + permitted_params.key?(:token) || permitted_params.key?(:base_url) + end + + def self.update_item_with_cache_expiration(brex_item, family:, attributes:) + expire_accounts_cache = cache_sensitive_update?(attributes) + updated = brex_item.update(attributes) + + Rails.cache.delete(cache_key(family, brex_item)) if updated && expire_accounts_cache + + updated + end + + def selected? + brex_item.present? + end + + def selection_required? + credentialed_items.count > 1 && brex_item_id.blank? + end + + def preload_payload + return selection_error_payload if !selected? + return { success: false, error: "no_credentials", has_accounts: false } unless brex_item.credentials_configured? + + cached_accounts = Rails.cache.read(cache_key) + cached = !cached_accounts.nil? + available_accounts = cached ? cached_accounts : fetch_and_cache_accounts + + { success: true, has_accounts: available_accounts.any?, cached: cached } + rescue NoApiTokenError + { success: false, error: "no_api_token", has_accounts: false } + rescue Provider::Brex::BrexError => e + Rails.logger.error("Brex preload error: #{e.message}") + { success: false, error: "api_error", error_message: e.message, has_accounts: nil } + rescue StandardError => e + Rails.logger.error("Unexpected error preloading Brex accounts: #{e.class}: #{e.message}") + { success: false, error: "unexpected_error", error_message: I18n.t("brex_items.errors.unexpected_error"), has_accounts: nil } + end + + def select_accounts_result(accountable_type:) + selection_result_for( + scope: "brex_items.select_accounts", + accountable_type: accountable_type, + empty_message_key: "no_accounts_found", + log_context: "select_accounts" + ) + end + + def select_existing_account_result(account:) + return linked_account_result if account.account_providers.exists? + + selection_result_for( + scope: "brex_items.select_existing_account", + accountable_type: account.accountable_type, + empty_message_key: "all_accounts_already_linked", + log_context: "select_existing_account" + ) + end + + def link_new_accounts_result(account_ids:, accountable_type:) + return navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.no_accounts_selected")) if account_ids.blank? + return navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.invalid_account_type")) unless supported_account_type?(accountable_type) + return navigation(:settings_providers, :alert, I18n.t("brex_items.link_accounts.select_connection")) unless selected? + + link_navigation_result(link_new_accounts!(account_ids: account_ids, accountable_type: accountable_type)) + rescue NoApiTokenError + navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.no_api_token")) + rescue Provider::Brex::BrexError => e + navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.api_error", message: e.message)) + rescue StandardError => e + Rails.logger.error("Brex account linking failed: #{e.class} - #{e.message}") + Rails.logger.error(Array(e.backtrace).first(10).join("\n")) + navigation(:new_account, :alert, I18n.t("brex_items.errors.unexpected_error")) + end + + def link_existing_account_result(account:, brex_account_id:) + return navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.missing_parameters")) if account.blank? || brex_account_id.blank? + return navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.account_already_linked")) if account.account_providers.exists? + return navigation(:settings_providers, :alert, I18n.t("brex_items.link_existing_account.select_connection")) unless selected? + + link_existing_account!(account: account, brex_account_id: brex_account_id) + + navigation(:return_to_or_accounts, :notice, I18n.t("brex_items.link_existing_account.success", account_name: account.name)) + rescue NoApiTokenError + navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.no_api_token")) + rescue AccountNotFoundError + navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.provider_account_not_found")) + rescue InvalidAccountNameError + navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.invalid_account_name")) + rescue AccountAlreadyLinkedError + navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.provider_account_already_linked")) + rescue Provider::Brex::BrexError => e + navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.api_error", message: e.message)) + rescue StandardError => e + Rails.logger.error("Brex existing account linking failed: #{e.class} - #{e.message}") + Rails.logger.error(Array(e.backtrace).first(10).join("\n")) + navigation(:accounts, :alert, I18n.t("brex_items.errors.unexpected_error")) + end + + def link_new_accounts!(account_ids:, accountable_type:) + raise ArgumentError, "Unsupported Brex account type: #{accountable_type}" unless supported_account_type?(accountable_type) + + created_accounts = [] + already_linked_names = [] + invalid_account_ids = [] + accounts_by_id = indexed_accounts + + ActiveRecord::Base.transaction do + account_ids.each do |account_id| + account_data = accounts_by_id[account_id.to_s] + next unless account_data + + account_name = BrexAccount.name_for(account_data) + + if account_name.blank? + invalid_account_ids << account_id + Rails.logger.warn "BrexItem::AccountFlow - Skipping account #{account_id} with blank name" + next + end + + brex_account = upsert_brex_account!(account_id, account_data) + + if brex_account.account_provider.present? + already_linked_names << account_name + next + end + + account = Account.create_and_sync( + { + family: family, + name: account_name, + balance: 0, + currency: BrexAccount.currency_for(account_data), + accountable_type: accountable_type, + accountable_attributes: BrexAccount.default_accountable_attributes(accountable_type) + }, + skip_initial_sync: true + ) + + AccountProvider.create!(account: account, provider: brex_account) + created_accounts << account + end + end + + brex_item.sync_later if created_accounts.any? + + LinkAccountsResult.new( + created_accounts: created_accounts, + already_linked_names: already_linked_names, + invalid_account_ids: invalid_account_ids + ) + end + + def link_existing_account!(account:, brex_account_id:) + account_data = indexed_accounts[brex_account_id.to_s] + raise AccountNotFoundError unless account_data + + account_name = BrexAccount.name_for(account_data) + raise InvalidAccountNameError if account_name.blank? + + brex_account = nil + + ActiveRecord::Base.transaction do + brex_account = upsert_brex_account!(brex_account_id, account_data) + raise AccountAlreadyLinkedError if brex_account.account_provider.present? + + AccountProvider.create!(account: account, provider: brex_account) + end + + brex_item.sync_later + + brex_account + end + + private + + def selection_error_payload + if brex_item_id.present? + return { + success: false, + error: "select_connection", + error_message: I18n.t("brex_items.select_accounts.select_connection"), + has_accounts: nil + } + end + + return { success: false, error: "no_credentials", has_accounts: false } unless selection_required? + + { + success: false, + error: "select_connection", + error_message: I18n.t("brex_items.select_accounts.select_connection"), + has_accounts: nil + } + end + + def selection_failure_result(scope, accountable_type: nil) + if selection_required? + SelectionResult.new( + status: :select_connection, + brex_item: nil, + available_accounts: [], + accountable_type: accountable_type, + message: I18n.t("#{scope}.select_connection") + ) + else + SelectionResult.new( + status: :setup_required, + brex_item: nil, + available_accounts: [], + accountable_type: accountable_type, + message: I18n.t("#{scope}.no_credentials_configured") + ) + end + end + + def selection_result_for(scope:, accountable_type:, empty_message_key:, log_context:) + return selection_failure_result(scope, accountable_type: accountable_type) unless selected? + + available_accounts = filter_accounts(unlinked_available_accounts, accountable_type) + if available_accounts.empty? + return selection_result( + status: :empty, + accountable_type: accountable_type, + message: I18n.t("#{scope}.#{empty_message_key}") + ) + end + + selection_result(status: :success, accountable_type: accountable_type, available_accounts: available_accounts) + rescue NoApiTokenError + selection_result( + status: :no_api_token, + accountable_type: accountable_type, + message: I18n.t("#{scope}.no_api_token") + ) + rescue Provider::Brex::BrexError => e + Rails.logger.error("Brex API error in #{log_context}: #{e.message}") + selection_result(status: :api_error, accountable_type: accountable_type, message: e.message) + rescue StandardError => e + Rails.logger.error("Unexpected error in #{log_context}: #{e.class}: #{e.message}") + selection_result( + status: :unexpected_error, + accountable_type: accountable_type, + message: I18n.t("#{scope}.unexpected_error") + ) + end + + def selection_result(status:, accountable_type:, available_accounts: [], message: nil) + SelectionResult.new( + status: status, + brex_item: brex_item, + available_accounts: available_accounts, + accountable_type: accountable_type, + message: message + ) + end + + def linked_account_result + SelectionResult.new( + status: :account_already_linked, + brex_item: brex_item, + available_accounts: [], + accountable_type: nil, + message: I18n.t("brex_items.select_existing_account.account_already_linked") + ) + end + + def link_navigation_result(result) + if result.invalid_count.positive? && result.created_count.zero? && result.already_linked_count.zero? + navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.invalid_account_names", count: result.invalid_count)) + elsif result.invalid_count.positive? && (result.created_count.positive? || result.already_linked_count.positive?) + navigation( + :return_to_or_accounts, + :alert, + I18n.t( + "brex_items.link_accounts.partial_invalid", + created_count: result.created_count, + already_linked_count: result.already_linked_count, + invalid_count: result.invalid_count + ) + ) + elsif result.created_count.positive? && result.already_linked_count.positive? + navigation( + :return_to_or_accounts, + :notice, + I18n.t( + "brex_items.link_accounts.partial_success", + created_count: result.created_count, + already_linked_count: result.already_linked_count, + already_linked_names: result.already_linked_names.join(", ") + ) + ) + elsif result.created_count.positive? + navigation(:return_to_or_accounts, :notice, I18n.t("brex_items.link_accounts.success", count: result.created_count)) + elsif result.already_linked_count.positive? + navigation( + :return_to_or_accounts, + :alert, + I18n.t( + "brex_items.link_accounts.all_already_linked", + count: result.already_linked_count, + names: result.already_linked_names.join(", ") + ) + ) + else + navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.link_failed")) + end + end + + def navigation(target, flash_type, message) + NavigationResult.new(target: target, flash_type: flash_type, message: message) + end + + def cache_key + self.class.cache_key(family, brex_item) + end + + def fetch_accounts + provider = brex_item&.brex_provider + raise NoApiTokenError unless provider.present? + + accounts_data = provider.get_accounts + accounts_data[:accounts] || [] + end + + def accounts + cached_accounts = Rails.cache.read(cache_key) + return cached_accounts unless cached_accounts.nil? + + fetch_and_cache_accounts + end + + def fetch_and_cache_accounts + available_accounts = fetch_accounts + Rails.cache.write(cache_key, available_accounts, expires_in: CACHE_TTL) + available_accounts + end + + def unlinked_available_accounts + linked_account_ids = brex_item.brex_accounts + .joins(:account_provider) + .pluck("#{BrexAccount.table_name}.account_id") + .map(&:to_s) + accounts.reject { |account| linked_account_ids.include?(account.with_indifferent_access[:id].to_s) } + end + + def filter_accounts(accounts, accountable_type) + return [] unless Provider::BrexAdapter.supported_account_types.include?(accountable_type) + + accounts.select do |account| + case accountable_type + when "CreditCard" + BrexAccount.kind_for(account) == "card" + when "Depository" + BrexAccount.kind_for(account) == "cash" + else + true + end + end + end + + def indexed_accounts + accounts.index_by { |account| account.with_indifferent_access[:id].to_s } + end + + def upsert_brex_account!(account_id, account_data) + brex_account = brex_item.brex_accounts.find_or_initialize_by(account_id: account_id.to_s) + brex_account.upsert_brex_snapshot!(account_data) + brex_account + end + + def supported_account_type?(accountable_type) + Provider::BrexAdapter.supported_account_types.include?(accountable_type) + end +end diff --git a/app/models/brex_item/account_flow/setup.rb b/app/models/brex_item/account_flow/setup.rb new file mode 100644 index 000000000..730892b4e --- /dev/null +++ b/app/models/brex_item/account_flow/setup.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +class BrexItem::AccountFlow + module Setup + def import_accounts_from_api_if_needed + raise NoApiTokenError unless brex_item&.credentials_configured? + + available_accounts = fetch_accounts + return nil if available_accounts.empty? + + existing_accounts = brex_item.brex_accounts.index_by(&:account_id) + + available_accounts.each do |account_data| + account_id = account_data.with_indifferent_access[:id].to_s + account_name = BrexAccount.name_for(account_data) + next if account_id.blank? || account_name.blank? + + brex_account = existing_accounts[account_id] + next if brex_account.present? && !brex_account_snapshot_changed?(brex_account, account_data) + + upsert_brex_account!(account_id, account_data) + end + + nil + end + + def unlinked_brex_accounts + brex_item.brex_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + end + + def account_type_options + supported_types = Provider::BrexAdapter.supported_account_types + account_type_keys = { + "depository" => "Depository", + "credit_card" => "CreditCard", + "investment" => "Investment", + "loan" => "Loan", + "other_asset" => "OtherAsset" + } + + options = account_type_keys.filter_map do |key, type| + next unless supported_types.include?(type) + + [ I18n.t("brex_items.setup_accounts.account_types.#{key}"), type ] + end + + [ [ I18n.t("brex_items.setup_accounts.account_types.skip"), "skip" ] ] + options + end + + def displayable_account_type_options + account_type_options.reject { |_, type| type == "skip" } + end + + def subtype_options + supported_types = Provider::BrexAdapter.supported_account_types + all_subtype_options = { + "Depository" => { + label: I18n.t("brex_items.setup_accounts.subtype_labels.depository"), + options: translate_subtypes("depository", Depository::SUBTYPES) + }, + "CreditCard" => { + label: I18n.t("brex_items.setup_accounts.subtype_labels.credit_card"), + options: [], + message: I18n.t("brex_items.setup_accounts.subtype_messages.credit_card") + }, + "Investment" => { + label: I18n.t("brex_items.setup_accounts.subtype_labels.investment"), + options: translate_subtypes("investment", Investment::SUBTYPES) + }, + "Loan" => { + label: I18n.t("brex_items.setup_accounts.subtype_labels.loan"), + options: translate_subtypes("loan", Loan::SUBTYPES) + }, + "OtherAsset" => { + label: I18n.t("brex_items.setup_accounts.subtype_labels.other_asset", default: "Other asset"), + options: [], + message: I18n.t("brex_items.setup_accounts.subtype_messages.other_asset") + } + } + + all_subtype_options.slice(*supported_types) + end + + def complete_setup!(account_types:, account_subtypes:) + created_accounts = [] + skipped_count = 0 + valid_types = Provider::BrexAdapter.supported_account_types + failed_count = 0 + + submitted_brex_accounts = brex_item.brex_accounts + .where(id: account_types.keys) + .includes(:account_provider) + .index_by { |brex_account| brex_account.id.to_s } + + account_types.each do |brex_account_id, selected_type| + if selected_type == "skip" || selected_type.blank? + skipped_count += 1 + next + end + + unless valid_types.include?(selected_type) + Rails.logger.warn("Invalid account type '#{selected_type}' submitted for Brex account #{brex_account_id}") + skipped_count += 1 + next + end + + brex_account = submitted_brex_accounts[brex_account_id.to_s] + unless brex_account + Rails.logger.warn("Brex account #{brex_account_id} not found for item #{brex_item.id}") + next + end + + if brex_account.account_provider.present? + Rails.logger.info("Brex account #{brex_account_id} already linked, skipping") + next + end + + selected_subtype = selected_subtype_for( + selected_type: selected_type, + submitted_subtype: account_subtypes[brex_account_id] + ) + + begin + ActiveRecord::Base.transaction do + account = Account.create_and_sync( + { + family: family, + name: brex_account.name, + balance: brex_account.current_balance || 0, + currency: brex_account.currency.presence || family.currency, + accountable_type: selected_type, + accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {} + }, + skip_initial_sync: true + ) + + AccountProvider.create!(account: account, provider: brex_account) + created_accounts << account + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + failed_count += 1 + Rails.logger.error("Brex account setup failed for #{brex_account_id}: #{e.class} - #{e.message}") + Rails.logger.error(Array(e.backtrace).first(10).join("\n")) + end + end + + brex_item.sync_later if created_accounts.any? + + SetupResult.new(created_accounts: created_accounts, skipped_count: skipped_count, failed_count: failed_count) + end + + def import_accounts_with_user_facing_error + import_accounts_from_api_if_needed + rescue NoApiTokenError + I18n.t("brex_items.setup_accounts.no_api_token") + rescue Provider::Brex::BrexError => e + Rails.logger.error("Brex API error: #{e.message}") + I18n.t("brex_items.setup_accounts.api_error", message: e.message) + rescue StandardError => e + Rails.logger.error("Unexpected error fetching Brex accounts: #{e.class}: #{e.message}") + I18n.t("brex_items.setup_accounts.api_error", message: I18n.t("brex_items.errors.unexpected_error")) + end + + def complete_setup_result(account_types:, account_subtypes:) + result = complete_setup!(account_types: account_types, account_subtypes: account_subtypes) + + SetupCompletion.new(success: result.failed_count.zero? && result.created_count.positive?, message: setup_notice(result)) + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error("Brex account setup failed: #{e.class} - #{e.message}") + Rails.logger.error(Array(e.backtrace).first(10).join("\n")) + SetupCompletion.new( + success: false, + message: I18n.t("brex_items.complete_account_setup.creation_failed", error: e.message) + ) + rescue StandardError => e + Rails.logger.error("Brex account setup failed unexpectedly: #{e.class} - #{e.message}") + Rails.logger.error(Array(e.backtrace).first(10).join("\n")) + SetupCompletion.new( + success: false, + message: I18n.t( + "brex_items.complete_account_setup.creation_failed", + error: I18n.t("brex_items.complete_account_setup.unexpected_error") + ) + ) + end + + private + + def setup_notice(result) + if result.failed_count.positive? && result.created_count.positive? + I18n.t("brex_items.complete_account_setup.partial_success", created_count: result.created_count, failed_count: result.failed_count) + elsif result.skipped_count.positive? && result.created_count.positive? + I18n.t("brex_items.complete_account_setup.partial_skipped", created_count: result.created_count, skipped_count: result.skipped_count) + elsif result.failed_count.positive? + I18n.t("brex_items.complete_account_setup.creation_failed_count", count: result.failed_count) + elsif result.created_count.positive? + I18n.t("brex_items.complete_account_setup.success", count: result.created_count) + elsif result.skipped_count.positive? + I18n.t("brex_items.complete_account_setup.all_skipped") + else + I18n.t("brex_items.complete_account_setup.no_accounts") + end + end + + def brex_account_snapshot_changed?(brex_account, account_data) + snapshot = account_data.with_indifferent_access + balances = snapshot.slice(:current_balance, :available_balance, :account_limit) + + expected = { + account_kind: BrexAccount.kind_for(snapshot), + account_status: snapshot[:status], + account_type: snapshot[:type], + available_balance: BrexAccount.money_to_decimal(balances[:available_balance]), + current_balance: BrexAccount.money_to_decimal(balances[:current_balance]), + account_limit: BrexAccount.money_to_decimal(balances[:account_limit]), + currency: BrexAccount.currency_code_from_money(balances[:current_balance] || balances[:available_balance] || balances[:account_limit]), + name: BrexAccount.name_for(snapshot), + raw_payload: BrexAccount.sanitize_payload(account_data) + } + + expected.any? { |attribute, value| brex_account.public_send(attribute) != value } + end + + def translate_subtypes(type_key, subtypes_hash) + subtypes_hash.map do |key, value| + [ + I18n.t("brex_items.setup_accounts.subtypes.#{type_key}.#{key}", default: value[:long] || key.to_s.humanize), + key + ] + end + end + + def selected_subtype_for(selected_type:, submitted_subtype:) + return CreditCard::DEFAULT_SUBTYPE if selected_type == "CreditCard" && submitted_subtype.blank? + return Depository::DEFAULT_SUBTYPE if selected_type == "Depository" && submitted_subtype.blank? + + submitted_subtype + end + end +end diff --git a/app/models/brex_item/importer.rb b/app/models/brex_item/importer.rb new file mode 100644 index 000000000..a053c16e2 --- /dev/null +++ b/app/models/brex_item/importer.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +class BrexItem::Importer + attr_reader :brex_item, :brex_provider, :sync_start_date + + def initialize(brex_item, brex_provider:, sync_start_date: nil) + @brex_item = brex_item + @brex_provider = brex_provider + @sync_start_date = sync_start_date + end + + def import + Rails.logger.info "BrexItem::Importer - Starting import for item #{brex_item.id}" + + accounts_data = fetch_accounts_data + return failed_result("Failed to fetch accounts data") unless accounts_data + + store_item_snapshot(accounts_data) + + account_result = import_accounts(accounts_data[:accounts].to_a) + transaction_result = import_transactions + + brex_item.update!(status: :good) if account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero? + + { + success: account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero?, + **account_result, + **transaction_result + } + end + + private + + def fetch_accounts_data + accounts_data = brex_provider.get_accounts + + unless accounts_data.is_a?(Hash) + Rails.logger.error "BrexItem::Importer - Invalid accounts_data format: expected Hash, got #{accounts_data.class}" + return nil + end + + accounts_data + rescue Provider::Brex::BrexError => e + mark_requires_update_if_credentials_error(e) + Rails.logger.error "BrexItem::Importer - Brex API error: #{e.message} trace_id=#{e.trace_id}" + nil + rescue JSON::ParserError => e + Rails.logger.error "BrexItem::Importer - Failed to parse Brex API response: #{e.message}" + nil + rescue => e + Rails.logger.error "BrexItem::Importer - Unexpected error fetching accounts: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).join("\n") + nil + end + + def store_item_snapshot(accounts_data) + brex_item.upsert_brex_snapshot!(accounts_data) + rescue => e + Rails.logger.error "BrexItem::Importer - Failed to store accounts snapshot: #{e.message}" + Sentry.capture_exception(e) do |scope| + scope.set_tags(brex_item_id: brex_item.id) + scope.set_context("brex_item_snapshot", { + brex_item_id: brex_item.id, + accounts_data: BrexAccount.sanitize_payload(accounts_data) + }) + end + raise + end + + def import_accounts(accounts) + accounts_updated = 0 + accounts_created = 0 + accounts_failed = 0 + + all_existing_ids = brex_item.brex_accounts.pluck("#{BrexAccount.table_name}.account_id").map(&:to_s) + + accounts.each do |account_data| + snapshot = account_data.with_indifferent_access + account_id = snapshot[:id].to_s + account_name = BrexAccount.name_for(snapshot) + next if account_id.blank? || account_name.blank? + + if all_existing_ids.include?(account_id) + import_account(snapshot) + accounts_updated += 1 + else + import_account(snapshot) + accounts_created += 1 + all_existing_ids << account_id + end + rescue => e + accounts_failed += 1 + Rails.logger.error "BrexItem::Importer - Failed to import account #{account_id.presence || 'unknown'}: #{e.message}" + end + + { + accounts_updated: accounts_updated, + accounts_created: accounts_created, + accounts_failed: accounts_failed + } + end + + def import_account(account_data) + account_id = account_data[:id].to_s + raise ArgumentError, "Account ID is required" if account_id.blank? + + brex_account = brex_item.brex_accounts.find_or_initialize_by(account_id: account_id) + brex_account.name ||= BrexAccount.name_for(account_data) + brex_account.currency ||= BrexAccount.currency_code_from_money(account_data[:current_balance] || account_data[:available_balance] || account_data[:account_limit]) + brex_account.upsert_brex_snapshot!(account_data) + brex_account + end + + def import_transactions + transactions_imported = 0 + transactions_failed = 0 + + brex_item.brex_accounts.joins(:account).merge(Account.visible).find_each do |brex_account| + result = fetch_and_store_transactions(brex_account) + if result[:success] + transactions_imported += result[:transactions_count] + else + transactions_failed += 1 + end + rescue => e + transactions_failed += 1 + Rails.logger.error "BrexItem::Importer - Failed to fetch/store transactions for account #{brex_account.account_id}: #{e.message}" + end + + { + transactions_imported: transactions_imported, + transactions_failed: transactions_failed + } + end + + def fetch_and_store_transactions(brex_account) + start_date = determine_sync_start_date(brex_account) + Rails.logger.info "BrexItem::Importer - Fetching #{brex_account.account_kind} transactions for account #{brex_account.account_id} from #{start_date}" + + transactions_data = if brex_account.card? + brex_provider.get_primary_card_transactions(start_date: start_date) + else + brex_provider.get_cash_transactions(brex_account.account_id, start_date: start_date) + end + + unless transactions_data.is_a?(Hash) + Rails.logger.error "BrexItem::Importer - Invalid transactions_data format for account #{brex_account.account_id}" + return { success: false, transactions_count: 0, error: "Invalid response format" } + end + + transactions = transactions_data[:transactions].to_a + created_count = store_new_transactions(brex_account, transactions, window_start_date: start_date) + + { success: true, transactions_count: created_count } + rescue Provider::Brex::BrexError => e + mark_requires_update_if_credentials_error(e) + Rails.logger.error "BrexItem::Importer - Brex API error for account #{brex_account.account_id}: #{e.message} trace_id=#{e.trace_id}" + { success: false, transactions_count: 0, error: e.message } + rescue JSON::ParserError => e + Rails.logger.error "BrexItem::Importer - Failed to parse transaction response for account #{brex_account.account_id}: #{e.message}" + { success: false, transactions_count: 0, error: "Failed to parse response" } + rescue => e + Rails.logger.error "BrexItem::Importer - Unexpected error fetching transactions for account #{brex_account.account_id}: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).join("\n") + { success: false, transactions_count: 0, error: "Unexpected error: #{e.message}" } + end + + def store_new_transactions(brex_account, transactions, window_start_date:) + existing_payload = brex_account.raw_transactions_payload.to_a + existing_transactions = transactions_in_window(existing_payload, window_start_date) + existing_ids = existing_transactions.map { |tx| tx.with_indifferent_access[:id] }.to_set + + new_transactions = transactions.select do |tx| + tx_id = tx.with_indifferent_access[:id] + tx_id.present? && !existing_ids.include?(tx_id) && transaction_in_window?(tx, window_start_date) + end + + return 0 if new_transactions.empty? && existing_transactions.count == existing_payload.count + + brex_account.upsert_brex_transactions_snapshot!(existing_transactions + new_transactions) + new_transactions.count + end + + def transactions_in_window(transactions, window_start_date) + transactions.select { |transaction| transaction_in_window?(transaction, window_start_date) } + end + + def transaction_in_window?(transaction, window_start_date) + return true if window_start_date.blank? + + transaction_date = transaction_date_for(transaction) + return true if transaction_date.blank? + + transaction_date >= window_start_date.to_date + end + + def transaction_date_for(transaction) + data = transaction.with_indifferent_access + date_value = data[:posted_at_date].presence || data[:initiated_at_date].presence || data[:posted_at].presence || data[:created_at].presence + + case date_value + when Date + date_value + when Time, DateTime + date_value.to_date + when String + Date.parse(date_value) + else + nil + end + rescue ArgumentError, TypeError + nil + end + + def determine_sync_start_date(brex_account) + return sync_start_date if sync_start_date.present? + + if brex_account.raw_transactions_payload.to_a.any? + brex_item.last_synced_at ? brex_item.last_synced_at - 7.days : 90.days.ago + else + account_baseline = brex_account.created_at || Time.current + [ account_baseline - 7.days, 90.days.ago ].max + end + end + + def mark_requires_update_if_credentials_error(error) + return unless error.error_type.in?([ :unauthorized, :access_forbidden ]) + + brex_item.update!(status: :requires_update) + rescue => update_error + Rails.logger.error "BrexItem::Importer - Failed to update item status: #{update_error.message}" + end + + def failed_result(error) + { + success: false, + error: error, + accounts_updated: 0, + accounts_created: 0, + accounts_failed: 0, + transactions_imported: 0, + transactions_failed: 0 + } + end +end diff --git a/app/models/brex_item/provided.rb b/app/models/brex_item/provided.rb new file mode 100644 index 000000000..6e4b22d14 --- /dev/null +++ b/app/models/brex_item/provided.rb @@ -0,0 +1,16 @@ +module BrexItem::Provided + extend ActiveSupport::Concern + + def brex_provider + return nil unless credentials_configured? + + base_url = effective_base_url + return nil unless base_url.present? + + Provider::Brex.new(token.to_s.strip, base_url: base_url) + end + + def syncer + BrexItem::Syncer.new(self) + end +end diff --git a/app/models/brex_item/syncer.rb b/app/models/brex_item/syncer.rb new file mode 100644 index 000000000..3e5de1686 --- /dev/null +++ b/app/models/brex_item/syncer.rb @@ -0,0 +1,148 @@ +class BrexItem::Syncer + include SyncStats::Collector + + SafeSyncError = Class.new(StandardError) + + attr_reader :brex_item + + def initialize(brex_item) + @brex_item = brex_item + end + + def perform_sync(sync) + sync_errors = [] + + # Phase 1: Import data from Brex API + update_status(sync, :importing_accounts) + import_result = brex_item.import_latest_brex_data(sync_start_date: sync.window_start_date) + sync_errors.concat(import_result_errors(import_result)) + + # Phase 2: Collect setup statistics + update_status(sync, :checking_account_configuration) + + linked_count = brex_item.brex_accounts.joins(:account_provider).count + unlinked_count = brex_item.brex_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .count + total_count = linked_count + unlinked_count + collect_brex_setup_stats( + sync, + total_count: total_count, + linked_count: linked_count, + unlinked_count: unlinked_count + ) + + # Set pending_account_setup if there are unlinked accounts + if unlinked_count.positive? + brex_item.update!(pending_account_setup: true) + update_status(sync, :accounts_need_setup, count: unlinked_count) + else + brex_item.update!(pending_account_setup: false) + end + + # Phase 3: Process transactions for linked accounts only + if linked_count.positive? + linked_accounts = brex_item.brex_accounts.joins(:account_provider) + update_status(sync, :processing_transactions) + mark_import_started(sync) + Rails.logger.info "BrexItem::Syncer - Processing #{linked_count} linked accounts" + process_results = brex_item.process_accounts + sync_errors.concat(result_failure_errors(process_results, category: :account_processing_error, message_key: :account_processing_failed)) + Rails.logger.info "BrexItem::Syncer - Finished processing accounts" + + # Phase 4: Schedule balance calculations for linked accounts + update_status(sync, :calculating_balances) + schedule_results = brex_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + sync_errors.concat(result_failure_errors(schedule_results, category: :account_sync_error, message_key: :account_sync_failed)) + + # Phase 5: Collect transaction statistics + account_ids = linked_accounts + .includes(account_provider: :account) + .filter_map { |ma| ma.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "brex") + else + Rails.logger.info "BrexItem::Syncer - No linked accounts to process" + end + + # Mark sync health + collect_health_stats(sync, errors: sync_errors.presence) + rescue => e + safe_message = user_safe_error_message(e) + Rails.logger.error "BrexItem::Syncer - sync failed for Brex item #{brex_item.id}: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + Sentry.capture_exception(e) do |scope| + scope.set_tags(brex_item_id: brex_item.id) + end + collect_health_stats(sync, errors: [ { message: safe_message, category: "sync_error" } ]) + raise SafeSyncError, safe_message + end + + def perform_post_sync + # no-op + end + + private + + def update_status(sync, key, **options) + return unless sync.respond_to?(:status_text) + + sync.update!(status_text: I18n.t("brex_items.syncer.#{key}", **options)) + end + + def collect_brex_setup_stats(sync, total_count:, linked_count:, unlinked_count:) + return {} unless sync.respond_to?(:sync_stats) + + setup_stats = { + "total_accounts" => total_count, + "linked_accounts" => linked_count, + "unlinked_accounts" => unlinked_count + } + + merge_sync_stats(sync, setup_stats) + setup_stats + end + + def import_result_errors(result) + return [] if result.is_a?(Hash) && result[:success] + + unless result.is_a?(Hash) + return [ sync_error(:import_error, :import_failed) ] + end + + errors = [] + accounts_failed = result[:accounts_failed].to_i + transactions_failed = result[:transactions_failed].to_i + + errors << sync_error(:account_import_error, :accounts_failed, count: accounts_failed) if accounts_failed.positive? + errors << sync_error(:transaction_import_error, :transactions_failed, count: transactions_failed) if transactions_failed.positive? + errors << sync_error(:import_error, :import_failed) if errors.empty? + errors + end + + def result_failure_errors(results, category:, message_key:) + failed_count = Array(results).count { |result| result.is_a?(Hash) && result[:success] == false } + return [] unless failed_count.positive? + + [ sync_error(category, message_key, count: failed_count) ] + end + + def sync_error(category, message_key, **options) + { + message: I18n.t("brex_items.syncer.#{message_key}", **options), + category: category.to_s + } + end + + def user_safe_error_message(error) + if error.is_a?(Provider::Brex::BrexError) && error.error_type.in?([ :unauthorized, :access_forbidden ]) + I18n.t("brex_items.syncer.credentials_invalid") + else + I18n.t("brex_items.syncer.failed") + end + end +end diff --git a/app/models/brex_item/unlinking.rb b/app/models/brex_item/unlinking.rb new file mode 100644 index 000000000..a2c1d3703 --- /dev/null +++ b/app/models/brex_item/unlinking.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module BrexItem::Unlinking + # Concern that encapsulates unlinking logic for a Brex item. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this Brex item and local accounts. + # - Detaches any AccountProvider links for each BrexAccount + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-account result payload for observability + def unlink_all!(dry_run: false) + results = [] + + brex_accounts.find_each do |provider_account| + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: [] + } + results << result + + if dry_run + result[:provider_link_ids] = AccountProvider.where(provider_type: "BrexAccount", provider_id: provider_account.id).ids + next + end + + link_ids = [] + + begin + ActiveRecord::Base.transaction do + links = AccountProvider.where(provider_type: "BrexAccount", provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result[:provider_link_ids] = link_ids + + # Detach holdings for any provider links found + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + # Destroy all provider links + links.each do |ap| + ap.destroy! + end + end + rescue StandardError => e + Rails.logger.warn( + "BrexItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + # Record error for observability; continue with other accounts + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/budget.rb b/app/models/budget.rb index 64fcdb73d..f08f1e0d0 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -31,13 +31,14 @@ class Budget < ApplicationRecord end def budget_date_valid?(date, family:) - if family.uses_custom_month_start? - budget_start = family.custom_month_start_for(date) - budget_start >= oldest_valid_budget_date(family) && budget_start <= family.custom_month_end_for(Date.current) + budget_start = if family.uses_custom_month_start? + family.custom_month_start_for(date) else - beginning_of_month = date.beginning_of_month - beginning_of_month >= oldest_valid_budget_date(family) && beginning_of_month <= Date.current.end_of_month + date.beginning_of_month end + + budget_start >= oldest_valid_budget_date(family) && + budget_start <= latest_valid_budget_start_date(family) end def find_or_bootstrap(family, start_date:, user: nil) @@ -73,6 +74,14 @@ class Budget < ApplicationRecord oldest_entry_date = family.oldest_entry_date.beginning_of_month [ two_years_ago, oldest_entry_date ].min end + + def latest_valid_budget_start_date(family) + if family.uses_custom_month_start? + family.current_custom_month_period.start_date + 2.years + else + Date.current.beginning_of_month + 2.years + end + end end def period @@ -121,11 +130,11 @@ class Budget < ApplicationRecord if family.uses_custom_month_start? I18n.t( "budgets.name.custom_range", - start: start_date.strftime("%b %d"), - end_date: end_date.strftime("%b %d, %Y") + start: I18n.l(start_date, format: :short), + end_date: I18n.l(end_date, format: :long) ) else - I18n.t("budgets.name.month_year", month: start_date.strftime("%B %Y")) + I18n.t("budgets.name.month_year", month: I18n.l(start_date, format: :month_year)) end end @@ -188,8 +197,6 @@ class Budget < ApplicationRecord end def next_budget_param - return nil if current? - next_date = start_date + 1.month return nil unless self.class.budget_date_valid?(next_date, family: family) @@ -202,7 +209,7 @@ class Budget < ApplicationRecord # Continuous gray segment for empty budgets return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: unused_segment_id } ] unless allocations_valid? - segments = budget_categories.map do |bc| + segments = budget_categories.reject(&:subcategory?).map do |bc| { color: bc.category.color, amount: budget_category_actual_spending(bc), id: bc.id } end diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb index 764e3d744..312db666a 100644 --- a/app/models/budget_category.rb +++ b/app/models/budget_category.rb @@ -162,6 +162,35 @@ class BudgetCategory < ApplicationRecord available_to_spend.negative? end + def budgeted? + display_budgeted_spending.to_d.positive? + end + + def unbudgeted_with_spending? + !budgeted? && actual_spending.to_d.positive? + end + + def over_budget_with_budget? + budgeted? && over_budget? + end + + def on_track? + budgeted? && !over_budget? + end + + def any_over_budget? + unbudgeted_with_spending? || over_budget_with_budget? + end + + def visible_on_track? + return false unless on_track? + + # Subcategories inheriting parent budget are hidden until they have spending. + return true unless subcategory? && inherits_parent_budget? + + actual_spending.to_d.positive? + end + def near_limit? !over_budget? && percent_of_budget_spent >= 90 end diff --git a/app/models/category.rb b/app/models/category.rb index 39a740f7d..2eb9ed736 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -197,28 +197,28 @@ class Category < ApplicationRecord private def default_categories [ - [ "Income", "#22c55e", "circle-dollar-sign" ], - [ "Food & Drink", "#f97316", "utensils" ], - [ "Groceries", "#407706", "shopping-bag" ], - [ "Shopping", "#3b82f6", "shopping-cart" ], - [ "Transportation", "#0ea5e9", "bus" ], - [ "Travel", "#2563eb", "plane" ], - [ "Entertainment", "#a855f7", "drama" ], - [ "Healthcare", "#4da568", "pill" ], - [ "Personal Care", "#14b8a6", "scissors" ], - [ "Home Improvement", "#d97706", "hammer" ], - [ "Mortgage / Rent", "#b45309", "home" ], - [ "Utilities", "#eab308", "lightbulb" ], - [ "Subscriptions", "#6366f1", "wifi" ], - [ "Insurance", "#0284c7", "shield" ], - [ "Sports & Fitness", "#10b981", "dumbbell" ], - [ "Gifts & Donations", "#61c9ea", "hand-helping" ], - [ "Taxes", "#dc2626", "landmark" ], - [ "Loan Payments", "#e11d48", "credit-card" ], - [ "Services", "#7c3aed", "briefcase" ], - [ "Fees", "#6b7280", "receipt" ], - [ "Savings & Investments", "#059669", "piggy-bank" ], - [ investment_contributions_name, "#0d9488", "trending-up" ] + [ I18n.t("models.category.defaults.income"), "#22c55e", "circle-dollar-sign" ], + [ I18n.t("models.category.defaults.food_and_drink"), "#f97316", "utensils" ], + [ I18n.t("models.category.defaults.groceries"), "#407706", "shopping-bag" ], + [ I18n.t("models.category.defaults.shopping"), "#3b82f6", "shopping-cart" ], + [ I18n.t("models.category.defaults.transportation"), "#0ea5e9", "bus" ], + [ I18n.t("models.category.defaults.travel"), "#2563eb", "plane" ], + [ I18n.t("models.category.defaults.entertainment"), "#a855f7", "drama" ], + [ I18n.t("models.category.defaults.healthcare"), "#4da568", "pill" ], + [ I18n.t("models.category.defaults.personal_care"), "#14b8a6", "scissors" ], + [ I18n.t("models.category.defaults.home_improvement"), "#d97706", "hammer" ], + [ I18n.t("models.category.defaults.mortgage_rent"), "#b45309", "home" ], + [ I18n.t("models.category.defaults.utilities"), "#eab308", "lightbulb" ], + [ I18n.t("models.category.defaults.subscriptions"), "#6366f1", "wifi" ], + [ I18n.t("models.category.defaults.insurance"), "#0284c7", "shield" ], + [ I18n.t("models.category.defaults.sports_and_fitness"), "#10b981", "dumbbell" ], + [ I18n.t("models.category.defaults.gifts_and_donations"), "#61c9ea", "hand-helping" ], + [ I18n.t("models.category.defaults.taxes"), "#dc2626", "landmark" ], + [ I18n.t("models.category.defaults.loan_payments"), "#e11d48", "credit-card" ], + [ I18n.t("models.category.defaults.services"), "#7c3aed", "briefcase" ], + [ I18n.t("models.category.defaults.fees"), "#6b7280", "receipt" ], + [ I18n.t("models.category.defaults.savings_and_investments"), "#059669", "piggy-bank" ], + [ investment_contributions_name, "#0d9488", "trending-up" ] ] end end diff --git a/app/models/category_import.rb b/app/models/category_import.rb index f58a50b70..1bf4564bb 100644 --- a/app/models/category_import.rb +++ b/app/models/category_import.rb @@ -17,7 +17,7 @@ class CategoryImport < Import parent = ensure_placeholder_category(row.category_parent) if parent && parent == category - errors.add(:base, "Category '#{category.name}' cannot be its own parent") + errors.add(:base, :own_parent, name: category.name) raise ActiveRecord::RecordInvalid.new(self) end @@ -65,8 +65,9 @@ class CategoryImport < Import parent_header = header_for("parent_category", "parent category") icon_header = header_for("lucide_icon", "lucide icon", "icon") - csv_rows.each do |row| + csv_rows.each.with_index(1) do |row, index| rows.create!( + source_row_number: index, name: row[name_header].to_s.strip, category_color: row[color_header].to_s.strip, category_parent: row[parent_header].to_s.strip, @@ -81,7 +82,7 @@ class CategoryImport < Import missing_headers = required_column_keys.map(&:to_s).reject { |key| header_for(key).present? } return if missing_headers.empty? - errors.add(:base, "Missing required columns: #{missing_headers.join(', ')}") + errors.add(:base, :missing_columns, columns: missing_headers.join(", ")) raise ActiveRecord::RecordInvalid.new(self) end diff --git a/app/models/chat.rb b/app/models/chat.rb index d47dcccac..1198ee4be 100644 --- a/app/models/chat.rb +++ b/app/models/chat.rb @@ -1,6 +1,32 @@ class Chat < ApplicationRecord include Debuggable + RATE_LIMIT_PATTERNS = [ + /\b429\b/i, + /rate limit/i, + /too many requests/i, + /quota exceeded/i + ].freeze + + TEMPORARY_PROVIDER_PATTERNS = [ + /\b5\d\d\b/i, + /service unavailable/i, + /temporarily unavailable/i, + /gateway timeout/i, + /bad gateway/i, + /overloaded/i, + /time(?:out|d?\s*out)/i, + /connection reset/i + ].freeze + + AUTH_CONFIGURATION_PATTERNS = [ + /unauthorized/i, + /authentication/i, + /invalid api key/i, + /incorrect api key/i, + /access token/i + ].freeze + belongs_to :user has_one :viewer, class_name: "User", foreign_key: :last_viewed_chat_id, dependent: :nullify # "Last chat user has viewed" @@ -26,9 +52,9 @@ class Chat < ApplicationRecord end # Returns the default AI model to use for chats - # Priority: ENV variable > Setting > OpenAI default + # Priority: AI Config > Setting def default_model - ENV["OPENAI_MODEL"].presence || Setting.openai_model.presence || Provider::Openai::DEFAULT_MODEL + Provider::Openai.effective_model.presence || Setting.openai_model end end @@ -52,29 +78,97 @@ class Chat < ApplicationRecord end def add_error(e) - update! error: e.to_json - broadcast_append target: "messages", partial: "chats/error", locals: { chat: self } + update!(error: build_error_payload(e).to_json) + broadcast_append target: messages_target, partial: "chats/error", locals: { chat: self } + end + + def presentable_error_message + return nil if error.blank? + parsed_error_payload["message"].presence || classify_error_message(error) + end + + def technical_error_message + parsed_error_payload["technical_message"].presence || parsed_legacy_error_message || error end def clear_error update! error: nil - broadcast_remove target: "chat-error" - end - - def assistant - @assistant ||= Assistant.for_chat(self) - end - - def ask_assistant_later(message) - clear_error - AssistantResponseJob.perform_later(message) - end - - def ask_assistant(message) - assistant.respond_to(message) + broadcast_remove target: error_target end def conversation_messages messages.where(type: [ "UserMessage", "AssistantMessage" ]) end + + def messages_target + ActionView::RecordIdentifier.dom_id(self, :messages) + end + + def error_target + ActionView::RecordIdentifier.dom_id(self, :chat_error) + end + + def ask_assistant_later(message) + clear_error + pending = messages.create!(type: "AssistantMessage", content: "", ai_model: message.ai_model, status: :pending) + AssistantResponseJob.perform_later(message, pending) + end + + def ask_assistant(message, assistant_message: nil) + assistant.respond_to(message, assistant_message: assistant_message) + end + + private + + def build_error_payload(error) + technical_message = error_message_for(error) + + { + message: classify_error_message(technical_message), + technical_message: technical_message, + type: error.class.name + } + end + + def classify_error_message(message) + normalized_message = message.to_s.strip + return I18n.t("chat.errors.default") if normalized_message.blank? + + if RATE_LIMIT_PATTERNS.any? { |pattern| normalized_message.match?(pattern) } + I18n.t("chat.errors.rate_limited") + elsif TEMPORARY_PROVIDER_PATTERNS.any? { |pattern| normalized_message.match?(pattern) } + I18n.t("chat.errors.temporarily_unavailable") + elsif AUTH_CONFIGURATION_PATTERNS.any? { |pattern| normalized_message.match?(pattern) } + I18n.t("chat.errors.misconfigured") + else + I18n.t("chat.errors.default") + end + end + + def parsed_error_payload + return {} if error.blank? + return error if error.is_a?(Hash) + + parsed = JSON.parse(error) + parsed.is_a?(Hash) ? parsed : {} + rescue JSON::ParserError, TypeError + {} + end + + def error_message_for(error) + error.respond_to?(:message) ? error.message.to_s : error.to_s + rescue StandardError + "" + end + + def parsed_legacy_error_message + parsed = JSON.parse(error) + parsed.is_a?(String) ? parsed : nil + rescue JSON::ParserError, TypeError + nil + end + + def assistant + @assistant ||= Assistant.for_chat(self) + end end diff --git a/app/models/coinstats_account/source_classification.rb b/app/models/coinstats_account/source_classification.rb index 97f867d4a..a3e2ed520 100644 --- a/app/models/coinstats_account/source_classification.rb +++ b/app/models/coinstats_account/source_classification.rb @@ -3,9 +3,15 @@ module CoinstatsAccount::SourceClassification def wallet_source? payload = raw_payload.to_h.with_indifferent_access + return false if payload[:source] == "defi" payload[:source] == "wallet" || (payload[:address].present? && payload[:blockchain].present?) end + def defi_source? + payload = raw_payload.to_h.with_indifferent_access + payload[:source] == "defi" + end + def exchange_source? exchange_source_for?(raw_payload) end diff --git a/app/models/coinstats_item/defi_account_manager.rb b/app/models/coinstats_item/defi_account_manager.rb new file mode 100644 index 000000000..a5962608f --- /dev/null +++ b/app/models/coinstats_item/defi_account_manager.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +# Manages DeFi/staking accounts for a CoinStats wallet connection. +# Discovers staking, LP, and yield farming positions via the CoinStats DeFi API +# and keeps the corresponding CoinstatsAccounts up to date. +class CoinstatsItem::DefiAccountManager + attr_reader :coinstats_item + + def initialize(coinstats_item) + @coinstats_item = coinstats_item + end + + # Fetches DeFi positions for the given wallet and creates/updates CoinstatsAccounts. + # Positions that disappear from the API (fully unstaked) are zeroed out. + # Returns true on success, false on failure. + def sync_wallet!(address:, blockchain:, provider:) + normalized_address = address.to_s.downcase + normalized_blockchain = blockchain.to_s.downcase + + response = provider.get_wallet_defi(address: address, connection_id: blockchain) + unless response.success? + Rails.logger.warn "CoinstatsItem::DefiAccountManager - DeFi fetch failed for #{normalized_blockchain}:#{normalized_address}" + return false + end + + defi_data = response.data.to_h.with_indifferent_access + protocols = Array(defi_data[:protocols]) + active_defi_ids = [] + had_upsert_failures = false + + protocols.each do |protocol| + protocol = protocol.with_indifferent_access + + Array(protocol[:investments]).each do |investment| + investment = investment.with_indifferent_access + + Array(investment[:assets]).each do |asset| + asset = asset.with_indifferent_access + next if asset[:amount].to_f.zero? + next if asset[:coinId].blank? && asset[:symbol].blank? + + account_id = build_account_id(protocol, investment, asset, blockchain: normalized_blockchain) + + if upsert_account!(address: normalized_address, blockchain: normalized_blockchain, protocol: protocol, investment: investment, asset: asset, account_id: account_id) + active_defi_ids << account_id + else + had_upsert_failures = true + end + end + end + end + + # Skip zero-out when upserts failed — active_defi_ids is incomplete and we'd risk + # zeroing accounts that are still active but failed to save this cycle. + return false if had_upsert_failures + + zero_out_inactive_accounts!(normalized_address, normalized_blockchain, active_defi_ids) + true + rescue => e + Rails.logger.warn "CoinstatsItem::DefiAccountManager - Sync failed for #{blockchain}:#{address}: #{e.message}" + false + end + + # Creates the local Account for a DeFi CoinstatsAccount if it doesn't exist yet. + def ensure_local_account!(coinstats_account) + return false if coinstats_account.account.present? + + account = Account.create_and_sync({ + family: coinstats_item.family, + name: coinstats_account.name, + balance: coinstats_account.current_balance || 0, + cash_balance: 0, + currency: coinstats_account.currency, + accountable_type: "Crypto", + accountable_attributes: { + subtype: "wallet", + tax_treatment: "taxable" + } + }, skip_initial_sync: true) + + AccountProvider.create!(account: account, provider: coinstats_account) + true + rescue ActiveRecord::RecordNotUnique + # Another concurrent sync created the AccountProvider; destroy the orphaned Account we just created. + account&.destroy + false + end + + private + + # Builds a stable, unique account_id for a DeFi asset position. + # Format: "defi:::::" + # Blockchain is included to avoid collisions when the same wallet address exists on + # multiple EVM-compatible chains (e.g. Ethereum and Polygon). + def build_account_id(protocol, investment, asset, blockchain:) + chain = blockchain.to_s.downcase.gsub(/\s+/, "_").presence || "unknown" + protocol_id = protocol[:id].to_s.downcase.gsub(/\s+/, "_").presence || "unknown" + coin_id = (asset[:coinId] || asset[:symbol]).to_s.downcase + title = asset[:title].to_s.downcase.gsub(/\s+/, "_").presence || "position" + investment_type = investment[:name].to_s.downcase.gsub(/\s+/, "_").presence + parts = [ "defi", chain, protocol_id, coin_id, title ] + parts.insert(3, investment_type) if investment_type.present? + parts.join(":") + end + + def build_account_name(protocol, asset) + protocol_name = protocol[:name].to_s + symbol = asset[:symbol].to_s.upcase + + case asset[:title].to_s.downcase + when "deposit", "supplied" + "#{symbol} (#{protocol_name} Staking)" + when "reward", "yield" + "#{symbol} (#{protocol_name} Rewards)" + else + label = asset[:title].to_s.presence || "Position" + "#{symbol} (#{protocol_name} #{label})" + end + end + + # Returns true on success, false on failure (so the caller can track active positions correctly). + def upsert_account!(address:, blockchain:, protocol:, investment:, asset:, account_id:) + coinstats_account = coinstats_item.coinstats_accounts.find_or_initialize_by( + account_id: account_id, + wallet_address: address + ) + + # The DeFi API returns asset.price as a TotalValueDto (total position value, not per-token price). + # Store it as `balance` so inferred_current_balance uses it directly instead of quantity * price. + # Guard against a missing USD key falling back to the whole hash (which would raise on .to_f). + total_balance_usd = if asset[:price].is_a?(Hash) + price_hash = asset[:price].with_indifferent_access + (price_hash[:USD] || price_hash["USD"] || 0).to_f + else + asset[:price].to_f + end + + # Convert the USD balance to the family's base currency for consistent portfolio reporting. + # convert_usd_balance returns the actual currency used — it may fall back to "USD" if the + # exchange rate is unavailable, so we use the returned currency rather than assuming success. + balance, actual_currency = convert_usd_balance(total_balance_usd, family_currency) + quantity = asset[:amount].to_f + per_token_price = quantity > 0 ? balance / quantity : 0 + + snapshot = { + source: "defi", + id: account_id, + address: address, + blockchain: blockchain, + protocol_id: protocol[:id], + protocol_name: protocol[:name], + protocol_logo: protocol[:logo], + investment_type: investment[:name], + coinId: asset[:coinId], + symbol: asset[:symbol], + name: asset[:symbol].to_s.upcase, + amount: asset[:amount], + balance: balance, + priceUsd: per_token_price, + asset_title: asset[:title], + currency: actual_currency, + institution_logo: protocol[:logo] + }.compact + + coinstats_account.name = build_account_name(protocol, asset) unless coinstats_account.persisted? + coinstats_account.currency = actual_currency + coinstats_account.raw_payload = snapshot + coinstats_account.current_balance = coinstats_account.inferred_current_balance(snapshot) + coinstats_account.institution_metadata = { logo: protocol[:logo] }.compact + coinstats_account.save! + + ensure_local_account!(coinstats_account) + true + rescue => e + Rails.logger.warn "CoinstatsItem::DefiAccountManager - Failed to upsert account #{account_id}: #{e.message}" + false + end + + # Sets balance to zero for DeFi accounts no longer present in the API response. + def zero_out_inactive_accounts!(address, blockchain, active_defi_ids) + coinstats_item.coinstats_accounts.where(wallet_address: address).each do |account| + raw = account.raw_payload.to_h.with_indifferent_access + next unless raw[:source] == "defi" + next unless raw[:blockchain].to_s.casecmp?(blockchain.to_s) + next if active_defi_ids.include?(account.account_id) + + account.update!(current_balance: 0, raw_payload: raw.merge(amount: 0, balance: 0, priceUsd: 0)) + end + end + + def family_currency + coinstats_item.family.currency.presence || "USD" + end + + # Converts a USD amount to the target currency using Money exchange rates. + # Returns [amount, currency] so the caller always knows what currency the amount is in. + # Falls back to [usd_amount, "USD"] if conversion is unavailable. + def convert_usd_balance(usd_amount, target_currency) + return [ usd_amount, "USD" ] if target_currency == "USD" || usd_amount.zero? + + [ Money.new(usd_amount, "USD").exchange_to(target_currency).amount, target_currency ] + rescue => e + Rails.logger.warn "CoinstatsItem::DefiAccountManager - FX conversion USD->#{target_currency} failed: #{e.message}" + [ usd_amount, "USD" ] + end +end diff --git a/app/models/coinstats_item/importer.rb b/app/models/coinstats_item/importer.rb index 9f48dd71a..b3e35250d 100644 --- a/app/models/coinstats_item/importer.rb +++ b/app/models/coinstats_item/importer.rb @@ -38,6 +38,9 @@ class CoinstatsItem::Importer wallet_accounts = linked_accounts.select(&:wallet_source?) exchange_accounts = linked_accounts.select(&:exchange_source?) + failed_defi_wallet_keys = sync_defi_for_wallets!(wallet_accounts) + accounts_failed += failed_defi_wallet_keys.size + bulk_balance_data = fetch_balances_for_accounts(wallet_accounts) bulk_transactions_data = fetch_transactions_for_accounts(wallet_accounts) portfolio_coins_data = fetch_portfolio_coins_for_exchange(exchange_accounts) @@ -52,6 +55,12 @@ class CoinstatsItem::Importer portfolio_coins_data: portfolio_coins_data, portfolio_transactions_data: portfolio_transactions_data ) + elsif coinstats_account.defi_source? + # DeFi/staking accounts are kept up to date by sync_defi_for_wallets! above. + # Mark as failed if the wallet sync for this account's address didn't succeed. + raw = coinstats_account.raw_payload.to_h.with_indifferent_access + wallet_key = "#{raw[:blockchain]}:#{raw[:address]}".downcase + { success: !failed_defi_wallet_keys.include?(wallet_key), transactions_count: 0 } else update_wallet_account( coinstats_account, @@ -576,4 +585,36 @@ class CoinstatsItem::Importer def family_currency coinstats_item.family.currency.presence || "USD" end + + # Syncs DeFi/staking positions for all unique wallet addresses by delegating + # to DefiAccountManager, which owns discovery, upsert, and zero-out logic. + # Returns a Set of "blockchain:address" keys for wallets that failed to sync, + # so the caller can accurately mark individual DeFi accounts as failed. + def sync_defi_for_wallets!(wallet_accounts) + # Include existing DeFi accounts as address sources so staking positions stay + # current even if the parent wallet account has been removed. + defi_accounts = coinstats_item.coinstats_accounts.joins(:account_provider).select(&:defi_source?) + + unique_wallets = (wallet_accounts + defi_accounts).uniq(&:id).filter_map do |account| + raw = account.raw_payload.to_h.with_indifferent_access + next unless raw[:address].present? && raw[:blockchain].present? + + { address: raw[:address], blockchain: raw[:blockchain] } + end.uniq { |w| [ w[:address].downcase, w[:blockchain].downcase ] } + + return Set.new if unique_wallets.empty? + + manager = CoinstatsItem::DefiAccountManager.new(coinstats_item) + failed_wallet_keys = Set.new + + unique_wallets.each do |wallet| + result = manager.sync_wallet!(address: wallet[:address], blockchain: wallet[:blockchain], provider: coinstats_provider) + failed_wallet_keys << "#{wallet[:blockchain]}:#{wallet[:address]}".downcase unless result + end + + failed_wallet_keys + rescue => e + Rails.logger.warn "CoinstatsItem::Importer - DeFi sync failed: #{e.message}" + Set.new + end end diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index 9324e34ab..f008fea5e 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -58,8 +58,29 @@ module Accountable classification == "asset" ? "up" : "down" end + def singular_display_name + I18n.t("accounts.types.#{name.underscore}", default: legacy_singular_display_name) + end + def display_name - self.name.pluralize.titleize + I18n.t("accounts.types_plural.#{name.underscore}", default: -> { legacy_display_name }) + end + + def legacy_display_name + return singular_display_name if name.in?([ "Depository", "Crypto" ]) + + singular_display_name.pluralize + end + + def legacy_singular_display_name + case name + when "Depository" + "Cash" + when "Crypto" + "Crypto" + else + name.underscore.humanize + end end # Sums the balances of all active accounts of this type, converting foreign currencies to the family's currency. @@ -80,6 +101,10 @@ module Accountable end end + def singular_display_name + self.class.singular_display_name + end + def display_name self.class.display_name end diff --git a/app/models/concerns/encryptable.rb b/app/models/concerns/encryptable.rb index 0ec5ae923..a04c773de 100644 --- a/app/models/concerns/encryptable.rb +++ b/app/models/concerns/encryptable.rb @@ -6,11 +6,7 @@ module Encryptable # This allows encryption to be optional - if not configured, sensitive fields # are stored in plaintext (useful for development or legacy deployments). def encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready + ActiveRecordEncryptionConfig.explicitly_configured? end end end diff --git a/app/models/concerns/monetizable.rb b/app/models/concerns/monetizable.rb index e80fcb5f6..b5eb83886 100644 --- a/app/models/concerns/monetizable.rb +++ b/app/models/concerns/monetizable.rb @@ -7,7 +7,7 @@ module Monetizable define_method("#{field}_money") do |**args| value = self.send(field, **args) - return nil if value.nil? || monetizable_currency.nil? + return nil if value.blank? || monetizable_currency.nil? Money.new(value, monetizable_currency) end diff --git a/app/models/credit_card.rb b/app/models/credit_card.rb index 05bf7746a..4f42beaee 100644 --- a/app/models/credit_card.rb +++ b/app/models/credit_card.rb @@ -1,6 +1,8 @@ class CreditCard < ApplicationRecord include Accountable + DEFAULT_SUBTYPE = "credit_card" + SUBTYPES = { "credit_card" => { short: "Credit Card", long: "Credit Card" } }.freeze diff --git a/app/models/crypto.rb b/app/models/crypto.rb index 6f3f5c0cd..fdff70c74 100644 --- a/app/models/crypto.rb +++ b/app/models/crypto.rb @@ -34,9 +34,5 @@ class Crypto < ApplicationRecord def icon "bitcoin" end - - def display_name - "Crypto" - end end end diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index 5c720cc1f..d14b47eb5 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -1,5 +1,19 @@ class DataEnrichment < ApplicationRecord belongs_to :enrichable, polymorphic: true - enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital" } + enum :source, { + rule: "rule", + plaid: "plaid", + simplefin: "simplefin", + lunchflow: "lunchflow", + synth: "synth", + ai: "ai", + enable_banking: "enable_banking", + coinstats: "coinstats", + mercury: "mercury", + brex: "brex", + indexa_capital: "indexa_capital", + sophtron: "sophtron", + ibkr: "ibkr" + } end diff --git a/app/models/debug_log_entry.rb b/app/models/debug_log_entry.rb new file mode 100644 index 000000000..fa2949b89 --- /dev/null +++ b/app/models/debug_log_entry.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class DebugLogEntry < ApplicationRecord + LEVELS = %w[debug info warn error].freeze + + belongs_to :family, optional: true + belongs_to :account, optional: true + belongs_to :user, optional: true + belongs_to :account_provider, optional: true + + validates :category, :level, :message, :source, presence: true + validates :level, inclusion: { in: LEVELS } + + scope :recent, -> { order(created_at: :desc) } + scope :with_category, ->(category) { category.present? ? where(category: category) : all } + scope :with_level, ->(level) { level.present? ? where(level: level) : all } + scope :with_source, ->(source) { source.present? ? where(source: source) : all } + scope :with_provider_key, ->(provider_key) { provider_key.present? ? where(provider_key: provider_key) : all } + + class << self + def log!(category:, level:, message:, source:, metadata: {}, family: nil, family_id: nil, + account: nil, account_id: nil, user: nil, user_id: nil, + account_provider: nil, account_provider_id: nil, provider_key: nil, provider: nil) + create!( + category:, + level:, + message:, + source:, + metadata: normalize_metadata(metadata), + family: resolve_family(family, family_id, account, account_id, user, user_id, account_provider, account_provider_id), + account: resolve_account(account, account_id, account_provider, account_provider_id), + user: resolve_user(user, user_id), + account_provider: resolve_account_provider(account_provider, account_provider_id), + provider_key: normalize_provider_key(provider_key, provider) + ) + end + + def capture(...) + log!(...) + rescue => e + Rails.logger.error("DebugLogEntry.capture failed: #{e.class}: #{e.message}") + nil + end + + private + def normalize_metadata(metadata) + return {} if metadata.blank? + return metadata.deep_stringify_keys if metadata.respond_to?(:deep_stringify_keys) + + { value: metadata.to_s } + end + + def normalize_provider_key(provider_key, provider) + return provider_key.to_s if provider_key.present? + return if provider.blank? + + provider_name = provider.is_a?(String) || provider.is_a?(Symbol) ? provider.to_s : provider.class.name.demodulize + provider_name.to_s.underscore + end + + def resolve_family(family, family_id, account, account_id, user, user_id, account_provider, account_provider_id) + family || + find_record(Family, family_id) || + resolve_account(account, account_id, account_provider, account_provider_id)&.family || + resolve_user(user, user_id)&.family + end + + def resolve_account(account, account_id, account_provider, account_provider_id) + account || + find_record(Account, account_id) || + resolve_account_provider(account_provider, account_provider_id)&.account + end + + def resolve_user(user, user_id) + user || find_record(User, user_id) + end + + def resolve_account_provider(account_provider, account_provider_id) + account_provider || find_record(AccountProvider, account_provider_id) + end + + def find_record(klass, id) + return if id.blank? + + klass.find_by(id: id) + end + end +end diff --git a/app/models/depository.rb b/app/models/depository.rb index b788a6d4e..0ebeda390 100644 --- a/app/models/depository.rb +++ b/app/models/depository.rb @@ -1,6 +1,8 @@ class Depository < ApplicationRecord include Accountable + DEFAULT_SUBTYPE = "checking" + SUBTYPES = { "checking" => { short: "Checking", long: "Checking" }, "savings" => { short: "Savings", long: "Savings" }, @@ -10,10 +12,6 @@ class Depository < ApplicationRecord }.freeze class << self - def display_name - "Cash" - end - def color "#875BF7" end diff --git a/app/models/enable_banking_account.rb b/app/models/enable_banking_account.rb index a94815d0e..91bf07539 100644 --- a/app/models/enable_banking_account.rb +++ b/app/models/enable_banking_account.rb @@ -62,38 +62,65 @@ class EnableBankingAccount < ApplicationRecord type_mappings[account_type.upcase] || account_type.titleize end + CASH_ACCOUNT_TYPE_MAP = { + "CACC" => { type: "Depository", subtype: "checking" }, + "SVGS" => { type: "Depository", subtype: "savings" }, + "CARD" => { type: "CreditCard", subtype: "credit_card" }, + "CRCD" => { type: "CreditCard", subtype: "credit_card" }, + "LOAN" => { type: "Loan", subtype: nil }, + "MORT" => { type: "Loan", subtype: "mortgage" }, + "ODFT" => { type: "Depository", subtype: "checking" }, + "TRAN" => { type: "Depository", subtype: "checking" }, + "SALA" => { type: "Depository", subtype: "checking" }, + "MOMA" => { type: "Depository", subtype: "savings" }, + "NREX" => { type: "Depository", subtype: "checking" }, + "TAXE" => { type: "Depository", subtype: "checking" }, + "TRAS" => { type: "Depository", subtype: "checking" }, + "ONDP" => { type: "Depository", subtype: "savings" }, + "CASH" => { type: "Depository", subtype: "checking" }, + "OTHR" => nil + }.freeze + + def suggested_account_type + CASH_ACCOUNT_TYPE_MAP[account_type&.upcase]&.dig(:type) + end + + def suggested_subtype + CASH_ACCOUNT_TYPE_MAP[account_type&.upcase]&.dig(:subtype) + end + def upsert_enable_banking_snapshot!(account_snapshot) - # Convert to symbol keys or handle both string and symbol keys snapshot = account_snapshot.with_indifferent_access - # Map Enable Banking field names to our field names - # Enable Banking API returns: { uid, iban, account_id: { iban }, currency, cash_account_type, ... } - # account_id can be a hash with iban, or an array of account identifiers raw_account_id = snapshot[:account_id] account_id_data = if raw_account_id.is_a?(Hash) raw_account_id elsif raw_account_id.is_a?(Array) && raw_account_id.first.is_a?(Hash) - # If it's an array of hashes, find the one with iban raw_account_id.find { |item| item[:iban].present? } || {} else {} end + credit_limit_amount = snapshot.dig(:credit_limit, :amount) + update!( - current_balance: nil, # Balance fetched separately via /accounts/{uid}/balances + current_balance: nil, currency: parse_currency(snapshot[:currency]) || "EUR", name: build_account_name(snapshot), - # account_id stores the API UUID for fetching balances/transactions account_id: snapshot[:uid], - # uid is the stable identifier (identification_hash) for matching accounts across sessions uid: snapshot[:identification_hash] || snapshot[:uid], iban: account_id_data[:iban] || snapshot[:iban], account_type: snapshot[:cash_account_type] || snapshot[:account_type], account_status: "active", provider: "enable_banking", + product: snapshot[:product], + credit_limit: parse_decimal_safe(credit_limit_amount), + identification_hashes: snapshot[:identification_hashes] || [], institution_metadata: { name: enable_banking_item&.aspsp_name, - aspsp_name: enable_banking_item&.aspsp_name + aspsp_name: enable_banking_item&.aspsp_name, + bic: snapshot.dig(:account_servicer, :bic_fi), + servicer_name: snapshot.dig(:account_servicer, :name) }.compact, raw_payload: account_snapshot ) @@ -134,4 +161,11 @@ class EnableBankingAccount < ApplicationRecord def log_invalid_currency(currency_value) Rails.logger.warn("Invalid currency code '#{currency_value}' for EnableBanking account #{id}, defaulting to EUR") end + + def parse_decimal_safe(value) + return nil if value.blank? + BigDecimal(value.to_s) + rescue ArgumentError, TypeError + nil + end end diff --git a/app/models/enable_banking_account/processor.rb b/app/models/enable_banking_account/processor.rb index ada55916f..a6b546f0a 100644 --- a/app/models/enable_banking_account/processor.rb +++ b/app/models/enable_banking_account/processor.rb @@ -1,4 +1,5 @@ class EnableBankingAccount::Processor + class ProcessingError < StandardError; end include CurrencyNormalizable attr_reader :enable_banking_account @@ -37,23 +38,53 @@ class EnableBankingAccount::Processor account = enable_banking_account.current_account balance = enable_banking_account.current_balance || 0 + available_credit = nil - # For credit cards, compute balance based on credit limit - if account.accountable_type == "CreditCard" - available_credit = account.accountable.available_credit || 0 - balance = available_credit - balance - # For liability accounts, ensure positive balances - elsif account.accountable_type == "Loan" - balance = -balance + # For liability accounts, ensure balance sign is correct. + # DELIBERATE UX DECISION: For CreditCards, we display the available credit (credit_limit - outstanding debt) + # rather than the raw outstanding debt. Do not revert this behavior, as future maintainers should understand + # users expect to see how much credit they have left rather than their debt balance. + # The 'available_credit' calculation overrides the 'balance' variable. + if account.accountable_type == "Loan" + balance = balance.abs + elsif account.accountable_type == "CreditCard" + if enable_banking_account.credit_limit.present? + available = enable_banking_account.credit_limit - balance.abs + available_credit = [ available, 0 ].max + balance = available_credit + unless account.accountable.present? + Rails.logger.warn "EnableBankingAccount::Processor - CreditCard accountable missing for account #{account.id}" + end + elsif account.accountable&.available_credit.present? + # Fallback: no credit_limit from API — compute it using available_credit defined at account level + Rails.logger.info "Using stored available_credit fallback for account #{account.id}" + available_credit = account.accountable.available_credit + outstanding = balance.abs + balance = [ available_credit - outstanding, 0 ].max + else + # Fallback: no credit_limit from API — display raw outstanding balance + # We cannot derive available credit without knowing the limit; leave balance unchanged. + end end currency = parse_currency(enable_banking_account.currency) || account.currency || "EUR" - account.update!( - balance: balance, - cash_balance: balance, - currency: currency - ) + # Wrap both writes in a transaction so a failure on either rolls back both. + ActiveRecord::Base.transaction do + if account.accountable.present? && account.accountable.respond_to?(:available_credit=) + account.accountable.update!(available_credit: available_credit) + end + account.update!(currency: currency, cash_balance: balance) + + # Use set_current_balance to create a current_anchor valuation entry. + # This enables Balance::ReverseCalculator, which works backward from the + # bank-reported balance — eliminating spurious cash adjustment spikes. + result = account.set_current_balance(balance) + raise ProcessingError, "Failed to set current balance: #{result.error}" unless result.success? + end + + # TODO: pass explicit window_start_date to sync_later to avoid full history recalculation on every sync + # Currently relies on set_current_balance's implicit sync trigger; window params would require refactor end def process_transactions diff --git a/app/models/enable_banking_account/transactions/processor.rb b/app/models/enable_banking_account/transactions/processor.rb index 9791ad96f..2809c6f9e 100644 --- a/app/models/enable_banking_account/transactions/processor.rb +++ b/app/models/enable_banking_account/transactions/processor.rb @@ -15,14 +15,77 @@ class EnableBankingAccount::Transactions::Processor Rails.logger.info "EnableBankingAccount::Transactions::Processor - Processing #{total_count} transactions for enable_banking_account #{enable_banking_account.id}" imported_count = 0 + skipped_count = 0 failed_count = 0 errors = [] + shared_adapter = if enable_banking_account.current_account.present? + Account::ProviderImportAdapter.new(enable_banking_account.current_account) + end + + # Pre-fetch external_ids that must not be re-imported. + # One query per category per sync; O(1) Set lookup per transaction — avoids N+1. + excluded_ids = if enable_banking_account.current_account + account_id = enable_banking_account.current_account.id + + # 1. Manually merged: pending entries the user explicitly merged into a posted transaction. + # Uses a lateral join to extract merged_from_external_id from the manual_merge JSON + # (handles both Array current format and legacy Hash format via jsonb_typeof). + manually_merged_ids = Transaction.joins(:entry) + .where(entries: { account_id: account_id }) + .where("transactions.extra ? 'manual_merge'") + .joins( + Arel.sql(<<~SQL.squish) + CROSS JOIN LATERAL jsonb_array_elements( + CASE jsonb_typeof(transactions.extra->'manual_merge') + WHEN 'array' THEN transactions.extra->'manual_merge' + WHEN 'object' THEN jsonb_build_array(transactions.extra->'manual_merge') + ELSE '[]'::jsonb + END + ) AS merge_elem + SQL + ) + .pluck(Arel.sql("merge_elem->>'merged_from_external_id'")) + .compact + .to_set + + # 2. Auto-claimed: pending entries that were automatically matched to a booked transaction + # by the amount/date heuristic. Their old external_ids are stored in + # extra["auto_claimed_pending_ids"] so they are not re-imported as new pending entries + # on subsequent syncs (the stored raw payload still contains the old pending data). + auto_claimed_ids = Transaction.joins(:entry) + .where(entries: { account_id: account_id }) + .where("transactions.extra ? 'auto_claimed_pending_ids'") + .joins( + Arel.sql(<<~SQL.squish) + CROSS JOIN LATERAL jsonb_array_elements_text( + transactions.extra->'auto_claimed_pending_ids' + ) AS claimed_id + SQL + ) + .pluck(Arel.sql("claimed_id")) + .compact + .to_set + + manually_merged_ids | auto_claimed_ids + else + Set.new + end + enable_banking_account.raw_transactions_payload.each_with_index do |transaction_data, index| begin + ext_id = EnableBankingEntry::Processor.compute_external_id(transaction_data) + + if ext_id && excluded_ids.include?(ext_id) + Rails.logger.info("EnableBankingAccount::Transactions::Processor - Skipping re-import of manually merged pending transaction: #{ext_id}") + skipped_count += 1 + next + end + result = EnableBankingEntry::Processor.new( transaction_data, - enable_banking_account: enable_banking_account + enable_banking_account: enable_banking_account, + import_adapter: shared_adapter ).process if result.nil? @@ -51,6 +114,7 @@ class EnableBankingAccount::Transactions::Processor success: failed_count == 0, total: total_count, imported: imported_count, + skipped: skipped_count, failed: failed_count, errors: errors } diff --git a/app/models/enable_banking_entry/processor.rb b/app/models/enable_banking_entry/processor.rb index 643991491..4f41b21f8 100644 --- a/app/models/enable_banking_entry/processor.rb +++ b/app/models/enable_banking_entry/processor.rb @@ -10,14 +10,43 @@ class EnableBankingEntry::Processor # transaction_amount: { amount, currency }, # creditor_name, debtor_name, remittance_information, ... # } - def initialize(enable_banking_transaction, enable_banking_account:) + def self.compute_external_id(raw_transaction_data) + data = raw_transaction_data.with_indifferent_access + id = data[:transaction_id].presence || data[:entry_reference].presence + return "enable_banking_#{id}" if id + + # Some ASPSPs omit both transaction_id and entry_reference (both are optional + # in PSD2). Generate a deterministic content-based ID so these transactions + # can still be imported idempotently. Uses the same fields as the importer's + # dedup key so the two strategies stay in sync. + date = data[:booking_date].presence || data[:value_date].presence || data[:transaction_date] + amount = data.dig(:transaction_amount, :amount).presence || data[:amount] + currency = data.dig(:transaction_amount, :currency).presence || data[:currency] + direction = data[:credit_debit_indicator] + creditor = data.dig(:creditor, :name).presence || data[:creditor_name] + debtor = data.dig(:debtor, :name).presence || data[:debtor_name] + remittance = data[:remittance_information] + remittance_key = remittance.is_a?(Array) ? remittance.compact.map(&:to_s).sort.join("|") : remittance.to_s + + content = [ date, amount, currency, direction, creditor, debtor, remittance_key ].map(&:to_s).join("\x1F") + return nil if content.gsub("\x1F", "").blank? + + "enable_banking_content_#{Digest::MD5.hexdigest(content)}" + end + + def initialize(enable_banking_transaction, enable_banking_account:, import_adapter: nil) @enable_banking_transaction = enable_banking_transaction @enable_banking_account = enable_banking_account + @import_adapter = import_adapter end def process + # Cache a safe diagnostic id upfront — used in all logging paths so rescue + # blocks never call the potentially-raising private external_id method. + safe_id = self.class.compute_external_id(@enable_banking_transaction) || "unknown" + unless account.present? - Rails.logger.warn "EnableBankingEntry::Processor - No linked account for enable_banking_account #{enable_banking_account.id}, skipping transaction #{external_id}" + Rails.logger.warn "EnableBankingEntry::Processor - No linked account for enable_banking_account #{enable_banking_account.id}, skipping transaction #{safe_id}" return nil end @@ -30,16 +59,17 @@ class EnableBankingEntry::Processor name: name, source: "enable_banking", merchant: merchant, - notes: notes + notes: notes, + extra: extra ) rescue ArgumentError => e - Rails.logger.error "EnableBankingEntry::Processor - Validation error for transaction #{external_id}: #{e.message}" + Rails.logger.error "EnableBankingEntry::Processor - Validation error for transaction #{safe_id}: #{e.message}" raise rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e - Rails.logger.error "EnableBankingEntry::Processor - Failed to save transaction #{external_id}: #{e.message}" + Rails.logger.error "EnableBankingEntry::Processor - Failed to save transaction #{safe_id}: #{e.message}" raise StandardError.new("Failed to import transaction: #{e.message}") rescue => e - Rails.logger.error "EnableBankingEntry::Processor - Unexpected error processing transaction #{external_id}: #{e.class} - #{e.message}" + Rails.logger.error "EnableBankingEntry::Processor - Unexpected error processing transaction #{safe_id}: #{e.class} - #{e.message}" Rails.logger.error e.backtrace.join("\n") raise StandardError.new("Unexpected error importing transaction: #{e.message}") end @@ -62,50 +92,41 @@ class EnableBankingEntry::Processor end def external_id - id = data[:transaction_id].presence || data[:entry_reference].presence - raise ArgumentError, "Enable Banking transaction missing required field 'transaction_id'" unless id - "enable_banking_#{id}" + id = self.class.compute_external_id(data) + raise ArgumentError, "Enable Banking transaction missing required identifier (transaction_id, entry_reference, or identifiable content)" unless id + id end def name # Build name from available Enable Banking transaction fields # Priority: counterparty name > bank_transaction_code description > remittance_information - # Determine counterparty based on transaction direction - # For outgoing payments (DBIT), counterparty is the creditor (who we paid) - # For incoming payments (CRDT), counterparty is the debtor (who paid us) - counterparty = if credit_debit_indicator == "CRDT" - data.dig(:debtor, :name) || data[:debtor_name] - else - data.dig(:creditor, :name) || data[:creditor_name] - end + counterparty = counterparty_name + return counterparty if counterparty.present? && !technical_card_counterparty?(counterparty) - return counterparty if counterparty.present? + # Some institutions (e.g. Wise) use technical CARD-* identifiers as counterparties + # Prefer remittance_information first in that case since it contains the real merchant label for Wise + if technical_card_counterparty?(counterparty) + remittance = primary_remittance_information + return remittance.truncate(100) if remittance.present? + end # Fall back to bank_transaction_code description bank_tx_description = data.dig(:bank_transaction_code, :description) return bank_tx_description if bank_tx_description.present? # Fall back to remittance_information - remittance = data[:remittance_information] - return remittance.first.truncate(100) if remittance.is_a?(Array) && remittance.first.present? + remittance = primary_remittance_information + return remittance.truncate(100) if remittance.present? # Final fallback: use transaction type indicator credit_debit_indicator == "CRDT" ? "Incoming Transfer" : "Outgoing Transfer" end def merchant - # For outgoing payments (DBIT), merchant is the creditor (who we paid) - # For incoming payments (CRDT), merchant is the debtor (who paid us) - merchant_name = if credit_debit_indicator == "CRDT" - data.dig(:debtor, :name) || data[:debtor_name] - else - data.dig(:creditor, :name) || data[:creditor_name] - end - - return nil unless merchant_name.present? - - merchant_name = merchant_name.to_s.strip + # Use the counterparty when it is human readable; otherwise fall back to remittance + # for CARD-* transactions where the remittance often contains the actual merchant + merchant_name = merchant_name_candidate return nil if merchant_name.blank? merchant_id = Digest::MD5.hexdigest(merchant_name.downcase) @@ -123,10 +144,34 @@ class EnableBankingEntry::Processor end def notes - remittance = data[:remittance_information] - return nil unless remittance.is_a?(Array) && remittance.any? + parts = [] - remittance.join("\n") + remittance = data[:remittance_information] + if remittance.is_a?(Array) && remittance.any? + parts << remittance.join("\n") + elsif remittance.is_a?(String) && remittance.present? + parts << remittance + end + + parts << data[:note] if data[:note].present? + + parts.join("\n\n").presence + end + + def extra + eb = {} + + if data[:exchange_rate].present? + eb[:fx_rate] = data.dig(:exchange_rate, :exchange_rate) + eb[:fx_unit_currency] = data.dig(:exchange_rate, :unit_currency) + eb[:fx_instructed_amount] = data.dig(:exchange_rate, :instructed_amount, :amount) + end + + eb[:merchant_category_code] = data[:merchant_category_code] if data[:merchant_category_code].present? + eb[:pending] = true if data[:_pending] == true + + eb.compact! + eb.empty? ? nil : { enable_banking: eb } end def amount_value @@ -143,8 +188,9 @@ class EnableBankingEntry::Processor BigDecimal("0") end - # CRDT (credit) = money coming in = positive - # DBIT (debit) = money going out = negative + # Sure convention: positive = outflow (expense/debit from account), negative = inflow (income/credit) + # Enable Banking: DBIT = debit from account (outflow), CRDT = credit to account (inflow) + # Therefore: DBIT → +absolute_amount, CRDT → -absolute_amount credit_debit_indicator == "CRDT" ? -absolute_amount : absolute_amount rescue ArgumentError => e Rails.logger.error "Failed to parse Enable Banking transaction amount: #{raw_amount.inspect} - #{e.message}" @@ -156,10 +202,43 @@ class EnableBankingEntry::Processor data[:credit_debit_indicator] end + def counterparty_name + # Determine counterparty based on transaction direction + # For outgoing payments (DBIT), counterparty is the creditor (who we paid) + # For incoming payments (CRDT), counterparty is the debtor (who paid us) + if credit_debit_indicator == "CRDT" + data.dig(:debtor, :name).presence || data[:debtor_name].presence + else + data.dig(:creditor, :name).presence || data[:creditor_name].presence + end + end + + def technical_card_counterparty?(value) + # Some providers expose card transactions with CARD- placeholders instead of a real counterparty name + value.to_s.strip.match?(/\ACARD-\d+\z/i) + end + + def primary_remittance_information + remittance = data[:remittance_information] + Array.wrap(remittance) + .map { |value| value.to_s.strip.presence } + .compact + .first + end + + def merchant_name_candidate + counterparty = counterparty_name.to_s.strip + return counterparty if counterparty.present? && !technical_card_counterparty?(counterparty) + # For technical CARD-* counterparties, reuse remittance as the best merchant candidate + remittance = primary_remittance_information + return remittance.truncate(100, omission: "") if remittance.present? && technical_card_counterparty?(counterparty) + + nil + end + def amount - # Enable Banking uses PSD2 Berlin Group convention: negative = debit (outflow), positive = credit (inflow) - # Sure uses the same convention: negative = expense, positive = income - # Therefore, use the amount as-is from the API without inversion + # Sure convention: positive = outflow (debit/expense), negative = inflow (credit/income) + # amount_value already applies this: DBIT → +absolute, CRDT → -absolute amount_value end @@ -169,7 +248,8 @@ class EnableBankingEntry::Processor end def log_invalid_currency(currency_value) - Rails.logger.warn("Invalid currency code '#{currency_value}' in Enable Banking transaction #{external_id}, falling back to account currency") + safe_id = self.class.compute_external_id(data) || "unknown" + Rails.logger.warn("Invalid currency code '#{currency_value}' in Enable Banking transaction #{safe_id}, falling back to account currency") end def date diff --git a/app/models/enable_banking_item.rb b/app/models/enable_banking_item.rb index d1e8684c9..7f3b6a540 100644 --- a/app/models/enable_banking_item.rb +++ b/app/models/enable_banking_item.rb @@ -48,24 +48,63 @@ class EnableBankingItem < ApplicationRecord !session_valid? end + # TODO: implement data retention policy for last_psu_ip (GDPR/CCPA — nullify after session expiry or 90 days) + + validate :psu_type_in_aspsp_types + + def psu_type_in_aspsp_types + return if psu_type.blank? || aspsp_psu_types.blank? + unless aspsp_psu_types.include?(psu_type) + errors.add(:psu_type, "must be one of the ASPSP supported types") + end + end + # Start the OAuth authorization flow - # Returns a redirect URL for the user - def start_authorization(aspsp_name:, redirect_url:, state: nil) + # @param aspsp_name [String] Name of the selected ASPSP + # @param redirect_url [String] Callback URL + # @param state [String, nil] State parameter (passed through to callback) + # @param psu_type [String] "personal" or "business" + # @param aspsp_data [Hash, nil] Full ASPSP object from GET /aspsps (used to store metadata) + # @param language [String, nil] Two-letter language code + # @return [String] Redirect URL for the user + def start_authorization(aspsp_name:, redirect_url:, state: nil, psu_type: "personal", + aspsp_data: nil, language: nil) provider = enable_banking_provider raise StandardError.new("Enable Banking provider is not configured") unless provider + validated_psu_type = psu_type + + # Store ASPSP metadata before calling provider so it's available even if auth fails + if aspsp_data.present? + aspsp_data = aspsp_data.with_indifferent_access + first_auth_method = aspsp_data.dig(:auth_methods, 0) || aspsp_data.dig("auth_methods", 0) + aspsp_types = aspsp_data[:psu_types] || [] + update!( + aspsp_required_psu_headers: aspsp_data[:required_psu_headers] || [], + aspsp_maximum_consent_validity: aspsp_data[:maximum_consent_validity], + aspsp_auth_approach: first_auth_method&.dig(:approach) || first_auth_method&.dig("approach"), + aspsp_psu_types: aspsp_types + ) + validated_psu_type = psu_type.present? && aspsp_types.include?(psu_type) ? psu_type : nil + end + result = provider.start_authorization( aspsp_name: aspsp_name, aspsp_country: country_code, redirect_url: redirect_url, - state: state + state: state, + psu_type: validated_psu_type, + maximum_consent_validity: aspsp_maximum_consent_validity, + language: language ) - # Store the authorization ID for later use - update!( + attributes = { authorization_id: result[:authorization_id], aspsp_name: aspsp_name - ) + } + attributes[:psu_type] = validated_psu_type if validated_psu_type.present? + + update!(attributes) result[:url] end @@ -250,14 +289,13 @@ class EnableBankingItem < ApplicationRecord private def parse_session_expiry(session_result) - # Enable Banking sessions typically last 90 days - # The exact expiry depends on the ASPSP consent if session_result[:access].present? && session_result[:access][:valid_until].present? - Time.parse(session_result[:access][:valid_until]) + parsed = Time.zone.parse(session_result[:access][:valid_until]) + parsed || 90.days.from_now else 90.days.from_now end - rescue => e + rescue ArgumentError, TypeError => e Rails.logger.warn "EnableBankingItem #{id} - Failed to parse session expiry: #{e.message}" 90.days.from_now end diff --git a/app/models/enable_banking_item/importer.rb b/app/models/enable_banking_item/importer.rb index 9facd18d2..696b4f673 100644 --- a/app/models/enable_banking_item/importer.rb +++ b/app/models/enable_banking_item/importer.rb @@ -3,6 +3,14 @@ class EnableBankingItem::Importer # Enable Banking typically returns ~100 transactions per page, so 100 pages = ~10,000 transactions MAX_PAGINATION_PAGES = 100 + NETWORK_ERRORS = [ + ::SocketError, + ::Errno::ECONNREFUSED, + ::Timeout::Error, + ::Net::ReadTimeout, + ::Net::OpenTimeout + ].freeze + attr_reader :enable_banking_item, :enable_banking_provider def initialize(enable_banking_item, enable_banking_provider:) @@ -13,12 +21,12 @@ class EnableBankingItem::Importer def import unless enable_banking_item.session_valid? enable_banking_item.update!(status: :requires_update) - return { success: false, error: "Session expired or invalid", accounts_updated: 0, transactions_imported: 0 } + return { success: false, error: I18n.t("enable_banking_items.errors.session_invalid"), accounts_updated: 0, transactions_imported: 0 } end session_data = fetch_session_data unless session_data - error_msg = @session_error || "Failed to fetch session data" + error_msg = @session_error || I18n.t("enable_banking_items.errors.unexpected") return { success: false, error: error_msg, accounts_updated: 0, transactions_imported: 0 } end @@ -29,6 +37,8 @@ class EnableBankingItem::Importer Rails.logger.error "EnableBankingItem::Importer - Failed to store session snapshot: #{e.message}" end + sync_uids_from_accounts_data(session_data[:accounts]) + # Update accounts from session accounts_updated = 0 accounts_failed = 0 @@ -66,6 +76,7 @@ class EnableBankingItem::Importer end rescue => e accounts_failed += 1 + @sync_error = promote_session_invalid(@sync_error, handle_sync_error(e)) Rails.logger.error "EnableBankingItem::Importer - Failed to update account #{uid}: #{e.message}" end end @@ -79,16 +90,22 @@ class EnableBankingItem::Importer linked_accounts_query.each do |enable_banking_account| begin - fetch_and_update_balance(enable_banking_account) + unless fetch_and_update_balance(enable_banking_account) + transactions_failed += 1 + # @sync_error already set in fetch_and_update_balance + next + end result = fetch_and_store_transactions(enable_banking_account) if result[:success] transactions_imported += result[:transactions_count] else transactions_failed += 1 + @sync_error = promote_session_invalid(@sync_error, result[:error]) end rescue => e transactions_failed += 1 + @sync_error = promote_session_invalid(@sync_error, handle_sync_error(e)) Rails.logger.error "EnableBankingItem::Importer - Failed to process account #{enable_banking_account.uid}: #{e.message}" end end @@ -100,46 +117,48 @@ class EnableBankingItem::Importer transactions_imported: transactions_imported, transactions_failed: transactions_failed } - if !result[:success] && (accounts_failed > 0 || transactions_failed > 0) - parts = [] - parts << "#{accounts_failed} #{'account'.pluralize(accounts_failed)} failed" if accounts_failed > 0 - parts << "#{transactions_failed} #{'transaction'.pluralize(transactions_failed)} failed" if transactions_failed > 0 - result[:error] = parts.join(", ") - end + + result[:error] = @sync_error || I18n.t("enable_banking_items.errors.unexpected") if !result[:success] result end private - def extract_friendly_error_message(exception) - [ exception, exception.cause ].compact.each do |ex| - case ex - when SocketError then return "DNS resolution failed: check your network/DNS configuration" - when Net::OpenTimeout, Net::ReadTimeout then return "Connection timed out: the Enable Banking API may be unreachable" - when Errno::ECONNREFUSED then return "Connection refused: the Enable Banking API is unreachable" - end + def handle_sync_error(exception) + # Check the underlying cause first, then the exception itself + exceptions = [ exception.cause, exception ].compact + + provider_error = exceptions.find { |ex| ex.is_a?(Provider::EnableBanking::EnableBankingError) } + + # Handle session expiration status update + if provider_error && [ :unauthorized, :not_found ].include?(provider_error.error_type) + enable_banking_item.update!(status: :requires_update) + return I18n.t("enable_banking_items.errors.session_invalid") end - msg = exception.message.to_s - return "DNS resolution failed: check your network/DNS configuration" if msg.include?("getaddrinfo") || msg.match?(/name or service not known/i) - return "Connection timed out: the Enable Banking API may be unreachable" if msg.include?("execution expired") || msg.include?("timeout") || msg.match?(/timed out/i) - return "Connection refused: the Enable Banking API is unreachable" if msg.include?("ECONNREFUSED") || msg.match?(/connection refused/i) + is_network_error = exceptions.any? do |ex| + NETWORK_ERRORS.any? { |err| ex.is_a?(err) } || + (ex.is_a?(Provider::EnableBanking::EnableBankingError) && [ :request_failed, :timeout ].include?(ex.error_type)) + end - msg + if is_network_error + I18n.t("enable_banking_items.errors.network_unreachable") + elsif provider_error + I18n.t("enable_banking_items.errors.api_error") + else + I18n.t("enable_banking_items.errors.unexpected") + end end def fetch_session_data enable_banking_provider.get_session(session_id: enable_banking_item.session_id) rescue Provider::EnableBanking::EnableBankingError => e - if e.error_type == :unauthorized || e.error_type == :not_found - enable_banking_item.update!(status: :requires_update) - end Rails.logger.error "EnableBankingItem::Importer - Enable Banking API error: #{e.message}" - @session_error = extract_friendly_error_message(e) + @session_error = handle_sync_error(e) nil rescue => e Rails.logger.error "EnableBankingItem::Importer - Unexpected error fetching session: #{e.class} - #{e.message}" - @session_error = extract_friendly_error_message(e) + @session_error = handle_sync_error(e) nil end @@ -147,7 +166,7 @@ class EnableBankingItem::Importer # Use identification_hash as the stable identifier across sessions uid = account_data[:identification_hash] || account_data[:uid] - enable_banking_account = enable_banking_item.enable_banking_accounts.find_by(uid: uid.to_s) + enable_banking_account = find_enable_banking_account_by_hash(uid) return unless enable_banking_account enable_banking_account.upsert_enable_banking_snapshot!(account_data) @@ -155,113 +174,187 @@ class EnableBankingItem::Importer end def fetch_and_update_balance(enable_banking_account) - balance_data = enable_banking_provider.get_account_balances(account_id: enable_banking_account.api_account_id) + balance_data = enable_banking_provider.get_account_balances( + account_id: enable_banking_account.api_account_id, + psu_headers: enable_banking_item.build_psu_headers + ) - # Enable Banking returns an array of balances + # Enable Banking returns an array of balances. We prioritize types based on reliability. + # closingBooked (CLBD) > interimAvailable (ITAV) > expected (XPCD) balances = balance_data[:balances] || [] - return if balances.empty? + return true if balances.empty? - # Find the most relevant balance (prefer "ITAV" or "CLAV" types) - balance = balances.find { |b| b[:balance_type] == "ITAV" } || - balances.find { |b| b[:balance_type] == "CLAV" } || - balances.find { |b| b[:balance_type] == "ITBD" } || - balances.find { |b| b[:balance_type] == "CLBD" } || - balances.first + priority_types = [ "CLBD", "ITAV", "XPCD", "CLAV", "ITBD" ] + balance = nil + + priority_types.each do |type| + balance = balances.find { |b| b[:balance_type] == type } + break if balance + end + + balance ||= balances.first if balance.present? amount = balance.dig(:balance_amount, :amount) || balance[:amount] currency = balance.dig(:balance_amount, :currency) || balance[:currency] if amount.present? + indicator = balance[:credit_debit_indicator] + parsed_amount = amount.to_d + + # Enable Banking uses positive amounts for both credit and debit. + # DBIT indicates a negative balance (money owed/withdrawn). + parsed_amount = -parsed_amount if indicator == "DBIT" + enable_banking_account.update!( - current_balance: amount.to_d, + current_balance: parsed_amount, currency: currency.presence || enable_banking_account.currency ) end end + true rescue Provider::EnableBanking::EnableBankingError => e + @sync_error = promote_session_invalid(@sync_error, handle_sync_error(e)) Rails.logger.error "EnableBankingItem::Importer - Error fetching balance for account #{enable_banking_account.uid}: #{e.message}" + false + end + + def promote_session_invalid(existing, new) + return new if existing.nil? + return new if new == I18n.t("enable_banking_items.errors.session_invalid") + existing + end + + def include_pending? + Setting.syncs_include_pending end def fetch_and_store_transactions(enable_banking_account) start_date = determine_sync_start_date(enable_banking_account) + include_pending = include_pending? - all_transactions = [] - continuation_key = nil - previous_continuation_key = nil - page_count = 0 + all_transactions = fetch_paginated_transactions( + enable_banking_account, + start_date: start_date, + transaction_status: "BOOK", + psu_headers: enable_banking_item.build_psu_headers + ) - # Paginate through all transactions with safeguards against infinite loops - loop do - page_count += 1 - - # Safeguard: prevent infinite loops from excessive pagination - if page_count > MAX_PAGINATION_PAGES - Rails.logger.error( - "EnableBankingItem::Importer - Pagination limit exceeded for account #{enable_banking_account.uid}. " \ - "Stopped after #{MAX_PAGINATION_PAGES} pages (#{all_transactions.count} transactions). " \ - "Last continuation_key: #{continuation_key.inspect}" + pending_transactions = [] + if include_pending + # Also fetch pending transactions (visible for 1-3 days before they become BOOK) if setting is enabled + begin + pending_transactions = fetch_paginated_transactions( + enable_banking_account, + start_date: start_date, + transaction_status: "PDNG", + psu_headers: enable_banking_item.build_psu_headers ) - break + rescue Provider::EnableBanking::EnableBankingError => e + raise unless e.error_type == :validation_error && e.message.include?("transactionStatus") + Rails.logger.warn "EnableBankingItem::Importer - ASPSP does not support PDNG transaction status for account #{enable_banking_account.uid}, skipping pending transactions. API error: #{e.message}" end - - transactions_data = enable_banking_provider.get_account_transactions( - account_id: enable_banking_account.api_account_id, - date_from: start_date, - continuation_key: continuation_key - ) - - transactions = transactions_data[:transactions] || [] - all_transactions.concat(transactions) - - previous_continuation_key = continuation_key - continuation_key = transactions_data[:continuation_key] - - # Safeguard: detect repeated continuation_key (provider returning same key) - if continuation_key.present? && continuation_key == previous_continuation_key - Rails.logger.error( - "EnableBankingItem::Importer - Repeated continuation_key detected for account #{enable_banking_account.uid}. " \ - "Breaking loop after #{page_count} pages (#{all_transactions.count} transactions). " \ - "Repeated key: #{continuation_key.inspect}, last response had #{transactions.count} transactions" - ) - break - end - - break if continuation_key.blank? end + book_fingerprints = all_transactions + .map { |tx| EnableBankingEntry::Processor.compute_external_id(tx) } + .compact.to_set + + # Also index all booked entry_references so a pending row that lacks + # transaction_id can still be matched when the settled BOOK row adds one + # (fingerprints differ; entry_reference stays the same across settlement). + book_entry_refs = all_transactions + .map { |tx| tx.with_indifferent_access[:entry_reference].presence } + .compact.to_set + + pending_transactions.reject! do |tx| + tx_ia = tx.with_indifferent_access + fp = EnableBankingEntry::Processor.compute_external_id(tx_ia) + entry_ref = tx_ia[:entry_reference].presence + (fp.present? && book_fingerprints.include?(fp)) || + (entry_ref.present? && book_entry_refs.include?(entry_ref)) + end + + all_transactions = all_transactions + tag_as_pending(pending_transactions) + # Deduplicate API response: Enable Banking sometimes returns the same logical # transaction with different entry_reference IDs in the same response. # Remove content-level duplicates before storing. (Issue #954) all_transactions = deduplicate_api_transactions(all_transactions) + # Post-fetch safety filter: some ASPSPs ignore date_from or return extra transactions + all_transactions = filter_transactions_by_date(all_transactions, start_date) + transactions_count = all_transactions.count - if all_transactions.any? - existing_transactions = enable_banking_account.raw_transactions_payload.to_a - existing_ids = existing_transactions.map { |tx| + existing_transactions = enable_banking_account.raw_transactions_payload.to_a + + removed_pending = false + + unless include_pending + removed_pending = existing_transactions.reject! do |tx| tx = tx.with_indifferent_access - tx[:transaction_id].presence || tx[:entry_reference].presence + tx.dig(:extra, :enable_banking, :pending) || tx[:_pending] + end + end + + if all_transactions.any? + + # C4: Remove stored PDNG entries that have now settled as BOOK. + # Two match strategies run in parallel: + # 1. Fingerprint: covers same-ID rows and ID-less rows matched by content. + # 2. Entry-reference cross-match: covers the case where a pending row had + # no transaction_id but the settled BOOK row gained one — fingerprints + # diverge (enable_banking_ vs enable_banking_) but the + # shared entry_reference is a reliable settlement signal. + book_fingerprints = all_transactions + .reject { |tx| tx.with_indifferent_access[:_pending] } + .map { |tx| EnableBankingEntry::Processor.compute_external_id(tx) } + .compact.to_set + + book_entry_refs = all_transactions + .reject { |tx| tx.with_indifferent_access[:_pending] } + .map { |tx| tx.with_indifferent_access[:entry_reference].presence } + .compact.to_set + + if include_pending + removed_pending ||= existing_transactions.reject! do |tx| + tx = tx.with_indifferent_access + pending_flag = tx.dig(:extra, :enable_banking, :pending) || tx[:_pending] + next false unless pending_flag + + fp = EnableBankingEntry::Processor.compute_external_id(tx) + entry_ref = tx[:entry_reference].presence + (fp.present? && book_fingerprints.include?(fp)) || + (entry_ref.present? && book_entry_refs.include?(entry_ref)) + end + end + + existing_ids = existing_transactions.map { |tx| + EnableBankingEntry::Processor.compute_external_id(tx) }.compact.to_set new_transactions = all_transactions.select do |tx| - # Use transaction_id if present, otherwise fall back to entry_reference - tx_id = tx[:transaction_id].presence || tx[:entry_reference].presence - tx_id.present? && !existing_ids.include?(tx_id) + ext_id = EnableBankingEntry::Processor.compute_external_id(tx) + ext_id.present? && !existing_ids.include?(ext_id) end - if new_transactions.any? + if new_transactions.any? || removed_pending enable_banking_account.upsert_enable_banking_transactions_snapshot!(existing_transactions + new_transactions) end + elsif removed_pending + enable_banking_account.upsert_enable_banking_transactions_snapshot!( + existing_transactions + ) end { success: true, transactions_count: transactions_count } rescue Provider::EnableBanking::EnableBankingError => e Rails.logger.error "EnableBankingItem::Importer - Error fetching transactions for account #{enable_banking_account.uid}: #{e.message}" - { success: false, transactions_count: 0, error: e.message } + { success: false, transactions_count: 0, error: handle_sync_error(e) } rescue => e Rails.logger.error "EnableBankingItem::Importer - Unexpected error fetching transactions for account #{enable_banking_account.uid}: #{e.class} - #{e.message}" - { success: false, transactions_count: 0, error: e.message } + { success: false, transactions_count: 0, error: handle_sync_error(e) } end # Deduplicate transactions from the Enable Banking API response. @@ -308,6 +401,8 @@ class EnableBankingItem::Importer # unique. credit_debit_indicator (CRDT/DBIT) is included because # transaction_amount.amount is always positive — without it, a payment # and a same-day refund of the same amount would produce identical keys. + # status (BOOK/PDNG) is intentionally excluded: the same logical transaction + # may appear as PDNG then BOOK across imports and must not create duplicates. # Known limitation: when transaction_id is nil for both, pure content # comparison applies. This means two genuinely distinct transactions # with identical content (same date, amount, direction, creditor, etc.) @@ -315,18 +410,113 @@ class EnableBankingItem::Importer # omit transaction_id rarely produce such exact duplicates in the same # API response; timestamps or remittance info usually differ. (Issue #954) def build_transaction_content_key(tx) - date = tx[:booking_date].presence || tx[:value_date] + date = tx[:booking_date].presence || tx[:value_date].presence || tx[:transaction_date] amount = tx.dig(:transaction_amount, :amount).presence || tx[:amount] currency = tx.dig(:transaction_amount, :currency).presence || tx[:currency] creditor = tx.dig(:creditor, :name).presence || tx[:creditor_name] debtor = tx.dig(:debtor, :name).presence || tx[:debtor_name] remittance = tx[:remittance_information] remittance_key = remittance.is_a?(Array) ? remittance.compact.map(&:to_s).sort.join("|") : remittance.to_s - status = tx[:status] tid = tx[:transaction_id] direction = tx[:credit_debit_indicator] - [ date, amount, currency, creditor, debtor, remittance_key, status, tid, direction ].map(&:to_s).join("\x1F") + [ date, amount, currency, creditor, debtor, remittance_key, tid, direction ].map(&:to_s).join("\x1F") + end + + class PaginationTruncatedError < StandardError; end + + def fetch_paginated_transactions(enable_banking_account, start_date:, transaction_status:, psu_headers: {}) + all_transactions = [] + continuation_key = nil + previous_continuation_key = nil + page_count = 0 + + loop do + page_count += 1 + + if page_count > MAX_PAGINATION_PAGES + msg = "EnableBankingItem::Importer - Pagination limit exceeded for account #{enable_banking_account.uid} (status=#{transaction_status}). Stopped after #{MAX_PAGINATION_PAGES} pages." + raise PaginationTruncatedError, msg + end + + transactions_data = enable_banking_provider.get_account_transactions( + account_id: enable_banking_account.api_account_id, + date_from: start_date, + continuation_key: continuation_key, + transaction_status: transaction_status, + psu_headers: psu_headers + ) + + transactions = transactions_data[:transactions] || [] + all_transactions.concat(transactions) + + previous_continuation_key = continuation_key + continuation_key = transactions_data[:continuation_key] + + if continuation_key.present? && continuation_key == previous_continuation_key + msg = "EnableBankingItem::Importer - Repeated continuation_key detected for account #{enable_banking_account.uid} (status=#{transaction_status}). Breaking after #{page_count} pages." + raise PaginationTruncatedError, msg + end + + break if continuation_key.blank? + end + + all_transactions + rescue PaginationTruncatedError => e + # Log as warning and return collected partial data instead of failing entirely. + # This ensures accounts with huge history don't lose all synced data. + Rails.logger.warn(e.message) + all_transactions + end + + def filter_transactions_by_date(transactions, start_date) + return transactions unless start_date + + transactions.reject do |tx| + tx = tx.with_indifferent_access + date_str = tx[:booking_date] || tx[:value_date] || tx[:transaction_date] + next false if date_str.blank? # Keep if no date (cannot determine) + + begin + Date.parse(date_str.to_s) < start_date + rescue ArgumentError + false # Keep if date is unparseable + end + end + end + + def tag_as_pending(transactions) + transactions.map { |tx| tx.merge(_pending: true) } + end + + def find_enable_banking_account_by_hash(hash_value) + return nil if hash_value.blank? + + # First: exact uid match (primary identification_hash) + account = enable_banking_item.enable_banking_accounts.find_by(uid: hash_value.to_s) + return account if account + + # Second: search in identification_hashes array (PostgreSQL JSONB contains operator) + enable_banking_item.enable_banking_accounts + .where("identification_hashes @> ?", [ hash_value.to_s ].to_json) + .first + end + + def sync_uids_from_accounts_data(accounts_data) + return if accounts_data.blank? + + accounts_data.each do |ad| + next unless ad.is_a?(Hash) + ad = ad.with_indifferent_access + identification_hash = ad[:identification_hash] + current_uid = ad[:uid] + next if identification_hash.blank? || current_uid.blank? + + eb_acc = find_enable_banking_account_by_hash(identification_hash) + next unless eb_acc + # Update the API account_id (UUID) if it has changed (UIDs are session-scoped) + eb_acc.update!(account_id: current_uid) if eb_acc.account_id != current_uid + end end def determine_sync_start_date(enable_banking_account) diff --git a/app/models/enable_banking_item/provided.rb b/app/models/enable_banking_item/provided.rb index bd57d2795..1466d79cb 100644 --- a/app/models/enable_banking_item/provided.rb +++ b/app/models/enable_banking_item/provided.rb @@ -9,4 +9,22 @@ module EnableBankingItem::Provided client_certificate: client_certificate ) end + + # Build PSU context headers for data endpoint calls. + # The Enable Banking API spec mandates: "either all required PSU headers or none". + # We can only provide Psu-Ip-Address (from last_psu_ip stored at request time). + # If the ASPSP requires other PSU headers we cannot satisfy server-side, we send none + # to avoid a PSU_HEADER_NOT_PROVIDED error for partially-supplied headers. + def build_psu_headers + return {} if aspsp_required_psu_headers.blank? + + required = aspsp_required_psu_headers.map(&:downcase) + + # Only attempt to satisfy the headers if the only required one is Psu-Ip-Address + # (the one we can populate from stored data) + satisfiable = required.all? { |h| h == "psu-ip-address" } + return {} unless satisfiable && last_psu_ip.present? + + { "Psu-Ip-Address" => last_psu_ip } + end end diff --git a/app/models/entry.rb b/app/models/entry.rb index 48b216f36..84c2719f2 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -1,6 +1,9 @@ class Entry < ApplicationRecord include Monetizable, Enrichable + TRUTHY_VALUES = [ true, "true", "1", 1 ].freeze + private_constant :TRUTHY_VALUES + attr_accessor :unsplitting monetize :amount @@ -48,27 +51,20 @@ class Entry < ApplicationRecord # Pending transaction scopes - check Transaction.extra for provider pending flags # Works with any provider that stores pending status in extra["provider_name"]["pending"] scope :pending, -> { + conditions = Transaction::PENDING_PROVIDERS.map { |p| "(transactions.extra -> '#{p}' ->> 'pending')::boolean = true" } joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") - .where(<<~SQL.squish) - (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true - OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true - OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true - SQL + .where(conditions.join(" OR ")) } scope :excluding_pending, -> { # For non-Transaction entries (Trade, Valuation), always include - # For Transaction entries, exclude if pending flag is true + # For Transaction entries, exclude if any provider marks it pending where(<<~SQL.squish) entries.entryable_type != 'Transaction' OR NOT EXISTS ( SELECT 1 FROM transactions t WHERE t.id = entries.entryable_id - AND ( - (t.extra -> 'simplefin' ->> 'pending')::boolean = true - OR (t.extra -> 'plaid' ->> 'pending')::boolean = true - OR (t.extra -> 'lunchflow' ->> 'pending')::boolean = true - ) + AND (#{Transaction::PENDING_CHECK_SQL}) ) SQL } @@ -148,6 +144,10 @@ class Entry < ApplicationRecord def self.reconcile_pending_duplicates(account: nil, dry_run: false, date_window: 8, amount_tolerance: 0.25) stats = { checked: 0, reconciled: 0, details: [] } + not_pending_sql = Transaction::PENDING_PROVIDERS + .map { |p| "(transactions.extra -> '#{p}' ->> 'pending')::boolean IS NOT TRUE" } + .join(" AND ") + # Get pending entries to check scope = Entry.pending.where(excluded: false) scope = scope.where(account: account) if account @@ -164,11 +164,7 @@ class Entry < ApplicationRecord .where(currency: pending_entry.currency) .where(amount: pending_entry.amount) .where(date: pending_entry.date..(pending_entry.date + date_window.days)) # Posted must be ON or AFTER pending date - .where(<<~SQL.squish) - (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE - AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE - AND (transactions.extra -> 'lunchflow' ->> 'pending')::boolean IS NOT TRUE - SQL + .where(not_pending_sql) .limit(2) # Only need to know if 0, 1, or 2+ candidates .to_a # Load limited records to avoid COUNT(*) on .size @@ -211,11 +207,7 @@ class Entry < ApplicationRecord .where(currency: pending_entry.currency) .where(date: pending_entry.date..(pending_entry.date + fuzzy_date_window.days)) # Posted ON or AFTER pending .where("ABS(entries.amount) BETWEEN ? AND ?", min_amount, max_amount) - .where(<<~SQL.squish) - (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE - AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE - AND (transactions.extra -> 'lunchflow' ->> 'pending')::boolean IS NOT TRUE - SQL + .where(not_pending_sql) # Match by name similarity (first 3 words) name_words = pending_entry.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") @@ -253,10 +245,12 @@ class Entry < ApplicationRecord pending_transaction.update!( extra: existing_extra.merge( "potential_posted_match" => { - "entry_id" => fuzzy_match.id, - "reason" => "fuzzy_amount_match", + "entry_id" => fuzzy_match.id, + "reason" => "fuzzy_amount_match", "posted_amount" => fuzzy_match.amount.to_s, - "detected_at" => Date.current.to_s + "confidence" => "medium", + "dismissed" => false, + "detected_at" => Date.current.to_s } ) ) @@ -370,7 +364,7 @@ class Entry < ApplicationRecord # Splits this entry into child entries. Marks parent as excluded. # - # @param splits [Array] array of { name:, amount:, category_id: } hashes + # @param splits [Array] array of { name:, amount:, category_id:, excluded: } hashes # @return [Array] the created child entries def split!(splits) total = splits.sum { |s| s[:amount].to_d } @@ -392,6 +386,7 @@ class Entry < ApplicationRecord name: split_attrs[:name], amount: split_attrs[:amount], currency: currency, + excluded: TRUTHY_VALUES.include?(split_attrs[:excluded]), entryable: child_transaction ) end @@ -440,6 +435,7 @@ class Entry < ApplicationRecord bulk_attributes = { date: bulk_update_params[:date], notes: bulk_update_params[:notes], + name: bulk_update_params[:name], entryable_attributes: { category_id: bulk_update_params[:category_id], merchant_id: bulk_update_params[:merchant_id] @@ -459,6 +455,7 @@ class Entry < ApplicationRecord if bulk_attributes.present? attrs = bulk_attributes.dup attrs.delete(:date) if entry.split_child? + attrs.delete(:entryable_attributes) unless entry.transaction? if attrs.present? attrs[:entryable_attributes] = attrs[:entryable_attributes].dup if attrs[:entryable_attributes].present? diff --git a/app/models/exchange_rate/importer.rb b/app/models/exchange_rate/importer.rb index a077401b3..bd1533b57 100644 --- a/app/models/exchange_rate/importer.rb +++ b/app/models/exchange_rate/importer.rb @@ -2,6 +2,8 @@ class ExchangeRate::Importer MissingExchangeRateError = Class.new(StandardError) MissingStartRateError = Class.new(StandardError) + PROVISIONAL_LOOKBACK_DAYS = 5 + def initialize(exchange_rate_provider:, from:, to:, start_date:, end_date:, clear_cache: false) @exchange_rate_provider = exchange_rate_provider @from = from @@ -11,10 +13,10 @@ class ExchangeRate::Importer @clear_cache = clear_cache end - # Constructs a daily series of rates for the given currency pair for date range def import_provider_rates if !clear_cache && all_rates_exist? Rails.logger.info("No new rates to sync for #{from} to #{to} between #{start_date} and #{end_date}, skipping") + backfill_inverse_rates_if_needed return end @@ -25,6 +27,24 @@ class ExchangeRate::Importer prev_rate_value = start_rate_value + # Always find the earliest valid provider rate for pair metadata tracking. + # record_first_provider_rate_on's atomic guard prevents moving the date forward. + earliest_valid_provider_date = provider_rates.values + .select { |r| r.rate.present? && r.rate.to_f > 0 } + .min_by(&:date)&.date + + # When no anchor rate exists, advance the loop start to the earliest provider rate + loop_start_date = fill_start_date + if prev_rate_value.blank? && earliest_valid_provider_date + earliest_rate = provider_rates[earliest_valid_provider_date] + Rails.logger.info( + "#{from}->#{to}: no provider rate on or before #{start_date}; " \ + "advancing gapfill start to earliest valid provider date #{earliest_valid_provider_date}" + ) + prev_rate_value = earliest_rate.rate + loop_start_date = earliest_valid_provider_date + end + unless prev_rate_value.present? error = MissingStartRateError.new("Could not find a start rate for #{from} to #{to} between #{start_date} and #{end_date}") Rails.logger.error(error.message) @@ -32,20 +52,18 @@ class ExchangeRate::Importer return end - gapfilled_rates = effective_start_date.upto(end_date).map do |date| + # Gapfill with LOCF strategy (last observation carried forward): + # when the provider returns nothing for weekends/holidays, carry the previous rate. + gapfilled_rates = loop_start_date.upto(end_date).map do |date| db_rate_value = db_rates[date]&.rate provider_rate_value = provider_rates[date]&.rate - chosen_rate = if clear_cache - provider_rate_value || db_rate_value # overwrite when possible + chosen_rate = if provider_rate_value.present? && provider_rate_value.to_f > 0 + provider_rate_value + elsif db_rate_value.present? && db_rate_value.to_f > 0 + db_rate_value else - db_rate_value || provider_rate_value # fill gaps - end - - # Gapfill with LOCF strategy (last observation carried forward) - # Treat nil or zero rates as invalid and use previous rate - if chosen_rate.nil? || chosen_rate.to_f <= 0 - chosen_rate = prev_rate_value + prev_rate_value end prev_rate_value = chosen_rate @@ -59,11 +77,43 @@ class ExchangeRate::Importer end upsert_rows(gapfilled_rates) + + # Compute and upsert inverse rates (e.g., EUR→USD from USD→EUR) to avoid + # separate API calls for the reverse direction. + inverse_rates = gapfilled_rates.filter_map do |row| + next if row[:rate].to_f <= 0 + + { + from_currency: row[:to_currency], + to_currency: row[:from_currency], + date: row[:date], + rate: (BigDecimal("1") / BigDecimal(row[:rate].to_s)).round(12) + } + end + + upsert_rows(inverse_rates) + + # Backfill inverse rows for any forward rates that existed in the DB + # before the loop range (i.e. dates not covered by gapfilled_rates). + backfill_inverse_rates_if_needed + + if earliest_valid_provider_date.present? + ExchangeRatePair.record_first_provider_rate_on( + from: from, to: to, date: earliest_valid_provider_date, + provider_name: current_provider_name + ) + end end private attr_reader :exchange_rate_provider, :from, :to, :start_date, :end_date, :clear_cache + # Resolves the provider name the same way as ExchangeRate::Provided.provider: + # ENV takes precedence over the DB Setting to stay consistent in env-configured deployments. + def current_provider_name + @current_provider_name ||= (ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider).to_s + end + def upsert_rows(rows) batch_size = 200 @@ -82,34 +132,82 @@ class ExchangeRate::Importer total_upsert_count end - # Since provider may not return values on weekends and holidays, we grab the first rate from the provider that is on or before the start date def start_rate_value - provider_rate_value = provider_rates.select { |date, _| date <= start_date }.max_by { |date, _| date }&.last - db_rate_value = db_rates[start_date]&.rate - provider_rate_value || db_rate_value + if fill_start_date == start_date + provider_rate_value = latest_valid_provider_rate(before_or_on: start_date) + db_rate_value = db_rates[start_date]&.rate + + return provider_rate_value if provider_rate_value.present? + return db_rate_value if db_rate_value.present? && db_rate_value.to_f > 0 + return nil + end + + cutoff_date = fill_start_date + + provider_rate_value = latest_valid_provider_rate(before: cutoff_date) + return provider_rate_value if provider_rate_value.present? + + ExchangeRate + .where(from_currency: from, to_currency: to) + .where("date < ?", cutoff_date) + .where("rate > 0") + .order(date: :desc) + .limit(1) + .pick(:rate) + end + + # Scans provider_rates for the most recent entry with a positive rate, + # rather than just picking the latest row (which could be zero/nil). + def latest_valid_provider_rate(before_or_on: nil, before: nil) + cutoff = before_or_on || before + comparator = before_or_on ? :<= : :< + + provider_rates + .select { |date, r| date.send(comparator, cutoff) && r.rate.present? && r.rate.to_f > 0 } + .max_by { |date, _| date }&.last&.rate + end + + def clamped_start_date + @clamped_start_date ||= begin + listed = exchange_rate_pair.first_provider_rate_on + listed.present? && listed > start_date ? listed : start_date + end + end + + def exchange_rate_pair + @exchange_rate_pair ||= ExchangeRatePair.for_pair(from: from, to: to, provider_name: current_provider_name) + end + + def fill_start_date + @fill_start_date ||= [ provider_fetch_start_date, effective_start_date ].max + end + + def provider_fetch_start_date + @provider_fetch_start_date ||= begin + base = effective_start_date - PROVISIONAL_LOOKBACK_DAYS.days + max_days = exchange_rate_provider.respond_to?(:max_history_days) ? exchange_rate_provider.max_history_days : nil + + if max_days && (end_date - base).to_i > max_days + clamped = end_date - max_days.days + Rails.logger.info( + "#{exchange_rate_provider.class.name} max history is #{max_days} days; " \ + "clamping #{from}->#{to} start_date from #{base} to #{clamped}" + ) + clamped + else + base + end + end end - # No need to fetch/upsert rates for dates that we already have in the DB def effective_start_date return start_date if clear_cache - first_missing_date = nil - - start_date.upto(end_date) do |date| - unless db_rates.key?(date) - first_missing_date = date - break - end - end - - first_missing_date || end_date + (clamped_start_date..end_date).detect { |d| !db_rates.key?(d) } || end_date end def provider_rates @provider_rates ||= begin - # Always fetch with a 5 day buffer to ensure we have a starting rate (for weekends and holidays) - provider_fetch_start_date = effective_start_date - 5.days - provider_response = exchange_rate_provider.fetch_exchange_rates( from: from, to: to, @@ -118,6 +216,7 @@ class ExchangeRate::Importer ) if provider_response.success? + Rails.logger.debug("Fetched #{provider_response.data.size} rates from #{exchange_rate_provider.class.name} for #{from}/#{to} between #{provider_fetch_start_date} and #{end_date}") provider_response.data.index_by(&:date) else message = "#{exchange_rate_provider.class.name} could not fetch exchange rate pair from: #{from} to: #{to} between: #{effective_start_date} and: #{Date.current}. Provider error: #{provider_response.error.message}" @@ -128,16 +227,37 @@ class ExchangeRate::Importer end end + def backfill_inverse_rates_if_needed + existing_inverse_dates = ExchangeRate.where(from_currency: to, to_currency: from, date: clamped_start_date..end_date).pluck(:date).to_set + return if existing_inverse_dates.size >= expected_count + + inverse_rows = db_rates.filter_map do |_date, rate| + next if existing_inverse_dates.include?(rate.date) + next if rate.rate.to_f <= 0 + + { + from_currency: to, + to_currency: from, + date: rate.date, + rate: (BigDecimal("1") / BigDecimal(rate.rate.to_s)).round(12) + } + end + + upsert_rows(inverse_rows) if inverse_rows.any? + end + def all_rates_exist? db_count == expected_count end def expected_count - (start_date..end_date).count + (clamped_start_date..end_date).count end def db_count - db_rates.count + ExchangeRate + .where(from_currency: from, to_currency: to, date: clamped_start_date..end_date) + .count end def db_rates @@ -148,8 +268,7 @@ class ExchangeRate::Importer end # Normalizes an end date so that it never exceeds today's date in the - # America/New_York timezone. If the caller passes a future date we clamp - # it to today so that upstream provider calls remain valid and predictable. + # America/New_York timezone. def normalize_end_date(requested_end_date) today_est = Date.current.in_time_zone("America/New_York").to_date [ requested_end_date, today_est ].min diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index 8a3f88af3..157d793a9 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -58,6 +58,11 @@ module ExchangeRate::Provided def rates_for(currencies, to:, date: Date.current) currencies.uniq.each_with_object({}) do |currency, map| rate = find_or_fetch_rate(from: currency, to: to, date: date) + if rate.nil? + Rails.logger.warn("No exchange rate found for #{currency}/#{to} on #{date}, using 1") + elsif rate.date != date + Rails.logger.debug("FX rate #{currency}/#{to}: using #{rate.date} for #{date} (gap=#{(date - rate.date).to_i}d)") + end map[currency] = rate&.rate || 1 end end @@ -69,14 +74,37 @@ module ExchangeRate::Provided return 0 end - ExchangeRate::Importer.new( - exchange_rate_provider: provider, - from: from, - to: to, - start_date: start_date, - end_date: end_date, - clear_cache: clear_cache - ).import_provider_rates + # Prevent concurrent syncs from fetching the same currency pair for overlapping + # date ranges. The lock is scoped to (pair + start_date) so that a broader range + # (e.g. daily job needing older history) is not blocked by a narrower account sync. + # + # Uses an owner-token pattern: the lock value is a unique token so the ensure + # block only deletes its own lock, not one acquired by a different worker after + # expiry. TTL is 5 minutes to cover worst-case throttle + rate-limit retry waits + # (~3 minutes with TwelveData). + lock_key = "exchange_rate_import:#{from}:#{to}:#{start_date}" + lock_token = SecureRandom.uuid + acquired = Rails.cache.write(lock_key, lock_token, expires_in: 5.minutes, unless_exist: true) + + unless acquired + Rails.logger.info("Skipping exchange rate import for #{from}/#{to} from #{start_date} — already in progress") + return 0 + end + + begin + ExchangeRate::Importer.new( + exchange_rate_provider: provider, + from: from, + to: to, + start_date: start_date, + end_date: end_date, + clear_cache: clear_cache + ).import_provider_rates + ensure + # Only delete the lock if we still own it (it hasn't expired and been + # re-acquired by another worker). + Rails.cache.delete(lock_key) if Rails.cache.read(lock_key) == lock_token + end end end end diff --git a/app/models/exchange_rate_pair.rb b/app/models/exchange_rate_pair.rb new file mode 100644 index 000000000..2da5f72be --- /dev/null +++ b/app/models/exchange_rate_pair.rb @@ -0,0 +1,42 @@ +class ExchangeRatePair < ApplicationRecord + validates :from_currency, :to_currency, presence: true + + def self.for_pair(from:, to:, provider_name: nil) + pair = find_or_create_by!(from_currency: from, to_currency: to) + current_provider = provider_name || resolve_provider_name + + if pair.provider_name != current_provider && pair.first_provider_rate_on.present? + ExchangeRatePair + .where(id: pair.id) + .where.not(provider_name: current_provider) + .update_all(first_provider_rate_on: nil, provider_name: current_provider, updated_at: Time.current) + pair.reload + end + + pair + rescue ActiveRecord::RecordNotUnique + find_by!(from_currency: from, to_currency: to) + end + + # Resolves the runtime provider name the same way as ExchangeRate::Provided.provider: + # ENV takes precedence over the DB Setting. + def self.resolve_provider_name + (ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider).to_s + end + + def self.record_first_provider_rate_on(from:, to:, date:, provider_name: nil) + return if date.blank? + + current_provider = provider_name || resolve_provider_name + pair = for_pair(from: from, to: to, provider_name: current_provider) + + ExchangeRatePair + .where(id: pair.id) + .where("first_provider_rate_on IS NULL OR first_provider_rate_on > ?", date) + .update_all( + first_provider_rate_on: date, + provider_name: current_provider, + updated_at: Time.current + ) + end +end diff --git a/app/models/family.rb b/app/models/family.rb index a287c8957..73a3da1ef 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,8 +1,8 @@ class Family < ApplicationRecord include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable - include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable - include IndexaCapitalConnectable + include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable + include IndexaCapitalConnectable, IbkrConnectable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], @@ -28,6 +28,7 @@ class Family < ApplicationRecord has_many :imports, dependent: :destroy has_many :family_exports, dependent: :destroy + has_many :account_statements, dependent: :destroy has_many :entries, through: :accounts has_many :transactions, through: :accounts @@ -53,13 +54,55 @@ class Family < ApplicationRecord validates :assistant_type, inclusion: { in: ASSISTANT_TYPES } validates :default_account_sharing, inclusion: { in: SHARING_DEFAULTS } + before_validation :normalize_enabled_currencies! + + def primary_currency_code + normalize_currency_code(currency) || "USD" + end + + def custom_enabled_currencies? + enabled_currencies.present? + end + + def enabled_currency_codes(extra: []) + selected_codes = if custom_enabled_currencies? + [ primary_currency_code, *Array(enabled_currencies) ] + else + Money::Currency.as_options.map(&:iso_code) + end + + normalize_currency_codes([ *selected_codes, *Array(extra) ]) + end + + def enabled_currency_objects(extra: []) + enabled_currency_codes(extra:).map { |code| Money::Currency.new(code) } + end + + def secondary_enabled_currency_objects(extra: []) + enabled_currency_objects(extra:).reject { |currency| currency.iso_code == primary_currency_code } + end + def moniker_label - moniker.presence || "Family" + case moniker.presence + when nil, "Family" + I18n.t("shared.family_moniker.singular", default: "Family") + when "Group" + I18n.t("shared.family_moniker.group_singular", default: "Group") + else + moniker + end end def moniker_label_plural - moniker_label == "Group" ? "Groups" : "Families" + case moniker.presence + when nil, "Family" + I18n.t("shared.family_moniker.plural", default: "Families") + when "Group" + I18n.t("shared.family_moniker.group_plural", default: "Groups") + else + "#{moniker}s" + end end def share_all_by_default? @@ -300,4 +343,29 @@ class Family < ApplicationRecord def self_hoster? Rails.application.config.app_mode.self_hosted? end + + private + def normalize_enabled_currencies! + if enabled_currencies.blank? + self.enabled_currencies = nil + return + end + + normalized_codes = normalize_currency_codes([ primary_currency_code, *Array(enabled_currencies) ]) + all_codes = Money::Currency.as_options.map(&:iso_code) + all_selected = normalized_codes.size == all_codes.size && (normalized_codes - all_codes).empty? + self.enabled_currencies = all_selected ? nil : normalized_codes + end + + def normalize_currency_codes(values) + Array(values).filter_map { |value| normalize_currency_code(value) }.uniq + end + + def normalize_currency_code(value) + return if value.blank? + + Money::Currency.new(value).iso_code + rescue Money::Currency::UnknownCurrencyError, ArgumentError + nil + end end diff --git a/app/models/family/brex_connectable.rb b/app/models/family/brex_connectable.rb new file mode 100644 index 000000000..49fe3e560 --- /dev/null +++ b/app/models/family/brex_connectable.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Family::BrexConnectable + extend ActiveSupport::Concern + + included do + has_many :brex_items, dependent: :destroy + end + + def can_connect_brex? + true + end + + def create_brex_item!(token:, base_url: nil, item_name: nil) + brex_item = brex_items.create!( + name: item_name.presence || I18n.t("brex_items.default_connection_name"), + token: token, + base_url: base_url + ) + + brex_item.sync_later + + brex_item + end + + def has_brex_credentials? + brex_items.active.with_credentials.exists? + end +end diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index dc0ffae13..0435e1ec3 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -2,6 +2,8 @@ require "zip" require "csv" class Family::DataExporter + EXPORT_VERSION = 2 + def initialize(family) @family = family end @@ -9,6 +11,10 @@ class Family::DataExporter def generate_export # Create a StringIO to hold the zip data in memory zip_data = Zip::OutputStream.write_buffer do |zipfile| + # Add export version marker for downstream tooling + zipfile.put_next_entry("version.txt") + zipfile.write generate_version_txt + # Add accounts.csv zipfile.put_next_entry("accounts.csv") zipfile.write generate_accounts_csv @@ -29,6 +35,10 @@ class Family::DataExporter zipfile.put_next_entry("rules.csv") zipfile.write generate_rules_csv + # Add attachment manifest metadata. Binary file payloads are not included. + zipfile.put_next_entry("attachments.json") + zipfile.write generate_attachments_manifest + # Add all.ndjson zipfile.put_next_entry("all.ndjson") zipfile.write generate_ndjson @@ -40,6 +50,11 @@ class Family::DataExporter end private + def generate_version_txt + <<~TEXT + export_version: #{EXPORT_VERSION} + TEXT + end def generate_accounts_csv CSV.generate do |csv| @@ -66,17 +81,16 @@ class Family::DataExporter # Only export transactions from accounts belonging to this family # Exclude split parents (export children instead) - @family.transactions + exportable_transactions .includes(:category, :tags, entry: :account) - .merge(Entry.excluding_split_parents) .find_each do |transaction| csv << [ - transaction.entry.date.iso8601, + transaction.entry.date&.iso8601, transaction.entry.account.name, transaction.entry.amount.to_s, transaction.entry.name, transaction.category&.name, - transaction.tags.pluck(:name).join(","), + transaction.tags.map { |tag| escape_legacy_tag_name(tag.name) }.join(","), transaction.entry.notes, transaction.entry.currency ] @@ -84,6 +98,10 @@ class Family::DataExporter end end + def escape_legacy_tag_name(name) + name.to_s.gsub(/[\\,|]/) { |char| "\\#{char}" } + end + def generate_trades_csv CSV.generate do |csv| csv << [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ] @@ -93,7 +111,7 @@ class Family::DataExporter .includes(:security, entry: :account) .find_each do |trade| csv << [ - trade.entry.date.iso8601, + trade.entry.date&.iso8601, trade.entry.account.name, trade.security.ticker, trade.qty.to_s, @@ -139,6 +157,69 @@ class Family::DataExporter end end + def generate_attachments_manifest + { + version: 1, + binary_included: false, + attachments: attachment_manifest_items + }.to_json + end + + def attachment_manifest_items + (transaction_attachment_manifest_items + family_document_attachment_manifest_items) + .sort_by { |item| [ item[:record_type], item[:record_id].to_s, item[:filename].to_s, item[:id].to_s ] } + end + + def transaction_attachment_manifest_items + @family.transactions + .with_attached_attachments + .includes(:attachments_attachments, entry: :account) + .flat_map do |transaction| + transaction.attachments.map do |attachment| + attachment_manifest_item( + attachment, + record_type: "Transaction", + record_id: transaction.id, + extra: { + entry_id: transaction.entry.id, + account_id: transaction.entry.account_id + } + ) + end + end + end + + def family_document_attachment_manifest_items + @family.family_documents.with_attached_file.filter_map do |document| + next unless document.file.attached? + + attachment_manifest_item( + document.file.attachment, + record_type: "FamilyDocument", + record_id: document.id, + extra: { + status: document.status + } + ) + end + end + + def attachment_manifest_item(attachment, record_type:, record_id:, extra: {}) + blob = attachment.blob + { + id: attachment.id, + record_type: record_type, + record_id: record_id, + name: attachment.name, + filename: blob.filename.to_s, + content_type: blob.content_type, + byte_size: blob.byte_size, + checksum: blob.checksum, + binary_included: false, + created_at: attachment.created_at + }.merge(extra) + end + def generate_ndjson lines = [] @@ -154,6 +235,39 @@ class Family::DataExporter }.to_json end + Balance.joins(:account) + .where(accounts: { family_id: @family.id }) + .chronological + .each do |balance| + lines << { + type: "Balance", + data: { + id: balance.id, + account_id: balance.account_id, + date: balance.date, + balance: balance.balance, + currency: balance.currency, + cash_balance: balance.cash_balance, + start_cash_balance: balance.start_cash_balance, + start_non_cash_balance: balance.start_non_cash_balance, + cash_inflows: balance.cash_inflows, + cash_outflows: balance.cash_outflows, + non_cash_inflows: balance.non_cash_inflows, + non_cash_outflows: balance.non_cash_outflows, + net_market_flows: balance.net_market_flows, + cash_adjustments: balance.cash_adjustments, + non_cash_adjustments: balance.non_cash_adjustments, + flows_factor: balance.flows_factor, + start_balance: balance.start_balance, + end_cash_balance: balance.end_cash_balance, + end_non_cash_balance: balance.end_non_cash_balance, + end_balance: balance.end_balance, + created_at: balance.created_at, + updated_at: balance.updated_at + } + }.to_json + end + # Export categories @family.categories.find_each do |category| lines << { @@ -178,8 +292,16 @@ class Family::DataExporter }.to_json end + # Export recurring transactions after accounts and merchants so import can remap dependencies. + @family.recurring_transactions.includes(:account, :merchant).find_each do |recurring_transaction| + lines << { + type: "RecurringTransaction", + data: serialize_recurring_transaction_for_export(recurring_transaction) + }.to_json + end + # Export transactions with full data (exclude split parents, export children instead) - @family.transactions.includes(:category, :merchant, :tags, entry: :account).merge(Entry.excluding_split_parents).find_each do |transaction| + exportable_transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction| lines << { type: "Transaction", data: { @@ -202,6 +324,35 @@ class Family::DataExporter }.to_json end + # Export transfer decisions after transactions so import can remap both sides. + family_transfers.find_each do |transfer| + lines << { + type: "Transfer", + data: { + id: transfer.id, + inflow_transaction_id: transfer.inflow_transaction_id, + outflow_transaction_id: transfer.outflow_transaction_id, + status: transfer.status, + notes: transfer.notes, + created_at: transfer.created_at, + updated_at: transfer.updated_at + } + }.to_json + end + + family_rejected_transfers.find_each do |rejected_transfer| + lines << { + type: "RejectedTransfer", + data: { + id: rejected_transfer.id, + inflow_transaction_id: rejected_transfer.inflow_transaction_id, + outflow_transaction_id: rejected_transfer.outflow_transaction_id, + created_at: rejected_transfer.created_at, + updated_at: rejected_transfer.updated_at + } + }.to_json + end + # Export trades with full data @family.trades.includes(:security, entry: :account).find_each do |trade| lines << { @@ -212,6 +363,8 @@ class Family::DataExporter account_id: trade.entry.account_id, security_id: trade.security_id, ticker: trade.security.ticker, + security_name: trade.security.name, + exchange_operating_mic: trade.security.exchange_operating_mic, date: trade.entry.date, qty: trade.qty, price: trade.price, @@ -223,6 +376,35 @@ class Family::DataExporter }.to_json end + # Export holding snapshots for backup and portfolio verification. + @family.holdings.includes(:account, :security).find_each do |holding| + lines << { + type: "Holding", + data: { + id: holding.id, + account_id: holding.account_id, + security_id: holding.security_id, + ticker: holding.security.ticker, + security_name: holding.security.name, + exchange_operating_mic: holding.security.exchange_operating_mic, + exchange_mic: holding.security.exchange_mic, + exchange_acronym: holding.security.exchange_acronym, + country_code: holding.security.country_code, + kind: holding.security.kind, + website_url: holding.security.website_url, + date: holding.date, + qty: holding.qty, + price: holding.price, + amount: holding.amount, + currency: holding.currency, + cost_basis: holding.cost_basis, + cost_basis_source: holding.cost_basis_source, + cost_basis_locked: holding.cost_basis_locked, + security_locked: holding.security_locked + } + }.to_json + end + # Export valuations @family.entries.valuations.includes(:account, :entryable).find_each do |entry| lines << { @@ -235,6 +417,7 @@ class Family::DataExporter amount: entry.amount, currency: entry.currency, name: entry.name, + kind: entry.entryable.kind, created_at: entry.created_at, updated_at: entry.updated_at } @@ -269,6 +452,50 @@ class Family::DataExporter lines.join("\n") end + def exportable_transactions + @family.transactions.merge(Entry.excluding_split_parents) + end + + def family_transaction_ids + @family_transaction_ids ||= exportable_transactions.select(:id) + end + + def family_transfers + Transfer.where( + inflow_transaction_id: family_transaction_ids, + outflow_transaction_id: family_transaction_ids + ) + end + + def family_rejected_transfers + RejectedTransfer.where( + inflow_transaction_id: family_transaction_ids, + outflow_transaction_id: family_transaction_ids + ) + end + + def serialize_recurring_transaction_for_export(recurring_transaction) + { + id: recurring_transaction.id, + account_id: recurring_transaction.account_id, + merchant_id: recurring_transaction.merchant_id, + amount: recurring_transaction.amount, + currency: recurring_transaction.currency, + expected_day_of_month: recurring_transaction.expected_day_of_month, + last_occurrence_date: recurring_transaction.last_occurrence_date, + next_expected_date: recurring_transaction.next_expected_date, + status: recurring_transaction.status, + occurrence_count: recurring_transaction.occurrence_count, + name: recurring_transaction.name, + manual: recurring_transaction.manual, + expected_amount_min: recurring_transaction.expected_amount_min, + expected_amount_max: recurring_transaction.expected_amount_max, + expected_amount_avg: recurring_transaction.expected_amount_avg, + created_at: recurring_transaction.created_at, + updated_at: recurring_transaction.updated_at + } + end + def serialize_rule_for_export(rule) { name: rule.name, @@ -281,11 +508,14 @@ class Family::DataExporter end def serialize_condition(condition) + operand = resolve_condition_operand(condition) data = { condition_type: condition.condition_type, operator: condition.operator, - value: resolve_condition_value(condition) + value: operand[:value] } + value_ref = operand[:value_ref] + data[:value_ref] = value_ref if value_ref.present? if condition.compound? && condition.sub_conditions.any? data[:sub_conditions] = condition.sub_conditions.map { |sub| serialize_condition(sub) } @@ -295,52 +525,79 @@ class Family::DataExporter end def serialize_action(action) - { + operand = resolve_action_operand(action) + data = { action_type: action.action_type, - value: resolve_action_value(action) + value: operand[:value] } + value_ref = operand[:value_ref] + data[:value_ref] = value_ref if value_ref.present? + + data end - def resolve_condition_value(condition) - return condition.value unless condition.value.present? + def resolve_condition_operand(condition) + return rule_operand(condition.value) unless condition.value.present? # Map category UUIDs to names for portability - if condition.condition_type == "transaction_category" && condition.value.present? - category = @family.categories.find_by(id: condition.value) - return category&.name || condition.value + if condition.condition_type == "transaction_category" + return rule_operand(condition.value, type: "Category", relation: @family.categories) end # Map merchant UUIDs to names for portability - if condition.condition_type == "transaction_merchant" && condition.value.present? - merchant = @family.merchants.find_by(id: condition.value) - return merchant&.name || condition.value + if condition.condition_type == "transaction_merchant" + return rule_operand(condition.value, type: "Merchant", relation: @family.merchants) end - condition.value + rule_operand(condition.value) end - def resolve_action_value(action) - return action.value unless action.value.present? + def resolve_action_operand(action) + return rule_operand(action.value) unless action.value.present? # Map category UUIDs to names for portability - if action.action_type == "set_transaction_category" && action.value.present? - category = @family.categories.find_by(id: action.value) || @family.categories.find_by(name: action.value) - return category&.name || action.value + if action.action_type == "set_transaction_category" + return rule_operand(action.value, type: "Category", relation: @family.categories, fallback_to_name: true) end # Map merchant UUIDs to names for portability - if action.action_type == "set_transaction_merchant" && action.value.present? - merchant = @family.merchants.find_by(id: action.value) || @family.merchants.find_by(name: action.value) - return merchant&.name || action.value + if action.action_type == "set_transaction_merchant" + return rule_operand(action.value, type: "Merchant", relation: @family.merchants, fallback_to_name: true) end # Map tag UUIDs to names for portability - if action.action_type == "set_transaction_tags" && action.value.present? - tag = @family.tags.find_by(id: action.value) || @family.tags.find_by(name: action.value) - return tag&.name || action.value + if action.action_type == "set_transaction_tags" + return rule_operand(action.value, type: "Tag", relation: @family.tags, fallback_to_name: true) end - action.value + rule_operand(action.value) + end + + def rule_operand(value, type: nil, relation: nil, fallback_to_name: false) + record = relation && resolve_rule_operand_record(relation, value, fallback_to_name: fallback_to_name) + + { + value: record&.name || value, + value_ref: record ? rule_value_ref(type, record) : nil + } + end + + def resolve_rule_operand_record(relation, value, fallback_to_name:) + return relation.find_by(id: value) if uuid_like?(value) + + relation.find_by(name: value) if fallback_to_name + end + + def rule_value_ref(type, record) + { + type: type, + id: record.id, + name: record.name + } + end + + def uuid_like?(value) + UuidFormat.valid?(value) end def serialize_conditions_for_csv(conditions) diff --git a/app/models/family/data_importer.rb b/app/models/family/data_importer.rb index 4b6f2339b..8ee468dc8 100644 --- a/app/models/family/data_importer.rb +++ b/app/models/family/data_importer.rb @@ -1,5 +1,7 @@ +require "set" + class Family::DataImporter - SUPPORTED_TYPES = %w[Account Category Tag Merchant Transaction Trade Valuation Budget BudgetCategory Rule].freeze + SUPPORTED_TYPES = %w[Account Balance Category Tag Merchant RecurringTransaction Transaction Transfer RejectedTransfer Trade Holding Valuation Budget BudgetCategory Rule].freeze ACCOUNTABLE_TYPES = Accountable::TYPES.freeze def initialize(family, ndjson_content) @@ -10,24 +12,34 @@ class Family::DataImporter categories: {}, tags: {}, merchants: {}, + recurring_transactions: {}, + transactions: {}, budgets: {}, securities: {} } + @security_cache = {} @created_accounts = [] @created_entries = [] end def import! records = parse_ndjson + @oldest_import_entry_dates_by_account = oldest_import_entry_dates_by_account(records) + @imported_opening_anchor_account_ids = imported_opening_anchor_account_ids(records["Valuation"] || []) Import.transaction do # Import in dependency order import_accounts(records["Account"] || []) + import_balances(records["Balance"] || []) import_categories(records["Category"] || []) import_tags(records["Tag"] || []) import_merchants(records["Merchant"] || []) + import_recurring_transactions(records["RecurringTransaction"] || []) import_transactions(records["Transaction"] || []) + import_transfers(records["Transfer"] || []) + import_rejected_transfers(records["RejectedTransfer"] || []) import_trades(records["Trade"] || []) + import_holdings(records["Holding"] || []) import_valuations(records["Valuation"] || []) import_budgets(records["Budget"] || []) import_budget_categories(records["BudgetCategory"] || []) @@ -92,15 +104,20 @@ class Family::DataImporter institution_name: data["institution_name"], institution_domain: data["institution_domain"], notes: data["notes"], - status: "active" + status: importable_account_status(data["status"]) ) account.save! - # Set opening balance if we have a historical balance - if data["balance"].present? + # Set opening balance if we have a historical balance and the import + # does not provide an explicit opening-anchor valuation for this account. + if data["balance"].present? && !@imported_opening_anchor_account_ids.include?(old_id) manager = Account::OpeningBalanceManager.new(account) - manager.set_opening_balance(balance: data["balance"].to_d) + result = manager.set_opening_balance( + balance: data["balance"].to_d, + date: opening_balance_date_for(old_id, data) + ) + log_failed_opening_balance_import(account, old_id, result) unless result.success? end @id_mappings[:accounts][old_id] = account.id @@ -108,6 +125,53 @@ class Family::DataImporter end end + def importable_account_status(status) + status.to_s.in?(%w[active disabled draft]) ? status.to_s : "active" + end + + def import_balances(records) + records.each do |record| + data = record["data"] || {} + new_account_id = @id_mappings[:accounts][data["account_id"]] + balance_date = parse_import_date(data["date"]) + next if new_account_id.blank? || balance_date.blank? || data["balance"].blank? + + account = @family.accounts.find(new_account_id) + currency = data["currency"].presence || account.currency + balance = account.balances.find_or_initialize_by(date: balance_date, currency: currency) + + balance.assign_attributes(imported_balance_attributes(data)) + balance.save! + end + end + + def imported_balance_attributes(data) + attributes = { + balance: data["balance"].to_d, + cash_balance: optional_decimal(data["cash_balance"]), + start_cash_balance: optional_decimal(data["start_cash_balance"]), + start_non_cash_balance: optional_decimal(data["start_non_cash_balance"]), + cash_inflows: optional_decimal(data["cash_inflows"]), + cash_outflows: optional_decimal(data["cash_outflows"]), + non_cash_inflows: optional_decimal(data["non_cash_inflows"]), + non_cash_outflows: optional_decimal(data["non_cash_outflows"]), + net_market_flows: optional_decimal(data["net_market_flows"]), + cash_adjustments: optional_decimal(data["cash_adjustments"]), + non_cash_adjustments: optional_decimal(data["non_cash_adjustments"]) + }.compact + + attributes[:flows_factor] = balance_flows_factor_for(data["flows_factor"]) if data["flows_factor"].present? + attributes + end + + def optional_decimal(value) + value.presence&.to_d + end + + def balance_flows_factor_for(value) + value.to_i.in?([ -1, 1 ]) ? value.to_i : 1 + end + def import_categories(records) # First pass: create all categories without parent relationships parent_mappings = {} @@ -174,9 +238,72 @@ class Family::DataImporter end end + def import_recurring_transactions(records) + records.each do |record| + data = record["data"] + old_id = data["id"] + + new_account_id = remap_optional_id(:accounts, data["account_id"]) + next if data["account_id"].present? && new_account_id.blank? + + new_merchant_id = remap_optional_id(:merchants, data["merchant_id"]) + next if data["merchant_id"].present? && new_merchant_id.blank? + + expected_day_of_month = recurring_expected_day_for(data["expected_day_of_month"]) + next unless expected_day_of_month + last_occurrence_date = parse_import_date(data["last_occurrence_date"]) + next_expected_date = parse_import_date(data["next_expected_date"]) + next unless last_occurrence_date && next_expected_date + + recurring_transaction = @family.recurring_transactions.build( + account_id: new_account_id, + merchant_id: new_merchant_id, + amount: data["amount"].to_d, + currency: data["currency"] || @family.currency, + expected_day_of_month: expected_day_of_month, + last_occurrence_date: last_occurrence_date, + next_expected_date: next_expected_date, + status: recurring_transaction_status_for(data["status"]), + occurrence_count: data["occurrence_count"].to_i, + name: data["name"], + manual: boolean_import_value(data, "manual", default: false), + expected_amount_min: data["expected_amount_min"]&.to_d, + expected_amount_max: data["expected_amount_max"]&.to_d, + expected_amount_avg: data["expected_amount_avg"]&.to_d + ) + + recurring_transaction.save! + @id_mappings[:recurring_transactions][old_id] = recurring_transaction.id + end + end + + def remap_optional_id(mapping_key, old_id) + return if old_id.blank? + + @id_mappings[mapping_key][old_id] + end + + def recurring_transaction_status_for(status) + status.to_s.in?(RecurringTransaction.statuses.keys) ? status.to_s : "active" + end + + def recurring_expected_day_for(value) + return if value.blank? + + expected_day = value.to_i + expected_day if expected_day.between?(1, 31) + end + + def boolean_import_value(data, key, default:) + return default unless data.key?(key) + + ActiveModel::Type::Boolean.new.cast(data[key]) + end + def import_transactions(records) records.each do |record| data = record["data"] + old_id = data["id"] # Map account ID new_account_id = @id_mappings[:accounts][data["account_id"]] @@ -227,9 +354,49 @@ class Family::DataImporter end @created_entries << entry + @id_mappings[:transactions][old_id] = transaction.id end end + def import_transfers(records) + records.each do |record| + data = record["data"] + inflow_transaction_id = @id_mappings[:transactions][data["inflow_transaction_id"]] + outflow_transaction_id = @id_mappings[:transactions][data["outflow_transaction_id"]] + next unless inflow_transaction_id && outflow_transaction_id + + Transfer.find_or_create_by!( + inflow_transaction_id: inflow_transaction_id, + outflow_transaction_id: outflow_transaction_id + ) do |transfer| + transfer.status = transfer_status_for(data["status"]) + transfer.notes = data["notes"] + end + end + end + + def import_rejected_transfers(records) + records.each do |record| + data = record["data"] + inflow_transaction_id = @id_mappings[:transactions][data["inflow_transaction_id"]] + outflow_transaction_id = @id_mappings[:transactions][data["outflow_transaction_id"]] + next unless inflow_transaction_id && outflow_transaction_id + + RejectedTransfer.find_or_create_by!( + inflow_transaction_id: inflow_transaction_id, + outflow_transaction_id: outflow_transaction_id + ) + end + end + + def transfer_status_for(status) + status = status.to_s + return status if Transfer.statuses.key?(status) + + Rails.logger.debug("Unknown transfer status #{status.inspect}; defaulting to pending") if status.present? + "pending" + end + def import_trades(records) records.each do |record| data = record["data"] @@ -244,7 +411,13 @@ class Family::DataImporter ticker = data["ticker"] next unless ticker.present? - security = find_or_create_security(ticker, data["currency"]) + security = find_or_create_security( + ticker, + data["currency"], + old_security_id: data["security_id"], + name: data["security_name"], + exchange_operating_mic: data["exchange_operating_mic"] + ) trade = Trade.new( security: security, @@ -267,6 +440,51 @@ class Family::DataImporter end end + def import_holdings(records) + accounts_by_id = @family.accounts.where(id: records.filter_map { |record| @id_mappings[:accounts][record.dig("data", "account_id")] }).index_by(&:id) + + records.each do |record| + data = record["data"] + + new_account_id = @id_mappings[:accounts][data["account_id"]] + next unless new_account_id + + account = accounts_by_id[new_account_id] + next unless account + + ticker = data["ticker"] + next unless ticker.present? + + security = find_or_create_security( + ticker, + data["currency"], + old_security_id: data["security_id"], + name: data["security_name"], + exchange_operating_mic: data["exchange_operating_mic"], + exchange_mic: data["exchange_mic"], + exchange_acronym: data["exchange_acronym"], + country_code: data["country_code"], + kind: data["kind"], + website_url: data["website_url"] + ) + + holding_date = Date.parse(data["date"].to_s) + holding_currency = data["currency"] || account.currency + holding_attributes = { + qty: data["qty"].to_d, + price: data["price"].to_d, + amount: data["amount"].to_d, + currency: holding_currency, + cost_basis: data["cost_basis"]&.to_d, + cost_basis_source: importable_cost_basis_source(data["cost_basis_source"]), + cost_basis_locked: truthy?(data["cost_basis_locked"]) || false, + security_locked: truthy?(data["security_locked"]) || false + } + + upsert_imported_holding!(account, security, holding_date, holding_currency, holding_attributes) + end + end + def import_valuations(records) records.each do |record| data = record["data"] @@ -277,7 +495,7 @@ class Family::DataImporter account = @family.accounts.find(new_account_id) - valuation = Valuation.new + valuation = Valuation.new(kind: valuation_kind_for(data["kind"])) entry = Entry.new( account: account, @@ -293,6 +511,63 @@ class Family::DataImporter end end + def oldest_import_entry_dates_by_account(records) + dates_by_account = {} + + # Account-level opening balances must precede every imported account + # activity, including standalone valuation snapshots. + %w[Balance Transaction Trade Holding Valuation].each do |type| + records[type].to_a.each do |record| + data = record["data"] || {} + account_id = data["account_id"] + date = parse_import_date(data["date"]) + next if account_id.blank? || date.blank? + + dates_by_account[account_id] = [ dates_by_account[account_id], date ].compact.min + end + end + + dates_by_account + end + + def imported_opening_anchor_account_ids(records) + records.each_with_object(Set.new) do |record, account_ids| + data = record["data"] || {} + next unless data["kind"].to_s == "opening_anchor" + next if data["account_id"].blank? + + account_ids.add(data["account_id"]) + end + end + + def opening_balance_date_for(old_id, data) + explicit_date = parse_import_date( + data["opening_balance_date"] || data["opening_balance_on"] + ) + + max_allowed_date = @oldest_import_entry_dates_by_account[old_id]&.prev_day + [ explicit_date, max_allowed_date ].compact.min + end + + def log_failed_opening_balance_import(account, old_id, result) + Rails.logger.warn( + "Failed to import opening balance for account #{account.id} from source account #{old_id}: #{result.error}" + ) + end + + def valuation_kind_for(value) + kind = value.to_s + Valuation.kinds.key?(kind) ? kind : "reconciliation" + end + + def parse_import_date(value) + return if value.blank? + + Date.parse(value.to_s) + rescue Date::Error + nil + end + def import_budgets(records) records.each do |record| data = record["data"] @@ -396,7 +671,7 @@ class Family::DataImporter def resolve_rule_condition_value(condition_data) condition_type = condition_data["condition_type"] - value = condition_data["value"] + value = rule_operand_value(condition_data) return value unless value.present? @@ -424,7 +699,7 @@ class Family::DataImporter def resolve_rule_action_value(action_data) action_type = action_data["action_type"] - value = action_data["value"] + value = rule_operand_value(action_data) return value unless value.present? @@ -457,18 +732,110 @@ class Family::DataImporter value end - def find_or_create_security(ticker, currency) + def rule_operand_value(data) + raw_value = data["value"] + value = raw_value.is_a?(String) ? raw_value.presence : raw_value + value_ref_name = data.dig("value_ref", "name") + + return value_ref_name if value.is_a?(String) && uuid_like?(value) && value_ref_name.present? + return value unless value.nil? + + value_ref_name + end + + def uuid_like?(value) + UuidFormat.valid?(value) + end + + def importable_cost_basis_source(value) + source = value.to_s + Holding::COST_BASIS_SOURCES.include?(source) ? source : nil + end + + def truthy?(value) + ActiveModel::Type::Boolean.new.cast(value) + end + + def find_or_create_security(ticker, currency, old_security_id: nil, **attributes) # Check cache first - cache_key = "#{ticker}:#{currency}" - return @id_mappings[:securities][cache_key] if @id_mappings[:securities][cache_key] + normalized_ticker = ticker.to_s.upcase + exchange_operating_mic = attributes[:exchange_operating_mic].presence&.upcase + cache_key = "#{normalized_ticker}:#{exchange_operating_mic}:#{currency}" - security = Security.find_by(ticker: ticker.upcase) - security ||= Security.create!( - ticker: ticker.upcase, - name: ticker.upcase - ) + if @security_cache[cache_key] + security = @security_cache[cache_key] + apply_security_metadata(security, normalized_ticker, attributes) + return security + end - @id_mappings[:securities][cache_key] = security + if old_security_id.present? && @id_mappings[:securities][old_security_id] + security = Security.find(@id_mappings[:securities][old_security_id]) + apply_security_metadata(security, normalized_ticker, attributes) + @security_cache[cache_key] = security + return security + end + + security = find_security_by_identity(normalized_ticker, exchange_operating_mic) + apply_security_metadata(security, normalized_ticker, attributes) + + @security_cache[cache_key] = security + @id_mappings[:securities][old_security_id] = security.id if old_security_id.present? security end + + def find_security_by_identity(ticker, exchange_operating_mic) + if exchange_operating_mic.present? + return Security.find_or_initialize_by(ticker: ticker, exchange_operating_mic: exchange_operating_mic) + end + + # Without an exchange MIC, matching by ticker is a best-effort restore path and can merge same-ticker securities from different venues. + Security.find_by(ticker: ticker, exchange_operating_mic: nil) || + Security.where(ticker: ticker).order(:created_at).first || + Security.new(ticker: ticker) + end + + def apply_security_metadata(security, ticker, attributes) + assign_if_blank_or_placeholder(security, :name, attributes[:name].presence, placeholder: ticker) + assign_if_blank(security, :exchange_operating_mic, attributes[:exchange_operating_mic].presence&.upcase) + assign_if_blank(security, :exchange_mic, attributes[:exchange_mic].presence) + assign_if_blank(security, :exchange_acronym, attributes[:exchange_acronym].presence) + assign_if_blank(security, :country_code, attributes[:country_code].presence) + assign_if_blank(security, :website_url, attributes[:website_url].presence) + security.kind = security_kind_for(attributes[:kind]) if security.new_record? || security.kind.blank? + + security.save! if security.new_record? || security.changed? + end + + def assign_if_blank(record, attribute, value) + return if value.blank? + return if record.public_send(attribute).present? + + record.public_send("#{attribute}=", value) + end + + def assign_if_blank_or_placeholder(record, attribute, value, placeholder:) + return if value.blank? + + current_value = record.public_send(attribute) + return if current_value.present? && current_value != placeholder + + record.public_send("#{attribute}=", value) + end + + def upsert_imported_holding!(account, security, date, currency, attributes) + holding = account.holdings.find_or_initialize_by(security: security, date: date, currency: currency) + holding.assign_attributes(attributes) + + begin + Holding.transaction(requires_new: true) { holding.save! } + rescue ActiveRecord::RecordNotUnique + existing = account.holdings.find_by!(security: security, date: date, currency: currency) + existing.update!(attributes) + end + end + + def security_kind_for(value) + kind = value.to_s + Security::KINDS.include?(kind) ? kind : Security::KINDS.first + end end diff --git a/app/models/family/ibkr_connectable.rb b/app/models/family/ibkr_connectable.rb new file mode 100644 index 000000000..bebfcb1e8 --- /dev/null +++ b/app/models/family/ibkr_connectable.rb @@ -0,0 +1,22 @@ +module Family::IbkrConnectable + extend ActiveSupport::Concern + + included do + has_many :ibkr_items, dependent: :destroy + end + + def can_connect_ibkr? + true + end + + def create_ibkr_item!(query_id:, token:, item_name: nil) + ibkr_item = ibkr_items.create!( + name: item_name.presence || "Interactive Brokers", + query_id: query_id, + token: token + ) + + ibkr_item.sync_later + ibkr_item + end +end diff --git a/app/models/family/kraken_connectable.rb b/app/models/family/kraken_connectable.rb new file mode 100644 index 000000000..6bc02d235 --- /dev/null +++ b/app/models/family/kraken_connectable.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Family::KrakenConnectable + extend ActiveSupport::Concern + + included do + has_many :kraken_items, dependent: :destroy + end + + def can_connect_kraken? + true + end + + def create_kraken_item!(api_key:, api_secret:, item_name: nil) + item = kraken_items.create!( + name: item_name || "Kraken", + api_key: api_key, + api_secret: api_secret + ) + + item.set_kraken_institution_defaults! + item.sync_later + item + end + + def has_kraken_credentials? + kraken_items.active.any?(&:credentials_configured?) + end +end diff --git a/app/models/family/mercury_connectable.rb b/app/models/family/mercury_connectable.rb index d0bf9fe27..31f3998e8 100644 --- a/app/models/family/mercury_connectable.rb +++ b/app/models/family/mercury_connectable.rb @@ -23,6 +23,6 @@ module Family::MercuryConnectable end def has_mercury_credentials? - mercury_items.where.not(token: nil).exists? + mercury_items.active.any?(&:credentials_configured?) end end diff --git a/app/models/family/sophtron_connectable.rb b/app/models/family/sophtron_connectable.rb new file mode 100644 index 000000000..cc1fb3b0c --- /dev/null +++ b/app/models/family/sophtron_connectable.rb @@ -0,0 +1,31 @@ +module Family::SophtronConnectable + extend ActiveSupport::Concern + + included do + has_many :sophtron_items, dependent: :destroy + end + + def can_connect_sophtron? + # Families can now configure their own Sophtron credentials + true + end + + def create_sophtron_item!(user_id:, access_key:, base_url: nil, item_name: nil) + sophtron_item = sophtron_items.create!( + name: item_name || "Sophtron Connection", + user_id: user_id, + access_key: access_key, + base_url: base_url + ) + + sophtron_item + end + + def has_sophtron_credentials? + sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).exists? + end + + def configured_sophtron_item + sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.first + end +end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 858ed9bc4..7873c5ff0 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -17,7 +17,10 @@ class Family::Syncer coinbase_items coinstats_items mercury_items + brex_items + binance_items snaptrade_items + sophtron_items ].freeze def initialize(family) diff --git a/app/models/holding.rb b/app/models/holding.rb index 0b18b3e9c..ac8c37c26 100644 --- a/app/models/holding.rb +++ b/app/models/holding.rb @@ -38,7 +38,7 @@ class Holding < ApplicationRecord return nil unless amount return 0 if amount.zero? - account.balance.zero? ? 1 : amount / account.balance * 100 + account.balance.zero? ? 1 : amount_in_account_currency / account.balance * 100 end # Returns average cost per share, or nil if unknown. @@ -256,6 +256,14 @@ class Holding < ApplicationRecord end private + def amount_in_account_currency + return amount if currency == account.currency + + Money.new(amount, currency).exchange_to(account.currency, date: date).amount + rescue Money::ConversionError + amount + end + def calculate_trend return nil unless amount_money return nil if avg_cost.nil? # Can't calculate trend without cost basis (0 is valid for airdrops) diff --git a/app/models/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb index ecb59e826..8be1eec34 100644 --- a/app/models/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -18,7 +18,7 @@ class Holding::ForwardCalculator trades = portfolio_cache.get_trades(date: date) update_cost_basis_tracker(trades) next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward) - holdings += build_holdings(next_portfolio, date) + holdings.concat(build_holdings(next_portfolio, date)) current_portfolio = next_portfolio end @@ -89,7 +89,11 @@ class Holding::ForwardCalculator # Convert trade price to account currency if needed trade_price = Money.new(trade.price, trade.currency) - converted_price = trade_price.exchange_to(account.currency, fallback_rate: 1).amount + begin + converted_price = trade_price.exchange_to(account.currency).amount + rescue Money::ConversionError + converted_price = trade.price + end tracker[:total_cost] += converted_price * trade.qty tracker[:total_qty] += trade.qty diff --git a/app/models/holding/gapfillable.rb b/app/models/holding/gapfillable.rb index 45c05089d..7088cba80 100644 --- a/app/models/holding/gapfillable.rb +++ b/app/models/holding/gapfillable.rb @@ -9,10 +9,11 @@ module Holding::Gapfillable next if security_holdings.empty? sorted = security_holdings.sort_by(&:date) + holdings_by_date = security_holdings.index_by(&:date) previous_holding = sorted.first sorted.first.date.upto(Date.current) do |date| - holding = security_holdings.find { |h| h.date == date } + holding = holdings_by_date[date] if holding filled_holdings << holding diff --git a/app/models/holding/materializer.rb b/app/models/holding/materializer.rb index e4ad1737c..5832e2033 100644 --- a/app/models/holding/materializer.rb +++ b/app/models/holding/materializer.rb @@ -22,10 +22,10 @@ class Holding::Materializer # securities are still needed to derive sane balance charts between sync snapshots. cleanup_shadowed_calculated_holdings - # Also remove calculated rows on the provider's latest snapshot date when those - # securities are no longer present in the provider payload. This keeps "current" - # holdings/balance composition aligned with the provider snapshot while preserving - # older calculated history. + # Also remove non-provider rows on the provider's latest snapshot date for securities + # that appear in the provider snapshot. The provider snapshot is authoritative for + # those securities on that day, even when it is denominated in a different currency + # than the account or the reverse-calculated holdings. cleanup_stale_calculated_rows_on_latest_provider_snapshot # Reload holdings association to clear any cached stale data @@ -152,17 +152,12 @@ class Holding::Materializer .where(date: provider_snapshot_date) .distinct .pluck(:security_id) + return if provider_security_ids.empty? - scope = account.holdings - .where(account_provider_id: nil, date: provider_snapshot_date) + deleted_count = account.holdings + .where(account_provider_id: nil, date: provider_snapshot_date, security_id: provider_security_ids) + .delete_all - scope = if provider_security_ids.any? - scope.where.not(security_id: provider_security_ids) - else - scope - end - - deleted_count = scope.delete_all Rails.logger.info("Cleaned up #{deleted_count} stale calculated holdings on latest provider snapshot date") if deleted_count > 0 end @@ -171,7 +166,7 @@ class Holding::Materializer end def purge_stale_holdings - portfolio_security_ids = account.entries.trades.map { |entry| entry.entryable.security_id }.uniq + portfolio_security_ids = account.trades.distinct.pluck(:security_id) # Never delete provider-sourced holdings - they're authoritative from the provider # If there are no securities in the portfolio, only delete non-provider holdings diff --git a/app/models/holding/portfolio_cache.rb b/app/models/holding/portfolio_cache.rb index 6763d1fd1..4ea52920e 100644 --- a/app/models/holding/portfolio_cache.rb +++ b/app/models/holding/portfolio_cache.rb @@ -18,7 +18,7 @@ class Holding::PortfolioCache if date.blank? trades else - trades.select { |t| t.date == date } + trades_by_date[date]&.dup || [] end end @@ -26,17 +26,24 @@ class Holding::PortfolioCache security = @security_cache[security_id] raise SecurityNotFound.new(security_id, account.id) unless security - if source.present? - price = security[:prices].select { |p| p.price.date == date && p.source == source }.min_by(&:priority)&.price + price_with_priority = if source.present? + security[:prices_by_date_and_source][[ date, source ]] else - price = security[:prices].select { |p| p.price.date == date }.min_by(&:priority)&.price + security[:prices_by_date][date] end + return nil unless price_with_priority + + price = price_with_priority.price return nil unless price price_money = Money.new(price.price, price.currency) - converted_amount = price_money.exchange_to(account.currency, fallback_rate: 1).amount + begin + converted_amount = price_money.exchange_to(account.currency, date: date).amount + rescue Money::ConversionError + converted_amount = price.price + end Security::Price.new( security_id: security_id, @@ -57,20 +64,28 @@ class Holding::PortfolioCache @trades ||= account.entries.includes(entryable: :security).trades.chronological.to_a end + def trades_by_date + @trades_by_date ||= trades.group_by(&:date) + end + + def trades_by_security_id + @trades_by_security_id ||= trades.group_by { |t| t.entryable.security_id } + end + def holdings @holdings ||= account.holdings.chronological.to_a end + def holdings_by_security_id + @holdings_by_security_id ||= holdings.group_by(&:security_id) + end + def collect_unique_securities - unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq - unique_securities_from_trades = unique_securities_from_trades.select { |s| @security_ids.include?(s.id) } if @security_ids + ids = trades_by_security_id.keys + ids |= holdings_by_security_id.keys if use_holdings + ids &= @security_ids if @security_ids - return unique_securities_from_trades unless use_holdings - - unique_securities_from_holdings = holdings.map(&:security).uniq - unique_securities_from_holdings = unique_securities_from_holdings.select { |s| @security_ids.include?(s.id) } if @security_ids - - (unique_securities_from_trades + unique_securities_from_holdings).uniq + Security.where(id: ids).to_a end # Loads all known prices for all securities in the account with priority based on source: @@ -83,11 +98,18 @@ class Holding::PortfolioCache Rails.logger.info "Preloading #{securities.size} securities for account #{account.id}" + security_ids = securities.map(&:id) + + # Bulk-load all DB prices for all securities in one query, grouped by security_id + db_prices_by_security_id = Security::Price + .where(security_id: security_ids, date: account.start_date..Date.current) + .group_by(&:security_id) + securities.each do |security| Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}" # High priority prices from DB (synced from provider) - db_prices = security.prices.where(date: account.start_date..Date.current).map do |price| + db_prices = (db_prices_by_security_id[security.id] || []).map do |price| PriceWithPriority.new( price: price, priority: 1, @@ -96,8 +118,7 @@ class Holding::PortfolioCache end # Medium priority prices from trades - trade_prices = trades - .select { |t| t.entryable.security_id == security.id } + trade_prices = (trades_by_security_id[security.id] || []) .map do |trade| PriceWithPriority.new( price: Security::Price.new( @@ -113,7 +134,7 @@ class Holding::PortfolioCache # Low priority prices from holdings (if applicable) holding_prices = if use_holdings - holdings.select { |h| h.security_id == security.id }.map do |holding| + (holdings_by_security_id[security.id] || []).map do |holding| PriceWithPriority.new( price: Security::Price.new( security: security, @@ -129,9 +150,18 @@ class Holding::PortfolioCache [] end + all_prices = db_prices + trade_prices + holding_prices + + # Index by date for O(1) lookup in get_price instead of O(N) linear scan + prices_by_date = all_prices.group_by { |p| p.price.date } + .transform_values { |ps| ps.min_by(&:priority) } + prices_by_date_and_source = all_prices.group_by { |p| [ p.price.date, p.source ] } + .transform_values { |ps| ps.min_by(&:priority) } + @security_cache[security.id] = { security: security, - prices: db_prices + trade_prices + holding_prices + prices_by_date: prices_by_date, + prices_by_date_and_source: prices_by_date_and_source } end end diff --git a/app/models/holding/reverse_calculator.rb b/app/models/holding/reverse_calculator.rb index d9ed2efe0..0df96a616 100644 --- a/app/models/holding/reverse_calculator.rb +++ b/app/models/holding/reverse_calculator.rb @@ -35,7 +35,7 @@ class Holding::ReverseCalculator previous_portfolio = transform_portfolio(current_portfolio, today_trades, direction: :reverse) # If current day, always use holding prices (since that's what Plaid gives us). For historical values, use market data (since Plaid doesn't supply historical prices) - holdings += build_holdings(current_portfolio, date, price_source: date == Date.current ? "holding" : nil) + holdings.concat(build_holdings(current_portfolio, date, price_source: date == Date.current ? "holding" : nil)) current_portfolio = previous_portfolio end @@ -79,41 +79,46 @@ class Holding::ReverseCalculator end.compact end - # Pre-compute cost basis for all securities at all dates using forward pass through trades - # Stores: { security_id => { date => cost_basis } } def precompute_cost_basis - @cost_basis_by_date = Hash.new { |h, k| h[k] = {} } + @cost_basis_snapshots = Hash.new { |h, k| h[k] = [] } tracker = Hash.new { |h, k| h[k] = { total_cost: BigDecimal("0"), total_qty: BigDecimal("0") } } - trades = portfolio_cache.get_trades.sort_by(&:date) - trade_index = 0 + portfolio_cache.get_trades.sort_by(&:date).each do |trade_entry| + trade = trade_entry.entryable + next unless trade.qty > 0 - account.start_date.upto(Date.current).each do |date| - # Process all trades up to and including this date - while trade_index < trades.size && trades[trade_index].date <= date - trade_entry = trades[trade_index] - trade = trade_entry.entryable - - if trade.qty > 0 # Only track buys - security_id = trade.security_id - trade_price = Money.new(trade.price, trade.currency) - converted_price = trade_price.exchange_to(account.currency, fallback_rate: 1).amount - - tracker[security_id][:total_cost] += converted_price * trade.qty - tracker[security_id][:total_qty] += trade.qty - end - trade_index += 1 + security_id = trade.security_id + trade_price = Money.new(trade.price, trade.currency) + begin + converted_price = trade_price.exchange_to(account.currency).amount + rescue Money::ConversionError + converted_price = trade.price end - # Store current cost basis snapshot for each security at this date - tracker.each do |security_id, data| - next if data[:total_qty].zero? - @cost_basis_by_date[security_id][date] = data[:total_cost] / data[:total_qty] - end + tracker[security_id][:total_cost] += converted_price * trade.qty + tracker[security_id][:total_qty] += trade.qty + + @cost_basis_snapshots[security_id] << [ + trade_entry.date, + tracker[security_id][:total_cost] / tracker[security_id][:total_qty] + ] end end def cost_basis_for(security_id, date) - @cost_basis_by_date.dig(security_id, date) + snapshots = @cost_basis_snapshots[security_id] + return nil if snapshots.empty? + + lo, hi, result = 0, snapshots.size - 1, nil + while lo <= hi + mid = (lo + hi) / 2 + if snapshots[mid][0] <= date + result = snapshots[mid][1] + lo = mid + 1 + else + hi = mid - 1 + end + end + result end end diff --git a/app/models/ibkr_account.rb b/app/models/ibkr_account.rb new file mode 100644 index 000000000..eb491f96a --- /dev/null +++ b/app/models/ibkr_account.rb @@ -0,0 +1,78 @@ +class IbkrAccount < ApplicationRecord + include CurrencyNormalizable, Encryptable + include IbkrAccount::DataHelpers + + if encryption_ready? + encrypts :raw_holdings_payload + encrypts :raw_activities_payload + encrypts :raw_cash_report_payload + encrypts :raw_equity_summary_payload + end + + belongs_to :ibkr_item + + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + validates :ibkr_account_id, uniqueness: { scope: :ibkr_item_id, allow_nil: true } + + def current_account + account || linked_account + end + + def ensure_account_provider!(account = nil) + if account_provider.present? + account_provider.update!(account: account) if account && account_provider.account_id != account.id + return account_provider + end + + acct = account || current_account + return nil unless acct + + provider = AccountProvider + .find_or_initialize_by(provider_type: "IbkrAccount", provider_id: id) + .tap do |record| + record.account = acct + record.save! + end + + reload_account_provider + provider + rescue => e + Rails.logger.warn("IbkrAccount##{id}: failed to ensure AccountProvider link: #{e.class} - #{e.message}") + nil + end + + def upsert_from_ibkr_statement!(account_data) + data = account_data.with_indifferent_access + + update!( + ibkr_account_id: data[:ibkr_account_id], + name: data[:name], + currency: parse_currency(data[:currency]) || "USD", + current_balance: data[:current_balance], + cash_balance: data[:cash_balance], + institution_metadata: { + provider_name: "Interactive Brokers", + statement_from_date: data.dig(:statement, :from_date), + statement_to_date: data.dig(:statement, :to_date) + }.compact, + report_date: data[:report_date], + raw_holdings_payload: data[:open_positions] || [], + raw_activities_payload: { + trades: data[:trades] || [], + cash_transactions: data[:cash_transactions] || [] + }, + raw_cash_report_payload: data[:cash_report] || [], + raw_equity_summary_payload: data[:equity_summary_in_base] || [], + last_holdings_sync: Time.current, + last_activities_sync: Time.current + ) + end + + def ibkr_provider + ibkr_item.ibkr_provider + end +end diff --git a/app/models/ibkr_account/activities_processor.rb b/app/models/ibkr_account/activities_processor.rb new file mode 100644 index 000000000..e2f906d07 --- /dev/null +++ b/app/models/ibkr_account/activities_processor.rb @@ -0,0 +1,220 @@ +class IbkrAccount::ActivitiesProcessor + include IbkrAccount::DataHelpers + + SUPPORTED_CASH_TRANSACTION_TYPES = [ "DEPOSITS/WITHDRAWALS", "DIVIDENDS" ].freeze + + def initialize(ibkr_account) + @ibkr_account = ibkr_account + end + + def process + return { trades: 0, transactions: 0 } unless account.present? + + activities = (@ibkr_account.raw_activities_payload || {}).with_indifferent_access + trades = Array(activities[:trades]) + cash_transactions = Array(activities[:cash_transactions]) + + trade_results = trades.map { |trade| process_trade(trade.with_indifferent_access) } + trades_count = trade_results.count { |r| r[:imported] } + fee_count = trade_results.sum { |r| r[:fees] } + + cash_count = cash_transactions.sum { |t| process_cash_transaction(t.with_indifferent_access) ? 1 : 0 } + + { trades: trades_count, transactions: cash_count + fee_count } + end + + private + + def account + @ibkr_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def process_trade(row) + return { imported: false, fees: 0 } unless supported_trade?(row) + + security = resolve_security(row) + return { imported: false, fees: 0 } unless security + + quantity = parse_decimal(row[:quantity]) + native_price = parse_decimal(row[:trade_price]) + return { imported: false, fees: 0 } if quantity.nil? || native_price.nil? + + buy_sell = row[:buy_sell].to_s.upcase + signed_quantity = buy_sell == "SELL" ? -quantity.abs : quantity.abs + native_amount = buy_sell == "SELL" ? -(native_price * quantity.abs) : (native_price * quantity.abs) + currency = extract_currency(row, fallback: @ibkr_account.currency) + date = trade_date_for(row) + external_id = "ibkr_trade_#{row[:trade_id]}" + + import_adapter.import_trade( + external_id: external_id, + security: security, + quantity: signed_quantity, + price: native_price, + amount: native_amount, + currency: currency, + date: date, + name: build_trade_name(security.ticker, signed_quantity), + source: "ibkr", + activity_label: buy_sell == "SELL" ? "Sell" : "Buy", + exchange_rate: parse_decimal(row[:fx_rate_to_base])&.to_f + ) + + fees = import_commission_transaction(row, security, date) ? 1 : 0 + { imported: true, fees: fees } + rescue => e + Rails.logger.error("IbkrAccount::ActivitiesProcessor - Failed to process trade #{row[:trade_id]}: #{e.message}") + { imported: false, fees: 0 } + end + + def process_cash_transaction(row) + return false unless supported_cash_transaction?(row) + + amount = parse_decimal(row[:amount]) + return false if amount.nil? || amount.zero? + + label, signed_amount = classify_cash_transaction(row, amount) + return false unless label + currency = extract_currency(row, fallback: @ibkr_account.currency) + security = resolve_security_for_cash_transaction(row) + + import_adapter.import_transaction( + external_id: "ibkr_cash_#{row[:transaction_id]}", + amount: signed_amount, + currency: currency, + date: parse_date(row[:report_date]), + name: build_cash_transaction_name(row, label, security), + source: "ibkr", + investment_activity_label: label, + extra: { + exchange_rate: parse_decimal(row[:fx_rate_to_base])&.to_f, + security_id: security&.id, + ibkr: { + transaction_id: row[:transaction_id], + type: row[:type], + conid: row[:conid], + amount: row[:amount], + currency: row[:currency], + fx_rate_to_base: row[:fx_rate_to_base], + report_date: row[:report_date] + }.compact + } + ) + + true + rescue => e + Rails.logger.error("IbkrAccount::ActivitiesProcessor - Failed to process cash transaction #{row[:transaction_id]}: #{e.message}") + false + end + + def import_commission_transaction(row, security, date) + commission = parse_decimal(row[:ib_commission]) + return false if commission.nil? || commission.zero? + + currency = row[:ib_commission_currency].to_s.upcase.presence || @ibkr_account.currency + ticker = security&.ticker || row[:symbol] + + result = import_adapter.import_transaction( + external_id: "ibkr_trade_fee_#{row[:trade_id]}", + amount: commission.abs, + currency: currency, + date: date, + name: "Trade Commission for #{ticker}", + source: "ibkr", + investment_activity_label: "Fee", + extra: { + exchange_rate: parse_decimal(row[:fx_rate_to_base])&.to_f, + security_id: security&.id, + ibkr: { + trade_id: row[:trade_id], + transaction_id: row[:transaction_id], + ib_commission: row[:ib_commission], + ib_commission_currency: row[:ib_commission_currency], + fx_rate_to_base: row[:fx_rate_to_base] + }.compact + } + ) + + !!result + end + + def build_trade_name(ticker, signed_quantity) + action = signed_quantity.negative? ? "Sell" : "Buy" + "#{action} #{signed_quantity.abs} shares of #{ticker}" + end + + def supported_trade?(row) + row[:asset_category].to_s == "STK" && + row[:buy_sell].present? && + row[:conid].present? && + row[:currency].present? && + row[:quantity].present? && + row[:symbol].present? && + row[:trade_date].present? && + row[:trade_id].present? && + row[:trade_price].present? && + row[:transaction_id].present? && + fx_rate_available?(row) + end + + def supported_cash_transaction?(row) + type = row[:type].to_s.upcase.strip + return false unless SUPPORTED_CASH_TRANSACTION_TYPES.include?(type) + return false unless row[:transaction_id].present? && row[:amount].present? && row[:currency].present? && row[:report_date].present? + return false unless fx_rate_available?(row) + + type != "DIVIDENDS" || row[:conid].present? + end + + # supported_cash_transaction? ensures only known types reach here; no else branch needed + def classify_cash_transaction(row, amount) + type = row[:type].to_s.upcase.strip + + case type + when "DEPOSITS/WITHDRAWALS" + amount.positive? ? [ "Contribution", -amount.abs ] : [ "Withdrawal", amount.abs ] + when "DIVIDENDS" + [ "Dividend", -amount.abs ] + end + end + + def build_cash_transaction_name(row, label, security = nil) + return label unless label == "Dividend" + + ticker = security&.ticker || security_symbol_for_conid(row[:conid]) || row[:conid] + "Dividend from #{ticker}" + end + + def resolve_security_for_cash_transaction(row) + symbol = security_symbol_for_conid(row[:conid]) + return nil if symbol.blank? + + resolve_security({ symbol: symbol }) + end + + def security_symbol_for_conid(conid) + return nil if conid.blank? + + holding_symbol = Array(@ibkr_account.raw_holdings_payload).find do |holding| + holding.with_indifferent_access[:conid].to_s == conid.to_s + end&.with_indifferent_access&.dig(:symbol) + return holding_symbol if holding_symbol.present? + + activities = (@ibkr_account.raw_activities_payload || {}).with_indifferent_access + Array(activities[:trades]).find do |trade| + trade.with_indifferent_access[:conid].to_s == conid.to_s + end&.with_indifferent_access&.dig(:symbol) + end + + def fx_rate_available?(row) + source_currency = extract_currency(row, fallback: nil) + return false if source_currency.blank? + return true if source_currency == @ibkr_account.currency + + row[:fx_rate_to_base].present? + end +end diff --git a/app/models/ibkr_account/data_helpers.rb b/app/models/ibkr_account/data_helpers.rb new file mode 100644 index 000000000..6135557a2 --- /dev/null +++ b/app/models/ibkr_account/data_helpers.rb @@ -0,0 +1,81 @@ +module IbkrAccount::DataHelpers + extend ActiveSupport::Concern + + private + + def parse_decimal(value) + return nil if value.nil? + + normalized = value.is_a?(String) ? value.delete(",").strip : value.to_s + return nil if normalized.blank? || normalized == "-" + + # Convert accounting parentheses notation: "(1234.56)" → "-1234.56" + normalized = "-#{normalized[1..-2]}" if normalized.start_with?("(") && normalized.end_with?(")") + + BigDecimal(normalized) + rescue ArgumentError + nil + end + + def parse_date(value) + return nil if value.blank? + + case value + when Date + value + when Time, DateTime, ActiveSupport::TimeWithZone + value.to_date + else + normalized = value.to_s.tr(";", " ") + Time.zone.parse(normalized)&.to_date || Date.parse(normalized) + end + rescue ArgumentError, TypeError + nil + end + + def parse_datetime(value) + return nil if value.blank? + + case value + when Time, DateTime, ActiveSupport::TimeWithZone + value.in_time_zone + when Date + value.in_time_zone + else + Time.zone.parse(value.to_s.tr(";", " ")) + end + rescue ArgumentError, TypeError + nil + end + + def resolve_security(row) + data = row.with_indifferent_access + ticker = data[:symbol].to_s.strip.upcase + return nil if ticker.blank? + + Security.find_by(ticker: ticker) || create_security_from_row(ticker) + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique + Security.find_by(ticker: ticker) + end + + def trade_date_for(row) + data = row.with_indifferent_access + parsed_trade_date = parse_date(data[:trade_date]) + return parsed_trade_date if parsed_trade_date + + Rails.logger.warn( + "IbkrAccount::DataHelpers - Missing or invalid trade_date, falling back to Date.current. " \ + "trade_id=#{data[:trade_id].inspect}" + ) + Date.current + end + + def extract_currency(row, fallback: nil) + value = row.with_indifferent_access[:currency] + value.present? ? value.to_s.upcase : fallback + end + + def create_security_from_row(ticker) + Security.create!(ticker: ticker, name: ticker) + end +end diff --git a/app/models/ibkr_account/historical_balances_sync.rb b/app/models/ibkr_account/historical_balances_sync.rb new file mode 100644 index 000000000..4d302d7ba --- /dev/null +++ b/app/models/ibkr_account/historical_balances_sync.rb @@ -0,0 +1,139 @@ +class IbkrAccount::HistoricalBalancesSync + include IbkrAccount::DataHelpers + + attr_reader :ibkr_account + + def initialize(ibkr_account) + @ibkr_account = ibkr_account + end + + def sync! + return unless account.present? + return if normalized_rows.empty? + + account.balances.upsert_all( + balance_rows, + unique_by: %i[account_id date currency] + ) + end + + private + + def account + ibkr_account.current_account + end + + def account_currency + ibkr_account.currency.to_s.upcase + end + + def normalized_rows + @normalized_rows ||= begin + # Batch-load the materializer's already-computed balances so we can + # preserve its cash split rather than reading cash from the equity summary. + # Real IBKR Flex exports do not reliably include a cash/stock breakdown in + # EquitySummaryByReportDateInBase — only the total is consistently present. + existing_balances = account.balances + .where(currency: account.currency) + .index_by(&:date) + + trading_day_rows = Array(ibkr_account.raw_equity_summary_payload) + .filter_map do |row| + next unless row.is_a?(Hash) + + data = row.with_indifferent_access + currency = data[:currency].presence&.upcase + + # BASE_SUMMARY rows aggregate across all currencies — not a per-date balance + next if currency == "BASE_SUMMARY" + # Reject rows with an explicit wrong currency; absent currency is accepted + # (some Flex configurations omit it and the row is implicitly in base currency) + next if currency.present? && currency != account_currency + + date = parse_date(data[:report_date]) + next unless date + + total = parse_decimal(data[:total]) + if total.nil? + Rails.logger.warn( + "IbkrAccount::HistoricalBalancesSync - Skipping equity summary row with missing or unparseable total " \ + "for date=#{data[:report_date].inspect} account=#{account.id}" + ) + next + end + + # Use the materializer's cash_balance as ground truth for the cash split. + # This is consistent with how the reverse calculator handles present-day + # weekends and holidays — derive cash from holdings, not from IBKR's field. + cash = existing_balances[date]&.cash_balance || BigDecimal("0") + + { date: date, total: total, cash: cash, non_cash: total - cash } + end + .sort_by { |r| r[:date] } + + fill_gaps(trading_day_rows, existing_balances) + end + end + + # IBKR does not emit rows for weekends and some holidays. The reverse + # calculator fills those dates using only imported holdings — which only + # cover the current snapshot — so it cannot reconstruct the correct + # non-cash value for historical gap dates. We carry the most recent + # IBKR total forward to every missing calendar day and pair it with the + # materializer's already-correct cash for that date. + # + # The range is extended to the account's current anchor date so that days + # after the last equity summary row (e.g. a Saturday sync where the payload + # ends on Friday) are also covered and not left with the materializer's + # stale total=cash value. + def fill_gaps(rows, existing_balances) + return [] if rows.empty? + + by_date = rows.index_by { |r| r[:date] } + first_date = rows.first[:date] + anchor_date = [ account.current_anchor_date || Date.current, Date.current ].min + last_date = [ rows.last[:date], anchor_date ].max + + last_total = nil + (first_date..last_date).filter_map do |date| + if by_date[date] + last_total = by_date[date][:total] + by_date[date] + else + next unless last_total + cash = existing_balances[date]&.cash_balance || BigDecimal("0") + { date: date, total: last_total, cash: cash, non_cash: last_total - cash } + end + end + end + + def balance_rows + current_time = Time.current + + normalized_rows.each_with_index.map do |row, index| + previous_row = index.zero? ? nil : normalized_rows[index - 1] + start_cash_balance = previous_row ? previous_row[:cash] : row[:cash] + start_non_cash_balance = previous_row ? previous_row[:non_cash] : row[:non_cash] + + { + account_id: account.id, + date: row[:date], + balance: row[:total], + cash_balance: row[:cash], + currency: account.currency, + start_cash_balance: start_cash_balance, + start_non_cash_balance: start_non_cash_balance, + cash_inflows: 0, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: row[:cash] - start_cash_balance, + non_cash_adjustments: row[:non_cash] - start_non_cash_balance, + flows_factor: 1, + created_at: current_time, + updated_at: current_time + } + end + end +end diff --git a/app/models/ibkr_account/holdings_processor.rb b/app/models/ibkr_account/holdings_processor.rb new file mode 100644 index 000000000..8d5ebc704 --- /dev/null +++ b/app/models/ibkr_account/holdings_processor.rb @@ -0,0 +1,116 @@ +class IbkrAccount::HoldingsProcessor + include IbkrAccount::DataHelpers + + def initialize(ibkr_account) + @ibkr_account = ibkr_account + end + + def process + return unless account.present? + + grouped_positions.each do |(_, _, report_date), group| + process_group(group, report_date) + end + end + + private + + def account + @ibkr_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def grouped_positions + Array(@ibkr_account.raw_holdings_payload).each_with_object({}) do |position, groups| + data = position.with_indifferent_access + next unless supported_position?(data) + + # conid is guaranteed present by supported_position?, so no fallbacks needed + currency = extract_currency(data, fallback: @ibkr_account.currency) + report_date = parse_date(data[:report_date]) || @ibkr_account.report_date || Date.current + key = [ data[:conid], currency, report_date ] + groups[key] ||= [] + groups[key] << data + end + end + + def process_group(rows, report_date) + sample = rows.first + security = resolve_security(sample) + return unless security + + price = parse_decimal(sample[:mark_price]) + # quantity and cost_basis are derived from the same set of valid lots so + # they are always consistent — a lot with an unparseable cost_basis_price + # is excluded from both counts rather than inflating qty while shrinking basis. + aggregate = valid_lots(rows) + return unless price && aggregate + + quantity = aggregate[:quantity] + cost_basis = aggregate[:cost_basis] + amount = quantity * price + currency = extract_currency(sample, fallback: @ibkr_account.currency) + external_id = [ "ibkr", @ibkr_account.ibkr_account_id, sample[:conid], report_date, currency ].join("_") + + import_adapter.import_holding( + security: security, + quantity: quantity, + amount: amount, + currency: currency, + date: report_date, + price: price, + cost_basis: cost_basis, + external_id: external_id, + source: "ibkr", + account_provider_id: @ibkr_account.account_provider&.id, + delete_future_holdings: false + ) + end + + # Aggregates only the lots that have both a parseable position and cost_basis_price. + # Returns { quantity:, cost_basis: } so the caller uses a consistent lot set for + # both values — a lot skipped here is excluded from quantity too, preventing the + # case where qty covers more shares than the cost basis was computed from. + def valid_lots(rows) + total_quantity = BigDecimal("0") + total_cost = BigDecimal("0") + + rows.each do |row| + row_quantity = parse_decimal(row[:position]) + row_cost_basis = parse_decimal(row[:cost_basis_price]) + + unless row_quantity && row_cost_basis + Rails.logger.warn( + "IbkrAccount::HoldingsProcessor - Skipping lot with missing position or cost_basis_price " \ + "for conid=#{row[:conid].inspect}" + ) + next + end + + total_quantity += row_quantity.abs + total_cost += row_quantity.abs * row_cost_basis + end + + return nil if total_quantity.zero? + + { quantity: total_quantity, cost_basis: total_cost / total_quantity } + end + + def supported_position?(row) + row[:asset_category].to_s == "STK" && + row[:side].to_s == "Long" && + row[:conid].present? && + row[:security_id].present? && + row[:security_id_type].present? && + row[:symbol].present? && + row[:currency].present? && + row[:fx_rate_to_base].present? && + row[:position].present? && + row[:mark_price].present? && + row[:cost_basis_price].present? && + row[:report_date].present? + end +end diff --git a/app/models/ibkr_account/processor.rb b/app/models/ibkr_account/processor.rb new file mode 100644 index 000000000..0a924d4a7 --- /dev/null +++ b/app/models/ibkr_account/processor.rb @@ -0,0 +1,63 @@ +class IbkrAccount::Processor + attr_reader :ibkr_account + + def initialize(ibkr_account) + @ibkr_account = ibkr_account + end + + def process + return unless account.present? + + update_account_balance! + IbkrAccount::HoldingsProcessor.new(ibkr_account).process + IbkrAccount::ActivitiesProcessor.new(ibkr_account).process + repair_default_opening_anchor! + + account.broadcast_sync_complete + end + + private + + def account + @account ||= ibkr_account.current_account + end + + def update_account_balance! + total_balance = ibkr_account.current_balance || ibkr_account.cash_balance || 0 + cash_balance = ibkr_account.cash_balance || 0 + + account.assign_attributes( + balance: total_balance, + cash_balance: cash_balance, + currency: ibkr_account.currency + ) + account.save! + account.set_current_balance(total_balance) + end + + def repair_default_opening_anchor! + return unless account&.linked_to?("IbkrAccount") + return unless account.has_opening_anchor? + + opening_anchor_entry = account.valuations.opening_anchor.includes(:entry).first&.entry + return unless opening_anchor_entry + return unless opening_anchor_entry.created_at.to_date == account.created_at.to_date + return unless account.entries.where.not(entryable_type: "Valuation").exists? + + imported_current_balance = (ibkr_account.current_balance || ibkr_account.cash_balance || 0).to_d + return unless opening_anchor_entry.amount.to_d == imported_current_balance + + result = Account::OpeningBalanceManager.new(account).set_opening_balance( + balance: 0, + date: opening_anchor_entry.date + ) + + # Don't raise — broadcast_sync_complete must still run after a repair failure. + if result.error + Rails.logger.error( + "IbkrAccount::Processor - Failed to repair opening anchor for account #{account.id}: #{result.error}" + ) + Sentry.capture_message(result.error) + end + end +end diff --git a/app/models/ibkr_item.rb b/app/models/ibkr_item.rb new file mode 100644 index 000000000..aca60d1b2 --- /dev/null +++ b/app/models/ibkr_item.rb @@ -0,0 +1,124 @@ +class IbkrItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + if encryption_ready? + encrypts :query_id, deterministic: true + encrypts :token + encrypts :raw_payload + end + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + + has_many :ibkr_accounts, dependent: :destroy + + validates :name, presence: true + validates :query_id, presence: true, on: :create + validates :token, presence: true, on: :create + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active.where.not(query_id: [ nil, "" ]).where.not(token: nil) } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def credentials_configured? + query_id.present? && token.present? + end + + def import_latest_ibkr_data + provider = ibkr_provider + raise StandardError, "IBKR provider is not configured" unless provider + + IbkrItem::Importer.new(self, ibkr_provider: provider).import + rescue => e + Rails.logger.error("IbkrItem #{id} - Failed to import data: #{e.message}") + raise + end + + def process_accounts + return [] if ibkr_accounts.empty? + + linked_ibkr_accounts.includes(account_provider: :account).each_with_object([]) do |ibkr_account, results| + account = ibkr_account.current_account + next unless account + next if account.pending_deletion? || account.disabled? + + begin + result = IbkrAccount::Processor.new(ibkr_account).process + results << { ibkr_account_id: ibkr_account.id, success: true, result: result } + rescue => e + Rails.logger.error("IbkrItem #{id} - Failed to process account #{ibkr_account.id}: #{e.message}") + results << { ibkr_account_id: ibkr_account.id, success: false, error: e.message } + end + end + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + accounts.reject { |account| account.pending_deletion? || account.disabled? }.each_with_object([]) do |account, results| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error("IbkrItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}") + results << { account_id: account.id, success: false, error: e.message } + end + end + end + + def upsert_ibkr_snapshot!(payload) + update!(raw_payload: payload, status: :good) + end + + def accounts + ibkr_accounts.includes(account_provider: :account).filter_map(&:current_account).uniq + end + + def linked_ibkr_accounts + ibkr_accounts.joins(:account_provider) + end + + def linked_accounts_count + ibkr_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + ibkr_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + ibkr_accounts.count + end + + def has_completed_initial_setup? + accounts.any? + end + + def sync_status_summary + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count + + if total_accounts.zero? + I18n.t("ibkr_items.sync_status.no_accounts") + elsif unlinked_count.zero? + I18n.t("ibkr_items.sync_status.all_linked", count: linked_count) + else + I18n.t("ibkr_items.sync_status.partial", linked: linked_count, unlinked: unlinked_count) + end + end + + def institution_display_name + I18n.t("ibkr_items.defaults.name") + end +end diff --git a/app/models/ibkr_item/importer.rb b/app/models/ibkr_item/importer.rb new file mode 100644 index 000000000..30a5916da --- /dev/null +++ b/app/models/ibkr_item/importer.rb @@ -0,0 +1,33 @@ +class IbkrItem::Importer + attr_reader :ibkr_item, :ibkr_provider + + def initialize(ibkr_item, ibkr_provider:) + @ibkr_item = ibkr_item + @ibkr_provider = ibkr_provider + end + + def import + xml_body = ibkr_provider.download_statement + parsed_report = IbkrItem::ReportParser.new(xml_body).parse + + accounts_imported = 0 + ibkr_item.transaction do + ibkr_item.upsert_ibkr_snapshot!(parsed_report[:metadata].merge("fetched_at" => Time.current.iso8601)) + + parsed_report[:accounts].each do |account_data| + next if account_data[:ibkr_account_id].blank? + + ibkr_account = ibkr_item.ibkr_accounts.find_or_initialize_by(ibkr_account_id: account_data[:ibkr_account_id]) + ibkr_account.upsert_from_ibkr_statement!(account_data) + accounts_imported += 1 + end + + ibkr_item.update!(status: :good) + end + + { + success: true, + accounts_imported: accounts_imported + } + end +end diff --git a/app/models/ibkr_item/provided.rb b/app/models/ibkr_item/provided.rb new file mode 100644 index 000000000..55c6d43fb --- /dev/null +++ b/app/models/ibkr_item/provided.rb @@ -0,0 +1,9 @@ +module IbkrItem::Provided + extend ActiveSupport::Concern + + def ibkr_provider + return nil unless credentials_configured? + + Provider::IbkrFlex.new(query_id: query_id, token: token) + end +end diff --git a/app/models/ibkr_item/report_parser.rb b/app/models/ibkr_item/report_parser.rb new file mode 100644 index 000000000..edab81812 --- /dev/null +++ b/app/models/ibkr_item/report_parser.rb @@ -0,0 +1,143 @@ +class IbkrItem::ReportParser + include IbkrAccount::DataHelpers + + class ParseError < StandardError; end + + POSITION_VALUE_CONTAINER_NAMES = %w[ChangeInPositionValues].freeze + POSITION_VALUE_ROW_NAMES = %w[ChangeInPositionValue].freeze + CASH_REPORT_CONTAINER_NAMES = %w[CashReport CashReports].freeze + CASH_REPORT_ROW_NAMES = %w[CashReport CashReportCurrency CashReportRow].freeze + EQUITY_SUMMARY_CONTAINER_NAMES = %w[EquitySummaryInBase].freeze + EQUITY_SUMMARY_ROW_NAMES = %w[EquitySummaryByReportDateInBase].freeze + OPEN_POSITION_CONTAINER_NAMES = %w[OpenPositions].freeze + OPEN_POSITION_ROW_NAMES = %w[OpenPosition].freeze + TRADES_CONTAINER_NAMES = %w[Trades].freeze + TRADE_ROW_NAMES = %w[Trade].freeze + CASH_TRANSACTION_CONTAINER_NAMES = %w[CashTransactions].freeze + CASH_TRANSACTION_ROW_NAMES = %w[CashTransaction].freeze + + def initialize(xml_body) + @document = Nokogiri::XML(xml_body.to_s) { |config| config.strict.noblanks } + rescue Nokogiri::XML::SyntaxError => e + raise ParseError, "Invalid IBKR Flex XML: #{e.message}" + end + + def parse + validate_document! + + { + metadata: root_metadata, + accounts: flex_statements.map { |statement| parse_statement(statement) } + } + end + + private + + def validate_document! + raise ParseError, "Invalid IBKR Flex XML: missing FlexQueryResponse root." unless @document.at_xpath("//FlexQueryResponse") + raise ParseError, "Invalid IBKR Flex XML: no FlexStatement nodes found." if flex_statements.empty? + end + + def flex_statements + @document.xpath("//FlexStatement") + end + + def root_metadata + node_attributes(@document.at_xpath("//FlexQueryResponse")) + end + + def parse_statement(statement) + statement_data = node_attributes(statement) + account_information = node_attributes(statement.at_xpath("./AccountInformation")) + position_values = section_rows(statement, POSITION_VALUE_CONTAINER_NAMES, POSITION_VALUE_ROW_NAMES) + cash_report = section_rows(statement, CASH_REPORT_CONTAINER_NAMES, CASH_REPORT_ROW_NAMES) + equity_summary_in_base = section_rows(statement, EQUITY_SUMMARY_CONTAINER_NAMES, EQUITY_SUMMARY_ROW_NAMES) + open_positions = section_rows(statement, OPEN_POSITION_CONTAINER_NAMES, OPEN_POSITION_ROW_NAMES) + trades = section_rows(statement, TRADES_CONTAINER_NAMES, TRADE_ROW_NAMES) + cash_transactions = section_rows(statement, CASH_TRANSACTION_CONTAINER_NAMES, CASH_TRANSACTION_ROW_NAMES) + account_id = account_information["account_id"].presence || statement_data["account_id"] + + raise ParseError, "Invalid IBKR Flex XML: missing account identifier in FlexStatement." if account_id.blank? + + currency = account_information["currency"].presence&.upcase || "USD" + report_date = open_positions.filter_map { |row| parse_date(row["report_date"]) }.max || + equity_summary_in_base.filter_map { |row| parse_date(row["report_date"]) }.max || + parse_date(statement_data["to_date"]) || + Date.current + + { + ibkr_account_id: account_id, + name: account_id, + currency: currency, + cash_balance: extract_cash_balance(cash_report, currency), + current_balance: extract_total_balance(position_values, cash_report, currency), + report_date: report_date, + statement: statement_data, + cash_report: cash_report, + equity_summary_in_base: equity_summary_in_base, + open_positions: open_positions, + trades: trades, + cash_transactions: cash_transactions, + raw_payload: { + statement: statement_data, + cash_report: cash_report, + equity_summary_in_base: equity_summary_in_base, + open_positions: open_positions, + trades: trades, + cash_transactions: cash_transactions + } + } + end + + def section_rows(statement, container_names, row_names) + rows = [] + + container_names.each do |container_name| + statement.xpath("./#{container_name}").each do |container| + children = container.element_children + + if children.any? + rows.concat(children.select { |child| row_names.include?(child.name) }) + elsif row_names.include?(container.name) + rows << container + end + end + end + + if rows.empty? + row_names.each do |row_name| + rows.concat(statement.xpath("./#{row_name}")) + end + end + + rows.map { |row| node_attributes(row) }.reject(&:blank?) + end + + def node_attributes(node) + return {} unless node + + node.attribute_nodes.each_with_object({}) do |attribute, result| + result[attribute.name.underscore] = attribute.value + end + end + + def extract_cash_balance(cash_rows, account_currency) + base_summary = cash_rows.find { |row| row["currency"] == "BASE_SUMMARY" } + account_row = cash_rows.find { |row| row["currency"] == account_currency } + row = base_summary || account_row + + parse_decimal(row&.fetch("ending_cash", nil)) || BigDecimal("0") + end + + def extract_current_balance(position_values, account_currency) + base_summary = position_values.find { |row| row["currency"] == "BASE_SUMMARY" } + account_row = position_values.find { |row| row["currency"] == account_currency } + row = base_summary || account_row + + parse_decimal(row&.fetch("end_of_period_value", nil)) || BigDecimal("0") + end + + def extract_total_balance(position_values, cash_rows, account_currency) + extract_current_balance(position_values, account_currency) + extract_cash_balance(cash_rows, account_currency) + end +end diff --git a/app/models/ibkr_item/sync_complete_event.rb b/app/models/ibkr_item/sync_complete_event.rb new file mode 100644 index 000000000..46ebe39ac --- /dev/null +++ b/app/models/ibkr_item/sync_complete_event.rb @@ -0,0 +1,22 @@ +class IbkrItem::SyncCompleteEvent + attr_reader :ibkr_item + + def initialize(ibkr_item) + @ibkr_item = ibkr_item + end + + def broadcast + ibkr_item.accounts.each do |account| + account.broadcast_sync_complete + end + + ibkr_item.broadcast_replace_to( + ibkr_item.family, + target: "ibkr_item_#{ibkr_item.id}", + partial: "ibkr_items/ibkr_item", + locals: { ibkr_item: ibkr_item } + ) + + ibkr_item.family.broadcast_sync_complete + end +end diff --git a/app/models/ibkr_item/syncer.rb b/app/models/ibkr_item/syncer.rb new file mode 100644 index 000000000..003c26855 --- /dev/null +++ b/app/models/ibkr_item/syncer.rb @@ -0,0 +1,68 @@ +class IbkrItem::Syncer + include SyncStats::Collector + + attr_reader :ibkr_item + + def initialize(ibkr_item) + @ibkr_item = ibkr_item + end + + def perform_sync(sync) + sync.update!(status_text: "Checking IBKR credentials...") if sync.respond_to?(:status_text) + unless ibkr_item.credentials_configured? + ibkr_item.update!(status: :requires_update) + raise Provider::IbkrFlex::ConfigurationError, "IBKR credentials are missing." + end + + sync.update!(status_text: "Importing IBKR accounts...") if sync.respond_to?(:status_text) + ibkr_item.import_latest_ibkr_data + + sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: ibkr_item.ibkr_accounts.to_a) + + unlinked_accounts = ibkr_item.ibkr_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + linked_accounts = ibkr_item.ibkr_accounts.joins(:account).merge(Account.visible) + + if unlinked_accounts.any? + ibkr_item.update!(pending_account_setup: true) + sync.update!(status_text: "#{unlinked_accounts.count} IBKR account(s) need setup...") if sync.respond_to?(:status_text) + else + ibkr_item.update!(pending_account_setup: false) + end + + if linked_accounts.any? + sync.update!(status_text: "Processing holdings and activity...") if sync.respond_to?(:status_text) + ibkr_item.process_accounts + + sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) + ibkr_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + + account_ids = linked_accounts.includes(:account).filter_map { |provider_account| provider_account.account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "ibkr") if account_ids.any? + collect_trades_stats(sync, account_ids: account_ids, source: "ibkr") if account_ids.any? + collect_holdings_stats(sync, holdings_count: count_holdings, label: "processed") + end + + collect_health_stats(sync, errors: nil) + rescue Provider::IbkrFlex::AuthenticationError, Provider::IbkrFlex::ConfigurationError => e + ibkr_item.update!(status: :requires_update) + collect_health_stats(sync, errors: [ { message: e.message, category: "auth_error" } ]) + raise + rescue => e + collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) + raise + end + + def perform_post_sync + end + + private + + def count_holdings + ibkr_item.ibkr_accounts.sum { |account| Array(account.raw_holdings_payload).size } + end +end diff --git a/app/models/ibkr_item/unlinking.rb b/app/models/ibkr_item/unlinking.rb new file mode 100644 index 000000000..ddc827a56 --- /dev/null +++ b/app/models/ibkr_item/unlinking.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IbkrItem::Unlinking + extend ActiveSupport::Concern + + def unlink_all!(dry_run: false) + results = [] + + ibkr_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: "IbkrAccount", provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) if link_ids.any? + links.each(&:destroy!) + end + rescue => e + Rails.logger.warn( + "IbkrItem Unlinker: failed to fully unlink provider account ##{provider_account.id} " \ + "(links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/import.rb b/app/models/import.rb index 941a6ead6..71887faed 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -2,6 +2,7 @@ class Import < ApplicationRecord MaxRowCountExceededError = Class.new(StandardError) MappingError = Class.new(StandardError) + # Shared CSV upload/content limit for web and API imports, including preflight. MAX_CSV_SIZE = 10.megabytes MAX_PDF_SIZE = 25.megabytes ALLOWED_CSV_MIME_TYPES = %w[text/csv text/plain application/vnd.ms-excel application/csv].freeze @@ -9,10 +10,17 @@ class Import < ApplicationRecord DOCUMENT_TYPES = %w[bank_statement credit_card_statement investment_statement financial_document contract other].freeze - TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport QifImport SureImport].freeze + TYPES = %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport PdfImport QifImport SureImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze + def self.separator_options + [ + [ I18n.t("activerecord.attributes.import.col_seps.comma"), "," ], + [ I18n.t("activerecord.attributes.import.col_seps.semicolon"), ";" ] + ] + end + NUMBER_FORMATS = { "1,234.56" => { separator: ".", delimiter: "," }, # US/UK/Asia "1.234,56" => { separator: ",", delimiter: "." }, # Most of Europe @@ -24,6 +32,10 @@ class Import < ApplicationRecord Date.new(1970, 1, 1)..Date.today.next_year(5) end + def self.max_csv_size + MAX_CSV_SIZE + end + AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze belongs_to :family @@ -202,21 +214,22 @@ class Import < ApplicationRecord def generate_rows_from_csv rows.destroy_all - mapped_rows = csv_rows.map do |row| + mapped_rows = csv_rows.map.with_index(1) do |row, index| { - account: row[account_col_label].to_s, - date: row[date_col_label].to_s, - qty: sanitize_number(row[qty_col_label]).to_s, - ticker: row[ticker_col_label].to_s, - exchange_operating_mic: row[exchange_operating_mic_col_label].to_s, - price: sanitize_number(row[price_col_label]).to_s, - amount: sanitize_number(row[amount_col_label]).to_s, - currency: (row[currency_col_label] || default_currency).to_s, - name: (row[name_col_label] || default_row_name).to_s, - category: row[category_col_label].to_s, - tags: row[tags_col_label].to_s, - entity_type: row[entity_type_col_label].to_s, - notes: row[notes_col_label].to_s + source_row_number: index, + account: csv_value(row, account_col_label, "account", "account_name").to_s, + date: csv_value(row, date_col_label, "date").to_s, + qty: sanitize_number(csv_value(row, qty_col_label, "qty", "quantity")).to_s, + ticker: csv_value(row, ticker_col_label, "ticker").to_s, + exchange_operating_mic: csv_value(row, exchange_operating_mic_col_label, "exchange_operating_mic").to_s, + price: sanitize_number(csv_value(row, price_col_label, "price")).to_s, + amount: sanitize_number(csv_value(row, amount_col_label, "amount", "balance")).to_s, + currency: (csv_value(row, currency_col_label, "currency") || default_currency).to_s, + name: (csv_value(row, name_col_label, "name") || default_row_name).to_s, + category: csv_value(row, category_col_label, "category").to_s, + tags: csv_value(row, tags_col_label, "tags").to_s, + entity_type: csv_value(row, entity_type_col_label, "entity_type", "account_type", "type").to_s, + notes: csv_value(row, notes_col_label, "notes").to_s } end @@ -258,6 +271,10 @@ class Import < ApplicationRecord uploaded? && rows_count > 0 end + def configured_for_status_detail? + configured? + end + def cleaned? configured? && rows.all?(&:valid?) end @@ -266,6 +283,23 @@ class Import < ApplicationRecord cleaned? && mappings.all?(&:valid?) end + def cleaned_from_validation_stats?(invalid_rows_count:) + configured? && invalid_rows_count.zero? + end + + def publishable_from_validation_stats?(invalid_rows_count:) + cleaned_from_validation_stats?(invalid_rows_count: invalid_rows_count) && mappings.all?(&:valid?) + end + + def mapping_status_counts + mappable_ids = mappings.pluck(:mappable_id) + + { + mappings_count: mappable_ids.size, + unassigned_mappings_count: mappable_ids.count(&:nil?) + } + end + def revertable? complete? || revert_failed? end @@ -354,6 +388,55 @@ class Import < ApplicationRecord account&.currency || family.currency end + def csv_value(row, label, *aliases) + return if label.blank? + + [ label, *aliases ].each do |candidate| + header = header_for(candidate) + next if header.blank? + + value = row[header] + return value if value.present? + end + + nil + end + + def header_for(candidate) + return if candidate.blank? + + normalized_csv_headers[normalize_header(candidate)] + end + + def normalized_csv_headers + @normalized_csv_headers ||= begin + grouped_headers = Array(csv_headers) + .filter_map do |header| + normalized = normalize_header(header) + next if normalized.blank? + + [ normalized, header ] + end + .group_by(&:first) + + duplicate_headers = grouped_headers.values.filter_map do |headers| + originals = headers.map(&:last).uniq + originals if originals.many? + end + + if duplicate_headers.any? + errors.add(:base, :duplicate_headers, columns: duplicate_headers.map { |headers| headers.join(", ") }.join("; ")) + raise ActiveRecord::RecordInvalid, self + end + + grouped_headers.transform_values { |headers| headers.first.last } + end + end + + def normalize_header(header) + header.to_s.strip.downcase.gsub(/\*/, "").gsub(/[\s-]+/, "_") + end + def parsed_csv return @parsed_csv if defined?(@parsed_csv) diff --git a/app/models/import/preflight.rb b/app/models/import/preflight.rb new file mode 100644 index 000000000..69051eac3 --- /dev/null +++ b/app/models/import/preflight.rb @@ -0,0 +1,454 @@ +# frozen_string_literal: true + +class Import::Preflight + Response = Struct.new(:status, :payload, keyword_init: true) + + class PreflightError < StandardError + attr_reader :status, :payload + + def initialize(response) + @status = response.status + @payload = response.payload + super(response.payload[:message]) + end + end + + CONFIG_PARAM_KEYS = %i[ + date_col_label + amount_col_label + name_col_label + category_col_label + tags_col_label + notes_col_label + account_col_label + qty_col_label + ticker_col_label + price_col_label + entity_type_col_label + currency_col_label + exchange_operating_mic_col_label + date_format + number_format + signage_convention + col_sep + amount_type_strategy + amount_type_inflow_value + rows_to_skip + ].freeze + + PARAM_KEYS = ([ + :type, + :account_id, + :file, + :raw_file_content + ] + CONFIG_PARAM_KEYS).freeze + + UNSUPPORTED_PREFLIGHT_IMPORT_TYPES = %w[PdfImport QifImport].freeze + IMPORT_TYPES = (Import::TYPES - UNSUPPORTED_PREFLIGHT_IMPORT_TYPES).freeze + + def initialize(family:, params:) + @family = family + @params = params.to_h.symbolize_keys + end + + def call + type = preflight_import_type + return invalid_import_type_response unless type + + type == "SureImport" ? sure_import_response : csv_import_response(type) + rescue PreflightError => e + Response.new(status: e.status, payload: e.payload) + end + + private + attr_reader :family, :params + + def preflight_import_type + type = params[:type].to_s + return "TransactionImport" if type.blank? + + type if IMPORT_TYPES.include?(type) + end + + def invalid_import_type_response + Response.new( + status: :unprocessable_entity, + payload: { + error: "invalid_import_type", + message: "type must be one of: #{IMPORT_TYPES.join(', ')}" + } + ) + end + + def sure_import_response + upload_attributes = sure_import_upload_attributes + return missing_sure_content_response unless upload_attributes + + content, filename, content_type = upload_attributes + Response.new( + status: :ok, + payload: { + data: sure_import_preflight_payload(content, filename, content_type) + } + ) + end + + def csv_import_response(type) + upload_attributes = csv_upload_attributes + return missing_csv_content_response unless upload_attributes + + content, filename, content_type = upload_attributes + import = family.imports.build(import_config_params.merge(type: type, raw_file_str: content)) + import.account = preflight_account if params[:account_id].present? + apply_import_defaults(import) + + return unsupported_import_type_response unless import.requires_csv_workflow? + + unless import.valid? + return Response.new( + status: :ok, + payload: { + data: csv_preflight_payload( + import: import, + type: type, + filename: filename, + content_type: content_type, + content: content, + parsed_rows_count: 0, + csv_headers: [], + missing_required_headers: [], + errors: validation_errors(import), + warnings: [] + ) + } + ) + end + + csv_content = csv_content_for(import, content) + csv = Import.parse_csv_str(csv_content, col_sep: import.col_sep) + parsed_rows_count = csv.length + csv_headers = Array(csv.headers).compact + missing_required_headers = missing_required_headers(import, csv_headers) + errors = validation_errors(import) + + if missing_required_headers.any? + errors << { + code: "missing_required_headers", + message: "Missing required columns: #{missing_required_headers.join(', ')}" + } + end + + if parsed_rows_count.zero? + errors << { + code: "no_data_rows", + message: "No data rows were found." + } + end + + warnings = [] + warnings << "Row count exceeds this import type's publish limit." if parsed_rows_count > import.max_row_count + + Response.new( + status: :ok, + payload: { + data: csv_preflight_payload( + import: import, + type: type, + filename: filename, + content_type: content_type, + content: content, + parsed_rows_count: parsed_rows_count, + csv_headers: csv_headers, + missing_required_headers: missing_required_headers, + errors: errors, + warnings: warnings + ) + } + ) + end + + def import_config_params + params.slice(*CONFIG_PARAM_KEYS) + end + + def preflight_account + raise ActiveRecord::RecordNotFound unless Api::V1::BaseController.valid_uuid?(params[:account_id]) + + family.accounts.find(params[:account_id]) + end + + def csv_upload_attributes + if params[:file].present? + csv_file_upload_attributes(params[:file]) + elsif params[:raw_file_content].present? + csv_raw_content_attributes(params[:raw_file_content].to_s) + end + end + + def csv_file_upload_attributes(file) + raise_response csv_file_too_large_response if file.size > Import.max_csv_size + raise_response invalid_csv_file_type_response unless Import::ALLOWED_CSV_MIME_TYPES.include?(file.content_type) + + [ + file.read, + file.original_filename.presence || "import.csv", + file.content_type.presence || "text/csv" + ] + end + + def csv_raw_content_attributes(content) + raise_response csv_content_too_large_response if content.bytesize > Import.max_csv_size + + [ content, "import.csv", "text/csv" ] + end + + def sure_import_upload_attributes + if params[:file].present? + sure_import_file_upload_attributes(params[:file]) + elsif params[:raw_file_content].present? + sure_import_raw_content_attributes(params[:raw_file_content].to_s) + end + end + + def sure_import_file_upload_attributes(file) + raise_response sure_file_too_large_response if file.size > SureImport.max_ndjson_size + + extension = File.extname(file.original_filename.to_s).downcase + unless SureImport::ALLOWED_NDJSON_CONTENT_TYPES.include?(file.content_type) || extension.in?(%w[.ndjson .json]) + raise_response invalid_sure_file_type_response + end + + [ + file.read, + file.original_filename.presence || "sure-import.ndjson", + file.content_type.presence || "application/x-ndjson" + ] + end + + def sure_import_raw_content_attributes(content) + raise_response sure_content_too_large_response if content.bytesize > SureImport.max_ndjson_size + + [ content, "sure-import.ndjson", "application/x-ndjson" ] + end + + def sure_import_preflight_payload(content, filename, content_type) + line_counts = Hash.new(0) + errors = [] + valid_rows_count = 0 + nonblank_rows_count = 0 + + content.each_line.with_index(1) do |line, line_number| + next if line.strip.blank? + + nonblank_rows_count += 1 + record = JSON.parse(line) + + unless record.is_a?(Hash) + errors << { + code: "invalid_ndjson_record", + message: "Line #{line_number} must be a JSON object." + } + next + end + + if record["type"].blank? || !record.key?("data") + errors << { + code: "invalid_ndjson_record", + message: "Line #{line_number} must include type and data." + } + next + end + + valid_rows_count += 1 + line_counts[record["type"]] += 1 + rescue JSON::ParserError => e + errors << { + code: "invalid_json", + message: "Line #{line_number} is not valid JSON: #{e.message}" + } + end + + if nonblank_rows_count.zero? + errors << { + code: "no_data_rows", + message: "No data rows were found." + } + end + + entity_counts = SureImport.dry_run_totals_from_line_type_counts(line_counts) + unsupported_types = line_counts.keys - SureImport.importable_ndjson_types + warnings = [] + warnings << "No importable records were found." if nonblank_rows_count.positive? && entity_counts.values.sum.zero? + warnings << "Some records use unsupported types: #{unsupported_types.join(', ')}" if unsupported_types.any? + warnings << "Row count exceeds this import type's publish limit." if nonblank_rows_count > SureImport.max_row_count + + { + type: "SureImport", + valid: errors.empty?, + content: content_payload(filename, content_type, content), + stats: { + rows_count: nonblank_rows_count, + valid_rows_count: valid_rows_count, + invalid_rows_count: nonblank_rows_count - valid_rows_count, + entity_counts: entity_counts, + record_type_counts: line_counts + }, + errors: errors, + warnings: warnings + } + end + + def content_payload(filename, content_type, content) + { + filename: filename, + content_type: content_type, + byte_size: content.bytesize + } + end + + def csv_content_for(import, content) + return content unless import.rows_to_skip.to_i.positive? + + content.lines.drop(import.rows_to_skip.to_i).join + end + + def apply_import_defaults(import) + return unless import.class.respond_to?(:default_column_mappings) + + import.class.default_column_mappings.each do |attribute, value| + import.public_send("#{attribute}=", value) if import.public_send(attribute).blank? + end + end + + def validation_errors(import) + import.errors.full_messages.map { |message| { code: "validation_failed", message: message } } + end + + def csv_preflight_payload(import:, type:, filename:, content_type:, content:, parsed_rows_count:, csv_headers:, missing_required_headers:, errors:, warnings:) + { + type: type, + valid: errors.empty?, + content: content_payload(filename, content_type, content), + stats: { + rows_count: parsed_rows_count + }, + headers: csv_headers, + required_headers: required_header_labels(import), + missing_required_headers: missing_required_headers, + errors: errors, + warnings: warnings + } + end + + def required_header_labels(import) + import.required_column_keys.filter_map do |key| + import.respond_to?("#{key}_col_label") ? import.public_send("#{key}_col_label").presence || key.to_s : key.to_s + end + end + + def missing_required_headers(import, headers) + normalized_headers = Array(headers).compact.to_h { |header| [ normalized_header(header), header ] } + + required_header_labels(import).reject do |header| + normalized_headers.key?(normalized_header(header)) + end + end + + def normalized_header(header) + header.to_s.strip.downcase.gsub(/\*/, "").gsub(/[\s-]+/, "_") + end + + def missing_csv_content_response + Response.new( + status: :unprocessable_entity, + payload: { + error: "missing_content", + message: "Provide a CSV file or raw_file_content." + } + ) + end + + def missing_sure_content_response + Response.new( + status: :unprocessable_entity, + payload: { + error: "missing_content", + message: "Provide a Sure NDJSON file or raw_file_content." + } + ) + end + + def csv_file_too_large_response + Response.new( + status: :unprocessable_entity, + payload: { + error: "file_too_large", + message: "File is too large. Maximum size is #{Import.max_csv_size / 1.megabyte}MB." + } + ) + end + + def csv_content_too_large_response + Response.new( + status: :unprocessable_entity, + payload: { + error: "content_too_large", + message: "Content is too large. Maximum size is #{Import.max_csv_size / 1.megabyte}MB." + } + ) + end + + def invalid_csv_file_type_response + Response.new( + status: :unprocessable_entity, + payload: { + error: "invalid_file_type", + message: "Invalid file type. Please upload a CSV file." + } + ) + end + + def sure_file_too_large_response + Response.new( + status: :unprocessable_entity, + payload: { + error: "file_too_large", + message: "File is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB." + } + ) + end + + def sure_content_too_large_response + Response.new( + status: :unprocessable_entity, + payload: { + error: "content_too_large", + message: "Content is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB." + } + ) + end + + def invalid_sure_file_type_response + Response.new( + status: :unprocessable_entity, + payload: { + error: "invalid_file_type", + message: "Invalid file type. Please upload a Sure NDJSON file." + } + ) + end + + def raise_response(response) + raise PreflightError, response + end + + def unsupported_import_type_response + Response.new( + status: :unprocessable_entity, + payload: { + error: "unsupported_import_type", + message: "Preflight supports CSV import types and SureImport." + } + ) + end +end diff --git a/app/models/import/row.rb b/app/models/import/row.rb index 6b68626bd..56375a593 100644 --- a/app/models/import/row.rb +++ b/app/models/import/row.rb @@ -8,13 +8,13 @@ class Import::Row < ApplicationRecord validate :required_columns validate :currency_is_valid - scope :ordered, -> { order(:id) } + scope :ordered, -> { order(:source_row_number, :id) } def tags_list if tags.blank? [ "" ] else - tags.split("|").map(&:strip) + split_tags(tags).map(&:strip) end end @@ -37,6 +37,58 @@ class Import::Row < ApplicationRecord end private + # Supports historical comma-delimited exports and pipe-delimited templates. + # Backslash escapes comma, pipe, and backslash so tag names can contain either delimiter. + def split_tags(value) + split_escaped_tags(value, tag_delimiter_for(value)) + end + + def tag_delimiter_for(value) + return "," if unescaped_delimiter?(value, ",") + return "|" if unescaped_delimiter?(value, "|") + + "," + end + + def unescaped_delimiter?(value, delimiter) + escaping = false + + value.each_char do |char| + if escaping + escaping = false + elsif char == "\\" + escaping = true + elsif char == delimiter + return true + end + end + + false + end + + def split_escaped_tags(value, delimiter) + tag_names = [] + current = +"" + escaping = false + + value.each_char do |char| + if escaping + current << (char.in?([ delimiter, ",", "|", "\\" ]) ? char : "\\#{char}") + escaping = false + elsif char == "\\" + escaping = true + elsif char == delimiter + tag_names << current + current = +"" + else + current << char + end + end + + current << "\\" if escaping + tag_names << current + end + # In the Sure system, positive quantities == "inflows" def apply_trade_signage_convention(value) value * (import.signage_convention == "inflows_positive" ? 1 : -1) diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index f885e3f81..6082f8e74 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -30,14 +30,23 @@ class IncomeStatement end def expense_totals(period: Period.current_month) - build_period_total(classification: "expense", period: period) + # Memoized per instance so callers that also invoke `net_category_totals` + @expense_totals_by_period ||= {} + @expense_totals_by_period[period_cache_key(period)] ||= + build_period_total(classification: "expense", period: period) end def income_totals(period: Period.current_month) - build_period_total(classification: "income", period: period) + @income_totals_by_period ||= {} + @income_totals_by_period[period_cache_key(period)] ||= + build_period_total(classification: "income", period: period) end def net_category_totals(period: Period.current_month) + @net_category_totals_by_period ||= {} + cached = @net_category_totals_by_period[period_cache_key(period)] + return cached if cached + expense = expense_totals(period: period) income = income_totals(period: period) @@ -87,7 +96,7 @@ class IncomeStatement CategoryTotal.new(category: r[:category], total: r[:total], currency: family.currency, weight: weight) end - NetCategoryTotals.new( + @net_category_totals_by_period[period_cache_key(period)] = NetCategoryTotals.new( net_expense_categories: net_expense_categories, net_income_categories: net_income_categories, total_net_expense: total_net_expense, @@ -126,6 +135,10 @@ class IncomeStatement @categories ||= family.categories.all.to_a end + def period_cache_key(period) + [ period.start_date, period.end_date ] + end + def build_period_total(classification:, period:) # Exclude pending transactions from budget calculations totals = totals_query(transactions_scope: family.transactions.visible.excluding_pending.in_period(period), date_range: period.date_range).select { |t| t.classification == classification } diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb index f9757da16..4dc40af05 100644 --- a/app/models/income_statement/category_stats.rb +++ b/app/models/income_statement/category_stats.rb @@ -45,6 +45,10 @@ class IncomeStatement::CategoryStats @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") end + def pending_providers_sql + Transaction.pending_providers_sql("t") + end + def exclude_tax_advantaged_sql ids = @family.tax_advantaged_account_ids return "" if ids.empty? @@ -62,8 +66,8 @@ class IncomeStatement::CategoryStats SELECT c.id as category_id, date_trunc(:interval, ae.date) as period, - CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(CASE WHEN t.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total + CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + SUM(CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total FROM transactions t JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id @@ -76,11 +80,10 @@ class IncomeStatement::CategoryStats WHERE a.family_id = :family_id AND t.kind NOT IN (#{budget_excluded_kinds_sql}) AND ae.excluded = false - AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true - AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + #{pending_providers_sql} #{exclude_tax_advantaged_sql} #{scope_to_account_ids_sql} - GROUP BY c.id, period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY c.id, period, CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT category_id, diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb index d172d4ebf..c3925025f 100644 --- a/app/models/income_statement/family_stats.rb +++ b/app/models/income_statement/family_stats.rb @@ -44,6 +44,10 @@ class IncomeStatement::FamilyStats @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") end + def pending_providers_sql + Transaction.pending_providers_sql("t") + end + def exclude_tax_advantaged_sql ids = @family.tax_advantaged_account_ids return "" if ids.empty? @@ -60,8 +64,8 @@ class IncomeStatement::FamilyStats WITH period_totals AS ( SELECT date_trunc(:interval, ae.date) as period, - CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(CASE WHEN t.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total + CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + SUM(CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total FROM transactions t JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id @@ -73,11 +77,10 @@ class IncomeStatement::FamilyStats WHERE a.family_id = :family_id AND t.kind NOT IN (#{budget_excluded_kinds_sql}) AND ae.excluded = false - AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true - AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + #{pending_providers_sql} #{exclude_tax_advantaged_sql} #{scope_to_account_ids_sql} - GROUP BY period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY period, CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT classification, diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index 54fb56732..81920038f 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -60,8 +60,8 @@ class IncomeStatement::Totals SELECT c.id as category_id, c.parent_id as parent_category_id, - CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, + CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, COUNT(ae.id) as transactions_count, false as is_uncategorized_investment FROM (#{@transactions_scope.to_sql}) at @@ -79,7 +79,7 @@ class IncomeStatement::Totals AND a.status IN ('draft', 'active') #{exclude_tax_advantaged_sql} #{include_finance_accounts_sql} - GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; + GROUP BY c.id, c.parent_id, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; SQL end @@ -88,8 +88,8 @@ class IncomeStatement::Totals SELECT c.id as category_id, c.parent_id as parent_category_id, - CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind = 'investment_contribution' THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, + CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, COUNT(ae.id) as entry_count, false as is_uncategorized_investment FROM (#{@transactions_scope.to_sql}) at @@ -111,7 +111,7 @@ class IncomeStatement::Totals AND a.status IN ('draft', 'active') #{exclude_tax_advantaged_sql} #{include_finance_accounts_sql} - GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + GROUP BY c.id, c.parent_id, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END SQL end diff --git a/app/models/indexa_capital_account/data_helpers.rb b/app/models/indexa_capital_account/data_helpers.rb index b7a86b8cd..db9283788 100644 --- a/app/models/indexa_capital_account/data_helpers.rb +++ b/app/models/indexa_capital_account/data_helpers.rb @@ -39,6 +39,25 @@ module IndexaCapitalAccount::DataHelpers nil end + # Extract the canonical security key from an Indexa fiscal-results row. + # Indexa's response shape varies between endpoints (and across time), so + # try the nested instrument hash first, then a few flat fallbacks. Both + # HoldingsProcessor and Processor#calculate_holdings_value go through + # this helper so they can't disagree on which rows refer to the same + # security. + def extract_instrument_key(data) + return nil unless data.respond_to?(:[]) + + hash = data.respond_to?(:with_indifferent_access) ? data.with_indifferent_access : data + instrument = hash[:instrument] + if instrument.is_a?(Hash) + nested = instrument.with_indifferent_access + return nested[:identifier] || nested[:isin_code] || nested[:isin] + end + + hash[:identifier] || hash[:isin_code] || hash[:isin] || hash[:symbol] || hash[:ticker] + end + def parse_date(date_value) return nil if date_value.nil? diff --git a/app/models/indexa_capital_account/holdings_processor.rb b/app/models/indexa_capital_account/holdings_processor.rb index f39f8677b..7a63fcea2 100644 --- a/app/models/indexa_capital_account/holdings_processor.rb +++ b/app/models/indexa_capital_account/holdings_processor.rb @@ -13,11 +13,26 @@ class IndexaCapitalAccount::HoldingsProcessor holdings_data = @indexa_capital_account.raw_holdings_payload return if holdings_data.blank? - Rails.logger.info "IndexaCapitalAccount::HoldingsProcessor - Processing #{holdings_data.size} holdings" + # The importer normalises to total_fiscal_results (one aggregated row + # per security). Defensively dedupe in case a future variant feeds the + # per-tax-lot fiscal_results array through here — same key extraction + # as Processor#calculate_holdings_value via the shared DataHelpers + # method, so the two can't disagree on which rows refer to the same + # security. + per_security = {} + holdings_data.each do |holding_data| + data = holding_data.respond_to?(:with_indifferent_access) ? holding_data.with_indifferent_access : holding_data + key = extract_instrument_key(data) + next if key.blank? - holdings_data.each_with_index do |holding_data, idx| - Rails.logger.info "IndexaCapitalAccount::HoldingsProcessor - Processing holding #{idx + 1}/#{holdings_data.size}" - process_holding(holding_data.with_indifferent_access) + per_security[key] = data + end + + Rails.logger.info "IndexaCapitalAccount::HoldingsProcessor - Processing #{per_security.size} holdings (from #{holdings_data.size} input rows)" + + per_security.each_value.with_index do |data, idx| + Rails.logger.info "IndexaCapitalAccount::HoldingsProcessor - Processing holding #{idx + 1}/#{per_security.size}" + process_holding(data) rescue => e Rails.logger.error "IndexaCapitalAccount::HoldingsProcessor - Failed to process holding #{idx + 1}: #{e.class} - #{e.message}" Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace @@ -45,7 +60,7 @@ class IndexaCapitalAccount::HoldingsProcessor # profit_loss → unrealized P&L # subscription_date → purchase date def process_holding(data) - ticker = extract_ticker(data) + ticker = extract_instrument_key(data) return if ticker.blank? Rails.logger.info "IndexaCapitalAccount::HoldingsProcessor - Processing holding for ticker: #{ticker}" @@ -80,19 +95,6 @@ class IndexaCapitalAccount::HoldingsProcessor update_holding_cost_basis(security, cost_price) if cost_price.present? end - # Extract ISIN from instrument data as ticker - def extract_ticker(data) - # Indexa Capital uses ISIN codes nested under instrument - instrument = data[:instrument] - if instrument.is_a?(Hash) - instrument = instrument.with_indifferent_access - return instrument[:identifier] || instrument[:isin] - end - - # Fallback to flat fields - data[:isin] || data[:identifier] || data[:symbol] || data[:ticker] - end - # Override security name extraction for Indexa Capital def extract_security_name(symbol_data, fallback_ticker) symbol_data = symbol_data.with_indifferent_access if symbol_data.respond_to?(:with_indifferent_access) diff --git a/app/models/indexa_capital_account/processor.rb b/app/models/indexa_capital_account/processor.rb index b128f0557..8a5d95186 100644 --- a/app/models/indexa_capital_account/processor.rb +++ b/app/models/indexa_capital_account/processor.rb @@ -70,22 +70,48 @@ class IndexaCapitalAccount::Processor end def calculate_total_balance - # Calculate total from holdings + cash for accuracy + # Trust the API's reported balance when available — Indexa's holdings payload + # contains time-series snapshots (one row per security per date), so summing + # the raw entries double-counts. Fall back to a per-security latest-snapshot + # sum + cash only when the API total is missing. + if indexa_capital_account.current_balance.present? + Rails.logger.info "IndexaCapitalAccount::Processor - Using API total: #{indexa_capital_account.current_balance}" + return indexa_capital_account.current_balance + end + holdings_value = calculate_holdings_value cash_value = indexa_capital_account.cash_balance || 0 - calculated_total = holdings_value + cash_value + Rails.logger.info "IndexaCapitalAccount::Processor - Using calculated total (API balance missing): holdings=#{holdings_value} + cash=#{cash_value} = #{calculated_total}" + calculated_total + end - # Use calculated total if we have holdings, otherwise trust API value - if holdings_value > 0 - Rails.logger.info "IndexaCapitalAccount::Processor - Using calculated total: holdings=#{holdings_value} + cash=#{cash_value} = #{calculated_total}" - calculated_total - elsif indexa_capital_account.current_balance.present? - Rails.logger.info "IndexaCapitalAccount::Processor - Using API total: #{indexa_capital_account.current_balance}" - indexa_capital_account.current_balance - else - calculated_total + def calculate_holdings_value + holdings_data = indexa_capital_account.raw_holdings_payload || [] + return 0 if holdings_data.empty? + + # The importer normalises to total_fiscal_results (one aggregated row + # per security) so a plain sum is correct. We still defensively dedupe + # by instrument key in case a future provider variant feeds the + # per-tax-lot fiscal_results array through here — the last value wins, + # consistent with how HoldingsProcessor upserts holdings. + per_security = {} + holdings_data.each do |holding| + instrument = extract_instrument_key(holding) + next if instrument.blank? + + data = holding.respond_to?(:with_indifferent_access) ? holding.with_indifferent_access : holding + amount = parse_decimal(data[:amount]) + unless amount + titles = parse_decimal(data[:titles] || data[:quantity] || data[:units]) || 0 + price = parse_decimal(data[:price]) || 0 + amount = titles * price + end + + per_security[instrument] = amount || 0 end + + per_security.values.sum end def calculate_cash_balance @@ -95,22 +121,4 @@ class IndexaCapitalAccount::Processor Rails.logger.info "IndexaCapitalAccount::Processor - Cash balance from API: #{cash.inspect}" cash || BigDecimal("0") end - - def calculate_holdings_value - holdings_data = indexa_capital_account.raw_holdings_payload || [] - return 0 if holdings_data.empty? - - holdings_data.sum do |holding| - data = holding.is_a?(Hash) ? holding.with_indifferent_access : {} - # Indexa Capital: amount = total market value, or titles * price - amount = parse_decimal(data[:amount]) - if amount - amount - else - titles = parse_decimal(data[:titles] || data[:quantity] || data[:units]) || 0 - price = parse_decimal(data[:price]) || 0 - titles * price - end - end - end end diff --git a/app/models/indexa_capital_item.rb b/app/models/indexa_capital_item.rb index ccea401b4..53bd562a8 100644 --- a/app/models/indexa_capital_item.rb +++ b/app/models/indexa_capital_item.rb @@ -176,6 +176,6 @@ class IndexaCapitalItem < ApplicationRecord def credentials_present_on_create return if credentials_configured? - errors.add(:base, "Either INDEXA_API_TOKEN env var or username/document/password credentials are required") + errors.add(:base, :credentials_required) end end diff --git a/app/models/indexa_capital_item/importer.rb b/app/models/indexa_capital_item/importer.rb index 0980919b8..2b2368710 100644 --- a/app/models/indexa_capital_item/importer.rb +++ b/app/models/indexa_capital_item/importer.rb @@ -108,12 +108,20 @@ class IndexaCapitalItem::Importer begin holdings_data = indexa_capital_provider.get_holdings(account_number: account_number) - stats["api_requests"] = stats.fetch("api_requests", 0) + 1 - # The API returns fiscal-results which may be a hash with an array inside holdings_array = normalize_holdings_response(holdings_data) + # Pension plans return empty fiscal-results. Fall back to the portfolio + # endpoint, which exposes positions for both mutual fund and pension + # accounts in a uniform shape we can adapt to the same structure. + if holdings_array.empty? + Rails.logger.info "IndexaCapitalItem::Importer - fiscal-results empty for #{account_number}, falling back to /portfolio" + portfolio_data = indexa_capital_provider.get_portfolio(account_number: account_number) + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + holdings_array = positions_from_portfolio(portfolio_data) + end + if holdings_array.any? holdings_hashes = holdings_array.map { |h| sdk_object_to_hash(h) } indexa_capital_account.upsert_holdings_snapshot!(holdings_hashes) @@ -125,13 +133,40 @@ class IndexaCapitalItem::Importer end end - # fiscal-results response may be an array or a hash containing an array + # Adapt /accounts/{id}/portfolio positions into the shape that + # HoldingsProcessor expects (i.e. the same field set as a + # total_fiscal_results row). Adds a derived cost_price (per-share cost) + # since portfolio rows only carry cost_amount. + def positions_from_portfolio(portfolio_data) + data = portfolio_data.is_a?(Hash) ? portfolio_data.with_indifferent_access : {} + Array(data[:instrument_accounts]).flat_map do |account| + Array(account.is_a?(Hash) ? account.with_indifferent_access[:positions] : nil).map do |pos| + row = (pos.is_a?(Hash) ? pos.with_indifferent_access : {}).dup + titles = row[:titles].to_d if row[:titles] + cost_amount = row[:cost_amount].to_d if row[:cost_amount] + if row[:cost_price].blank? && titles && titles.nonzero? && cost_amount + row[:cost_price] = (cost_amount / titles).to_s + end + row + end + end + end + + # fiscal-results response may be an array or a hash containing an array. + # Prefer total_fiscal_results: it contains one aggregated row per security + # with current titles/amount/cost. fiscal_results is per tax lot and also + # includes historical rebalance events (e.g. virtual sells/buys that + # generated tax events), so summing/iterating it over-counts the position. def normalize_holdings_response(data) return data if data.is_a?(Array) return [] if data.nil? - # Try common response shapes - data[:fiscal_results] || data[:results] || data[:positions] || data[:data] || [] + data[:total_fiscal_results].presence || + data[:fiscal_results] || + data[:results] || + data[:positions] || + data[:data] || + [] end def prune_removed_accounts(upstream_account_ids) diff --git a/app/models/indexa_capital_item/sync_complete_event.rb b/app/models/indexa_capital_item/sync_complete_event.rb new file mode 100644 index 000000000..103315132 --- /dev/null +++ b/app/models/indexa_capital_item/sync_complete_event.rb @@ -0,0 +1,22 @@ +class IndexaCapitalItem::SyncCompleteEvent + attr_reader :indexa_capital_item + + def initialize(indexa_capital_item) + @indexa_capital_item = indexa_capital_item + end + + def broadcast + indexa_capital_item.accounts.each do |account| + account.broadcast_sync_complete + end + + indexa_capital_item.broadcast_replace_to( + indexa_capital_item.family, + target: "indexa_capital_item_#{indexa_capital_item.id}", + partial: "indexa_capital_items/indexa_capital_item", + locals: { indexa_capital_item: indexa_capital_item } + ) + + indexa_capital_item.family.broadcast_sync_complete + end +end diff --git a/app/models/investment.rb b/app/models/investment.rb index 3d18a8334..ffb6331b3 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -50,14 +50,45 @@ class Investment < ApplicationRecord "smsf" => { short: "SMSF", long: "Self-Managed Super Fund", region: "au", tax_treatment: :tax_deferred }, # === Europe === + "assurance_vie" => { short: "AV", long: "Assurance Vie", region: "eu", tax_treatment: :tax_advantaged }, "pea" => { short: "PEA", long: "Plan d'Épargne en Actions", region: "eu", tax_treatment: :tax_advantaged }, "pillar_3a" => { short: "Pillar 3a", long: "Private Pension (Pillar 3a)", region: "eu", tax_treatment: :tax_deferred }, "riester" => { short: "Riester", long: "Riester-Rente", region: "eu", tax_treatment: :tax_deferred }, + # === India === + # Pensions & insurance + "nps" => { short: "NPS", long: "National Pension System", region: "in", tax_treatment: :tax_advantaged }, + "apy" => { short: "APY", long: "Atal Pension Yojana", region: "in", tax_treatment: :tax_advantaged }, + "life_insurance" => { short: "Life Insurance", long: "Life Insurance", region: "in", tax_treatment: :tax_advantaged }, + # Equity / market-linked + "indian_stocks" => { short: "Indian Stocks", long: "Indian Stocks (Demat)", region: "in", tax_treatment: :taxable }, + "indian_equity" => { short: "Indian Equity", long: "Indian Equity", region: "in", tax_treatment: :taxable }, + "indian_etf" => { short: "Indian ETF", long: "Indian ETF", region: "in", tax_treatment: :taxable }, + # Fixed-income / small-savings + "ppf" => { short: "PPF", long: "Public Provident Fund", region: "in", tax_treatment: :tax_exempt }, + "ssy" => { short: "SSY", long: "Sukanya Samriddhi Yojana", region: "in", tax_treatment: :tax_exempt }, + "nsc" => { short: "NSC", long: "National Savings Certificate", region: "in", tax_treatment: :tax_advantaged }, + "scss" => { short: "SCSS", long: "Senior Citizens' Savings Scheme", region: "in", tax_treatment: :taxable }, + "fd" => { short: "FD", long: "Fixed Deposit", region: "in", tax_treatment: :taxable }, + "rd" => { short: "RD", long: "Recurring Deposit", region: "in", tax_treatment: :taxable }, + "pomis" => { short: "POMIS", long: "Post Office Monthly Income Scheme", region: "in", tax_treatment: :taxable }, + "kvp" => { short: "KVP", long: "Kisan Vikas Patra", region: "in", tax_treatment: :taxable }, + # Bonds + "g_sec" => { short: "G-Sec", long: "Government Securities (G-Secs)", region: "in", tax_treatment: :taxable }, + "sdl" => { short: "SDL", long: "State Development Loans (SDLs)", region: "in", tax_treatment: :taxable }, + "corporate_bond" => { short: "Corporate Bond", long: "Corporate Bond", region: "in", tax_treatment: :taxable }, + "infrastructure_bond" => { short: "Infra Bond", long: "Infrastructure Bond", region: "in", tax_treatment: :tax_advantaged }, + "tax_free_bond" => { short: "Tax-Free Bond", long: "Tax-Free Bond", region: "in", tax_treatment: :tax_exempt }, + # India-specific gold instruments + "gold_etf" => { short: "Gold ETF", long: "Gold ETF", region: "in", tax_treatment: :taxable }, + "gold_mf" => { short: "Gold MF", long: "Gold Mutual Fund", region: "in", tax_treatment: :taxable }, + "sgb" => { short: "SGB", long: "Sovereign Gold Bond", region: "in", tax_treatment: :tax_advantaged }, + # === Generic (available everywhere) === "pension" => { short: "Pension", long: "Pension", region: nil, tax_treatment: :tax_deferred }, "retirement" => { short: "Retirement", long: "Retirement Account", region: nil, tax_treatment: :tax_deferred }, "mutual_fund" => { short: "Mutual Fund", long: "Mutual Fund", region: nil, tax_treatment: :taxable }, + "gold" => { short: "Gold", long: "Gold (physical or digital)", region: nil, tax_treatment: :taxable }, "angel" => { short: "Angel", long: "Angel Investment", region: nil, tax_treatment: :taxable }, "trust" => { short: "Trust", long: "Trust", region: nil, tax_treatment: :taxable }, "other" => { short: "Other", long: "Other Investment", region: nil, tax_treatment: :taxable } @@ -91,7 +122,8 @@ class Investment < ApplicationRecord "CAD" => "ca", "AUD" => "au", "EUR" => "eu", - "CHF" => "eu" + "CHF" => "eu", + "INR" => "in" }.freeze # Returns subtypes grouped by region for use with grouped_options_for_select @@ -101,8 +133,12 @@ class Investment < ApplicationRecord grouped = SUBTYPES.group_by { |_, v| v[:region] } # Build region order: user's region first (if known), then Generic, then others - other_regions = %w[us uk ca au eu] - [ user_region ].compact - region_order = [ user_region, nil, *other_regions ].compact.uniq + other_regions = %w[us uk ca au eu in] - [ user_region ].compact + region_order = if user_region + [ user_region, nil, *other_regions ].uniq + else + [ nil, *other_regions ].uniq + end region_order.filter_map do |region| next unless grouped[region] diff --git a/app/models/investment_statement.rb b/app/models/investment_statement.rb index 4475f1fce..e4f79fee1 100644 --- a/app/models/investment_statement.rb +++ b/app/models/investment_statement.rb @@ -38,7 +38,7 @@ class InvestmentStatement # Total portfolio value across all investment accounts def portfolio_value - investment_accounts.sum(&:balance) + investment_accounts.sum { |a| convert_to_family_currency(a.balance, a.currency) } end def portfolio_value_money @@ -47,7 +47,7 @@ class InvestmentStatement # Total cash in investment accounts def cash_balance - investment_accounts.sum(&:cash_balance) + investment_accounts.sum { |a| convert_to_family_currency(a.cash_balance, a.currency) } end def cash_balance_money @@ -63,55 +63,60 @@ class InvestmentStatement Money.new(holdings_value, family.currency) end - # All current holdings across investment accounts + # All current holdings across investment accounts. Holdings are returned in + # their native currency; callers that aggregate across accounts must convert + # to family currency via convert_to_family_currency. def current_holdings return Holding.none unless investment_accounts.any? - account_ids = investment_accounts.pluck(:id) - # Get the latest holding for each security per account Holding - .where(account_id: account_ids) - .where(currency: family.currency) + .where(account_id: investment_account_ids) .where.not(qty: 0) .where( id: Holding - .where(account_id: account_ids) - .where(currency: family.currency) + .where(account_id: investment_account_ids) .select("DISTINCT ON (holdings.account_id, holdings.security_id) holdings.id") .order(Arel.sql("holdings.account_id, holdings.security_id, holdings.date DESC")) ) .includes(:security, :account) - .order(amount: :desc) end - # Top holdings by value + # Top holdings by family-currency value def top_holdings(limit: 5) - current_holdings.limit(limit) + current_holdings + .to_a + .sort_by { |h| -convert_to_family_currency(h.amount, h.currency) } + .first(limit) end - # Portfolio allocation by security type/sector (simplified for now) + # Portfolio allocation by security. Weights and amounts are computed in the + # family's currency so cross-currency holdings compare correctly. def allocation - holdings = current_holdings.to_a - total = holdings.sum(&:amount) + converted = current_holdings.to_a.map do |holding| + [ holding, convert_to_family_currency(holding.amount, holding.currency) ] + end + total = converted.sum { |_, value| value } return [] if total.zero? - holdings.map do |holding| - HoldingAllocation.new( - security: holding.security, - amount: holding.amount_money, - weight: (holding.amount / total * 100).round(2), - trend: holding.trend - ) - end + converted + .sort_by { |_, value| -value } + .map do |holding, value| + HoldingAllocation.new( + security: holding.security, + amount: Money.new(value, family.currency), + weight: (value / total * 100).round(2), + trend: holding.trend + ) + end end - # Unrealized gains across all holdings + # Unrealized gains across all holdings, summed in family currency def unrealized_gains current_holdings.sum do |holding| trend = holding.trend - trend ? trend.value : 0 + trend ? convert_to_family_currency(trend.value, holding.currency) : 0 end end @@ -138,21 +143,12 @@ class InvestmentStatement holdings_with_cost_basis = holdings.select(&:avg_cost) return nil if holdings_with_cost_basis.empty? - current = holdings_with_cost_basis.sum(&:amount) - previous = holdings_with_cost_basis.sum { |h| h.qty * h.avg_cost.amount } - - Trend.new(current: current, previous: previous) - end - - # Day change across portfolio - def day_change - holdings = current_holdings.to_a - changes = holdings.map(&:day_change).compact - - return nil if changes.empty? - - current = changes.sum { |t| t.current.is_a?(Money) ? t.current.amount : t.current } - previous = changes.sum { |t| t.previous.is_a?(Money) ? t.previous.amount : t.previous } + current = holdings_with_cost_basis.sum do |h| + convert_to_family_currency(h.amount, h.currency) + end + previous = holdings_with_cost_basis.sum do |h| + convert_to_family_currency(h.qty * h.avg_cost.amount, h.currency) + end Trend.new( current: Money.new(current, family.currency), @@ -160,6 +156,27 @@ class InvestmentStatement ) end + # Day change across portfolio, summed in family currency + def day_change + changes = current_holdings.to_a.filter_map do |h| + t = h.day_change + next nil unless t + curr = t.current.is_a?(Money) ? t.current.amount : t.current + prev = t.previous.is_a?(Money) ? t.previous.amount : t.previous + [ + convert_to_family_currency(curr, h.currency), + convert_to_family_currency(prev, h.currency) + ] + end + + return nil if changes.empty? + + Trend.new( + current: Money.new(changes.sum { |c, _| c }, family.currency), + previous: Money.new(changes.sum { |_, p| p }, family.currency) + ) + end + # Investment accounts def investment_accounts @investment_accounts ||= begin @@ -170,6 +187,33 @@ class InvestmentStatement end private + # Today's rates for every currency present on the family's investment + # accounts and their holdings. Mirrors BalanceSheet::AccountTotals#exchange_rates. + def exchange_rates + @exchange_rates ||= begin + account_currencies = investment_accounts.map(&:currency) + holding_currencies = Holding.where(account_id: investment_account_ids).distinct.pluck(:currency) + foreign = (account_currencies + holding_currencies) + .compact + .uniq + .reject { |c| c == family.currency } + ExchangeRate.rates_for(foreign, to: family.currency, date: Date.current) + end + end + + # Unwrap Money first because this codebase's Money (lib/money.rb) ignores + # the currency arg of `Money.new` when the payload is already a Money, and + # `Money * numeric` preserves the source currency — so multiplying a + # foreign-currency Money by a rate would FX-scale the amount but keep the + # wrong currency label, corrupting downstream sums. + def convert_to_family_currency(amount, from_currency) + return amount if amount.nil? + numeric = amount.is_a?(Money) ? amount.amount : amount + return numeric if from_currency == family.currency + rate = exchange_rates[from_currency] || 1 + numeric * rate + end + def all_time_totals @all_time_totals ||= totals(period: Period.all_time) end diff --git a/app/models/kraken_account.rb b/app/models/kraken_account.rb new file mode 100644 index 000000000..e968f1e48 --- /dev/null +++ b/app/models/kraken_account.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class KrakenAccount < ApplicationRecord + include Encryptable + + STABLECOINS = %w[USDT USDC DAI PYUSD USDP TUSD USDG].freeze + FIAT_CURRENCIES = %w[USD EUR GBP CAD AUD CHF JPY AED].freeze + + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end + + belongs_to :kraken_item + + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + + validates :name, :account_id, :account_type, :currency, presence: true + + def current_account + account + end + + def ensure_account_provider!(target_account = nil) + acct = target_account || current_account + return nil unless acct + + AccountProvider + .find_or_initialize_by(provider_type: "KrakenAccount", provider_id: id) + .tap do |ap| + ap.account = acct + ap.save! + end + rescue StandardError => e + Rails.logger.warn("KrakenAccount #{id}: failed to link account provider - #{e.class}: #{e.message}") + nil + end +end diff --git a/app/models/kraken_account/asset_normalizer.rb b/app/models/kraken_account/asset_normalizer.rb new file mode 100644 index 000000000..7ad0a9e1c --- /dev/null +++ b/app/models/kraken_account/asset_normalizer.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class KrakenAccount::AssetNormalizer + SUFFIX_PATTERN = /(\.[A-Z])\z/ + FIAT_PREFIXES = { + "ZUSD" => "USD", + "ZEUR" => "EUR", + "ZGBP" => "GBP", + "ZCAD" => "CAD", + "ZAUD" => "AUD", + "ZCHF" => "CHF", + "ZJPY" => "JPY" + }.freeze + SYMBOL_FALLBACKS = { + "XBT" => "BTC", + "XXBT" => "BTC", + "XETH" => "ETH", + "ZUSD" => "USD" + }.freeze + + def initialize(asset_metadata = {}) + @asset_metadata = asset_metadata || {} + end + + def normalize(raw_asset) + raw = raw_asset.to_s.upcase + suffix = raw[SUFFIX_PATTERN, 1] + raw_base = suffix ? raw.delete_suffix(suffix) : raw + + metadata = metadata_for(raw, raw_base) + base_symbol = metadata_symbol(metadata, raw_base) + normalized_base = normalize_base_symbol(base_symbol) + symbol = suffix.present? ? "#{normalized_base}#{suffix}" : normalized_base + + { + raw_asset: raw, + raw_base: raw_base, + symbol: symbol, + price_symbol: normalized_base, + suffix: suffix, + metadata: metadata + } + end + + private + + attr_reader :asset_metadata + + def metadata_for(raw, raw_base) + asset_metadata[raw] || asset_metadata[raw_base] || asset_metadata.values.find do |metadata| + candidate = metadata_symbol(metadata, raw_base) + [ raw, raw_base ].include?(candidate.to_s.upcase) + end + end + + def metadata_symbol(metadata, fallback) + return fallback unless metadata.is_a?(Hash) + + metadata["altname"].presence || metadata["display_name"].presence || fallback + end + + def normalize_base_symbol(symbol) + value = symbol.to_s.upcase + value = FIAT_PREFIXES[value] if FIAT_PREFIXES.key?(value) + SYMBOL_FALLBACKS[value] || value + end +end diff --git a/app/models/kraken_account/holdings_processor.rb b/app/models/kraken_account/holdings_processor.rb new file mode 100644 index 000000000..d0a589ca4 --- /dev/null +++ b/app/models/kraken_account/holdings_processor.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class KrakenAccount::HoldingsProcessor + include KrakenAccount::UsdConverter + + def initialize(kraken_account) + @kraken_account = kraken_account + end + + def process + return unless account&.accountable_type == "Crypto" + + raw_assets.each { |asset| process_asset(asset) } + rescue StandardError => e + Rails.logger.error "KrakenAccount::HoldingsProcessor - error: #{e.message}" + nil + end + + private + + attr_reader :kraken_account + + def target_currency + kraken_account.kraken_item&.family&.currency + end + + def account + kraken_account.current_account + end + + def raw_assets + kraken_account.raw_payload&.dig("assets") || [] + end + + def process_asset(asset) + symbol = asset["symbol"] || asset[:symbol] + price_symbol = asset["price_symbol"] || asset[:price_symbol] || symbol + total = (asset["balance"] || asset[:balance] || 0).to_d + price_usd = asset["price_usd"] || asset[:price_usd] + source = asset["source"] || asset[:source] || "spot" + + return if symbol.blank? || total.zero? || price_usd.blank? + + security = resolve_security(symbol) + return unless security + + amount_usd = total * price_usd.to_d + amount, amount_stale, amount_rate_date = convert_from_usd(amount_usd, date: Date.current) + price, price_stale, price_rate_date = convert_from_usd(price_usd.to_d, date: Date.current) + log_stale_rate(symbol, "amount", amount_rate_date) if amount_stale + log_stale_rate(symbol, "price", price_rate_date) if price_stale + + import_adapter.import_holding( + security: security, + quantity: total, + amount: amount, + currency: target_currency, + date: Date.current, + price: price, + cost_basis: nil, + external_id: "kraken_#{symbol}_#{source}_#{Date.current}", + account_provider_id: kraken_account.account_provider&.id, + source: "kraken", + delete_future_holdings: false + ) + rescue StandardError => e + Rails.logger.error "KrakenAccount::HoldingsProcessor - failed asset symbol=#{symbol.presence || "unknown"}: #{e.message}" + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def resolve_security(symbol) + ticker = symbol.to_s.include?(":") ? symbol.to_s : "CRYPTO:#{symbol}" + KrakenAccount::SecurityResolver.resolve(ticker, symbol) + end + + def log_stale_rate(symbol, field, rate_date) + Rails.logger.warn( + "KrakenAccount::HoldingsProcessor - stale FX rate for #{field} symbol=#{symbol} rate_date=#{rate_date || "unknown"}" + ) + end +end diff --git a/app/models/kraken_account/processor.rb b/app/models/kraken_account/processor.rb new file mode 100644 index 000000000..483ac1f1e --- /dev/null +++ b/app/models/kraken_account/processor.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +class KrakenAccount::Processor + include KrakenAccount::UsdConverter + + attr_reader :kraken_account + + def initialize(kraken_account) + @kraken_account = kraken_account + end + + def process + return unless kraken_account.current_account.present? + + KrakenAccount::HoldingsProcessor.new(kraken_account).process + process_account! + process_trades + end + + private + + def target_currency + kraken_account.kraken_item&.family&.currency + end + + def process_account! + account = kraken_account.current_account + amount, stale, rate_date = convert_from_usd((kraken_account.current_balance || 0).to_d, date: Date.current) + + account.update!( + balance: amount, + cash_balance: 0, + currency: target_currency + ) + + kraken_account.update!(extra: kraken_account.extra.to_h.deep_merge(build_stale_extra(stale, rate_date, Date.current))) + end + + def process_trades + raw_trades.each do |txid, trade| + process_trade(txid, trade) + end + rescue StandardError => e + Rails.logger.error "KrakenAccount::Processor - trade processing failed: #{e.message}" + end + + def raw_trades + kraken_account.raw_transactions_payload&.dig("trades") || {} + end + + def process_trade(txid, trade) + account = kraken_account.current_account + return unless account + + external_id = "kraken_trade_#{txid}" + return if account.entries.exists?(external_id: external_id, source: "kraken") + + type = trade["type"].to_s.downcase + return unless %w[buy sell].include?(type) + + pair = trade["pair"].to_s + base_symbol, quote_symbol = infer_pair_symbols(pair, trade) + return if base_symbol.blank? + + qty = trade["vol"].to_d + return if qty.zero? + + price = trade["price"].to_d + cost = trade["cost"].presence&.to_d + cost ||= (qty * price).round(8) + fee = trade["fee"].presence&.to_d || 0 + currency = quote_symbol.presence || "USD" + date = Time.zone.at(trade["time"].to_d).to_date + security = KrakenAccount::SecurityResolver.resolve("CRYPTO:#{base_symbol}", base_symbol) + return unless security + + entry_amount = type == "buy" ? -cost : cost + trade_qty = type == "buy" ? qty : -qty + label = type == "buy" ? "Buy" : "Sell" + + account.entries.create!( + date: date, + name: "#{label} #{qty.round(8)} #{base_symbol}", + amount: entry_amount, + currency: currency, + external_id: external_id, + source: "kraken", + notes: trade["ordertxid"].presence, + entryable: Trade.new( + security: security, + qty: trade_qty, + price: price, + currency: currency, + fee: fee, + investment_activity_label: label + ) + ) + rescue StandardError => e + Rails.logger.error "KrakenAccount::Processor - failed to process trade #{txid}: #{e.message}" + end + + def infer_pair_symbols(pair, trade) + pair_metadata = kraken_account.raw_payload&.dig("pair_metadata") || {} + metadata = pair_metadata[pair] || pair_metadata.values.find { |candidate| candidate["altname"].to_s == pair } + normalizer = KrakenAccount::AssetNormalizer.new(kraken_account.raw_payload&.dig("asset_metadata") || {}) + + if metadata + base = normalizer.normalize(metadata["base"])[:symbol] + quote = normalizer.normalize(metadata["quote"])[:symbol] + return [ base, quote ] + end + + altname = trade["pair"].to_s + %w[USDT USDC USD EUR GBP BTC ETH].each do |quote| + next unless altname.end_with?(quote) + + return [ normalizer.normalize(altname.delete_suffix(quote))[:symbol], quote ] + end + + [ altname, "USD" ] + end +end diff --git a/app/models/kraken_account/security_resolver.rb b/app/models/kraken_account/security_resolver.rb new file mode 100644 index 000000000..036f9f986 --- /dev/null +++ b/app/models/kraken_account/security_resolver.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class KrakenAccount::SecurityResolver + EXCHANGE_MIC = "XKRA" + + def self.resolve(ticker, symbol) + Security::Resolver.new(ticker).resolve + rescue StandardError => e + Rails.logger.warn "KrakenAccount::SecurityResolver - resolver failed for #{ticker}: #{e.message}" + Security.find_or_initialize_by(ticker: ticker, exchange_operating_mic: EXCHANGE_MIC).tap do |security| + security.name = symbol if security.name.blank? + security.offline = true unless security.offline + security.save! if security.changed? + end + end +end diff --git a/app/models/kraken_account/usd_converter.rb b/app/models/kraken_account/usd_converter.rb new file mode 100644 index 000000000..054c587f0 --- /dev/null +++ b/app/models/kraken_account/usd_converter.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module KrakenAccount::UsdConverter + private + + def convert_from_usd(amount, date: Date.current) + return [ amount.to_d, false, nil ] if target_currency == "USD" + + rate = ExchangeRate.find_or_fetch_rate(from: "USD", to: target_currency, date: date) + return [ amount.to_d, true, nil ] if rate.nil? + + converted = Money.new(amount, "USD").exchange_to(target_currency, custom_rate: rate.rate).amount + stale = rate.date != date + rate_date = stale ? rate.date : nil + + [ converted, stale, rate_date ] + end + + def build_stale_extra(stale, rate_date, target_date) + kraken_meta = if stale + { + "stale_rate" => true, + "rate_date_used" => rate_date&.to_s, + "rate_target_date" => target_date&.to_s + } + else + { "stale_rate" => false } + end + + { "kraken" => kraken_meta } + end +end diff --git a/app/models/kraken_item.rb b/app/models/kraken_item.rb new file mode 100644 index 000000000..2c47d5f8b --- /dev/null +++ b/app/models/kraken_item.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +class KrakenItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + if encryption_ready? + encrypts :api_key, deterministic: true + encrypts :api_secret + encrypts :raw_payload + end + + validates :name, presence: true + validates :api_key, presence: true + validates :api_secret, presence: true + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + + has_many :kraken_accounts, dependent: :destroy + has_many :accounts, through: :kraken_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + scope :credentials_configured, -> { where.not(api_key: [ nil, "" ]).where.not(api_secret: nil) } + + before_validation :strip_credentials + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def import_latest_kraken_data + provider = kraken_provider + raise StandardError, "Kraken credentials not configured" unless provider + + KrakenItem::Importer.new(self, kraken_provider: provider).import + rescue StandardError => e + Rails.logger.error "KrakenItem #{id} - Failed to import: #{e.full_message}" + raise + end + + def process_accounts + return [] if kraken_accounts.empty? + + results = [] + kraken_accounts.joins(:account).merge(Account.visible).each do |kraken_account| + begin + result = KrakenAccount::Processor.new(kraken_account).process + results << { kraken_account_id: kraken_account.id, success: true, result: result } + rescue StandardError => e + Rails.logger.error "KrakenItem #{id} - Failed to process account #{kraken_account.id}: #{e.full_message}" + results << { kraken_account_id: kraken_account.id, success: false, error: e.message } + end + end + + results + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + accounts.visible.map do |account| + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + { account_id: account.id, success: true } + rescue StandardError => e + Rails.logger.error "KrakenItem #{id} - Failed to schedule sync for account #{account.id}: #{e.full_message}" + { account_id: account.id, success: false, error: e.message } + end + end + + def upsert_kraken_snapshot!(payload) + update!(raw_payload: payload) + end + + def has_completed_initial_setup? + accounts.any? + end + + def sync_status_summary + total = total_accounts_count + linked = linked_accounts_count + unlinked = unlinked_accounts_count + + if total.zero? + I18n.t("kraken_items.kraken_item.sync_status.no_accounts") + elsif unlinked.zero? + I18n.t("kraken_items.kraken_item.sync_status.all_synced", count: linked) + else + I18n.t("kraken_items.kraken_item.sync_status.partial_sync", linked_count: linked, unlinked_count: unlinked) + end + end + + def linked_accounts_count + kraken_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + kraken_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + kraken_accounts.count + end + + def stale_rate_accounts + kraken_accounts + .joins(:account) + .where(accounts: { status: "active" }) + .where("kraken_accounts.extra -> 'kraken' ->> 'stale_rate' = 'true'") + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + def credentials_configured? + api_key.to_s.strip.present? && api_secret.to_s.strip.present? + end + + def next_nonce! + with_lock do + candidate = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond) + candidate = last_nonce.to_i + 1 if candidate <= last_nonce.to_i + update!(last_nonce: candidate) + candidate.to_s + end + end + + def set_kraken_institution_defaults! + update!( + institution_name: "Kraken", + institution_domain: "kraken.com", + institution_url: "https://www.kraken.com", + institution_color: "#5841D8" + ) + end + + private + + def strip_credentials + self.api_key = api_key.to_s.strip if api_key_changed? && !api_key.nil? + self.api_secret = api_secret.to_s.strip if api_secret_changed? && !api_secret.nil? + end +end diff --git a/app/models/kraken_item/importer.rb b/app/models/kraken_item/importer.rb new file mode 100644 index 000000000..38944fe0f --- /dev/null +++ b/app/models/kraken_item/importer.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +class KrakenItem::Importer + MAX_TRADE_PAGES = 200 + TRADE_PAGE_SIZE = 50 + + attr_reader :kraken_item, :kraken_provider + + def initialize(kraken_item, kraken_provider:) + @kraken_item = kraken_item + @kraken_provider = kraken_provider + end + + def import + api_key_info = kraken_provider.get_api_key_info + + asset_metadata = kraken_provider.get_asset_info || {} + pair_metadata = kraken_provider.get_asset_pairs || {} + balances = kraken_provider.get_extended_balance || {} + assets = parse_assets(balances, asset_metadata) + trades = fetch_trades + + total_usd = assets.sum { |asset| asset[:amount_usd].to_d }.round(2) + kraken_account = upsert_kraken_account( + assets: assets, + balances: balances, + trades: trades, + asset_metadata: asset_metadata, + pair_metadata: pair_metadata, + api_key_info: api_key_info, + total_usd: total_usd + ) + + kraken_item.upsert_kraken_snapshot!({ + "api_key_info" => api_key_info, + "balances" => balances, + "asset_metadata" => asset_metadata, + "pair_metadata" => pair_metadata, + "imported_at" => Time.current.iso8601 + }) + + { success: true, account_id: kraken_account.id, assets_imported: assets.size, trades_imported: trades.size, total_usd: total_usd } + rescue Provider::Kraken::PermissionError => e + kraken_item.update!(status: :requires_update) + raise e + end + + private + def parse_assets(balances, asset_metadata) + normalizer = KrakenAccount::AssetNormalizer.new(asset_metadata) + + balances.filter_map do |raw_asset, balance_data| + parsed = normalizer.normalize(raw_asset) + balance = balance_data.fetch("balance", "0").to_d + credit = balance_data.fetch("credit", "0").to_d + credit_used = balance_data.fetch("credit_used", "0").to_d + hold_trade = balance_data.fetch("hold_trade", "0").to_d + available = balance + credit - credit_used - hold_trade + + next if balance.zero? && hold_trade.zero? + + price_usd, price_status = price_for(parsed[:price_symbol]) + amount_usd = price_usd ? (balance * price_usd).round(2) : 0.to_d + + parsed.merge( + balance: balance.to_s("F"), + available: available.to_s("F"), + hold_trade: hold_trade.to_s("F"), + price_usd: price_usd&.to_s("F"), + amount_usd: amount_usd.to_s("F"), + price_status: price_status, + source: "spot" + ) + end + end + + def price_for(symbol) + return [ 1.to_d, "exact" ] if symbol == "USD" || KrakenAccount::STABLECOINS.include?(symbol) + + if KrakenAccount::FIAT_CURRENCIES.include?(symbol) + rate = ExchangeRate.find_or_fetch_rate(from: symbol, to: "USD", date: Date.current) + return [ rate.rate.to_d, rate.date == Date.current ? "exact" : "stale" ] if rate + + return [ nil, "missing" ] + end + + ticker_price = ticker_price_for(symbol) + return [ ticker_price, "exact" ] if ticker_price + + [ nil, "missing" ] + rescue StandardError => e + Rails.logger.warn "KrakenItem::Importer - could not price #{symbol}: #{e.message}" + [ nil, "missing" ] + end + + def ticker_price_for(symbol) + pair_candidates_for(symbol).each do |pair| + response = kraken_provider.get_ticker(pair) + ticker_payload = response&.values&.first + price = ticker_payload&.dig("c", 0) + return price.to_d if price.present? + rescue Provider::Kraken::ApiError + next + end + + nil + end + + def pair_candidates_for(symbol) + kraken_symbol = symbol == "BTC" ? "XBT" : symbol + [ + "#{kraken_symbol}USD", + "#{symbol}USD", + "X#{kraken_symbol}ZUSD", + "#{kraken_symbol}USDT", + "#{symbol}USDT" + ].uniq + end + + def fetch_trades + start_time = kraken_item.sync_start_date&.to_i + offset = 0 + all_trades = {} + + MAX_TRADE_PAGES.times do + result = kraken_provider.get_trades_history(start: start_time, offset: offset) + trades = result.to_h.fetch("trades", {}) + duplicate_trade_ids = all_trades.keys & trades.keys + if duplicate_trade_ids.any? + Rails.logger.warn("KrakenItem::Importer - #{duplicate_trade_ids.size} duplicate trade ids from Kraken page ignored") + end + all_trades.merge!(trades.except(*duplicate_trade_ids)) + + count = result.to_h["count"].to_i + break if trades.size < TRADE_PAGE_SIZE + + offset += trades.size + break if count.positive? && offset >= count + end + + all_trades + end + + def upsert_kraken_account(assets:, balances:, trades:, asset_metadata:, pair_metadata:, api_key_info:, total_usd:) + kraken_item.kraken_accounts.find_or_initialize_by(account_id: "combined").tap do |account| + account.assign_attributes( + name: kraken_item.institution_name.presence || "Kraken", + account_type: "combined", + currency: "USD", + current_balance: total_usd, + institution_metadata: institution_metadata(assets), + raw_payload: { + "balances" => balances, + "assets" => assets.map(&:stringify_keys), + "asset_metadata" => asset_metadata, + "pair_metadata" => pair_metadata, + "api_key_info" => api_key_info, + "fetched_at" => Time.current.iso8601 + }, + raw_transactions_payload: { + "trades" => trades, + "fetched_at" => Time.current.iso8601 + }, + extra: account.extra.to_h.deep_merge(price_metadata(assets)) + ) + account.save! + end + end + + def institution_metadata(assets) + { + "name" => "Kraken", + "domain" => "kraken.com", + "url" => "https://www.kraken.com", + "color" => "#5841D8", + "asset_count" => assets.size, + "assets" => assets.map { |asset| asset[:symbol] } + } + end + + def price_metadata(assets) + missing = assets.select { |asset| asset[:price_status] == "missing" }.map { |asset| asset[:symbol] } + stale = assets.select { |asset| asset[:price_status] == "stale" }.map { |asset| asset[:symbol] } + + { "kraken" => { "missing_prices" => missing, "stale_prices" => stale } } + end +end diff --git a/app/models/kraken_item/provided.rb b/app/models/kraken_item/provided.rb new file mode 100644 index 000000000..830a6fd11 --- /dev/null +++ b/app/models/kraken_item/provided.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module KrakenItem::Provided + extend ActiveSupport::Concern + + def kraken_provider + return nil unless credentials_configured? + + Provider::Kraken.new( + api_key: api_key.to_s.strip, + api_secret: api_secret.to_s.strip, + nonce_generator: -> { next_nonce! } + ) + end +end diff --git a/app/models/kraken_item/sync_complete_event.rb b/app/models/kraken_item/sync_complete_event.rb new file mode 100644 index 000000000..a2b07d69e --- /dev/null +++ b/app/models/kraken_item/sync_complete_event.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class KrakenItem::SyncCompleteEvent + def initialize(kraken_item) + raise ArgumentError, "kraken_item is required" unless kraken_item.respond_to?(:family) && kraken_item.respond_to?(:id) + + @kraken_item = kraken_item + end + + def broadcast + Turbo::StreamsChannel.broadcast_replace_to( + @kraken_item.family, + target: ActionView::RecordIdentifier.dom_id(@kraken_item), + partial: "kraken_items/kraken_item", + locals: { kraken_item: @kraken_item } + ) + rescue StandardError => e + Rails.logger.warn("KrakenItem::SyncCompleteEvent failed for #{@kraken_item.id}: #{e.class}") + end +end diff --git a/app/models/kraken_item/syncer.rb b/app/models/kraken_item/syncer.rb new file mode 100644 index 000000000..80b066d36 --- /dev/null +++ b/app/models/kraken_item/syncer.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class KrakenItem::Syncer + include SyncStats::Collector + + attr_reader :kraken_item + + def initialize(kraken_item) + @kraken_item = kraken_item + end + + def perform_sync(sync) + sync.update!(status_text: I18n.t("kraken_item.syncer.checking_credentials")) if sync.respond_to?(:status_text) + unless kraken_item.credentials_configured? + kraken_item.update!(status: :requires_update) + mark_failed(sync, I18n.t("kraken_item.syncer.credentials_invalid")) + return + end + + sync.update!(status_text: I18n.t("kraken_item.syncer.importing_accounts")) if sync.respond_to?(:status_text) + kraken_item.import_latest_kraken_data + kraken_item.update!(status: :good) if kraken_item.requires_update? + + sync.update!(status_text: I18n.t("kraken_item.syncer.checking_configuration")) if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: kraken_item.kraken_accounts.to_a) + + unlinked = kraken_item.kraken_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + linked = kraken_item.kraken_accounts.joins(:account_provider).joins(:account).merge(Account.visible) + + if unlinked.any? + kraken_item.update!(pending_account_setup: true) + sync.update!(status_text: I18n.t("kraken_item.syncer.accounts_need_setup", count: unlinked.count)) if sync.respond_to?(:status_text) + else + kraken_item.update!(pending_account_setup: false) + end + + return unless linked.any? + + sync.update!(status_text: I18n.t("kraken_item.syncer.processing_accounts")) if sync.respond_to?(:status_text) + kraken_item.process_accounts + + sync.update!(status_text: I18n.t("kraken_item.syncer.calculating_balances")) if sync.respond_to?(:status_text) + kraken_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + + account_ids = linked.map { |kraken_account| kraken_account.current_account&.id }.compact + if account_ids.any? + collect_transaction_stats(sync, account_ids: account_ids, source: "kraken") + collect_trades_stats(sync, account_ids: account_ids, source: "kraken") + end + rescue Provider::Kraken::AuthenticationError, Provider::Kraken::PermissionError, Provider::Kraken::OTPRequiredError => e + kraken_item.update!(status: :requires_update) + mark_failed(sync, e.message) + raise + rescue StandardError => e + Rails.logger.error "KrakenItem::Syncer - unexpected error during sync: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}" + mark_failed(sync, e.message) + raise + end + + def perform_post_sync + end + + private + + def mark_failed(sync, error_message) + sync.start! if sync.respond_to?(:may_start?) && sync.may_start? + + if sync.respond_to?(:may_fail?) && sync.may_fail? + sync.fail! + elsif sync.respond_to?(:status) + sync.update!(status: :failed) + end + + sync.update!(error: error_message) if sync.respond_to?(:error) + sync.update!(status_text: error_message) if sync.respond_to?(:status_text) + end +end diff --git a/app/models/kraken_item/unlinking.rb b/app/models/kraken_item/unlinking.rb new file mode 100644 index 000000000..d3ef80bb7 --- /dev/null +++ b/app/models/kraken_item/unlinking.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module KrakenItem::Unlinking + extend ActiveSupport::Concern + + def unlink_all!(dry_run: false) + results = [] + links_by_provider_id = AccountProvider + .where(provider_type: KrakenAccount.name, provider_id: kraken_accounts.select(:id)) + .group_by { |link| link.provider_id.to_s } + + kraken_accounts.find_each do |provider_account| + links = links_by_provider_id[provider_account.id.to_s] || [] + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) if link_ids.any? + links.each(&:destroy!) + end + rescue StandardError => e + Rails.logger.warn("KrakenItem Unlinker: failed to unlink ##{provider_account.id}: #{e.class} - #{e.message}") + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/loan.rb b/app/models/loan.rb index ec56d65b8..980c19885 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -8,6 +8,8 @@ class Loan < ApplicationRecord "other" => { short: "Other Loan", long: "Other Loan" } }.freeze + validates :subtype, inclusion: { in: SUBTYPES.keys }, allow_blank: true + def monthly_payment return nil if term_months.nil? || interest_rate.nil? || rate_type.nil? || rate_type != "fixed" return Money.new(0, account.currency) if account.loan.original_balance.amount.zero? || term_months.zero? diff --git a/app/models/lunchflow_item.rb b/app/models/lunchflow_item.rb index ba9830c6b..94cf078e7 100644 --- a/app/models/lunchflow_item.rb +++ b/app/models/lunchflow_item.rb @@ -1,6 +1,8 @@ class LunchflowItem < ApplicationRecord include Syncable, Provided, Unlinking, Encryptable + DEFAULT_BASE_URL = "https://lunchflow.app/api/v1".freeze + enum :status, { good: "good", requires_update: "requires_update" }, default: :good # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured @@ -154,6 +156,17 @@ class LunchflowItem < ApplicationRecord end def effective_base_url - base_url.presence || "https://lunchflow.app/api/v1" + return DEFAULT_BASE_URL if base_url.blank? + + uri = URI.parse(base_url) + return DEFAULT_BASE_URL unless uri.is_a?(URI::HTTPS) + return DEFAULT_BASE_URL unless uri.host == "lunchflow.app" + return DEFAULT_BASE_URL unless [ "", "/", "/api/v1", "/api/v1/" ].include?(uri.path) + return DEFAULT_BASE_URL unless uri.query.blank? + return DEFAULT_BASE_URL unless uri.fragment.blank? + + DEFAULT_BASE_URL + rescue URI::InvalidURIError + DEFAULT_BASE_URL end end diff --git a/app/models/market_data_importer.rb b/app/models/market_data_importer.rb index d1ac7d7d7..d590859fe 100644 --- a/app/models/market_data_importer.rb +++ b/app/models/market_data_importer.rb @@ -17,7 +17,7 @@ class MarketDataImporter # Syncs historical security prices (and details) def import_security_prices - unless Security.provider + unless Security.providers.any? Rails.logger.warn("No provider configured for MarketDataImporter.import_security_prices, skipping sync") return end @@ -76,9 +76,6 @@ class MarketDataImporter .each do |(source, target), date| key = [ source, target ] pair_dates[key] = [ pair_dates[key], date ].compact.min - - inverse_key = [ target, source ] - pair_dates[inverse_key] = [ pair_dates[inverse_key], date ].compact.min end # 2. ACCOUNT-BASED PAIRS – use the account's oldest entry date @@ -94,9 +91,6 @@ class MarketDataImporter key = [ account.source, account.target ] pair_dates[key] = [ pair_dates[key], chosen_date ].compact.min - - inverse_key = [ account.target, account.source ] - pair_dates[inverse_key] = [ pair_dates[inverse_key], chosen_date ].compact.min end # Convert to array of hashes for ease of use diff --git a/app/models/mercury_item.rb b/app/models/mercury_item.rb index eb062b2b9..5869156fb 100644 --- a/app/models/mercury_item.rb +++ b/app/models/mercury_item.rb @@ -168,7 +168,7 @@ class MercuryItem < ApplicationRecord end def credentials_configured? - token.present? + token.to_s.strip.present? end def effective_base_url diff --git a/app/models/message.rb b/app/models/message.rb index 4bf5e9c00..736ac8785 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -8,9 +8,9 @@ class Message < ApplicationRecord failed: "failed" } - validates :content, presence: true + validates :content, presence: true, unless: :pending? - after_create_commit -> { broadcast_append_to chat, target: "messages" }, if: :broadcast? + after_create_commit -> { broadcast_append_to chat, target: chat.messages_target }, if: :broadcast? after_update_commit -> { broadcast_update_to chat }, if: :broadcast? scope :ordered, -> { order(created_at: :asc) } diff --git a/app/models/mint_import.rb b/app/models/mint_import.rb index 932353d8a..1dc5e5fb5 100644 --- a/app/models/mint_import.rb +++ b/app/models/mint_import.rb @@ -1,11 +1,30 @@ class MintImport < Import after_create :set_mappings + DEFAULT_COLUMN_MAPPINGS = { + signage_convention: "inflows_positive", + date_col_label: "Date", + date_format: "%m/%d/%Y", + name_col_label: "Description", + amount_col_label: "Amount", + currency_col_label: "Currency", + account_col_label: "Account Name", + category_col_label: "Category", + tags_col_label: "Labels", + notes_col_label: "Notes", + entity_type_col_label: "Transaction Type" + }.freeze + + def self.default_column_mappings + DEFAULT_COLUMN_MAPPINGS + end + def generate_rows_from_csv rows.destroy_all - mapped_rows = csv_rows.map do |row| + mapped_rows = csv_rows.map.with_index(1) do |row, index| { + source_row_number: index, account: row[account_col_label].to_s, date: row[date_col_label].to_s, amount: signed_csv_amount(row).to_s, @@ -82,18 +101,7 @@ class MintImport < Import private def set_mappings - self.signage_convention = "inflows_positive" - self.date_col_label = "Date" - self.date_format = "%m/%d/%Y" - self.name_col_label = "Description" - self.amount_col_label = "Amount" - self.currency_col_label = "Currency" - self.account_col_label = "Account Name" - self.category_col_label = "Category" - self.tags_col_label = "Labels" - self.notes_col_label = "Notes" - self.entity_type_col_label = "Transaction Type" - + assign_attributes(self.class.default_column_mappings) save! end end diff --git a/app/models/oidc_identity.rb b/app/models/oidc_identity.rb index e8993142f..a95daf10e 100644 --- a/app/models/oidc_identity.rb +++ b/app/models/oidc_identity.rb @@ -25,10 +25,13 @@ class OidcIdentity < ApplicationRecord groups: groups }) - # Sync name to user if provided (keep existing if IdP doesn't provide) + # Sync name to user only when Sure has nothing on file (first link, or an + # admin blanked the field). Edits made inside Sure must survive subsequent + # SSO logins — previously the IdP value won unconditionally and clobbered + # any manually-edited name on every login (#1103). user.update!( - first_name: auth.info&.first_name.presence || user.first_name, - last_name: auth.info&.last_name.presence || user.last_name + first_name: user.first_name.presence || auth.info&.first_name.presence, + last_name: user.last_name.presence || auth.info&.last_name.presence ) # Apply role mapping based on group membership @@ -95,7 +98,7 @@ class OidcIdentity < ApplicationRecord # Find the configured provider for this identity def provider_config - Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == provider || p[:id] == provider } + AuthConfig.sso_providers&.find { |p| p[:name] == provider || p[:id] == provider } end # Validate that the stored issuer matches the configured provider's issuer diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb index 38091bf2c..f610e6b0f 100644 --- a/app/models/pdf_import.rb +++ b/app/models/pdf_import.rb @@ -114,9 +114,10 @@ class PdfImport < Import currency = account&.currency || family.currency - mapped_rows = extracted_transactions.map do |txn| + mapped_rows = extracted_transactions.map.with_index(1) do |txn, index| { import_id: id, + source_row_number: index, date: format_date_for_import(txn["date"]), amount: txn["amount"].to_s, name: txn["name"].to_s, @@ -154,6 +155,14 @@ class PdfImport < Import account.present? && statement_with_transactions? && cleaned? && mappings.all?(&:valid?) end + def cleaned_from_validation_stats?(invalid_rows_count:) + account.present? && statement_with_transactions? && super + end + + def publishable_from_validation_stats?(invalid_rows_count:) + account.present? && statement_with_transactions? && super + end + def column_keys %i[date amount name category notes] end diff --git a/app/models/period.rb b/app/models/period.rb index 4188478f2..b4857daee 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -95,6 +95,10 @@ class Period } class << self + def valid_key?(key) + PERIODS.key?(key) + end + def from_key(key) unless PERIODS.key?(key) raise InvalidKeyError, "Invalid period key: #{key}" @@ -165,7 +169,9 @@ class Period end def interval - if days > 366 + if end_date > start_date.advance(years: 5) # more than 5 calendar years → monthly + "1 month" + elsif end_date > start_date.advance(years: 1) # more than 1 calendar year → weekly "1 week" else "1 day" @@ -173,24 +179,24 @@ class Period end def label - if key_metadata - key_metadata.fetch(:label) + if key + I18n.t("period.#{key}.label", default: key_metadata&.fetch(:label) || "Custom Period") else - "Custom Period" + I18n.t("period.custom.label", default: "Custom Period") end end def label_short - if key_metadata - key_metadata.fetch(:label_short) + if key + I18n.t("period.#{key}.label_short", default: key_metadata&.fetch(:label_short) || "Custom") else - "Custom" + I18n.t("period.custom.label_short", default: "Custom") end end def comparison_label - if key_metadata - key_metadata.fetch(:comparison_label) + if key + I18n.t("period.#{key}.comparison_label", default: key_metadata&.fetch(:comparison_label) || "#{start_date.strftime(@date_format)} to #{end_date.strftime(@date_format)}") else "#{start_date.strftime(@date_format)} to #{end_date.strftime(@date_format)}" end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index ae2ecfeae..fa5922ae1 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -70,6 +70,6 @@ class PlaidAccount < ApplicationRecord # Plaid guarantees at least one of these. This validation is a sanity check for that guarantee. def has_balance return if current_balance.present? || available_balance.present? - errors.add(:base, "Plaid account must have either current or available balance") + errors.add(:base, :no_balance) end end diff --git a/app/models/property.rb b/app/models/property.rb index 6114a9f41..c04b68cdc 100644 --- a/app/models/property.rb +++ b/app/models/property.rb @@ -7,7 +7,11 @@ class Property < ApplicationRecord "condominium" => { short: "Condo", long: "Condominium" }, "townhouse" => { short: "Townhouse", long: "Townhouse" }, "investment_property" => { short: "Investment Property", long: "Investment Property" }, - "second_home" => { short: "Second Home", long: "Second Home" } + "second_home" => { short: "Second Home", long: "Second Home" }, + "apartment" => { short: "Apartment", long: "Apartment" }, + "plot" => { short: "Plot", long: "Plot / Land" }, + "commercial" => { short: "Commercial", long: "Commercial Property" }, + "agri_land" => { short: "Agri Land", long: "Agricultural Land" } }.freeze has_one :address, as: :addressable, dependent: :destroy diff --git a/app/models/provider/alpha_vantage.rb b/app/models/provider/alpha_vantage.rb new file mode 100644 index 000000000..df5f2df5f --- /dev/null +++ b/app/models/provider/alpha_vantage.rb @@ -0,0 +1,340 @@ +class Provider::AlphaVantage < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + # Subclass so errors caught in this provider are raised as Provider::AlphaVantage::Error + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + # Minimum delay between requests to avoid rate limiting (in seconds) + MIN_REQUEST_INTERVAL = 1.0 + + # Maximum requests per day (Alpha Vantage free tier limit) + MAX_REQUESTS_PER_DAY = 25 + + # Free tier "compact" returns ~100 trading days (~140 calendar days). + # "full" requires a paid plan. + def max_history_days + 140 + end + + # MIC code to Alpha Vantage symbol suffix mapping + MIC_TO_AV_SUFFIX = { + "XNYS" => "", "XNAS" => "", "XASE" => "", + "XLON" => ".LON", + "XETR" => ".DEX", + "XTSE" => ".TRT", + "XPAR" => ".PAR", + "XAMS" => ".AMS", + "XSWX" => ".SWX", + "XHKG" => ".HKG", + "XASX" => ".ASX", + "XMIL" => ".MIL", + "XMAD" => ".BME", + "XOSL" => ".OSL", + "XSTO" => ".STO", + "XCSE" => ".CPH", + "XHEL" => ".HEL" + }.freeze + + # Alpha Vantage symbol suffix to MIC code mapping (auto-generated from forward map) + AV_SUFFIX_TO_MIC = MIC_TO_AV_SUFFIX + .reject { |_, suffix| suffix.empty? } + .each_with_object({}) { |(mic, suffix), h| h[suffix.delete(".")] = mic } + .merge("FRK" => "XFRA") # FRK is not in the forward map (no MIC→FRK entry) + .freeze + + # Alpha Vantage region names to ISO country codes + AV_REGION_TO_COUNTRY = { + "United States" => "US", "United Kingdom" => "GB", + "Frankfurt" => "DE", "XETRA" => "DE", + "Amsterdam" => "NL", "Paris/Brussels" => "FR", + "Switzerland" => "CH", "Toronto" => "CA", + "Brazil/Sao Paolo" => "BR", + "India/Bombay" => "IN", "Hong Kong" => "HK", + "Milan" => "IT", "Madrid" => "ES", + "Oslo" => "NO", "Helsinki" => "FI", + "Copenhagen" => "DK", "Stockholm" => "SE", + "Australia" => "AU", "Japan" => "JP" + }.freeze + + def initialize(api_key) + @api_key = api_key # pipelock:ignore + end + + # Alpha Vantage has no non-quota endpoint — every API call counts against + # the 25/day free-tier limit. Rather than burn a call, we just check that + # the API key is configured. + def healthy? + with_provider_response do + api_key.present? + end + end + + def usage + with_provider_response do + day_key = "alpha_vantage:daily:#{Date.current}" + used = Rails.cache.read(day_key).to_i + + UsageData.new( + used: used, + limit: max_requests_per_day, + utilization: (used.to_f / max_requests_per_day * 100).round(1), + plan: "Free" + ) + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + throttle_request + response = client.get("#{base_url}/query") do |req| + req.params["function"] = "SYMBOL_SEARCH" + req.params["keywords"] = symbol + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + data = parsed.dig("bestMatches") + + if data.nil? + raise Error, "No data returned from search endpoint" + end + + data.first(25).map do |match| + av_ticker = match["1. symbol"] + region = match["4. region"] + currency = match["8. currency"] + + # Cache the API-returned currency so fetch_security_prices can use it + # instead of relying solely on the hardcoded suffix→currency fallback + if currency.present? + cache_key = "alpha_vantage:currency:#{av_ticker.upcase}" + Rails.cache.write(cache_key, currency, expires_in: 24.hours) + end + + Security.new( + symbol: strip_av_suffix(av_ticker), + name: match["2. name"], + logo_url: nil, + exchange_operating_mic: extract_mic_from_symbol(av_ticker), + country_code: AV_REGION_TO_COUNTRY[region], + currency: currency + ) + end + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + av_symbol = to_av_symbol(symbol, exchange_operating_mic) + + throttle_request + response = client.get("#{base_url}/query") do |req| + req.params["function"] = "OVERVIEW" + req.params["symbol"] = av_symbol + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + name = parsed["Name"] + if name.blank? + raise Error, "No metadata returned for symbol #{av_symbol}" + end + + SecurityInfo.new( + symbol: parsed["Symbol"] || symbol, + name: name, + links: parsed["OfficialSite"].presence, + logo_url: nil, + description: parsed["Description"].presence, + kind: parsed["AssetType"]&.downcase, + exchange_operating_mic: exchange_operating_mic + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) + with_provider_response do + historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) + + raise historical_data.error if historical_data.error.present? + raise InvalidSecurityPriceError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.blank? + + historical_data.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) + with_provider_response do + av_symbol = to_av_symbol(symbol, exchange_operating_mic) + + throttle_request + response = client.get("#{base_url}/query") do |req| + req.params["function"] = "TIME_SERIES_DAILY" + req.params["symbol"] = av_symbol + req.params["outputsize"] = "compact" + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + time_series = parsed.dig("Time Series (Daily)") + + if time_series.nil? + raise InvalidSecurityPriceError, "No time series data returned for symbol #{av_symbol}" + end + + currency = infer_currency_from_symbol(av_symbol) + + time_series.filter_map do |date_str, values| + date = Date.parse(date_str) + next unless date >= start_date && date <= end_date + + price = values["4. close"] + + if price.nil? || price.to_f <= 0 + Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} on: #{date_str}. Price data: #{price.inspect}") + next + end + + Price.new( + symbol: symbol, + date: date, + price: price, + currency: currency, + exchange_operating_mic: exchange_operating_mic + ) + end + end + end + + private + attr_reader :api_key + + def base_url + ENV["ALPHA_VANTAGE_URL"] || "https://www.alphavantage.co" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.request(:retry, { + max: 3, + interval: 1.0, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.request :json + faraday.response :raise_error + faraday.params["apikey"] = api_key + end + end + + # Adds daily request counter on top of the interval throttle from RateLimitable. + def throttle_request + super + + # Global per-day request counter via cache (Redis). + # Atomic increment-then-check avoids the TOCTOU of read-check-increment. + day_key = "alpha_vantage:daily:#{Date.current}" + new_count = Rails.cache.increment(day_key, 1, expires_in: 24.hours).to_i + + if new_count > max_requests_per_day + Rails.logger.warn("AlphaVantage: daily request limit reached (#{new_count}/#{max_requests_per_day})") + raise RateLimitError, "Alpha Vantage daily request limit reached (#{max_requests_per_day} per day)" + end + end + + def max_requests_per_day + ENV.fetch("ALPHA_VANTAGE_MAX_REQUESTS_PER_DAY", MAX_REQUESTS_PER_DAY).to_i + end + + # Converts a symbol + MIC code to Alpha Vantage's ticker format + def to_av_symbol(symbol, exchange_operating_mic) + return symbol if exchange_operating_mic.blank? + + suffix = MIC_TO_AV_SUFFIX[exchange_operating_mic] + return symbol if suffix.nil? + return symbol if suffix.empty? + + # Avoid double-suffixing if the symbol already has the correct suffix + return symbol if symbol.end_with?(suffix) + + "#{symbol}#{suffix}" + end + + # Strips the Alpha Vantage exchange suffix to get the canonical ticker + # e.g., "CSPX.LON" -> "CSPX", "AAPL" -> "AAPL" + def strip_av_suffix(symbol) + return symbol unless symbol.include?(".") + + parts = symbol.split(".", 2) + AV_SUFFIX_TO_MIC.key?(parts.last) ? parts.first : symbol + end + + # Extracts MIC code from Alpha Vantage symbol suffix (e.g., "CSPX.LON" -> "XLON") + def extract_mic_from_symbol(symbol) + return nil unless symbol.include?(".") + + suffix = symbol.split(".").last + AV_SUFFIX_TO_MIC[suffix] + end + + # Infers currency from the exchange suffix of an Alpha Vantage symbol. + # Falls back to cached currency from search results if available. + def infer_currency_from_symbol(av_symbol) + cache_key = "alpha_vantage:currency:#{av_symbol.upcase}" + cached = Rails.cache.read(cache_key) + return cached if cached.present? + + # Default currency based on exchange suffix + suffix = av_symbol.include?(".") ? av_symbol.split(".").last : nil + + currency = case suffix + when "LON" then "GBP" + when "DEX", "FRK" then "EUR" + when "PAR", "AMS", "MIL", "BME", "HEL" then "EUR" + when "TRT" then "CAD" + when "SWX" then "CHF" + when "HKG" then "HKD" + when "ASX" then "AUD" + when "STO" then "SEK" + when "CPH" then "DKK" + when "OSL" then "NOK" + else "USD" + end + + Rails.cache.write(cache_key, currency, expires_in: 24.hours) + currency + end + + # Checks for Alpha Vantage-specific error responses. + # Alpha Vantage returns errors as JSON keys rather than HTTP status codes. + def check_api_error!(parsed) + return unless parsed.is_a?(Hash) + + # Rate limit: Alpha Vantage returns a "Note" key when rate-limited + if parsed["Note"].present? + Rails.logger.warn("AlphaVantage rate limit: #{parsed["Note"]}") + raise RateLimitError, parsed["Note"] + end + + # General info/limit messages + if parsed["Information"].present? + Rails.logger.warn("AlphaVantage info: #{parsed["Information"]}") + raise RateLimitError, parsed["Information"] + end + + # Explicit error messages for invalid parameters + if parsed["Error Message"].present? + raise Error, "API error: #{parsed["Error Message"]}" + end + end +end diff --git a/app/models/provider/binance.rb b/app/models/provider/binance.rb index 498084882..d1b8c018c 100644 --- a/app/models/provider/binance.rb +++ b/app/models/provider/binance.rb @@ -91,11 +91,11 @@ class Provider::Binance def signed_get(path, extra_params: {}) params = timestamp_params.merge(extra_params) - params["signature"] = sign(params) + query_string = URI.encode_www_form(params.sort) response = self.class.get( path, - query: params, + query: "#{query_string}&signature=#{sign(query_string)}", headers: auth_headers ) @@ -106,9 +106,10 @@ class Provider::Binance { "timestamp" => (Time.current.to_f * 1000).to_i.to_s, "recvWindow" => "5000" } end - # HMAC-SHA256 of the query string + # HMAC-SHA256 of the query string. + # Accepts either a Hash of params or a pre-built query string. def sign(params) - query_string = URI.encode_www_form(params.sort) + query_string = params.is_a?(Hash) ? URI.encode_www_form(params.sort) : params OpenSSL::HMAC.hexdigest("sha256", api_secret, query_string) end diff --git a/app/models/provider/binance_public.rb b/app/models/provider/binance_public.rb new file mode 100644 index 000000000..cc4a71f7c --- /dev/null +++ b/app/models/provider/binance_public.rb @@ -0,0 +1,366 @@ +class Provider::BinancePublic < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + MIN_REQUEST_INTERVAL = 0.1 + + # Binance's official ISO 10383 operating MIC (assigned Jan 2026, country AE). + # Crypto is not tied to a national jurisdiction, so we intentionally do NOT + # propagate the ISO-assigned country code to search results — the resolver + # treats a nil candidate country as a wildcard, letting any family resolve + # a Binance pick regardless of their own country. + BINANCE_MIC = "BNCX".freeze + + # Quote assets we expose in search results. Order = preference when multiple + # quote variants exist for the same base asset. USDT is Binance's dominant + # dollar quote and is surfaced to users as USD. GBP is absent because + # Binance has zero GBP trading pairs today; GBP-family users fall back to + # USDT->USD via the app's FX conversion, same as HUF/CZK/PLN users. + SUPPORTED_QUOTES = %w[USDT EUR JPY BRL TRY].freeze + + # Binance quote asset -> user-facing currency & ticker suffix. + QUOTE_TO_CURRENCY = { + "USDT" => "USD", + "EUR" => "EUR", + "JPY" => "JPY", + "BRL" => "BRL", + "TRY" => "TRY" + }.freeze + + KLINE_MAX_LIMIT = 1000 + MS_PER_DAY = 24 * 60 * 60 * 1000 + SEARCH_LIMIT = 25 + + # USD-pegged stablecoins. Binance has no self-pair (USDTUSDT is invalid) and + # the few stablecoin/USDT pairs that do exist (USDCUSDT, etc.) hover at ~1.0 + # with sub-cent noise — synthesizing a flat 1.0 USD price is both accurate + # enough and avoids surfacing transient depeg ticks from market data. + USD_STABLECOINS = %w[USDT USDC BUSD DAI FDUSD TUSD USDP PYUSD].freeze + + # Symbol prefix applied by holdings processors (CoinStats, Coinbase, Kraken, + # Binance, SimpleFIN, Lunchflow) to distinguish crypto from stock tickers. + CRYPTO_PREFIX = "CRYPTO:".freeze + + def initialize + # No API key required — public market data only. + end + + def healthy? + with_provider_response do + client.get("#{base_url}/api/v3/ping") + true + end + end + + def usage + with_provider_response do + UsageData.new(used: nil, limit: nil, utilization: nil, plan: "Free (no key required)") + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + query = symbol.to_s.strip.upcase.delete_prefix(CRYPTO_PREFIX) + next [] if query.empty? + + if USD_STABLECOINS.include?(query) + next [ stablecoin_search_result(query) ] + end + + symbols = exchange_info_symbols + + matches = symbols.select do |s| + base = s["baseAsset"].to_s.upcase + quote = s["quoteAsset"].to_s.upcase + symbol = s["symbol"].to_s.upcase + + next false unless SUPPORTED_QUOTES.include?(quote) + + # Match on either the base asset (so "BTC" surfaces every BTC pair) or + # the full Binance pair symbol (so users pasting their own portfolio + # ticker like "BTCEUR" or "BTCUSD" — which prefixes Binance's raw + # "BTCUSDT" — also hit a result). + base.include?(query) || symbol == query || symbol.start_with?(query) + end + + ranked = matches.sort_by do |s| + base = s["baseAsset"].to_s.upcase + quote = s["quoteAsset"].to_s.upcase + symbol = s["symbol"].to_s.upcase + quote_index = SUPPORTED_QUOTES.index(quote) || 99 + + relevance = if symbol == query + 0 # exact full-ticker match — highest priority + elsif symbol.start_with?(query) + 1 # ticker prefix match (e.g. "BTCUSD" against "BTCUSDT") + elsif base == query + 2 # exact base-asset match (e.g. "BTC") + elsif base.start_with?(query) + 3 + else + 4 + end + + [ relevance, quote_index, base ] + end + + ranked.first(SEARCH_LIMIT).map do |s| + base = s["baseAsset"].to_s.upcase + quote = s["quoteAsset"].to_s.upcase + display_currency = QUOTE_TO_CURRENCY[quote] + + Security.new( + symbol: "#{base}#{display_currency}", + name: base, + # Brandfetch /crypto/{base} URL — unknown coins (rare) will 400 and + # render as a broken img in the dropdown; same tradeoff as stocks + # with obscure tickers. `::Security` reaches the AR model — + # unqualified `Security` here resolves to the Data value-object + # from SecurityConcept. + logo_url: ::Security.brandfetch_crypto_url(base), + exchange_operating_mic: BINANCE_MIC, + country_code: nil, + currency: display_currency + ) + end + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + parsed = parse_ticker(symbol) + raise Error, "Unsupported Binance ticker: #{symbol}" if parsed.nil? + + # logo_url is intentionally nil — crypto logos are set at save time by + # Security#generate_logo_url_from_brandfetch via the /crypto/{base} + # route, not returned from this provider. + links = parsed[:binance_pair] ? "https://www.binance.com/en/trade/#{parsed[:binance_pair]}" : nil + + SecurityInfo.new( + symbol: symbol, + name: parsed[:base], + links: links, + logo_url: nil, + description: nil, + kind: "crypto", + exchange_operating_mic: exchange_operating_mic + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic:, date:) + with_provider_response do + historical = fetch_security_prices( + symbol: symbol, + exchange_operating_mic: exchange_operating_mic, + start_date: date, + end_date: date + ) + + raise historical.error if historical.error.present? + raise InvalidSecurityPriceError, "No price found for #{symbol} on #{date}" if historical.data.blank? + + historical.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:) + with_provider_response do + parsed = parse_ticker(symbol) + raise InvalidSecurityPriceError, "Unsupported Binance ticker: #{symbol}" if parsed.nil? + + if parsed[:stablecoin] + next stablecoin_prices(symbol, parsed, start_date, end_date, exchange_operating_mic) + end + + binance_pair = parsed[:binance_pair] + display_currency = parsed[:display_currency] + prices = [] + cursor = start_date + seen_data = false + + while cursor <= end_date + window_end = [ cursor + (KLINE_MAX_LIMIT - 1).days, end_date ].min + + throttle_request + response = client.get("#{base_url}/api/v3/klines") do |req| + req.params["symbol"] = binance_pair + req.params["interval"] = "1d" + req.params["startTime"] = date_to_ms(cursor) + req.params["endTime"] = date_to_ms(window_end) + MS_PER_DAY - 1 + req.params["limit"] = KLINE_MAX_LIMIT + end + + batch = JSON.parse(response.body) + + if batch.empty? + # Empty window. Two cases: + # 1. cursor is before the pair's listing date — keep advancing + # until we hit the first window containing valid klines. + # Critical for long-range imports (e.g. account sync from a + # trade start date that predates the Binance listing). + # 2. We have already collected prices and this window is past + # the end of available history — stop to avoid wasted calls + # on delisted pairs. + break if seen_data + else + seen_data = true + batch.each do |row| + open_time_ms = row[0].to_i + close_price = row[4].to_f + next if close_price <= 0 + + prices << Price.new( + symbol: symbol, + date: Time.at(open_time_ms / 1000).utc.to_date, + price: close_price, + currency: display_currency, + exchange_operating_mic: exchange_operating_mic + ) + end + end + + # Note: we intentionally do NOT break on a short (non-empty) batch. + # A window that straddles the pair's listing date legitimately returns + # fewer than KLINE_MAX_LIMIT rows while there is still valid data in + # subsequent windows. + cursor = window_end + 1.day + end + + prices + end + end + + private + # Synthetic search hit for a USD-pegged stablecoin. Binance has no self-pair + # (USDTUSDT etc. don't exist), so we manufacture a result instead of letting + # the resolver fall back to an offline CRYPTO:* row. The downstream price + # path short-circuits via parse_ticker -> stablecoin_prices. + def stablecoin_search_result(base) + Security.new( + symbol: "#{base}USD", + name: base, + logo_url: ::Security.brandfetch_crypto_url(base), + exchange_operating_mic: BINANCE_MIC, + country_code: nil, + currency: "USD" + ) + end + + # Synthesize flat 1.0 USD prices for USD-pegged stablecoins across the + # requested range. Avoids a Binance round-trip (there is no self-pair like + # USDTUSDT) and produces stable values for portfolio aggregation. + def stablecoin_prices(symbol, parsed, start_date, end_date, exchange_operating_mic) + (start_date..end_date).map do |date| + Price.new( + symbol: symbol, + date: date, + price: 1.0, + currency: parsed[:display_currency], + exchange_operating_mic: exchange_operating_mic + ) + end + end + + def base_url + ENV["BINANCE_PUBLIC_URL"] || "https://data-api.binance.vision" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + # Explicit timeouts so a hanging Binance endpoint can't stall a Sidekiq + # worker or Puma thread indefinitely. Values are deliberately generous + # enough for a full 1000-row klines response but capped to bound the + # worst-case retry chain (3 attempts * 20s + backoff ~= 65s). + faraday.options.open_timeout = 5 + faraday.options.timeout = 20 + + faraday.request(:retry, { + max: 3, + interval: 0.5, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.request :json + faraday.response :raise_error + faraday.headers["Accept"] = "application/json" + end + end + + # Maps a user-visible ticker to the Binance pair symbol, base asset, and + # display currency. Accepts: + # - "BTCUSD"/"ETHEUR" — fiat suffix from search_securities output + # - "CRYPTO:BTCUSD" — prefixed form stored by holdings processors + # - "CRYPTO:SOL"/"SOL" — bare base asset; defaults to the USDT pair (USD) + # - "CRYPTO:USDT"/"USDT" — USD-pegged stablecoin; binance_pair is nil and + # callers short-circuit to a synthetic 1.0 USD price + # Returns nil only when the input is empty after stripping the prefix. + def parse_ticker(ticker) + raw = ticker.to_s.upcase + prefixed = raw.start_with?(CRYPTO_PREFIX) + ticker_up = raw.delete_prefix(CRYPTO_PREFIX) + return nil if ticker_up.empty? + + if USD_STABLECOINS.include?(ticker_up) + return { binance_pair: nil, base: ticker_up, display_currency: "USD", stablecoin: true } + end + + SUPPORTED_QUOTES.each do |quote| + display_currency = QUOTE_TO_CURRENCY[quote] + next unless ticker_up.end_with?(display_currency) + + base = ticker_up.delete_suffix(display_currency) + next if base.empty? + + # "{stablecoin}USD" form (e.g. "USDTUSD" produced by search_securities) + # routes to synthetic 1.0 USD pricing — there is no Binance self-pair. + if display_currency == "USD" && USD_STABLECOINS.include?(base) + return { binance_pair: nil, base: base, display_currency: "USD", stablecoin: true } + end + + return { binance_pair: "#{base}#{quote}", base: base, display_currency: display_currency } + end + + # No fiat suffix matched. Only treat the input as a bare base asset when + # it arrived with the CRYPTO: prefix from a holdings processor — that + # tells us it really is a single coin symbol (SOL, TRUMP, KAITO), not a + # malformed pair like "BTCBNB" or "BTCGBP" that we want to reject. + return nil unless prefixed + + { binance_pair: "#{ticker_up}USDT", base: ticker_up, display_currency: "USD" } + end + + # Cached for 24h — exchangeInfo returns the full symbol universe (thousands + # of rows, weight 10) and rarely changes. + def exchange_info_symbols + Rails.cache.fetch("binance_public:exchange_info", expires_in: 24.hours) do + throttle_request + response = client.get("#{base_url}/api/v3/exchangeInfo") + parsed = JSON.parse(response.body) + (parsed["symbols"] || []).select { |s| s["status"] == "TRADING" } + end + end + + def date_to_ms(date) + Time.utc(date.year, date.month, date.day).to_i * 1000 + end + + # Preserve BinancePublic::Error subclasses (e.g. InvalidSecurityPriceError) + # through with_provider_response. The inherited RateLimitable transformer + # only preserves RateLimitError and would otherwise downcast our custom + # errors to the generic Error class. + def default_error_transformer(error) + return error if error.is_a?(self.class::Error) + super + end +end diff --git a/app/models/provider/brex.rb b/app/models/provider/brex.rb new file mode 100644 index 000000000..969dfee50 --- /dev/null +++ b/app/models/provider/brex.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +class Provider::Brex + include HTTParty + extend SslConfigurable + + DEFAULT_BASE_URL = "https://api.brex.com" + STAGING_BASE_URL = "https://api-staging.brex.com" + ALLOWED_BASE_URLS = [ DEFAULT_BASE_URL, STAGING_BASE_URL ].freeze + DEFAULT_LIMIT = 1000 + # Transaction syncs are date-window bounded; this is only a runaway cursor guard. + MAX_PAGES = 25 + + headers "User-Agent" => "Sure Finance Brex Client" + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) + + attr_reader :token, :base_url + + def initialize(token, base_url: DEFAULT_BASE_URL) + @token = token.to_s.strip + @base_url = self.class.normalize_base_url(base_url) + raise ArgumentError, "Brex base URL must be blank or one of: #{ALLOWED_BASE_URLS.join(', ')}" unless @base_url.present? + end + + def self.normalize_base_url(value) + stripped = value.to_s.strip + return DEFAULT_BASE_URL if stripped.blank? + + uri = URI.parse(stripped) + return nil unless uri.is_a?(URI::HTTPS) + return nil if uri.userinfo.present? + return nil if uri.query.present? || uri.fragment.present? + return nil unless uri.path.blank? || uri.path == "/" + return nil unless uri.port == 443 + + # This exact allowlist is the SSRF boundary; arbitrary Brex-like hosts are never accepted. + normalized = "#{uri.scheme.downcase}://#{uri.host.to_s.downcase}" + ALLOWED_BASE_URLS.include?(normalized) ? normalized : nil + rescue URI::InvalidURIError + nil + end + + def self.allowed_base_url?(value) + normalize_base_url(value).present? + end + + def get_accounts + cash_accounts = get_cash_accounts + card_accounts = get_card_accounts + + accounts = cash_accounts.dup + accounts << aggregate_card_account(card_accounts) if card_accounts.any? + + { + accounts: accounts, + cash_accounts: cash_accounts, + card_accounts: card_accounts + } + end + + def get_cash_accounts + get_paginated("/v2/accounts/cash").map { |account| account.with_indifferent_access.merge(account_kind: "cash") } + end + + def get_card_accounts + get_paginated("/v2/accounts/card").map { |account| account.with_indifferent_access.merge(account_kind: "card") } + end + + def get_cash_transactions(account_id, start_date: nil) + path = "/v2/transactions/cash/#{ERB::Util.url_encode(account_id.to_s)}" + { + transactions: get_paginated(path, params: posted_at_start_params(start_date)) + } + end + + def get_primary_card_transactions(start_date: nil) + { + transactions: get_paginated("/v2/transactions/card/primary", params: posted_at_start_params(start_date)) + } + end + + private + + def aggregate_card_account(card_accounts) + totals = %i[current_balance available_balance account_limit].index_with do |field| + sum_money(card_accounts.filter_map { |account| account.with_indifferent_access[field] }) + end + + { + id: BrexAccount.card_account_id, + name: "Brex Card", + account_kind: "card", + status: card_accounts.map { |account| account.with_indifferent_access[:status] }.compact.first, + card_accounts_count: card_accounts.count, + current_balance: totals[:current_balance], + available_balance: totals[:available_balance], + account_limit: totals[:account_limit], + raw_card_accounts: BrexAccount.sanitize_payload(card_accounts) + }.compact + end + + def sum_money(money_values) + normalized = money_values.compact + return nil if normalized.empty? + + currencies = normalized.map { |money| BrexAccount.currency_code_from_money(money) }.uniq + if currencies.many? + Rails.logger.warn "Brex API: Cannot aggregate card balances with mixed currencies: #{currencies.join(', ')}" + return nil + end + + currency = currencies.first + total = normalized.sum do |money| + money.with_indifferent_access[:amount].to_i + end + + { amount: total, currency: currency } + end + + def posted_at_start_params(start_date) + return {} if start_date.blank? + + { posted_at_start: rfc3339_start_date(start_date) } + end + + def get_paginated(path, params: {}) + records = [] + cursor = nil + seen_cursors = Set.new + page_count = 0 + + loop do + page_count += 1 + raise BrexError.new("Brex pagination exceeded #{MAX_PAGES} pages", :pagination_error) if page_count > MAX_PAGES + + page_params = params.compact.merge(limit: DEFAULT_LIMIT) + page_params[:cursor] = cursor if cursor.present? + + response_payload = get_json(path, params: page_params) + if response_payload.is_a?(Array) + records.concat(response_payload) + break + end + + page_records = extract_records(response_payload) + records.concat(page_records) + + next_cursor = response_payload.with_indifferent_access[:next_cursor] + break if next_cursor.blank? + + if seen_cursors.include?(next_cursor) + raise BrexError.new("Brex pagination returned a repeated cursor", :pagination_error) + end + + seen_cursors.add(next_cursor) + cursor = next_cursor + end + + records + end + + def get_json(path, params: {}) + query = params.present? ? "?#{URI.encode_www_form(params)}" : "" + request_path = "#{path}#{query}" + + response = self.class.get( + "#{base_url}#{request_path}", + headers: auth_headers + ) + + handle_response(response, path: path) + rescue BrexError + raise + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Brex API: GET #{path} failed: #{e.class}: #{e.message}" + raise BrexError.new("Exception during GET request: #{e.message}", :request_failed) + rescue JSON::ParserError => e + Rails.logger.error "Brex API: invalid JSON for GET #{path}: #{e.message}" + raise BrexError.new("Invalid response from Brex API", :invalid_response) + rescue => e + Rails.logger.error "Brex API: Unexpected error during GET #{path}: #{e.class}: #{e.message}" + raise BrexError.new("Exception during GET request: #{e.message}", :request_failed) + end + + def extract_records(response_payload) + return response_payload if response_payload.is_a?(Array) + + payload = response_payload.with_indifferent_access + payload[:items] || + payload[:data] || + payload[:accounts] || + payload[:transactions] || + [] + end + + def auth_headers + { + "Authorization" => "Bearer #{token}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def handle_response(response, path:) + trace_id = brex_trace_id(response) + + case response.code + when 200 + parse_json(response.body) + when 400 + Rails.logger.error "Brex API: bad request for #{path} trace_id=#{trace_id}" + raise BrexError.new("Bad request to Brex API", :bad_request, http_status: 400, trace_id: trace_id) + when 401 + Rails.logger.warn "Brex API: unauthorized for #{path} trace_id=#{trace_id}" + raise BrexError.new("Invalid Brex API token or account permissions", :unauthorized, http_status: 401, trace_id: trace_id) + when 403 + Rails.logger.warn "Brex API: access forbidden for #{path} trace_id=#{trace_id}" + raise BrexError.new("Access forbidden - check Brex API token scopes", :access_forbidden, http_status: 403, trace_id: trace_id) + when 404 + Rails.logger.warn "Brex API: resource not found for #{path} trace_id=#{trace_id}" + raise BrexError.new("Brex resource not found", :not_found, http_status: 404, trace_id: trace_id) + when 429 + Rails.logger.warn "Brex API: rate limited for #{path} trace_id=#{trace_id}" + raise BrexError.new("Brex rate limit exceeded. Please try again later.", :rate_limited, http_status: 429, trace_id: trace_id) + else + Rails.logger.error "Brex API: unexpected response code=#{response.code} path=#{path} trace_id=#{trace_id}" + raise BrexError.new("Failed to fetch data from Brex API: HTTP #{response.code}", :fetch_failed, http_status: response.code, trace_id: trace_id) + end + end + + def parse_json(body) + return {} if body.blank? + + JSON.parse(body, symbolize_names: true) + end + + def rfc3339_start_date(start_date) + time = + case start_date + when Time + start_date + when DateTime + start_date.to_time + when Date + start_date.to_time(:utc) + else + Time.zone.parse(start_date.to_s) + end + + raise ArgumentError, "Invalid start_date: #{start_date.inspect}" if time.nil? + + time.utc.iso8601 + end + + def brex_trace_id(response) + headers = response.respond_to?(:headers) ? response.headers : {} + headers["X-Brex-Trace-Id"].presence || + headers["x-brex-trace-id"].presence + end + + class BrexError < StandardError + attr_reader :error_type, :http_status, :trace_id + + def initialize(message, error_type = :unknown, http_status: nil, trace_id: nil) + super(message) + @error_type = error_type + @http_status = http_status + @trace_id = trace_id + end + end +end diff --git a/app/models/provider/brex_adapter.rb b/app/models/provider/brex_adapter.rb new file mode 100644 index 000000000..dbed8c7ca --- /dev/null +++ b/app/models/provider/brex_adapter.rb @@ -0,0 +1,119 @@ +class Provider::BrexAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("BrexAccount", self) + + def self.supported_account_types + %w[Depository CreditCard] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_brex? + + brex_items = family.brex_items.active.with_credentials.ordered + + return [ connection_config_for(nil) ] if brex_items.empty? + + brex_items.map { |brex_item| connection_config_for(brex_item) } + end + + def provider_name + "brex" + end + + # Build a Brex provider instance with family-specific credentials + # @param family [Family] The family to get credentials for (required) + # @return [Provider::Brex, nil] Returns nil if credentials are not configured + def self.build_provider(family: nil, brex_item_id: nil) + return nil unless family.present? + + brex_item = BrexItem.resolve_for(family: family, brex_item_id: brex_item_id) + return nil unless brex_item&.credentials_configured? + + base_url = brex_item.effective_base_url + return nil unless base_url.present? + + Provider::Brex.new( + brex_item.token.to_s.strip, + base_url: base_url + ) + end + + def self.connection_config_for(brex_item) + path_params = ->(extra = {}) do + brex_item.present? ? extra.merge(brex_item_id: brex_item.id) : extra + end + + { + key: brex_item.present? ? "brex_#{brex_item.id}" : "brex", + name: brex_item.present? ? I18n.t("brex_items.provider_connection.name", name: brex_item.name) : I18n.t("brex_items.provider_connection.default_name"), + description: brex_item.present? ? I18n.t("brex_items.provider_connection.description", name: brex_item.name) : I18n.t("brex_items.provider_connection.default_description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_brex_items_path( + path_params.call(accountable_type: accountable_type, return_to: return_to) + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_brex_items_path( + path_params.call(account_id: account_id) + ) + } + } + end + private_class_method :connection_config_for + + def sync_path + Rails.application.routes.url_helpers.sync_brex_item_path(item) + end + + def item + provider_account.brex_item + end + + def can_delete_holdings? + false + end + + def institution_domain + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + domain = metadata["domain"] + url = metadata["url"] + + # Derive domain from URL if missing + if domain.blank? && url.present? + begin + parsed_host = URI.parse(url).host + Rails.logger.warn("Brex account #{provider_account.id} institution URL has no host: #{url}") if parsed_host.nil? + domain = parsed_host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for Brex account #{provider_account.id}: #{url}") + end + end + + domain + end + + def institution_name + metadata = provider_account.institution_metadata + + metadata&.dig("name") || item&.institution_name + end + + def institution_url + metadata = provider_account.institution_metadata + + metadata&.dig("url") || item&.institution_url + end + + def institution_color + metadata = provider_account.institution_metadata + + metadata&.dig("color") || item&.institution_color + end +end diff --git a/app/models/provider/coinstats.rb b/app/models/provider/coinstats.rb index 1689c6189..7dda1f5ac 100644 --- a/app/models/provider/coinstats.rb +++ b/app/models/provider/coinstats.rb @@ -315,6 +315,25 @@ class Provider::Coinstats < Provider raise Error, "CoinStats API request failed: #{e.message}" end + # Get DeFi positions (staking, LP, yield farming) for a wallet address. + # https://coinstats.app/api-docs/openapi/get-wallet-defi + # @param address [String] Wallet address + # @param connection_id [String] Blockchain/connectionId identifier + # @return [Provider::Response] Response with DeFi position data + def get_wallet_defi(address:, connection_id:) + with_provider_response do + res = self.class.get( + "#{BASE_URL}/wallet/defi", + headers: auth_headers, + query: { address: address, connectionId: connection_id } + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /wallet/defi failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + # Get cryptocurrency balances for multiple wallets in a single request # https://coinstats.app/api-docs/openapi/get-wallet-balances # @param wallets [String] Comma-separated list of wallet addresses in format "blockchain:address" diff --git a/app/models/provider/enable_banking.rb b/app/models/provider/enable_banking.rb index eb7ebd025..88df7ec62 100644 --- a/app/models/provider/enable_banking.rb +++ b/app/models/provider/enable_banking.rb @@ -35,13 +35,21 @@ class Provider::EnableBanking # @param aspsp_name [String] Name of the ASPSP from get_aspsps # @param aspsp_country [String] Country code for the ASPSP # @param redirect_url [String] URL to redirect user back to after auth - # @param state [String] Optional state parameter to pass through + # @param state [String, nil] State parameter to pass through # @param psu_type [String] "personal" or "business" + # @param maximum_consent_validity [Integer, nil] Max consent duration in seconds from ASPSP (nil = use 90 days) + # @param language [String, nil] Two-letter language code (e.g. "fr", "en") # @return [Hash] Contains :url and :authorization_id - def start_authorization(aspsp_name:, aspsp_country:, redirect_url:, state: nil, psu_type: "personal") + def start_authorization(aspsp_name:, aspsp_country:, redirect_url:, state: nil, + psu_type: "personal", maximum_consent_validity: nil, language: nil) + max_seconds = maximum_consent_validity ? [ maximum_consent_validity, 1 ].max : 90.days.to_i + valid_until = [ Time.current + max_seconds.seconds, Time.current + 90.days ].min + body = { access: { - valid_until: (Time.current + 90.days).iso8601 + valid_until: valid_until.iso8601, + balances: true, + transactions: true }, aspsp: { name: aspsp_name, @@ -50,7 +58,9 @@ class Provider::EnableBanking state: state, redirect_url: redirect_url, psu_type: psu_type - }.compact + } + body[:language] = language if language.present? + body = body.compact response = self.class.post( "#{BASE_URL}/auth", @@ -111,12 +121,13 @@ class Provider::EnableBanking # Get account details # @param account_id [String] The account ID (UID from Enable Banking) + # @param psu_headers [Hash] Optional PSU context headers required by some ASPSPs # @return [Hash] Account details - def get_account_details(account_id:) + def get_account_details(account_id:, psu_headers: {}) encoded_id = CGI.escape(account_id.to_s) response = self.class.get( "#{BASE_URL}/accounts/#{encoded_id}/details", - headers: auth_headers + headers: auth_headers.merge(safe_psu_headers(psu_headers)) ) handle_response(response) @@ -126,12 +137,13 @@ class Provider::EnableBanking # Get account balances # @param account_id [String] The account ID (UID from Enable Banking) + # @param psu_headers [Hash] Optional PSU context headers required by some ASPSPs # @return [Hash] Balance information - def get_account_balances(account_id:) + def get_account_balances(account_id:, psu_headers: {}) encoded_id = CGI.escape(account_id.to_s) response = self.class.get( "#{BASE_URL}/accounts/#{encoded_id}/balances", - headers: auth_headers + headers: auth_headers.merge(safe_psu_headers(psu_headers)) ) handle_response(response) @@ -144,28 +156,51 @@ class Provider::EnableBanking # @param date_from [Date, nil] Start date for transactions # @param date_to [Date, nil] End date for transactions # @param continuation_key [String, nil] For pagination + # @param transaction_status [String, nil] Filter: "BOOK", "PDNG", or nil for all + # @param psu_headers [Hash] Optional PSU context headers required by some ASPSPs # @return [Hash] Transactions and continuation_key for pagination - def get_account_transactions(account_id:, date_from: nil, date_to: nil, continuation_key: nil) + def get_account_transactions(account_id:, date_from: nil, date_to: nil, + continuation_key: nil, transaction_status: nil, psu_headers: {}, retried_date_from: false) encoded_id = CGI.escape(account_id.to_s) query_params = {} - query_params[:transaction_status] = "BOOK" # Only accounted transactions + query_params[:transaction_status] = transaction_status if transaction_status.present? query_params[:date_from] = date_from.to_date.iso8601 if date_from query_params[:date_to] = date_to.to_date.iso8601 if date_to query_params[:continuation_key] = continuation_key if continuation_key response = self.class.get( "#{BASE_URL}/accounts/#{encoded_id}/transactions", - headers: auth_headers, + headers: auth_headers.merge(safe_psu_headers(psu_headers)), query: query_params.presence ) handle_response(response) + rescue EnableBankingError => e + corrected_date_from = e.corrected_date_from + + if !retried_date_from && e.wrong_transactions_period? && corrected_date_from.present? && corrected_date_from != date_from + get_account_transactions( + account_id: account_id, + date_from: corrected_date_from, + date_to: date_to, + continuation_key: continuation_key, + transaction_status: transaction_status, + psu_headers: psu_headers, + retried_date_from: true + ) + else + raise + end rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e raise EnableBankingError.new("Exception during GET request: #{e.message}", :request_failed) end private + def safe_psu_headers(headers) + headers.except("Authorization", :Authorization, "Accept", :Accept, "Content-Type", :"Content-Type") + end + def extract_private_key(certificate_pem) # Extract private key from PEM certificate OpenSSL::PKey::RSA.new(certificate_pem) @@ -215,8 +250,11 @@ class Provider::EnableBanking raise EnableBankingError.new("Access forbidden - check your application permissions", :access_forbidden) when 404 raise EnableBankingError.new("Resource not found", :not_found) + when 408 + raise EnableBankingError.new("Request timeout from Enable Banking API", :timeout) when 422 - raise EnableBankingError.new("Validation error from Enable Banking API: #{response.body}", :validation_error) + response_data = parse_response_body(response) + raise EnableBankingError.new("Validation error from Enable Banking API: #{response.body}", :validation_error, response_data: response_data) when 429 raise EnableBankingError.new("Rate limit exceeded. Please try again later.", :rate_limited) else @@ -234,11 +272,28 @@ class Provider::EnableBanking end class EnableBankingError < StandardError - attr_reader :error_type + attr_reader :error_type, :response_data - def initialize(message, error_type = :unknown) + def initialize(message, error_type = :unknown, response_data: nil) super(message) @error_type = error_type + @response_data = response_data + end + + def wrong_transactions_period? + error_type == :validation_error && response_data.is_a?(Hash) && response_data[:error] == "WRONG_TRANSACTIONS_PERIOD" + end + + def corrected_date_from + value = response_data&.dig(:detail, :date_from) + + if value.is_a?(Date) + value + elsif value.present? + Date.iso8601(value) + end + rescue ArgumentError + nil end end end diff --git a/app/models/provider/eodhd.rb b/app/models/provider/eodhd.rb new file mode 100644 index 000000000..ccb0f412a --- /dev/null +++ b/app/models/provider/eodhd.rb @@ -0,0 +1,304 @@ +class Provider::Eodhd < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + # Subclass so errors caught in this provider are raised as Provider::Eodhd::Error + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + # Minimum delay between requests to avoid rate limiting (in seconds) + MIN_REQUEST_INTERVAL = 0.5 + + # Maximum API calls per day (EODHD free/basic plans are very restrictive) + MAX_REQUESTS_PER_DAY = 20 + + # EODHD free tier provides ~1 year of EOD data + def max_history_days + 365 + end + + # EODHD uses {SYMBOL}.{EXCHANGE} ticker format with its own exchange codes + MIC_TO_EODHD_EXCHANGE = { + "XNYS" => "US", "XNAS" => "US", "XASE" => "US", + "XLON" => "LSE", + "XETR" => "XETRA", + "XTSE" => "TO", + "XPAR" => "PA", + "XAMS" => "AS", + "XSWX" => "SW", + "XHKG" => "HK", + "XASX" => "AU", + "XTKS" => "TSE", + "XMIL" => "MI", + "XMAD" => "MC", + "XOSL" => "OL", + "XHEL" => "HE", + "XCSE" => "CO", + "XSTO" => "ST", + "XKRX" => "KS", + "XBOM" => "BSE", + "XNSE" => "NSE" + }.freeze + + EODHD_EXCHANGE_TO_MIC = { + "US" => "XNYS", "LSE" => "XLON", "XETRA" => "XETR", + "TO" => "XTSE", "PA" => "XPAR", "AS" => "XAMS", + "SW" => "XSWX", "HK" => "XHKG", "AU" => "XASX", + "TSE" => "XTKS", "MI" => "XMIL", "MC" => "XMAD", + "OL" => "XOSL", "HE" => "XHEL", "CO" => "XCSE", + "ST" => "XSTO", "KS" => "XKRX", "BSE" => "XBOM", + "NSE" => "XNSE" + }.freeze + + EODHD_COUNTRY_TO_CODE = { + "USA" => "US", "UK" => "GB", "Germany" => "DE", "France" => "FR", + "Netherlands" => "NL", "Switzerland" => "CH", "Canada" => "CA", + "Japan" => "JP", "Australia" => "AU", "Hong Kong" => "HK", + "Italy" => "IT", "Spain" => "ES", "Norway" => "NO", + "Finland" => "FI", "Denmark" => "DK", "Sweden" => "SE", + "South Korea" => "KR", "India" => "IN" + }.freeze + + EXCHANGE_CURRENCY = { + "US" => "USD", "LSE" => "GBP", "XETRA" => "EUR", "TO" => "CAD", + "PA" => "EUR", "AS" => "EUR", "SW" => "CHF", "HK" => "HKD", + "AU" => "AUD", "TSE" => "JPY", "MI" => "EUR", "MC" => "EUR", + "OL" => "NOK", "HE" => "EUR", "CO" => "DKK", + "ST" => "SEK", "KS" => "KRW", "BSE" => "INR", + "NSE" => "INR" + }.freeze + + def initialize(api_key) + @api_key = api_key # pipelock:ignore + end + + def healthy? + with_provider_response do + response = client.get("#{base_url}/api/user") do |req| + req.params["api_token"] = api_key + req.params["fmt"] = "json" + end + + JSON.parse(response.body).dig("name").present? + end + end + + def usage + with_provider_response do + response = client.get("#{base_url}/api/user") do |req| + req.params["api_token"] = api_key + req.params["fmt"] = "json" + end + + parsed = JSON.parse(response.body) + + limit = parsed.dig("apiRequests").to_i + daily_limit = parsed.dig("dailyRateLimit").to_i + + daily_key = daily_cache_key + used = Rails.cache.read(daily_key).to_i + + UsageData.new( + used: used, + limit: daily_limit > 0 ? daily_limit : MAX_REQUESTS_PER_DAY, + utilization: daily_limit > 0 ? (used.to_f / daily_limit * 100) : (used.to_f / MAX_REQUESTS_PER_DAY * 100), + plan: parsed.dig("subscriptionType") || "unknown" + ) + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + enforce_daily_limit! + throttle_request + + response = client.get("#{base_url}/api/search/#{CGI.escape(symbol)}") do |req| + req.params["api_token"] = api_key + req.params["fmt"] = "json" + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + unless parsed.is_a?(Array) + raise Error, "Unexpected response format from search API" + end + + parsed.first(25).map do |security| + eodhd_exchange = security.dig("Exchange") + mic = EODHD_EXCHANGE_TO_MIC[eodhd_exchange] + country = EODHD_COUNTRY_TO_CODE[security.dig("Country")] + code = security.dig("Code") + currency = security.dig("Currency") + + # Cache the API-returned currency so fetch_security_prices can use it + if currency.present? && mic.present? + cache_key = "eodhd:currency:#{code.upcase}:#{mic}" + Rails.cache.write(cache_key, currency, expires_in: 24.hours) + end + + Security.new( + symbol: code, + name: security.dig("Name"), + logo_url: nil, + exchange_operating_mic: mic, + country_code: country, + currency: currency + ) + end + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + enforce_daily_limit! + throttle_request + + ticker = eodhd_symbol(symbol, exchange_operating_mic) + + response = client.get("#{base_url}/api/fundamentals/#{CGI.escape(ticker)}") do |req| + req.params["api_token"] = api_key + req.params["fmt"] = "json" + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + general = parsed.dig("General") || {} + + SecurityInfo.new( + symbol: symbol, + name: general.dig("Name"), + links: general.dig("WebURL"), + logo_url: general.dig("LogoURL"), + description: general.dig("Description"), + kind: general.dig("Type"), + exchange_operating_mic: exchange_operating_mic + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) + with_provider_response do + historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) + + raise historical_data.error if historical_data.error.present? + raise InvalidSecurityPriceError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.blank? + + historical_data.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) + with_provider_response do + enforce_daily_limit! + throttle_request + + ticker = eodhd_symbol(symbol, exchange_operating_mic) + + response = client.get("#{base_url}/api/eod/#{CGI.escape(ticker)}") do |req| + req.params["api_token"] = api_key + req.params["fmt"] = "json" + req.params["from"] = start_date.to_s + req.params["to"] = end_date.to_s + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + unless parsed.is_a?(Array) + raise InvalidSecurityPriceError, "Unexpected response format from EOD API" + end + + # Prefer cached currency from search results; fall back to hardcoded map + cache_key = "eodhd:currency:#{symbol.upcase}:#{exchange_operating_mic}" + eodhd_exchange = MIC_TO_EODHD_EXCHANGE[exchange_operating_mic] + currency = Rails.cache.read(cache_key) || EXCHANGE_CURRENCY[eodhd_exchange] + + parsed.map do |resp| + price = resp.dig("close") + date = resp.dig("date") + + if price.nil? || price.to_f <= 0 + Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}") + next + end + + Price.new( + symbol: symbol, + date: date.to_date, + price: price, + currency: currency, + exchange_operating_mic: exchange_operating_mic + ) + end.compact + end + end + + private + attr_reader :api_key + + def base_url + ENV["EODHD_URL"] || "https://eodhd.com" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.request(:retry, { + max: 3, + interval: 1.0, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.request :json + faraday.response :raise_error + end + end + + # Builds the EODHD ticker format: {SYMBOL}.{EXCHANGE} + def eodhd_symbol(symbol, exchange_operating_mic) + eodhd_exchange = MIC_TO_EODHD_EXCHANGE[exchange_operating_mic] if exchange_operating_mic.present? + + if eodhd_exchange.present? + "#{symbol}.#{eodhd_exchange}" + elsif exchange_operating_mic.present? + "#{symbol}.#{exchange_operating_mic}" + else + "#{symbol}.US" + end + end + + # Cache key for tracking daily API usage + def daily_cache_key + "eodhd:daily:#{Date.current}" + end + + # Enforces the daily rate limit. Raises RateLimitError if the limit is exhausted. + # Uses atomic increment-then-check to avoid TOCTOU races between concurrent workers. + def enforce_daily_limit! + new_count = Rails.cache.increment(daily_cache_key, 1, expires_in: 24.hours).to_i + + if new_count > max_requests_per_day + raise RateLimitError, "EODHD daily rate limit of #{max_requests_per_day} requests exhausted" + end + end + + # throttle_request and min_request_interval provided by RateLimitable + + def max_requests_per_day + ENV.fetch("EODHD_MAX_REQUESTS_PER_DAY", MAX_REQUESTS_PER_DAY).to_i + end + + def check_api_error!(parsed) + return unless parsed.is_a?(Hash) && parsed["error"].present? + + raise Error, "API error: #{parsed["error"]}" + end +end diff --git a/app/models/provider/exchange_rate_concept.rb b/app/models/provider/exchange_rate_concept.rb index 744204a27..dc120ba6a 100644 --- a/app/models/provider/exchange_rate_concept.rb +++ b/app/models/provider/exchange_rate_concept.rb @@ -10,4 +10,12 @@ module Provider::ExchangeRateConcept def fetch_exchange_rates(from:, to:, start_date:, end_date:) raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates" end + + # Maximum number of calendar days of historical FX data the provider can + # return. Returns nil when the provider has no known limit (unbounded). + # Callers should clamp start_date when non-nil to avoid requesting data + # beyond this window. Override in subclasses with provider-specific limits. + def max_history_days + nil + end end diff --git a/app/models/provider/ibkr_adapter.rb b/app/models/provider/ibkr_adapter.rb new file mode 100644 index 000000000..639831937 --- /dev/null +++ b/app/models/provider/ibkr_adapter.rb @@ -0,0 +1,59 @@ +class Provider::IbkrAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + Provider::Factory.register("IbkrAccount", self) + + def self.supported_account_types + %w[Investment] + end + + def self.connection_configs(family:) + return [] unless family.can_connect_ibkr? + + [ { + key: "ibkr", + name: I18n.t("providers.ibkr.name"), + description: I18n.t("providers.ibkr.connection_description"), + can_connect: true, + new_account_path: ->(_accountable_type, _return_to) { + Rails.application.routes.url_helpers.select_accounts_ibkr_items_path + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_ibkr_items_path(account_id: account_id) + } + } ] + end + + def provider_name + "ibkr" + end + + def sync_path + Rails.application.routes.url_helpers.sync_ibkr_item_path(item) + end + + def item + provider_account.ibkr_item + end + + def can_delete_holdings? + false + end + + def institution_domain + "interactivebrokers.com" + end + + def institution_name + I18n.t("providers.ibkr.institution_name") + end + + def institution_url + "https://www.interactivebrokers.com" + end + + def institution_color + "#D32F2F" + end +end diff --git a/app/models/provider/ibkr_flex.rb b/app/models/provider/ibkr_flex.rb new file mode 100644 index 000000000..16da5b803 --- /dev/null +++ b/app/models/provider/ibkr_flex.rb @@ -0,0 +1,144 @@ +class Provider::IbkrFlex + include HTTParty + extend SslConfigurable + + class Error < StandardError; end + class AuthenticationError < Error; end + class ConfigurationError < Error; end + class ApiError < Error + attr_reader :status_code, :response_body, :error_code + + def initialize(message, status_code: nil, response_body: nil, error_code: nil) + super(message) + @status_code = status_code + @response_body = response_body + @error_code = error_code + end + end + + base_uri "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService" + headers "User-Agent" => "Sure Finance IBKR Flex Client" + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) + + MAX_RETRIES = 3 + INITIAL_RETRY_DELAY = 2 + MAX_RETRY_DELAY = 30 + POLL_INTERVAL = 3 + MAX_POLL_ATTEMPTS = 20 + PENDING_ERROR_CODES = %w[1004 1019].freeze + + RETRYABLE_ERRORS = [ + SocketError, + Net::OpenTimeout, + Net::ReadTimeout, + Errno::ECONNRESET, + Errno::ECONNREFUSED, + Errno::ETIMEDOUT, + EOFError + ].freeze + + attr_reader :query_id, :token + + def initialize(query_id:, token:) + raise ConfigurationError, "query_id is required" if query_id.blank? + raise ConfigurationError, "token is required" if token.blank? + + @query_id = query_id.to_s.strip + @token = token.to_s.strip + end + + def download_statement + reference_code = request_reference_code + poll_statement(reference_code) + end + + private + + def request_reference_code + response = with_retries("SendRequest") do + self.class.get("/SendRequest", query: { t: token, q: query_id, v: 3 }) + end + + xml = parse_xml(response.body) + error = response_error(xml, response) + raise error if error + + reference_code = xml.at_xpath("//ReferenceCode")&.text.to_s.strip + raise ApiError.new("IBKR Flex did not return a reference code.", status_code: response.code, response_body: response.body) if reference_code.blank? + + reference_code + end + + def poll_statement(reference_code) + attempts = 0 + + loop do + attempts += 1 + response = with_retries("GetStatement") do + self.class.get("/GetStatement", query: { t: token, q: reference_code, v: 3 }) + end + + xml = parse_xml(response.body) + return response.body if xml.at_xpath("//FlexQueryResponse") + + error = response_error(xml, response) + if error.is_a?(ApiError) && PENDING_ERROR_CODES.include?(error.error_code.to_s) + raise ApiError.new("IBKR Flex statement is still being generated.", error_code: error.error_code) if attempts >= MAX_POLL_ATTEMPTS + + sleep(POLL_INTERVAL) + next + end + + raise(error || ApiError.new("IBKR Flex returned an unexpected response.", status_code: response.code, response_body: response.body)) + end + end + + def response_error(xml, response) + error_code = xml.at_xpath("//ErrorCode")&.text.to_s.strip.presence + error_message = xml.at_xpath("//ErrorMessage")&.text.to_s.strip.presence + + return nil if error_code.blank? && response.success? + + message = error_message.presence || "IBKR Flex request failed" + + case error_code + when "1012", "1015" + AuthenticationError.new(message) + when "1014" + ConfigurationError.new(message) + else + ApiError.new(message, status_code: response.code, response_body: response.body, error_code: error_code) + end + end + + def parse_xml(body) + Nokogiri::XML(body.to_s) + end + + def with_retries(operation_name, max_retries: MAX_RETRIES) + retries = 0 + + begin + yield + rescue *RETRYABLE_ERRORS => e + retries += 1 + + if retries <= max_retries + delay = calculate_retry_delay(retries) + Rails.logger.warn( + "IBKR Flex: #{operation_name} failed (attempt #{retries}/#{max_retries}): #{e.class}: #{e.message}. Retrying in #{delay}s..." + ) + sleep(delay) + retry + end + + raise ApiError.new("Network error after #{max_retries} retries: #{e.message}") + end + end + + def calculate_retry_delay(retry_count) + base_delay = INITIAL_RETRY_DELAY * (2**(retry_count - 1)) + jitter = base_delay * rand * 0.25 + [ base_delay + jitter, MAX_RETRY_DELAY ].min + end +end diff --git a/app/models/provider/indexa_capital.rb b/app/models/provider/indexa_capital.rb index ad7fc6ca6..52174def6 100644 --- a/app/models/provider/indexa_capital.rb +++ b/app/models/provider/indexa_capital.rb @@ -55,6 +55,21 @@ class Provider::IndexaCapital end end + # GET /accounts/{account_number}/portfolio → current snapshot with positions. + # Used as a fallback when fiscal-results is empty (e.g. pension plans, where + # Indexa returns {fiscal_results: [], total_fiscal_results: []} but exposes + # the same positions through this endpoint). + def get_portfolio(account_number:) + sanitize_account_number!(account_number) + with_retries("get_portfolio") do + response = self.class.get( + "#{base_url}/accounts/#{account_number}/portfolio", + headers: auth_headers + ) + handle_response(response) + end + end + # GET /accounts/{account_number}/performance → latest portfolio total_amount def get_account_balance(account_number:) sanitize_account_number!(account_number) diff --git a/app/models/provider/kraken.rb b/app/models/provider/kraken.rb new file mode 100644 index 000000000..38a2db6ca --- /dev/null +++ b/app/models/provider/kraken.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +class Provider::Kraken + include HTTParty + extend SslConfigurable + + class Error < StandardError; end + class AuthenticationError < Error; end + class PermissionError < Error; end + class RateLimitError < Error; end + class NonceError < Error; end + class OTPRequiredError < Error; end + class ApiError < Error; end + + BASE_URL = "https://api.kraken.com" + PRIVATE_PREFIX = "/0/private" + PUBLIC_PREFIX = "/0/public" + + base_uri BASE_URL + default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options)) + + attr_reader :api_key, :api_secret + + def initialize(api_key:, api_secret:, nonce_generator: nil) + @api_key = api_key # pipelock:ignore user-supplied Kraken credential kept in memory for signed requests + @api_secret = api_secret # pipelock:ignore user-supplied Kraken credential kept in memory for signed requests + @nonce_generator = nonce_generator || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s } + end + + def get_api_key_info + private_post("GetApiKeyInfo") + end + + def get_extended_balance + private_post("BalanceEx") + end + + def get_trades_history(start: nil, offset: nil) + params = {} + params["start"] = start.to_i.to_s if start.present? + params["ofs"] = offset.to_i.to_s if offset.present? + + private_post("TradesHistory", params) + end + + def get_asset_info(asset: nil) + params = {} + params["asset"] = asset if asset.present? + public_get("Assets", params) + end + + def get_asset_pairs(pair: nil) + params = {} + params["pair"] = pair if pair.present? + public_get("AssetPairs", params) + end + + def get_ticker(pair) + public_get("Ticker", "pair" => pair) + end + + def get_ohlc(pair, interval: 1440, since: nil) + params = { "pair" => pair, "interval" => interval.to_s } + params["since"] = since.to_i.to_s if since.present? + public_get("OHLC", params) + end + + private + + attr_reader :nonce_generator + + def public_get(method, params = {}) + response = self.class.get("#{PUBLIC_PREFIX}/#{method}", query: params) + handle_response(response) + end + + def private_post(method, params = {}) + path = "#{PRIVATE_PREFIX}/#{method}" + request_params = { "nonce" => nonce_generator.call.to_s }.merge(stringify_params(params)) + body = URI.encode_www_form(request_params) + + response = self.class.post( + path, + body: body, + headers: auth_headers(path, request_params).merge("Content-Type" => "application/x-www-form-urlencoded") + ) + + handle_response(response) + end + + def stringify_params(params) + params.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value.to_s } + end + + def auth_headers(path, params) + { + "API-Key" => api_key, + "API-Sign" => sign(path, params) + } + end + + def sign(path, params) + encoded_payload = URI.encode_www_form(params) + nonce = params.fetch("nonce").to_s + digest = OpenSSL::Digest::SHA256.digest(nonce + encoded_payload) + hmac = OpenSSL::HMAC.digest("sha512", Base64.decode64(api_secret), path + digest) + Base64.strict_encode64(hmac) + end + + def handle_response(response) + parsed = response.parsed_response + + unless response.code.between?(200, 299) + raise ApiError, "Kraken API request failed: #{response.code}" + end + + unless parsed.is_a?(Hash) + raise ApiError, "Malformed Kraken API response" + end + + unless parsed.key?("error") + raise ApiError, "Malformed Kraken API response: missing error" + end + + errors = Array(parsed["error"]).reject(&:blank?) + raise classified_error(errors) if errors.any? + + unless parsed.key?("result") + raise ApiError, "Malformed Kraken API response: missing result" + end + + parsed["result"] + end + + def classified_error(errors) + message = errors.join(", ") + + case message + when /Invalid key|Invalid signature|Temporary lockout/i + AuthenticationError.new(message) + when /Invalid nonce/i + NonceError.new(message) + when /Permission denied|Invalid permissions/i + PermissionError.new(message) + when /Rate limit exceeded|Too many requests|limit exceeded|Throttled/i + RateLimitError.new(message) + when /otp|2fa|two.factor/i + OTPRequiredError.new(message) + else + ApiError.new(message) + end + end +end diff --git a/app/models/provider/kraken_adapter.rb b/app/models/provider/kraken_adapter.rb new file mode 100644 index 000000000..c932a9efb --- /dev/null +++ b/app/models/provider/kraken_adapter.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +class Provider::KrakenAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + Provider::Factory.register("KrakenAccount", self) + + def self.supported_account_types + %w[Crypto] + end + + def self.connection_configs(family:) + return [] unless family.can_connect_kraken? + + kraken_items = family.kraken_items.active.credentials_configured.ordered.select(&:credentials_configured?) + return [ connection_config_for(nil) ] if kraken_items.empty? + + kraken_items.map { |kraken_item| connection_config_for(kraken_item) } + end + + def self.build_provider(family: nil, kraken_item_id: nil) + return nil unless family.present? + + kraken_item = resolve_kraken_item(family, kraken_item_id) + return nil unless kraken_item&.credentials_configured? + + kraken_item.kraken_provider + end + + def provider_name + "kraken" + end + + def sync_path + return unless item + + Rails.application.routes.url_helpers.sync_kraken_item_path(item) + end + + def item + provider_account.kraken_item + end + + def can_delete_holdings? + false + end + + def institution_domain + institution_metadata_value("domain") + end + + def institution_name + institution_metadata_value("name") + end + + def institution_url + institution_metadata_value("url") + end + + def institution_color + institution_metadata_value("color") + end + + def self.connection_config_for(kraken_item) + path_params = ->(extra = {}) do + kraken_item.present? ? extra.merge(kraken_item_id: kraken_item.id) : extra + end + + { + key: kraken_item.present? ? "kraken_#{kraken_item.id}" : "kraken", + name: kraken_item.present? ? I18n.t("kraken_items.provider_connection.name", name: kraken_item.name) : I18n.t("kraken_items.provider_connection.default_name"), + description: kraken_item.present? ? I18n.t("kraken_items.provider_connection.description", name: kraken_item.name) : I18n.t("kraken_items.provider_connection.default_description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_kraken_items_path( + path_params.call(accountable_type: accountable_type, return_to: return_to) + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_kraken_items_path( + path_params.call(account_id: account_id) + ) + } + } + end + private_class_method :connection_config_for + + def self.resolve_kraken_item(family, kraken_item_id) + if kraken_item_id.present? + item = family.kraken_items.active.credentials_configured.find_by(id: kraken_item_id) + return item if item&.credentials_configured? + + return nil + end + + credentialed_items = family.kraken_items.active.credentials_configured.ordered.select(&:credentials_configured?) + return credentialed_items.first if credentialed_items.one? + + nil + end + private_class_method :resolve_kraken_item + + private + + def institution_metadata_value(key) + metadata = provider_account.institution_metadata || {} + metadata[key] || item&.public_send("institution_#{key}") + end +end diff --git a/app/models/provider/llm_concept.rb b/app/models/provider/llm_concept.rb index 46e6dcd84..52550111f 100644 --- a/app/models/provider/llm_concept.rb +++ b/app/models/provider/llm_concept.rb @@ -40,6 +40,7 @@ module Provider::LlmConcept instructions: nil, functions: [], function_results: [], + messages: nil, streamer: nil, previous_response_id: nil, session_id: nil, diff --git a/app/models/provider/mercury_adapter.rb b/app/models/provider/mercury_adapter.rb index e1a70b839..96f6666ba 100644 --- a/app/models/provider/mercury_adapter.rb +++ b/app/models/provider/mercury_adapter.rb @@ -15,23 +15,11 @@ class Provider::MercuryAdapter < Provider::Base def self.connection_configs(family:) return [] unless family.can_connect_mercury? - [ { - key: "mercury", - name: "Mercury", - description: "Connect to your bank via Mercury", - can_connect: true, - new_account_path: ->(accountable_type, return_to) { - Rails.application.routes.url_helpers.select_accounts_mercury_items_path( - accountable_type: accountable_type, - return_to: return_to - ) - }, - existing_account_path: ->(account_id) { - Rails.application.routes.url_helpers.select_existing_account_mercury_items_path( - account_id: account_id - ) - } - } ] + mercury_items = family.mercury_items.active.ordered.select(&:credentials_configured?) + + return [ connection_config_for(nil) ] if mercury_items.empty? + + mercury_items.map { |mercury_item| connection_config_for(mercury_item) } end def provider_name @@ -41,19 +29,54 @@ class Provider::MercuryAdapter < Provider::Base # Build a Mercury provider instance with family-specific credentials # @param family [Family] The family to get credentials for (required) # @return [Provider::Mercury, nil] Returns nil if credentials are not configured - def self.build_provider(family: nil) + def self.build_provider(family: nil, mercury_item_id: nil) return nil unless family.present? - # Get family-specific credentials - mercury_item = family.mercury_items.where.not(token: nil).first + mercury_item = resolve_mercury_item(family, mercury_item_id) return nil unless mercury_item&.credentials_configured? Provider::Mercury.new( - mercury_item.token, + mercury_item.token.to_s.strip, base_url: mercury_item.effective_base_url ) end + def self.connection_config_for(mercury_item) + path_params = ->(extra = {}) do + mercury_item.present? ? extra.merge(mercury_item_id: mercury_item.id) : extra + end + + { + key: mercury_item.present? ? "mercury_#{mercury_item.id}" : "mercury", + name: mercury_item.present? ? I18n.t("mercury_items.provider_connection.name", name: mercury_item.name) : I18n.t("mercury_items.provider_connection.default_name"), + description: mercury_item.present? ? I18n.t("mercury_items.provider_connection.description", name: mercury_item.name) : I18n.t("mercury_items.provider_connection.default_description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_mercury_items_path( + path_params.call(accountable_type: accountable_type, return_to: return_to) + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_mercury_items_path( + path_params.call(account_id: account_id) + ) + } + } + end + private_class_method :connection_config_for + + def self.resolve_mercury_item(family, mercury_item_id) + if mercury_item_id.present? + item = family.mercury_items.active.find_by(id: mercury_item_id) + return item if item&.credentials_configured? + + return nil + end + + family.mercury_items.active.ordered.find(&:credentials_configured?) + end + private_class_method :resolve_mercury_item + def sync_path Rails.application.routes.url_helpers.sync_mercury_item_path(item) end diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb new file mode 100644 index 000000000..4c8263b8a --- /dev/null +++ b/app/models/provider/metadata.rb @@ -0,0 +1,25 @@ +class Provider + module Metadata + REGISTRY = { + simplefin: { region: "US", kind: "Bank", maturity: :stable, logo_text: "SF", logo_bg: "bg-blue-600" }, + lunchflow: { region: "US", kind: "Bank", maturity: :stable, logo_text: "LF", logo_bg: "bg-orange-500" }, + enable_banking: { region: "EU", kind: "Bank", maturity: :beta, logo_text: "EB", logo_bg: "bg-purple-600" }, + coinstats: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CS", logo_bg: "bg-pink-600" }, + mercury: { region: "US", kind: "Bank", maturity: :beta, logo_text: "ME", logo_bg: "bg-cyan-600" }, + brex: { region: "US", kind: "Bank", maturity: :beta, logo_text: "BX", logo_bg: "bg-emerald-600" }, + coinbase: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CB", logo_bg: "bg-blue-500" }, + binance: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "BI", logo_bg: "bg-yellow-600" }, + kraken: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "KR", logo_bg: "bg-violet-600" }, + snaptrade: { region: "US / CA", kind: "Investment", maturity: :beta, logo_text: "ST", logo_bg: "bg-green-600" }, + ibkr: { region: "Global", kind: "Investment", maturity: :beta, logo_text: "IB", logo_bg: "bg-red-600" }, + indexa_capital: { region: "ES", kind: "Investment", maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" }, + sophtron: { region: "US", kind: "Bank", maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" }, + plaid: { region: "US", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" }, + plaid_eu: { name: "Plaid EU", region: "EU", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" } + }.freeze + + def self.for(provider_key) + REGISTRY[provider_key.to_sym] || { logo_text: provider_key.to_s.first(2).upcase, logo_bg: "bg-gray-500" } + end + end +end diff --git a/app/models/provider/mfapi.rb b/app/models/provider/mfapi.rb new file mode 100644 index 000000000..0e597891f --- /dev/null +++ b/app/models/provider/mfapi.rb @@ -0,0 +1,168 @@ +class Provider::Mfapi < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + # Minimum delay between requests + MIN_REQUEST_INTERVAL = 0.5 + + def initialize + # No API key required + end + + def healthy? + with_provider_response do + response = client.get("#{base_url}/mf/125497/latest") + parsed = JSON.parse(response.body) + parsed.dig("meta", "scheme_name").present? + end + end + + def usage + with_provider_response do + UsageData.new( + used: nil, + limit: nil, + utilization: nil, + plan: "Free (no key required)" + ) + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + throttle_request + response = client.get("#{base_url}/mf/search") do |req| + req.params["q"] = symbol + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + unless parsed.is_a?(Array) + raise Error, "Unexpected response format from search endpoint" + end + + parsed.first(25).map do |fund| + Security.new( + symbol: fund["schemeCode"].to_s, + name: fund["schemeName"], + logo_url: nil, + exchange_operating_mic: "XBOM", + country_code: "IN", + currency: "INR" + ) + end + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + throttle_request + response = client.get("#{base_url}/mf/#{CGI.escape(symbol)}/latest") + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + meta = parsed["meta"] || {} + + SecurityInfo.new( + symbol: symbol, + name: meta["scheme_name"], + links: nil, + logo_url: nil, + description: [ meta["fund_house"], meta["scheme_category"] ].compact.join(" - "), + kind: "mutual fund", + exchange_operating_mic: exchange_operating_mic + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) + with_provider_response do + historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date - 7.days, end_date: date) + + raise historical_data.error if historical_data.error.present? + raise InvalidSecurityPriceError, "No NAV found for scheme #{symbol} on or before #{date}" if historical_data.data.blank? + + # Find exact date or closest previous + historical_data.data.select { |p| p.date <= date }.max_by(&:date) || historical_data.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) + with_provider_response do + throttle_request + response = client.get("#{base_url}/mf/#{CGI.escape(symbol)}") do |req| + req.params["startDate"] = start_date.to_s + req.params["endDate"] = end_date.to_s + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + nav_data = parsed["data"] + + if nav_data.nil? || !nav_data.is_a?(Array) + raise InvalidSecurityPriceError, "No NAV data returned for scheme #{symbol}" + end + + nav_data.filter_map do |entry| + nav = entry["nav"] + date_str = entry["date"] + + next if nav.nil? || nav.to_f <= 0 || date_str.blank? + + # MFAPI returns dates as DD-MM-YYYY + date = Date.strptime(date_str, "%d-%m-%Y") + + Price.new( + symbol: symbol, + date: date, + price: nav.to_f, + currency: "INR", + exchange_operating_mic: exchange_operating_mic + ) + end + end + end + + private + + def base_url + ENV["MFAPI_URL"] || "https://api.mfapi.in" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.request(:retry, { + max: 3, + interval: 1.0, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.request :json + faraday.response :raise_error + faraday.headers["Accept"] = "application/json" + end + end + + # throttle_request and min_request_interval provided by RateLimitable + + def check_api_error!(parsed) + return unless parsed.is_a?(Hash) + + if parsed["status"] == "ERROR" || parsed["status"] == "FAIL" + raise Error, "API error: #{parsed['message'] || parsed['status']}" + end + end +end diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 60c103a9e..0c04f63e1 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -4,31 +4,29 @@ class Provider::Openai < Provider # Subclass so errors caught in this provider are raised as Provider::Openai::Error Error = Class.new(Provider::Error) - # Supported OpenAI model prefixes (e.g., "gpt-4" matches "gpt-4", "gpt-4.1", "gpt-4-turbo", etc.) - DEFAULT_OPENAI_MODEL_PREFIXES = %w[gpt-4 gpt-5 o1 o3] - DEFAULT_MODEL = "gpt-4.1" - - # Models that support PDF/vision input (not all OpenAI models have vision capabilities) + DEFAULT_MODEL = "gpt-4.1".freeze + SUPPORTED_MODELS = %w[gpt-4 gpt-5 o1 o3].freeze VISION_CAPABLE_MODEL_PREFIXES = %w[gpt-4o gpt-4-turbo gpt-4.1 gpt-5 o1 o3].freeze - # Returns the effective model that would be used by the provider - # Uses the same logic as Provider::Registry and the initializer + # Returns the effective model that would be used by the provider. + # Priority: explicit ENV > Setting > DEFAULT_MODEL. def self.effective_model - configured_model = ENV.fetch("OPENAI_MODEL", Setting.openai_model) - configured_model.presence || DEFAULT_MODEL + ENV.fetch("OPENAI_MODEL") { Setting.openai_model }.presence || DEFAULT_MODEL end def initialize(access_token, uri_base: nil, model: nil) client_options = { access_token: access_token } - client_options[:uri_base] = uri_base if uri_base.present? + llm_uri_base = uri_base.presence + llm_model = model.presence + client_options[:uri_base] = llm_uri_base if llm_uri_base.present? client_options[:request_timeout] = ENV.fetch("OPENAI_REQUEST_TIMEOUT", 60).to_i @client = ::OpenAI::Client.new(**client_options) - @uri_base = uri_base - if custom_provider? && model.blank? + @uri_base = llm_uri_base + if custom_provider? && llm_model.blank? raise Error, "Model is required when using a custom OpenAI‑compatible provider" end - @default_model = model.presence || DEFAULT_MODEL + @default_model = llm_model.presence || self.class.effective_model end def supports_model?(model) @@ -36,7 +34,18 @@ class Provider::Openai < Provider return true if custom_provider? # Otherwise, check if model starts with any supported OpenAI prefix - DEFAULT_OPENAI_MODEL_PREFIXES.any? { |prefix| model.start_with?(prefix) } + SUPPORTED_MODELS.any? { |prefix| model.start_with?(prefix) } + end + + def supports_responses_endpoint? + return @supports_responses_endpoint if defined?(@supports_responses_endpoint) + + env_override = ENV["OPENAI_SUPPORTS_RESPONSES_ENDPOINT"] + if env_override.to_s.present? + return @supports_responses_endpoint = ActiveModel::Type::Boolean.new.cast(env_override) + end + + @supports_responses_endpoint = !custom_provider? end def provider_name @@ -47,7 +56,7 @@ class Provider::Openai < Provider if custom_provider? @default_model.present? ? "configured model: #{@default_model}" : "any model" else - "models starting with: #{DEFAULT_OPENAI_MODEL_PREFIXES.join(', ')}" + "models starting with: #{SUPPORTED_MODELS.join(", ")}" end end @@ -55,9 +64,43 @@ class Provider::Openai < Provider @uri_base.present? end + # Token-budget knobs. Precedence: ENV > Setting > default. Defaults match + # Ollama's historical 2048-token baseline so local small-context models work + # out of the box. Users on larger-context cloud models can raise via ENV or + # via the Self-Hosting settings page. + def context_window + positive_budget(ENV["LLM_CONTEXT_WINDOW"], Setting.llm_context_window, 2048) + end + + def max_response_tokens + positive_budget(ENV["LLM_MAX_RESPONSE_TOKENS"], Setting.llm_max_response_tokens, 512) + end + + def system_prompt_reserve + positive_budget(ENV["LLM_SYSTEM_PROMPT_RESERVE"], nil, 256) + end + + def max_history_tokens + explicit = ENV["LLM_MAX_HISTORY_TOKENS"].presence&.to_i + return explicit if explicit&.positive? + [ context_window - max_response_tokens - system_prompt_reserve, 256 ].max + end + + # Budget available for a one-shot (non-chat) request's full input, + # excluding reserved response tokens AND the system/instructions prompt. + # Drives the batch slicer for the auto_categorize / auto_detect_merchants / + # enhance_provider_merchants calls — each ships ~200–400 tokens of + # instructions + JSON schema that aren't counted in `fixed_tokens`. + def max_input_tokens + [ context_window - max_response_tokens - system_prompt_reserve, 256 ].max + end + + def max_items_per_call + positive_budget(ENV["LLM_MAX_ITEMS_PER_CALL"], Setting.llm_max_items_per_call, 25) + end + def auto_categorize(transactions: [], user_categories: [], model: "", family: nil, json_mode: nil) with_provider_response do - raise Error, "Too many transactions to auto-categorize. Max is 25 per request." if transactions.size > 25 if user_categories.blank? family_id = family&.id || "unknown" Rails.logger.error("Cannot auto-categorize transactions for family #{family_id}: no categories available") @@ -71,16 +114,20 @@ class Provider::Openai < Provider input: { transactions: transactions, user_categories: user_categories } ) - result = AutoCategorizer.new( - client, - model: effective_model, - transactions: transactions, - user_categories: user_categories, - custom_provider: custom_provider?, - langfuse_trace: trace, - family: family, - json_mode: json_mode - ).auto_categorize + batches = slice_for_context(transactions, fixed: user_categories) + + result = batches.flat_map do |batch| + AutoCategorizer.new( + client, + model: effective_model, + transactions: batch, + user_categories: user_categories, + custom_provider: custom_provider?, + langfuse_trace: trace, + family: family, + json_mode: json_mode + ).auto_categorize + end upsert_langfuse_trace(trace: trace, output: result.map(&:to_h)) @@ -90,8 +137,6 @@ class Provider::Openai < Provider def auto_detect_merchants(transactions: [], user_merchants: [], model: "", family: nil, json_mode: nil) with_provider_response do - raise Error, "Too many transactions to auto-detect merchants. Max is 25 per request." if transactions.size > 25 - effective_model = model.presence || @default_model trace = create_langfuse_trace( @@ -99,16 +144,20 @@ class Provider::Openai < Provider input: { transactions: transactions, user_merchants: user_merchants } ) - result = AutoMerchantDetector.new( - client, - model: effective_model, - transactions: transactions, - user_merchants: user_merchants, - custom_provider: custom_provider?, - langfuse_trace: trace, - family: family, - json_mode: json_mode - ).auto_detect_merchants + batches = slice_for_context(transactions, fixed: user_merchants) + + result = batches.flat_map do |batch| + AutoMerchantDetector.new( + client, + model: effective_model, + transactions: batch, + user_merchants: user_merchants, + custom_provider: custom_provider?, + langfuse_trace: trace, + family: family, + json_mode: json_mode + ).auto_detect_merchants + end upsert_langfuse_trace(trace: trace, output: result.map(&:to_h)) @@ -118,8 +167,6 @@ class Provider::Openai < Provider def enhance_provider_merchants(merchants: [], model: "", family: nil, json_mode: nil) with_provider_response do - raise Error, "Too many merchants to enhance. Max is 25 per request." if merchants.size > 25 - effective_model = model.presence || @default_model trace = create_langfuse_trace( @@ -127,15 +174,19 @@ class Provider::Openai < Provider input: { merchants: merchants } ) - result = ProviderMerchantEnhancer.new( - client, - model: effective_model, - merchants: merchants, - custom_provider: custom_provider?, - langfuse_trace: trace, - family: family, - json_mode: json_mode - ).enhance_merchants + batches = slice_for_context(merchants) + + result = batches.flat_map do |batch| + ProviderMerchantEnhancer.new( + client, + model: effective_model, + merchants: batch, + custom_provider: custom_provider?, + langfuse_trace: trace, + family: family, + json_mode: json_mode + ).enhance_merchants + end upsert_langfuse_trace(trace: trace, output: result.map(&:to_h)) @@ -171,7 +222,8 @@ class Provider::Openai < Provider pdf_content: pdf_content, custom_provider: custom_provider?, langfuse_trace: trace, - family: family + family: family, + max_response_tokens: max_response_tokens ).process upsert_langfuse_trace(trace: trace, output: result.to_h) @@ -207,25 +259,17 @@ class Provider::Openai < Provider instructions: nil, functions: [], function_results: [], + messages: nil, streamer: nil, previous_response_id: nil, session_id: nil, user_identifier: nil, family: nil ) - if custom_provider? - generic_chat_response( - prompt: prompt, - model: model, - instructions: instructions, - functions: functions, - function_results: function_results, - streamer: streamer, - session_id: session_id, - user_identifier: user_identifier, - family: family - ) - else + if supports_responses_endpoint? + # Native path uses the Responses API which chains history via + # `previous_response_id`; it does NOT need (and must not receive) + # inline message history in the input payload. native_chat_response( prompt: prompt, model: model, @@ -238,12 +282,48 @@ class Provider::Openai < Provider user_identifier: user_identifier, family: family ) + else + generic_chat_response( + prompt: prompt, + model: model, + instructions: instructions, + functions: functions, + function_results: function_results, + messages: messages, + streamer: streamer, + session_id: session_id, + user_identifier: user_identifier, + family: family + ) end end private attr_reader :client + # Returns the first positive integer among env, setting, default. Treats + # zero or negative values as "unset" and falls through — a 0-token budget + # is never what the user meant. + def positive_budget(env_value, setting_value, default) + from_env = env_value.to_s.strip.to_i + return from_env if from_env.positive? + return setting_value.to_i if setting_value.to_i.positive? + default + end + + # Routes one-shot (non-chat) inputs through the BatchSlicer so large + # caller batches are split to fit the model's context window. `fixed` is + # the portion of the prompt that stays constant across every sub-batch + # (e.g. user_categories, user_merchants), used for fixed-tokens accounting. + def slice_for_context(items, fixed: nil) + BatchSlicer.call( + Array(items), + max_items: max_items_per_call, + max_tokens: max_input_tokens, + fixed_tokens: fixed ? Assistant::TokenEstimator.estimate(fixed) : 0 + ) + end + def native_chat_response( prompt:, model:, @@ -278,7 +358,7 @@ class Provider::Openai < Provider nil end - input_payload = chat_config.build_input(prompt) + input_payload = chat_config.build_input(prompt: prompt) begin raw_response = client.responses.create(parameters: { @@ -293,7 +373,16 @@ class Provider::Openai < Provider # If streaming, Ruby OpenAI does not return anything, so to normalize this method's API, we search # for the "response chunk" in the stream and return it (it is already parsed) if stream_proxy.present? + error_chunk = collected_chunks.find { |chunk| chunk.type == "error" } response_chunk = collected_chunks.find { |chunk| chunk.type == "response" } + + if response_chunk.nil? + raise Error.new( + build_stream_error_message(error_chunk), + details: error_chunk&.data&.details + ) + end + response = response_chunk.data usage = response_chunk.usage Rails.logger.debug("Stream response usage: #{usage.inspect}") @@ -344,6 +433,7 @@ class Provider::Openai < Provider instructions: nil, functions: [], function_results: [], + messages: nil, streamer: nil, session_id: nil, user_identifier: nil, @@ -353,7 +443,8 @@ class Provider::Openai < Provider messages = build_generic_messages( prompt: prompt, instructions: instructions, - function_results: function_results + function_results: function_results, + messages: messages ) tools = build_generic_tools(functions) @@ -412,16 +503,24 @@ class Provider::Openai < Provider end end - def build_generic_messages(prompt:, instructions: nil, function_results: []) - messages = [] + def build_generic_messages(prompt:, instructions: nil, function_results: [], messages: nil) + payload = [] # Add system message if instructions present if instructions.present? - messages << { role: "system", content: instructions } + payload << { role: "system", content: instructions } end - # Add user prompt - messages << { role: "user", content: prompt } + # Add conversation history or user prompt. History is trimmed to fit the + # configured token budget so small-context local models (Ollama, LM Studio, + # LocalAI) don't silently truncate. tool_call/tool_result pairs are + # preserved atomically by HistoryTrimmer. + if messages.present? + trimmed = Assistant::HistoryTrimmer.new(messages, max_tokens: max_history_tokens).call + payload.concat(trimmed) + elsif prompt.present? + payload << { role: "user", content: prompt } + end # If there are function results, we need to add the assistant message that made the tool calls # followed by the tool messages with the results @@ -442,7 +541,7 @@ class Provider::Openai < Provider } end - messages << { + payload << { role: "assistant", content: "", # Some OpenAI-compatible APIs require string, not null tool_calls: tool_calls @@ -462,7 +561,7 @@ class Provider::Openai < Provider output.to_json end - messages << { + payload << { role: "tool", tool_call_id: fn_result[:call_id], name: fn_result[:name], @@ -471,7 +570,7 @@ class Provider::Openai < Provider end end - messages + payload end def build_generic_tools(functions) @@ -654,4 +753,27 @@ class Provider::Openai < Provider rescue => e "(message unavailable: #{e.class})" end + + # Builds a useful error message when the OpenAI Responses stream ended + # without delivering a `response.completed` event. Uses upstream details + # when present (e.g. `response.failed`, `response.incomplete`, top-level + # `error`) and falls back to a generic message that hints at the most + # common causes. + def build_stream_error_message(error_chunk) + if error_chunk&.data&.message.present? + upstream = error_chunk.data + prefix = case upstream.event + when "response.incomplete" then "OpenAI response was incomplete" + when "response.failed" then "OpenAI response failed" + else "OpenAI returned an error" + end + code_suffix = upstream.code.present? ? " [#{upstream.code}]" : "" + "#{prefix}#{code_suffix}: #{upstream.message}" + else + "OpenAI stream ended without a completion event. " \ + "This usually means the upstream call was cut short — common causes: " \ + "expired previous_response_id (Responses API state TTL), context-length overflow, " \ + "or a transient OpenAI error." + end + end end diff --git a/app/models/provider/openai/batch_slicer.rb b/app/models/provider/openai/batch_slicer.rb new file mode 100644 index 000000000..577ddba5d --- /dev/null +++ b/app/models/provider/openai/batch_slicer.rb @@ -0,0 +1,45 @@ +class Provider::Openai::BatchSlicer + class ContextOverflowError < StandardError; end + + # Splits `items` into sub-batches that respect both a hard item cap and a + # token-budget cap. Used by auto_categorize / auto_detect_merchants / + # enhance_provider_merchants so callers can pass larger batches and have the + # provider fan them out to fit small-context models. + def self.call(items, max_items:, max_tokens:, fixed_tokens: 0) + items = Array(items) + return [] if items.empty? + + available = max_tokens.to_i - fixed_tokens.to_i + if available <= 0 + raise ContextOverflowError, + "Fixed prompt tokens (#{fixed_tokens}) exceed context budget (#{max_tokens})" + end + + batches = [] + current = [] + current_tokens = 0 + + items.each do |item| + item_tokens = Assistant::TokenEstimator.estimate(item) + if item_tokens > available + raise ContextOverflowError, + "Single item requires ~#{item_tokens} tokens, which exceeds available budget (#{available})" + end + + would_exceed_items = current.size >= max_items.to_i + would_exceed_tokens = current_tokens + item_tokens > available + + if would_exceed_items || would_exceed_tokens + batches << current unless current.empty? + current = [] + current_tokens = 0 + end + + current << item + current_tokens += item_tokens + end + + batches << current unless current.empty? + batches + end +end diff --git a/app/models/provider/openai/chat_config.rb b/app/models/provider/openai/chat_config.rb index 5e98a66ce..65e683de4 100644 --- a/app/models/provider/openai/chat_config.rb +++ b/app/models/provider/openai/chat_config.rb @@ -16,7 +16,9 @@ class Provider::Openai::ChatConfig end end - def build_input(prompt) + def build_input(prompt: nil) + input_messages = prompt.present? ? [ { role: "user", content: prompt } ] : [] + results = function_results.map do |fn_result| # Handle nil explicitly to avoid serializing to "null" output = fn_result[:output] @@ -36,7 +38,7 @@ class Provider::Openai::ChatConfig end [ - { role: "user", content: prompt }, + *input_messages, *results ] end diff --git a/app/models/provider/openai/chat_stream_parser.rb b/app/models/provider/openai/chat_stream_parser.rb index dcfe44207..294a317a8 100644 --- a/app/models/provider/openai/chat_stream_parser.rb +++ b/app/models/provider/openai/chat_stream_parser.rb @@ -1,6 +1,8 @@ class Provider::Openai::ChatStreamParser Error = Class.new(StandardError) + StreamErrorData = Data.define(:event, :message, :code, :details) + def initialize(object) @object = object end @@ -15,6 +17,21 @@ class Provider::Openai::ChatStreamParser raw_response = object.dig("response") usage = raw_response.dig("usage") Chunk.new(type: "response", data: parse_response(raw_response), usage: usage) + when "response.failed" + Chunk.new(type: "error", data: build_response_error("response.failed"), usage: nil) + when "response.incomplete" + Chunk.new(type: "error", data: build_response_error("response.incomplete"), usage: nil) + when "error" + Chunk.new( + type: "error", + data: StreamErrorData.new( + event: "error", + message: object.dig("message").presence || "OpenAI stream returned an error event", + code: object.dig("code"), + details: object + ), + usage: nil + ) end end @@ -26,4 +43,22 @@ class Provider::Openai::ChatStreamParser def parse_response(response) Provider::Openai::ChatParser.new(response).parsed end + + def build_response_error(event) + raw_response = object.dig("response") || {} + error_message = + raw_response.dig("error", "message").presence || + raw_response.dig("incomplete_details", "reason").presence || + "OpenAI stream ended with #{event}" + code = + raw_response.dig("error", "code") || + raw_response.dig("incomplete_details", "reason") + + StreamErrorData.new( + event: event, + message: error_message, + code: code, + details: raw_response + ) + end end diff --git a/app/models/provider/openai/pdf_processor.rb b/app/models/provider/openai/pdf_processor.rb index f65510e87..1cb63e773 100644 --- a/app/models/provider/openai/pdf_processor.rb +++ b/app/models/provider/openai/pdf_processor.rb @@ -1,15 +1,16 @@ class Provider::Openai::PdfProcessor include Provider::Openai::Concerns::UsageRecorder - attr_reader :client, :model, :pdf_content, :custom_provider, :langfuse_trace, :family + attr_reader :client, :model, :pdf_content, :custom_provider, :langfuse_trace, :family, :max_response_tokens - def initialize(client, model: "", pdf_content: nil, custom_provider: false, langfuse_trace: nil, family: nil) + def initialize(client, model: "", pdf_content: nil, custom_provider: false, langfuse_trace: nil, family: nil, max_response_tokens:) @client = client @model = model @pdf_content = pdf_content @custom_provider = custom_provider @langfuse_trace = langfuse_trace @family = family + @max_response_tokens = max_response_tokens end def process @@ -175,7 +176,7 @@ class Provider::Openai::PdfProcessor { role: "system", content: instructions + "\n\nIMPORTANT: Respond with valid JSON only, no markdown or other formatting." }, { role: "user", content: content } ], - max_tokens: 4096 + max_tokens: max_response_tokens } response = client.chat(parameters: params) diff --git a/app/models/provider/plaid_adapter.rb b/app/models/provider/plaid_adapter.rb index ac4fd8187..f75c5d97f 100644 --- a/app/models/provider/plaid_adapter.rb +++ b/app/models/provider/plaid_adapter.rb @@ -80,13 +80,6 @@ class Provider::PlaidAdapter < Provider::Base # Configuration for Plaid US configure do - description <<~DESC - Setup instructions: - 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials - 2. Your Client ID and Secret Key are required to enable Plaid bank sync for US/CA banks - 3. For production use, set environment to 'production', for testing use 'sandbox' - DESC - field :client_id, label: "Client ID", required: false, diff --git a/app/models/provider/plaid_eu_adapter.rb b/app/models/provider/plaid_eu_adapter.rb index 497bb13c4..6db5331a0 100644 --- a/app/models/provider/plaid_eu_adapter.rb +++ b/app/models/provider/plaid_eu_adapter.rb @@ -19,13 +19,6 @@ class Provider::PlaidEuAdapter # Configuration for Plaid EU configure do - description <<~DESC - Setup instructions: - 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials - 2. Your Client ID and Secret Key are required to enable Plaid bank sync for European banks - 3. For production use, set environment to 'production', for testing use 'sandbox' - DESC - field :client_id, label: "Client ID", required: false, diff --git a/app/models/provider/rate_limitable.rb b/app/models/provider/rate_limitable.rb new file mode 100644 index 000000000..f1c1ca2d2 --- /dev/null +++ b/app/models/provider/rate_limitable.rb @@ -0,0 +1,59 @@ +# Shared concern for providers that need interval-based request throttling +# and a standard error transformation pattern. +# +# Providers that include this concern get: +# - `throttle_request`: sleeps to enforce MIN_REQUEST_INTERVAL between calls +# - `min_request_interval`: reads from ENV with fallback to the class constant +# - `default_error_transformer`: maps Faraday/rate-limit errors to provider-scoped types +# +# The including class MUST define: +# - `MIN_REQUEST_INTERVAL` (Float) — default seconds between requests +# - `Error` (Class) — provider-scoped error class +# - `RateLimitError` (Class) — provider-scoped rate-limit error class +# +# And MAY define a `PROVIDER_ENV_PREFIX` constant (e.g. "ALPHA_VANTAGE") used +# to derive the ENV key for the min request interval override. When omitted +# the prefix is derived from the class name (Provider::AlphaVantage → "ALPHA_VANTAGE"). +module Provider::RateLimitable + extend ActiveSupport::Concern + + private + # Enforces a minimum interval between consecutive requests on this instance. + # Subclasses that need additional rate-limit layers (daily counters, hourly + # counters) should call `super` or invoke this via `throttle_interval` and + # add their own checks. + def throttle_request + @last_request_time ||= Time.at(0) + elapsed = Time.current - @last_request_time + sleep_time = min_request_interval - elapsed + sleep(sleep_time) if sleep_time > 0 + @last_request_time = Time.current + end + + def min_request_interval + ENV.fetch("#{provider_env_prefix}_MIN_REQUEST_INTERVAL", self.class::MIN_REQUEST_INTERVAL).to_f + end + + def provider_env_prefix + self.class.const_defined?(:PROVIDER_ENV_PREFIX) ? self.class::PROVIDER_ENV_PREFIX : self.class.name.demodulize.underscore.upcase + end + + # Standard error transformation: maps common Faraday errors to provider-scoped + # error classes. Providers with extra error types (e.g. AuthenticationError) + # should override and call `super` for the default cases. + def default_error_transformer(error) + case error + when self.class::RateLimitError + error + when Faraday::TooManyRequestsError + self.class::RateLimitError.new( + "#{self.class.name.demodulize} rate limit exceeded", + details: error.response&.dig(:body) + ) + when Faraday::Error + self.class::Error.new(error.message, details: error.response&.dig(:body)) + else + self.class::Error.new(error.message) + end + end +end diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index aa7a443c5..4782c1ee1 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -81,6 +81,38 @@ class Provider::Registry def yahoo_finance Provider::YahooFinance.new end + + def tiingo + api_key = ENV["TIINGO_API_KEY"].presence || Setting.tiingo_api_key # pipelock:ignore + + return nil unless api_key.present? + + Provider::Tiingo.new(api_key) + end + + def eodhd + api_key = ENV["EODHD_API_KEY"].presence || Setting.eodhd_api_key # pipelock:ignore + + return nil unless api_key.present? + + Provider::Eodhd.new(api_key) + end + + def alpha_vantage + api_key = ENV["ALPHA_VANTAGE_API_KEY"].presence || Setting.alpha_vantage_api_key # pipelock:ignore + + return nil unless api_key.present? + + Provider::AlphaVantage.new(api_key) + end + + def mfapi + Provider::Mfapi.new + end + + def binance_public + Provider::BinancePublic.new + end end def initialize(concept) @@ -92,6 +124,11 @@ class Provider::Registry available_providers.map { |p| self.class.send(p) }.compact end + # Returns the list of provider key names (symbols) registered for this concept. + def provider_keys + available_providers + end + def get_provider(name) provider_method = available_providers.find { |p| p == name.to_sym } @@ -108,7 +145,7 @@ class Provider::Registry when :exchange_rates %i[twelve_data yahoo_finance] when :securities - %i[twelve_data yahoo_finance] + %i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public] when :llm %i[openai] else diff --git a/app/models/provider/security_concept.rb b/app/models/provider/security_concept.rb index fbc408c33..37fbd3efe 100644 --- a/app/models/provider/security_concept.rb +++ b/app/models/provider/security_concept.rb @@ -1,7 +1,14 @@ module Provider::SecurityConcept extend ActiveSupport::Concern - Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code) + # NOTE: This `Security` is a lightweight Data value object used for search results. + # Inside provider classes that `include SecurityConcept`, unqualified `Security` + # resolves to this Data class — NOT to `::Security` (the ActiveRecord model). + Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code, :currency) do + def initialize(symbol:, name:, logo_url:, exchange_operating_mic:, country_code:, currency: nil) + super + end + end SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic) Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic) @@ -20,4 +27,11 @@ module Provider::SecurityConcept def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:) raise NotImplementedError, "Subclasses must implement #fetch_security_prices" end + + # Maximum number of calendar days of historical data the provider can return. + # Callers should clamp start_date to avoid requesting data beyond this window. + # Override in subclasses with provider-specific limits. + def max_history_days + nil # nil means no known limit + end end diff --git a/app/models/provider/sophtron.rb b/app/models/provider/sophtron.rb new file mode 100644 index 000000000..69261133f --- /dev/null +++ b/app/models/provider/sophtron.rb @@ -0,0 +1,473 @@ +# Sophtron API client for account aggregation. +# +# Sophtron uses two API shapes: +# - V2 REST endpoints for customer provisioning. +# - V1 RPC-style endpoints for institution connection, jobs, MFA, accounts, and transactions. +class Provider::Sophtron < Provider + include HTTParty + + DEFAULT_BASE_URL = "https://api.sophtron.com/api" + USER_AGENT = "Sure Finance Sophtron Client" + FAILURE_JOB_STATUSES = %w[Completed Timeout Failed Failure Error].freeze + + headers "User-Agent" => USER_AGENT + default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + + Error = Class.new(Provider::Error) do + attr_reader :error_type + + def initialize(message, error_type = :unknown, details: nil) + @error_type = error_type + super(message, details: details) + end + end + + attr_reader :user_id, :access_key, :base_url + + def initialize(user_id, access_key, base_url: DEFAULT_BASE_URL) + @user_id = user_id + @access_key = access_key + @base_url = normalize_base_url(base_url) + super() + end + + def auth_header_for(method, api_path) + auth_path = self.class.auth_path(api_path) + plain_key = "#{method.to_s.upcase}\n#{auth_path}" + key_bytes = Base64.decode64(access_key.to_s) + raise ArgumentError, "decoded key is empty" if key_bytes.blank? + signature = OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), key_bytes, plain_key) + "FIApiAUTH:#{user_id}:#{Base64.strict_encode64(signature)}:#{auth_path}" + rescue ArgumentError => e + raise Error.new("Invalid Sophtron Access Key: #{e.message}", :invalid_access_key) + end + + def self.auth_path(api_path) + path = URI.parse(api_path.to_s).path + last_segment = path.to_s.split("/").last.to_s + "/#{last_segment}".downcase + rescue URI::InvalidURIError + last_segment = api_path.to_s.split("?").first.to_s.split("/").last.to_s + "/#{last_segment}".downcase + end + + def self.job_success?(job) + job = job.with_indifferent_access + job[:SuccessFlag] == true || job[:success_flag] == true || job[:LastStatus].to_s == "AccountsReady" || job[:last_status].to_s == "AccountsReady" + end + + def self.job_failed?(job) + job = job.with_indifferent_access + success_flag = job.key?(:SuccessFlag) ? job[:SuccessFlag] : job[:success_flag] + last_status = job[:LastStatus] || job[:last_status] + success_flag == false && failure_job_status?(last_status) + end + + def self.job_completed?(job) + job = job.with_indifferent_access + (job[:LastStatus] || job[:last_status]).to_s == "Completed" && !job_failed?(job) + end + + def self.failure_job_status?(last_status) + status = last_status.to_s + FAILURE_JOB_STATUSES.include?(status) || status.match?(/timeout|fail|error/i) + end + + def self.job_requires_input?(job) + job = job.with_indifferent_access + job[:SecurityQuestion].present? || + job[:security_question].present? || + job[:TokenMethod].present? || + job[:token_method].present? || + job_token_input_required?(job) || + job[:TokenRead].present? || + job[:token_read].present? || + job[:CaptchaImage].present? || + job[:captcha_image].present? + end + + def self.job_token_input_required?(job) + job = job.with_indifferent_access + token_input = job[:TokenInput] || job[:token_input] + token_input.blank? && ( + job[:TokenSentFlag] == true || + job[:token_sent_flag] == true || + job[:TokenInputName].present? || + job[:token_input_name].present? || + job[:LastStep].to_s == "TokenInput" || + job[:last_step].to_s == "TokenInput" + ) + end + + def self.parse_json_array(value) + return [] if value.blank? + return value if value.is_a?(Array) + + parsed = JSON.parse(value.to_s) + parsed.is_a?(Array) ? parsed : Array(parsed) + rescue JSON::ParserError + Array(value) + end + + def self.response_data!(response) + return response unless response.respond_to?(:success?) && response.respond_to?(:data) + return response.data if response.success? + + raise response.error || Error.new("Sophtron provider response did not include data", :invalid_response) + end + + # GET /api/Institution/HealthCheckAuth + def health_check_auth + with_provider_response do + request(:get, "/Institution/HealthCheckAuth", parse_json: false) + end + end + + # GET /api/v2/customers + def list_customers + with_provider_response do + parsed = request(:get, "/v2/customers") + extract_array_response(parsed, :customers, :Customers) + end + end + + # POST /api/v2/customers + def create_customer(unique_id:, name:, source: "Sure") + with_provider_response do + request( + :post, + "/v2/customers", + body: { + UniqueID: unique_id, + Name: name, + Source: source + } + ) + end + end + + # POST /api/Institution/GetInstitutionByName + def search_institutions(institution_name) + with_provider_response do + parsed = request( + :post, + "/Institution/GetInstitutionByName", + body: { InstitutionName: institution_name.to_s } + ) + extract_array_response(parsed, :institutions, :Institutions) + end + end + + # POST /api/UserInstitution/GetUserInstitutionsByUser + def list_user_institutions + with_provider_response do + parsed = request( + :post, + "/UserInstitution/GetUserInstitutionsByUser", + body: { UserID: user_id } + ) + extract_array_response(parsed, :user_institutions, :UserInstitutions) + end + end + + # POST /api/UserInstitution/CreateUserInstitution + def create_user_institution(institution_id:, username:, password:, pin: "") + with_provider_response do + request( + :post, + "/UserInstitution/CreateUserInstitution", + body: { + UserID: user_id, + InstitutionID: institution_id, + UserName: username, + Password: password, + PIN: pin.to_s + } + ) + end + end + + # POST /api/Job/GetJobInformationByID + def get_job_information(job_id) + with_provider_response do + fetch_job_information(job_id) + end + end + + # POST /api/Job/UpdateJobSecurityAnswer + def update_job_security_answer(job_id, answers) + security_answer = answers.is_a?(String) ? answers : Array(answers).to_json + + with_provider_response do + request( + :post, + "/Job/UpdateJobSecurityAnswer", + body: { JobID: job_id, SecurityAnswer: security_answer } + ) + end + end + + # POST /api/Job/UpdateJobTokenInput + def update_job_token_input(job_id, token_choice: nil, token_input: nil, verify_phone_flag: nil) + with_provider_response do + request( + :post, + "/Job/UpdateJobTokenInput", + body: { + JobID: job_id, + TokenChoice: token_choice, + TokenInput: token_input, + VerifyPhoneFlag: verify_phone_flag + } + ) + end + end + + # POST /api/Job/UpdateJobCaptcha + def update_job_captcha(job_id, captcha_input) + with_provider_response do + request( + :post, + "/Job/UpdateJobCaptcha", + body: { JobID: job_id, CaptchaInput: captcha_input } + ) + end + end + + # POST /api/UserInstitution/GetUserInstitutionAccounts + def get_user_institution_accounts(user_institution_id) + with_provider_response do + fetch_user_institution_accounts(user_institution_id) + end + end + + def get_accounts(user_institution_id) + with_provider_response do + accounts = fetch_user_institution_accounts(user_institution_id) + normalized = accounts.map { |account| normalize_account(account, user_institution_id: user_institution_id) } + { accounts: normalized, total: normalized.size } + end + end + + # POST /api/UserInstitutionAccount/RefreshUserInstitutionAccount + def refresh_account(account_id) + with_provider_response do + request( + :post, + "/UserInstitutionAccount/RefreshUserInstitutionAccount", + body: { AccountID: account_id } + ) + end + end + + # POST /api/Transaction/GetTransactionsByTransactionDate + def get_account_transactions(account_id, start_date: nil, end_date: nil) + with_provider_response do + parsed = request( + :post, + "/Transaction/GetTransactionsByTransactionDate", + body: { + AccountID: account_id, + StartDate: (start_date || 120.days.ago).to_date.to_s, + EndDate: (end_date || Date.tomorrow).to_date.to_s + } + ) + + raw_transactions = extract_array_response(parsed, :transactions, :Transactions) + transactions = raw_transactions.map { |transaction| normalize_transaction(transaction, account_id) } + + { transactions: transactions, total: transactions.size } + end + end + + def poll_job(job_id, **) + get_job_information(job_id) + end + + private + + def default_error_transformer(error) + return error if error.is_a?(Error) + + super + end + + def fetch_job_information(job_id) + request( + :post, + "/Job/GetJobInformationByID", + body: { JobID: job_id } + ) + end + + def fetch_user_institution_accounts(user_institution_id) + parsed = request( + :post, + "/UserInstitution/GetUserInstitutionAccounts", + body: { UserInstitutionID: user_institution_id } + ) + extract_array_response(parsed, :accounts, :Accounts) + end + + def extract_array_response(parsed, *keys) + return parsed if parsed.is_a?(Array) + return [] if parsed.respond_to?(:empty?) && parsed.empty? + + if parsed.respond_to?(:with_indifferent_access) + parsed = parsed.with_indifferent_access + keys.each do |key| + return Array(parsed[key]) if parsed.key?(key) + end + end + + raise Error.new("Invalid Sophtron response format", :invalid_response, details: parsed) + end + + def request(method, api_path, body: nil, parse_json: true) + options = { headers: auth_headers(method: method, api_path: api_path) } + options[:body] = body.to_json if body + + response = self.class.public_send(method, "#{base_url}#{api_path}", options) + handle_response(response, parse_json: parse_json) + rescue Error + raise + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise Error.new("Sophtron request failed: #{e.message}", :request_failed) + rescue StandardError => e + raise Error.new("Sophtron request failed: #{e.message}", :request_failed) + end + + def auth_headers(method:, api_path:) + { + "Authorization" => auth_header_for(method, api_path), + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def handle_response(response, parse_json: true) + body = response.body.to_s + + case response.code.to_i + when 200, 201, 204 + return {} if body.strip.blank? + + parse_json ? JSON.parse(body, symbolize_names: true) : parse_optional_json(body) + when 400 + raise Error.new("Bad request to Sophtron API: #{body}", :bad_request, details: body) + when 401 + raise Error.new("Invalid Sophtron User ID or Access Key", :unauthorized, details: body) + when 403 + raise Error.new("Access forbidden by Sophtron", :access_forbidden, details: body) + when 404 + raise Error.new("Sophtron resource not found", :not_found, details: body) + when 429 + raise Error.new("Sophtron rate limit exceeded. Please try again later.", :rate_limited, details: body) + else + raise Error.new( + "Sophtron API request failed: #{response.code} #{response.message} - #{body}", + :fetch_failed, + details: body + ) + end + rescue JSON::ParserError => e + raise Error.new("Invalid JSON response from Sophtron API: #{e.message}", :invalid_response, details: body) + end + + def parse_optional_json(body) + JSON.parse(body, symbolize_names: true) + rescue JSON::ParserError + body + end + + def normalize_base_url(value) + url = value.presence || DEFAULT_BASE_URL + url = url.to_s.chomp("/") + url = url.delete_suffix("/v2") if url.end_with?("/v2") + + parsed = URI.parse(url) + parsed.path.to_s.end_with?("/api") ? url : "#{url}/api" + rescue URI::InvalidURIError + DEFAULT_BASE_URL + end + + def normalize_account(account, user_institution_id:) + account = account.with_indifferent_access + account_id = first_present(account, :AccountID, :account_id, :id) + account_name = first_present(account, :AccountName, :account_name, :name) + account_number = first_present(account, :AccountNumber, :account_number) + currency = first_present(account, :BalanceCurrency, :balance_currency, :Currency, :currency).presence || "USD" + + { + id: account_id, + account_id: account_id, + account_name: account_name, + name: account_name, + account_type: first_present(account, :AccountType, :account_type, :type).presence || "unknown", + sub_type: first_present(account, :AccountSubType, :account_sub_type, :SubType, :sub_type).presence || "unknown", + balance: first_present(account, :AccountBalance, :account_balance, :Balance, :balance), + balance_currency: currency, + currency: currency, + account_number_mask: mask_account_number(account_number), + status: first_present(account, :AccountStatus, :account_status, :Status, :status).presence || "active", + user_institution_id: user_institution_id, + institution_name: first_present(account, :InstitutionName, :institution_name), + raw_payload: account.to_h + }.with_indifferent_access + end + + def normalize_transaction(transaction, account_id) + transaction = transaction.with_indifferent_access + + { + id: first_present(transaction, :TransactionID, :TransactionId, :transaction_id, :transactionId, :ID, :id), + accountId: account_id, + type: first_present(transaction, :Type, :type).presence || "unknown", + status: first_present(transaction, :Status, :status).presence || "completed", + amount: first_present(transaction, :Amount, :amount).presence || 0, + currency: first_present(transaction, :Currency, :currency).presence || "USD", + date: first_present(transaction, :TransactionDate, :transaction_date, :Date, :date), + merchant: first_present(transaction, :Merchant, :merchant).presence || extract_merchant(first_present(transaction, :Description, :description)).presence || "", + description: first_present(transaction, :Description, :description).presence || "" + }.with_indifferent_access + end + + def first_present(hash, *keys) + keys.each do |key| + value = hash[key] + return value if value.present? + end + + nil + end + + def mask_account_number(account_number) + return nil if account_number.blank? + + last_four = account_number.to_s.gsub(/\s+/, "").last(4) + last_four.present? ? "****#{last_four}" : nil + end + + def extract_merchant(line) + return nil if line.nil? + + line = line.to_s.strip + return nil if line.empty? + + if line =~ /INSUFFICIENT FUNDS FEE/i + "Bank Fee: Insufficient Funds" + elsif line =~ /OVERDRAFT PROTECTION/i + "Bank Transfer: Overdraft Protection" + elsif line =~ /AUTO PAY WF HOME MTG/i + "Wells Fargo Home Mortgage" + elsif line =~ /PAYDAY LOAN/i + "Payday Loan" + elsif line =~ /CHECKCARD \d{4}\s+(.+?)(?=\s{2,}|x{3,}|\s+\S+\s+[A-Z]{2}\b)/i + Regexp.last_match(1).strip + elsif line =~ /^(.+?)(?=\s+\d{2}\/\d{2}|\s+#)/ + Regexp.last_match(1).strip.gsub(/\s+POS$/i, "").strip + else + line[0..25].strip + end + end +end diff --git a/app/models/provider/sophtron_adapter.rb b/app/models/provider/sophtron_adapter.rb new file mode 100644 index 000000000..144bdd482 --- /dev/null +++ b/app/models/provider/sophtron_adapter.rb @@ -0,0 +1,114 @@ +class Provider::SophtronAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("SophtronAccount", self) + + # Define which account types this provider supports + def self.supported_account_types + %w[Depository CreditCard Loan Investment] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_sophtron? + + [ { + key: "sophtron", + name: "Sophtron", + description: "Connect to your bank via Sophtron's secure API aggregation service.", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_sophtron_items_path( + accountable_type: accountable_type, + return_to: return_to, + connect_new_institution: true + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_sophtron_items_path( + account_id: account_id + ) + } + } ] + end + + def provider_name + "sophtron" + end + + # Build a Sophtron provider instance with family-specific credentials + # Sophtron is now fully per-family - no global credentials supported + # @param family [Family] The family to get credentials for (required) + # @return [Provider::Sophtron, nil] Returns nil if User ID and Access key is not configured + def self.build_provider(family: nil) + return nil unless family.present? + + # Get family-specific credentials + sophtron_item = family.configured_sophtron_item + return nil unless sophtron_item&.credentials_configured? + + Provider::Sophtron.new( + sophtron_item.user_id, + sophtron_item.access_key, + base_url: sophtron_item.effective_base_url + ) + end + + def sync_path + Rails.application.routes.url_helpers.sync_sophtron_item_path(item) + end + + def item + provider_account.sophtron_item + end + + def can_delete_holdings? + false + end + + def institution_domain + # Sophtron may provide institution metadata in account data + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + domain = metadata["domain"] + url = metadata["url"] + + # Derive domain from URL if missing + if domain.blank? && url.present? + begin + domain = URI.parse(url).host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for Sophtron account #{provider_account.id}: #{url}") + end + end + + domain + end + + def institution_name + metadata = provider_account.institution_metadata || {} + return nil unless metadata.present? + + metadata_name = metadata["name"].presence || metadata["institution_name"].presence + return metadata_name if metadata_name.present? + + metadata_user_institution_id = metadata["user_institution_id"].presence || metadata["UserInstitutionID"].presence + return item&.institution_name if metadata_user_institution_id.present? && metadata_user_institution_id == item&.user_institution_id + + nil + end + + def institution_url + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["url"] || item&.institution_url + end + + def institution_color + item&.institution_color + end +end diff --git a/app/models/provider/tiingo.rb b/app/models/provider/tiingo.rb new file mode 100644 index 000000000..97c998e96 --- /dev/null +++ b/app/models/provider/tiingo.rb @@ -0,0 +1,294 @@ +class Provider::Tiingo < Provider + include SecurityConcept, RateLimitable + extend SslConfigurable + + # Subclass so errors caught in this provider are raised as Provider::Tiingo::Error + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + # Minimum delay between requests to avoid rate limiting (in seconds) + MIN_REQUEST_INTERVAL = 1.5 + + # Maximum unique symbols per month (Tiingo free tier limit) + MAX_SYMBOLS_PER_MONTH = 500 + + # Maximum requests per hour + MAX_REQUESTS_PER_HOUR = 1000 + + # Tiingo exchange names to MIC codes + TIINGO_EXCHANGE_TO_MIC = { + "NASDAQ" => "XNAS", + "NYSE" => "XNYS", + "NYSE ARCA" => "XARC", + "NYSE MKT" => "XASE", + "BATS" => "BATS", + "LSE" => "XLON", + "SHE" => "XSHE", + "SHG" => "XSHG", + "OTCMKTS" => "XOTC", + "OTCD" => "XOTC", + "PINK" => "XOTC" + }.freeze + + # Tiingo asset types to normalized kinds + TIINGO_ASSET_TYPE_MAP = { + "Stock" => "common stock", + "ETF" => "etf", + "Mutual Fund" => "mutual fund" + }.freeze + + def initialize(api_key) + @api_key = api_key # pipelock:ignore + end + + def healthy? + with_provider_response do + response = client.get("#{base_url}/tiingo/daily/AAPL") + parsed = JSON.parse(response.body) + parsed.dig("ticker").present? + end + end + + def usage + with_provider_response do + count_key = "tiingo:symbol_count:#{Date.current.strftime('%Y-%m')}" + symbols_used = Rails.cache.read(count_key).to_i + + UsageData.new( + used: symbols_used, + limit: MAX_SYMBOLS_PER_MONTH, + utilization: (symbols_used.to_f / MAX_SYMBOLS_PER_MONTH * 100).round(1), + plan: "Free" + ) + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + throttle_request + + response = client.get("#{base_url}/tiingo/utilities/search") do |req| + req.params["query"] = symbol + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + unless parsed.is_a?(Array) + raise Error, "Unexpected response format from search endpoint" + end + + parsed.first(25).map do |security| + ticker = security["ticker"] + currency = security["priceCurrency"] + + # Cache the API-returned currency so fetch_security_prices can use it + # without making a second search request + if currency.present? && ticker.present? + Rails.cache.write("tiingo:currency:#{ticker.upcase}", currency, expires_in: 24.hours) + end + + Security.new( + symbol: ticker, + name: security["name"], + logo_url: nil, + exchange_operating_mic: map_exchange_to_mic(security["exchange"]), + country_code: security["countryCode"].presence || country_code, + currency: currency + ) + end + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + throttle_request + track_symbol(symbol) + + response = client.get("#{base_url}/tiingo/daily/#{CGI.escape(symbol)}") + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + # The daily metadata endpoint returns exchangeCode (e.g., "NYSE ARCA", "OTCD") + resolved_mic = exchange_operating_mic.presence || map_exchange_to_mic(parsed["exchangeCode"]) + + SecurityInfo.new( + symbol: parsed["ticker"] || symbol, + name: parsed["name"], + links: nil, + logo_url: nil, + description: parsed["description"], + kind: nil, + exchange_operating_mic: resolved_mic + ) + end + end + + def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) + with_provider_response do + historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) + + raise historical_data.error if historical_data.error.present? + raise InvalidSecurityPriceError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.blank? + + historical_data.data.first + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) + with_provider_response do + throttle_request + track_symbol(symbol) + + response = client.get("#{base_url}/tiingo/daily/#{CGI.escape(symbol)}/prices") do |req| + req.params["startDate"] = start_date.to_s + req.params["endDate"] = end_date.to_s + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + unless parsed.is_a?(Array) + error_message = parsed.is_a?(Hash) ? (parsed["detail"] || "Unexpected response format") : "Unexpected response format" + raise InvalidSecurityPriceError, "API error: #{error_message}" + end + + # Prefer cached currency from search results to avoid a second API call + cache_key = "tiingo:currency:#{symbol.upcase}" + currency = Rails.cache.read(cache_key) || fetch_currency_for_symbol(symbol) + + parsed.map do |resp| + price = resp["close"] + date = resp["date"] + + if price.nil? || price.to_f <= 0 + Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}") + next + end + + Price.new( + symbol: symbol, + date: Date.parse(date), + price: price, + currency: currency, + exchange_operating_mic: exchange_operating_mic + ) + end.compact + end + end + + private + attr_reader :api_key + + def base_url + ENV["TIINGO_URL"] || "https://api.tiingo.com" + end + + def client + @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| + faraday.request(:retry, { + max: 3, + interval: 1.0, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] + }) + + faraday.request :json + faraday.response :raise_error + faraday.headers["Authorization"] = "Token #{api_key}" + faraday.headers["Content-Type"] = "application/json" + end + end + + # Adds hourly request counter on top of the interval throttle from RateLimitable. + def throttle_request + super + + # Global per-hour request counter via cache (Redis). + # Atomic increment-then-check avoids the TOCTOU of read-check-increment. + hour_key = "tiingo:requests:#{Time.current.to_i / 3600}" + new_count = Rails.cache.increment(hour_key, 1, expires_in: 7200.seconds).to_i + + if new_count >= max_requests_per_hour + raise RateLimitError, "Tiingo hourly request limit reached (#{new_count}/#{max_requests_per_hour})" + end + end + + # Tracks unique symbols queried per month to stay within Tiingo's 500 symbols/month limit. + # Uses atomic set-if-absent (Redis SETNX) to eliminate the read-then-write race + # where two concurrent workers could both see the symbol as untracked and both + # increment the counter. + def track_symbol(symbol) + symbol_key = "tiingo:symbol:#{Date.current.strftime('%Y-%m')}:#{symbol.upcase}" + count_key = "tiingo:symbol_count:#{Date.current.strftime('%Y-%m')}" + + # Atomic write-if-absent: returns false when the key already exists (Redis SETNX). + # Only the first worker to claim this symbol will proceed to increment the counter. + return unless Rails.cache.write(symbol_key, true, expires_in: 35.days, unless_exist: true) + + new_count = Rails.cache.increment(count_key, 1, expires_in: 35.days).to_i + + if new_count >= MAX_SYMBOLS_PER_MONTH + Rails.cache.decrement(count_key, 1) + Rails.cache.delete(symbol_key) + raise RateLimitError, "Tiingo unique symbol limit reached (#{MAX_SYMBOLS_PER_MONTH} per month)" + end + end + + # min_request_interval provided by RateLimitable + + def max_requests_per_hour + ENV.fetch("TIINGO_MAX_REQUESTS_PER_HOUR", MAX_REQUESTS_PER_HOUR).to_i + end + + # Fetches the price currency for a symbol via the search endpoint. + # Only called as a fallback when the cache (populated by search_securities) + # doesn't have the currency. Raises on failure to avoid silently mislabeling + # non-USD instruments as USD. + def fetch_currency_for_symbol(symbol) + throttle_request + + response = client.get("#{base_url}/tiingo/utilities/search") do |req| + req.params["query"] = symbol + end + + parsed = JSON.parse(response.body) + check_api_error!(parsed) + + if parsed.is_a?(Array) + match = parsed.find { |s| s["ticker"]&.upcase == symbol.upcase } + currency = match&.dig("priceCurrency") + + if currency.present? + Rails.cache.write("tiingo:currency:#{symbol.upcase}", currency, expires_in: 24.hours) + return currency + end + end + + raise Error, "Could not determine currency for #{symbol} from Tiingo search" + end + + def map_exchange_to_mic(exchange_name) + return nil if exchange_name.blank? + TIINGO_EXCHANGE_TO_MIC[exchange_name.strip] || exchange_name.strip + end + + def check_api_error!(parsed) + return unless parsed.is_a?(Hash) && parsed["detail"].present? + + detail = parsed["detail"] + + if detail.downcase.include?("rate limit") || detail.downcase.include?("too many") + raise RateLimitError, detail + end + + raise Error, "API error: #{detail}" + end +end diff --git a/app/models/provider/twelve_data.rb b/app/models/provider/twelve_data.rb index d360e5189..ba332dfe5 100644 --- a/app/models/provider/twelve_data.rb +++ b/app/models/provider/twelve_data.rb @@ -6,6 +6,10 @@ class Provider::TwelveData < Provider Error = Class.new(Provider::Error) InvalidExchangeRateError = Class.new(Error) InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + + # Minimum delay between requests to avoid rate limiting (in seconds) + MIN_REQUEST_INTERVAL = 1.0 # Pattern to detect plan upgrade errors in API responses PLAN_UPGRADE_PATTERN = /available starting with (\w+)/i @@ -59,20 +63,23 @@ class Provider::TwelveData < Provider def fetch_exchange_rate(from:, to:, date:) with_provider_response do + throttle_request response = client.get("#{base_url}/exchange_rate") do |req| req.params["symbol"] = "#{from}/#{to}" req.params["date"] = date.to_s end - rate = JSON.parse(response.body).dig("rate") + parsed = JSON.parse(response.body) + check_api_error!(parsed) - Rate.new(date: date.to_date, from:, to:, rate: rate) + Rate.new(date: date.to_date, from:, to:, rate: parsed.dig("rate")) end end def fetch_exchange_rates(from:, to:, start_date:, end_date:) with_provider_response do # Try to fetch the currency pair via the time_series API (consumes 1 credit) - this might not return anything as the API does not provide time series data for all possible currency pairs + throttle_request response = client.get("#{base_url}/time_series") do |req| req.params["symbol"] = "#{from}/#{to}" req.params["start_date"] = start_date.to_s @@ -81,11 +88,13 @@ class Provider::TwelveData < Provider end parsed = JSON.parse(response.body) + check_api_error!(parsed) data = parsed.dig("values") # If currency pair is not available, try to fetch via the time_series/cross API (consumes 5 credits) if data.nil? Rails.logger.info("#{self.class.name}: Currency pair #{from}/#{to} not available, fetching via time_series/cross API") + throttle_request(credits: 5) response = client.get("#{base_url}/time_series/cross") do |req| req.params["base"] = from req.params["quote"] = to @@ -95,6 +104,7 @@ class Provider::TwelveData < Provider end parsed = JSON.parse(response.body) + check_api_error!(parsed) data = parsed.dig("values") end @@ -123,12 +133,14 @@ class Provider::TwelveData < Provider def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) with_provider_response do + throttle_request response = client.get("#{base_url}/symbol_search") do |req| req.params["symbol"] = symbol req.params["outputsize"] = 25 end parsed = JSON.parse(response.body) + check_api_error!(parsed) data = parsed.dig("data") if data.nil? @@ -137,7 +149,7 @@ class Provider::TwelveData < Provider raise Error, "API error (code: #{error_code}): #{error_message}" end - data.map do |security| + data.reject { |row| crypto_row?(row) }.map do |security| country = ISO3166::Country.find_country_by_any_name(security.dig("country")) Security.new( @@ -145,7 +157,8 @@ class Provider::TwelveData < Provider name: security.dig("instrument_name"), logo_url: nil, exchange_operating_mic: security.dig("mic_code"), - country_code: country ? country.alpha2 : nil + country_code: country ? country.alpha2 : nil, + currency: security.dig("currency") ) end end @@ -153,19 +166,23 @@ class Provider::TwelveData < Provider def fetch_security_info(symbol:, exchange_operating_mic:) with_provider_response do + throttle_request response = client.get("#{base_url}/profile") do |req| req.params["symbol"] = symbol req.params["mic_code"] = exchange_operating_mic end profile = JSON.parse(response.body) + check_api_error!(profile) + throttle_request response = client.get("#{base_url}/logo") do |req| req.params["symbol"] = symbol req.params["mic_code"] = exchange_operating_mic end logo = JSON.parse(response.body) + check_api_error!(logo) SecurityInfo.new( symbol: symbol, @@ -183,7 +200,8 @@ class Provider::TwelveData < Provider with_provider_response do historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) - raise ProviderError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.empty? + raise historical_data.error if historical_data.error.present? + raise InvalidSecurityPriceError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.blank? historical_data.data.first end @@ -191,6 +209,7 @@ class Provider::TwelveData < Provider def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) with_provider_response do + throttle_request response = client.get("#{base_url}/time_series") do |req| req.params["symbol"] = symbol req.params["mic_code"] = exchange_operating_mic @@ -200,6 +219,7 @@ class Provider::TwelveData < Provider end parsed = JSON.parse(response.body) + check_api_error!(parsed) values = parsed.dig("values") if values.nil? @@ -230,6 +250,16 @@ class Provider::TwelveData < Provider private attr_reader :api_key + # TwelveData tags crypto symbols with `instrument_type: "Digital Currency"` and + # `mic_code: "DIGITAL_CURRENCY"`, and returns an empty `currency` field for them. + # We exclude them so crypto is handled exclusively by Provider::BinancePublic — + # TD's empty currency would otherwise cascade into Security::Price rows defaulting + # to USD, silently mispricing EUR/GBP crypto holdings. + def crypto_row?(row) + row["instrument_type"].to_s.casecmp?("Digital Currency") || + row["mic_code"].to_s.casecmp?("DIGITAL_CURRENCY") + end + def base_url ENV["TWELVE_DATA_URL"] || "https://api.twelvedata.com" end @@ -237,10 +267,11 @@ class Provider::TwelveData < Provider def client @client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday| faraday.request(:retry, { - max: 2, - interval: 0.05, + max: 3, + interval: 1.0, interval_randomness: 0.5, - backoff_factor: 2 + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ] }) faraday.request :json @@ -248,4 +279,69 @@ class Provider::TwelveData < Provider faraday.headers["Authorization"] = "apikey #{api_key}" end end + + # Paces API requests to stay within TwelveData's rate limits. Sleeps inline + # because the API physically cannot be called faster — this is unavoidable + # with a rate-limited provider. The 5-minute cache lock TTL in + # ExchangeRate::Provided accounts for worst-case throttle waits. + def throttle_request(credits: 1) + # Layer 1: Per-instance minimum interval between calls + @last_request_time ||= Time.at(0) + elapsed = Time.current - @last_request_time + sleep_time = min_request_interval - elapsed + sleep(sleep_time) if sleep_time > 0 + + # Layer 2: Global per-minute credit counter via cache (Redis in prod). + # Read current usage first — if adding these credits would exceed the limit, + # wait for the next minute BEFORE incrementing. This ensures credits are + # charged to the minute the request actually fires in, not a stale minute + # we slept through (which would undercount the new minute's usage). + minute_key = "twelve_data:credits:#{Time.current.to_i / 60}" + current_count = Rails.cache.read(minute_key).to_i + + if current_count + credits > max_requests_per_minute + wait_seconds = 60 - (Time.current.to_i % 60) + 1 + Rails.logger.info("TwelveData: #{current_count + credits}/#{max_requests_per_minute} credits this minute, waiting #{wait_seconds}s") + sleep(wait_seconds) + end + + # Charge credits to the minute the request actually fires in + active_minute_key = "twelve_data:credits:#{Time.current.to_i / 60}" + Rails.cache.increment(active_minute_key, credits, expires_in: 120.seconds) + + # Set timestamp after all waits so the next call's 1s pacing is measured + # from when this request actually fires, not from before the minute wait. + @last_request_time = Time.current + end + + def min_request_interval + ENV.fetch("TWELVE_DATA_MIN_REQUEST_INTERVAL", MIN_REQUEST_INTERVAL).to_f + end + + def max_requests_per_minute + ENV.fetch("TWELVE_DATA_MAX_REQUESTS_PER_MINUTE", 7).to_i + end + + def check_api_error!(parsed) + return unless parsed.is_a?(Hash) && parsed["code"].present? + + if parsed["code"] == 429 + raise RateLimitError, parsed["message"] || "Rate limit exceeded" + end + + raise Error, "API error (code: #{parsed["code"]}): #{parsed["message"] || "Unknown error"}" + end + + def default_error_transformer(error) + case error + when RateLimitError + error + when Faraday::TooManyRequestsError + RateLimitError.new("TwelveData rate limit exceeded", details: error.response&.dig(:body)) + when Faraday::Error + self.class::Error.new(error.message, details: error.response&.dig(:body)) + else + self.class::Error.new(error.message) + end + end end diff --git a/app/models/provider/yahoo_finance.rb b/app/models/provider/yahoo_finance.rb index d4a992184..36a983f93 100644 --- a/app/models/provider/yahoo_finance.rb +++ b/app/models/provider/yahoo_finance.rb @@ -1,3 +1,5 @@ +require "set" + class Provider::YahooFinance < Provider include ExchangeRateConcept, SecurityConcept extend SslConfigurable @@ -20,6 +22,10 @@ class Provider::YahooFinance < Provider # Maximum lookback window for historical data (configurable) MAX_LOOKBACK_WINDOW = 10.years + def max_history_days + (MAX_LOOKBACK_WINDOW / 1.day).to_i + end + # Minimum delay between requests to avoid rate limiting (in seconds) MIN_REQUEST_INTERVAL = 0.5 @@ -161,6 +167,8 @@ class Provider::YahooFinance < Provider ) end + securities = deduplicate_dual_listings(securities) unless exchange_operating_mic.present? + cache_result(cache_key, securities) securities end @@ -171,6 +179,8 @@ class Provider::YahooFinance < Provider def fetch_security_info(symbol:, exchange_operating_mic:) with_provider_response do + symbol = normalize_symbol(symbol, exchange_operating_mic) + # quoteSummary endpoint requires cookie/crumb authentication throttle_request cookie, crumb = fetch_cookie_and_crumb @@ -223,6 +233,7 @@ class Provider::YahooFinance < Provider def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) with_provider_response do + symbol = normalize_symbol(symbol, exchange_operating_mic) cache_key = "security_price_#{symbol}_#{exchange_operating_mic}_#{date}" if cached_result = get_cached_result(cache_key) cached_result @@ -259,6 +270,7 @@ class Provider::YahooFinance < Provider def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) with_provider_response do + symbol = normalize_symbol(symbol, exchange_operating_mic) validate_date_params!(start_date, end_date) # Convert dates to Unix timestamps using UTC to ensure consistent epoch boundaries across timezones period1 = start_date.to_time.utc.to_i @@ -281,7 +293,9 @@ class Provider::YahooFinance < Provider closes = quotes["close"] || [] # Get currency from metadata - raw_currency = chart_data.dig("meta", "currency") || "USD" + meta_exchange = chart_data.dig("meta", "exchangeName") || "" + raw_currency = chart_data.dig("meta", "currency") + raw_currency ||= default_currency_for_exchange(meta_exchange) || "USD" prices = [] timestamps.each_with_index do |timestamp, index| @@ -317,6 +331,15 @@ class Provider::YahooFinance < Provider # Currency Normalization # ================================ + # Per-exchange configuration for Yahoo Finance. Each entry maps an ISO + # MIC code to its Yahoo-specific symbol suffix, the default currency when + # Yahoo omits one, and an optional dual-listing group with a preference + # rank (lower = preferred). Adding a new market is a one-line hash entry. + EXCHANGE_CONFIG = { + "XNSE" => { yahoo_suffix: ".NS", default_currency: "INR", dual_list_group: :india, preference_rank: 0 }, + "XBOM" => { yahoo_suffix: ".BO", default_currency: "INR", dual_list_group: :india, preference_rank: 1 } + }.freeze + # Yahoo Finance sometimes returns currencies in minor units (pence, cents) # This is not part of ISO 4217 but is a convention used by financial data providers # Mapping of Yahoo Finance minor unit codes to standard currency codes and conversion multipliers @@ -336,6 +359,42 @@ class Provider::YahooFinance < Provider end end + # Appends the Yahoo Finance symbol suffix for exchanges that require one + # (e.g. XNSE → ".NS", XBOM → ".BO"). Already-suffixed symbols pass through. + def normalize_symbol(symbol, exchange_operating_mic) + suffix = EXCHANGE_CONFIG.dig(exchange_operating_mic, :yahoo_suffix) + return symbol if suffix.nil? || symbol.end_with?(suffix) + "#{symbol}#{suffix}" + end + + # Returns the default currency for a Yahoo exchange name (e.g. "NSE" → "INR") + # by resolving through map_exchange_mic → EXCHANGE_CONFIG. Returns nil for + # unknown exchanges so callers can fall back to their own default. + def default_currency_for_exchange(yahoo_exchange_name) + mic = map_exchange_mic(yahoo_exchange_name) + EXCHANGE_CONFIG.dig(mic, :default_currency) + end + + # De-duplicates dual-listed securities that share the same company name + # and dual_list_group (e.g. NSE + BSE for India), keeping the exchange + # with the lowest preference_rank. Preserves Yahoo's original relevance + # ordering by removing duplicates in-place rather than reordering. + def deduplicate_dual_listings(securities) + dominated = Set.new + + securities + .select { |s| EXCHANGE_CONFIG.dig(s.exchange_operating_mic, :dual_list_group) } + .group_by { |s| [ EXCHANGE_CONFIG[s.exchange_operating_mic][:dual_list_group], s.name.to_s.strip.downcase ] } + .each_value do |group| + next unless group.size > 1 + preferred = group.min_by { |s| EXCHANGE_CONFIG[s.exchange_operating_mic][:preference_rank] } + group.each { |s| dominated << s.object_id unless s.equal?(preferred) } + end + + return securities if dominated.empty? + securities.reject { |s| dominated.include?(s.object_id) } + end + # ================================ # Validation # ================================ @@ -438,7 +497,7 @@ class Provider::YahooFinance < Provider date: Time.at(timestamp).utc.to_date, from: from, to: to, - rate: (1.0 / close_rate.to_f).round(8) + rate: (BigDecimal("1") / BigDecimal(close_rate.to_s)).round(12) ) end @@ -772,6 +831,10 @@ class Provider::YahooFinance < Provider "XJPX" # Japan Exchange Group when "ASX" "XASX" # Australian Securities Exchange + when "NSE", "NSI" + "XNSE" # National Stock Exchange of India + when "BSE", "BOM" + "XBOM" # BSE (Bombay Stock Exchange) else exchange_code.upcase end diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb new file mode 100644 index 000000000..0563d6f77 --- /dev/null +++ b/app/models/provider_connection_status.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +class ProviderConnectionStatus + PROVIDERS = [ + { key: "plaid", type: "PlaidItem", association: :plaid_items, accounts: :plaid_accounts }, + { key: "simplefin", type: "SimplefinItem", association: :simplefin_items, accounts: :simplefin_accounts }, + { key: "lunchflow", type: "LunchflowItem", association: :lunchflow_items, accounts: :lunchflow_accounts }, + { key: "enable_banking", type: "EnableBankingItem", association: :enable_banking_items, accounts: :enable_banking_accounts }, + { key: "coinbase", type: "CoinbaseItem", association: :coinbase_items, accounts: :coinbase_accounts }, + { key: "binance", type: "BinanceItem", association: :binance_items, accounts: :binance_accounts }, + { key: "kraken", type: "KrakenItem", association: :kraken_items, accounts: :kraken_accounts }, + { key: "coinstats", type: "CoinstatsItem", association: :coinstats_items, accounts: :coinstats_accounts }, + { key: "snaptrade", type: "SnaptradeItem", association: :snaptrade_items, accounts: :snaptrade_accounts, linked_accounts: :linked_accounts }, + { key: "ibkr", type: "IbkrItem", association: :ibkr_items, accounts: :ibkr_accounts }, + { key: "mercury", type: "MercuryItem", association: :mercury_items, accounts: :mercury_accounts }, + { key: "brex", type: "BrexItem", association: :brex_items, accounts: :brex_accounts }, + { key: "sophtron", type: "SophtronItem", association: :sophtron_items, accounts: :sophtron_accounts }, + { key: "indexa_capital", type: "IndexaCapitalItem", association: :indexa_capital_items, accounts: :indexa_capital_accounts } + ].freeze + + class << self + def for_family(family) + PROVIDERS.flat_map do |provider| + relation = family.public_send(provider[:association]) + items = relation.includes(association_includes_for(relation, provider)).ordered.to_a + sync_contexts = sync_contexts_for(provider[:type], items) + + items.map do |item| + new(provider, item, sync_contexts.fetch(item.id, {})).to_h + end + end + end + + private + + def association_includes_for(relation, provider) + includes = [ { provider[:accounts] => :account_provider } ] + includes << provider[:linked_accounts] if provider[:linked_accounts] + includes << :accounts if relation.klass.reflect_on_association(:accounts) + includes + end + + def sync_contexts_for(provider_type, items) + item_ids = items.map(&:id) + return {} if item_ids.empty? + + latest_syncs = latest_syncs_for(provider_type, item_ids) + latest_completed_syncs = latest_syncs_for(provider_type, item_ids, scope: Sync.completed) + syncing_item_ids = Sync.visible + .where(syncable_type: provider_type, syncable_id: item_ids) + .distinct + .pluck(:syncable_id) + + item_ids.index_with do |item_id| + { + latest_sync: latest_syncs[item_id], + latest_completed_sync: latest_completed_syncs[item_id], + syncing: syncing_item_ids.include?(item_id) + } + end + end + + def latest_syncs_for(provider_type, item_ids, scope: Sync.all) + ranked_syncs = scope.where(syncable_type: provider_type, syncable_id: item_ids) + .select( + "syncs.*, " \ + "ROW_NUMBER() OVER (PARTITION BY syncable_id ORDER BY created_at DESC, id DESC) AS sync_rank" + ) + + Sync.from(ranked_syncs, :syncs).where("sync_rank = 1").index_by(&:syncable_id) + end + end + + def initialize(provider, item, sync_context = {}) + @provider = provider + @item = item + @sync_context = sync_context + end + + def to_h + { + id: item.id, + provider: provider[:key], + provider_type: provider[:type], + name: item_value(:name, provider[:key].humanize), + status: item_value(:status), + requires_update: item_boolean(:requires_update?), + credentials_configured: credentials_configured?, + scheduled_for_deletion: item_boolean(:scheduled_for_deletion?), + pending_account_setup: pending_account_setup?, + institution: institution_payload, + accounts: accounts_payload, + sync: sync_payload, + created_at: item.created_at, + updated_at: item.updated_at + } + end + + private + + attr_reader :provider, :item, :sync_context + + def credentials_configured? + item_boolean(:credentials_configured?) + end + + def pending_account_setup? + item_boolean(:pending_account_setup?) + end + + def institution_payload + { + name: item_value(:institution_display_name, item_value(:name, provider[:key].humanize)), + domain: item_value(:institution_domain), + url: item_value(:institution_url) + } + end + + def accounts_payload + @accounts_payload ||= begin + total = provider_account_count + linked = linked_account_count + + { + total_count: total, + linked_count: linked, + unlinked_count: [ total - linked, 0 ].max + } + end + end + + def provider_account_count + records = provider_account_records + return records.size if records + return item.total_accounts_count if item.respond_to?(:total_accounts_count) + + 0 + end + + def linked_account_count + records = provider_account_records + return records.count { |provider_account| linked_provider_account?(provider_account) } if records + return item.linked_accounts_count if item.respond_to?(:linked_accounts_count) + + if provider[:linked_accounts] && item.respond_to?(provider[:linked_accounts]) + return item.public_send(provider[:linked_accounts]).size + end + + return item.accounts.size if item.respond_to?(:accounts) + + 0 + end + + def provider_account_records + return unless item.respond_to?(provider[:accounts]) + + @provider_account_records ||= item.public_send(provider[:accounts]).to_a + end + + def linked_provider_account?(provider_account) + return false unless provider_account.respond_to?(:account_provider) + + association = provider_account.association(:account_provider) + association.loaded? ? association.target.present? : provider_account.account_provider.present? + end + + def sync_payload + { + syncing: syncing?, + status_summary: sync_status_summary, + last_synced_at: latest_completed_sync&.completed_at, + latest: latest_sync_payload(latest_sync) + } + end + + def sync_status_summary + stats = latest_completed_sync_stats + counts = accounts_payload + total = stats.fetch("total_accounts", counts[:total_count]).to_i + linked = stats.fetch("linked_accounts", counts[:linked_count]).to_i + unlinked = stats.fetch("unlinked_accounts", [ total - linked, 0 ].max).to_i + + if total.zero? + "No accounts found" + elsif unlinked.zero? + "#{linked} #{'account'.pluralize(linked)} synced" + else + "#{linked} synced, #{unlinked} need setup" + end + end + + def syncing? + return sync_context[:syncing] if sync_context.key?(:syncing) + + item_boolean(:syncing?) + end + + def latest_sync + sync_context[:latest_sync] + end + + def latest_completed_sync + sync_context[:latest_completed_sync] + end + + def latest_completed_sync_stats + stats = latest_completed_sync&.sync_stats + return stats.stringify_keys if stats.is_a?(Hash) + return {} unless stats.is_a?(String) + + parsed = JSON.parse(stats) + parsed.is_a?(Hash) ? parsed.stringify_keys : {} + rescue JSON::ParserError + {} + end + + def latest_sync_payload(sync) + return unless sync + + { + id: sync.id, + status: sync.status, + created_at: sync.created_at, + syncing_at: sync.syncing_at, + completed_at: sync.completed_at, + failed_at: sync.failed_at, + error: sync_error_payload(sync) + } + end + + def sync_error_payload(sync) + return unless sync.failed? || sync.stale? + + # Provider health treats stale connections as actionable even when the + # generic sync API suppresses stale-without-error payloads. + { + present: true, + message: sync.stale? ? "Sync became stale before completion" : "Sync failed" + } + end + + def item_boolean(method_name) + item_value(method_name, false) == true + end + + def item_value(method_name, default = nil) + return default unless item.respond_to?(method_name) + + item.public_send(method_name) + end +end diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 110a2535c..5cfb2fdf9 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -1,5 +1,5 @@ class ProviderMerchant < Merchant - enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/models/qif_import.rb b/app/models/qif_import.rb index 90a867d28..a0590cb21 100644 --- a/app/models/qif_import.rb +++ b/app/models/qif_import.rb @@ -73,6 +73,10 @@ class QifImport < Import account.present? && super end + def publishable_from_validation_stats?(invalid_rows_count:) + account.present? && super + end + # Returns true if import! will move the opening anchor back to cover transactions # that predate the current anchor date. Used to show a notice in the confirm step. def will_adjust_opening_anchor? @@ -162,8 +166,9 @@ class QifImport < Import def generate_transaction_rows transactions = QifParser.parse(raw_file_str, date_format: qif_date_format) - mapped_rows = transactions.map do |trn| + mapped_rows = transactions.map.with_index(1) do |trn, index| { + source_row_number: index, date: trn.date.to_s, amount: trn.amount.to_s, currency: default_currency.to_s, @@ -189,11 +194,12 @@ class QifImport < Import def generate_investment_rows inv_transactions = QifParser.parse_investment_transactions(raw_file_str, date_format: qif_date_format) - mapped_rows = inv_transactions.map do |trn| + mapped_rows = inv_transactions.map.with_index(1) do |trn, index| if QifParser::TRADE_ACTIONS.include?(trn.action) qty = trade_qty_for(trn.action, trn.qty) { + source_row_number: index, date: trn.date.to_s, ticker: trn.security_ticker.to_s, qty: qty.to_s, @@ -210,6 +216,7 @@ class QifImport < Import } else { + source_row_number: index, date: trn.date.to_s, amount: trn.amount.to_s, currency: default_currency.to_s, diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb index eb2784ef5..090d31549 100644 --- a/app/models/recurring_transaction.rb +++ b/app/models/recurring_transaction.rb @@ -3,6 +3,7 @@ class RecurringTransaction < ApplicationRecord belongs_to :family belongs_to :account, optional: true + belongs_to :destination_account, optional: true, class_name: "Account" belongs_to :merchant, optional: true monetize :amount @@ -15,12 +16,15 @@ class RecurringTransaction < ApplicationRecord validates :amount, presence: true validates :currency, presence: true validates :expected_day_of_month, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 31 } + validates :status, presence: true, inclusion: { in: statuses.keys } + validates :occurrence_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validate :merchant_or_name_present validate :amount_variance_consistency + validate :transfer_endpoints_consistent def merchant_or_name_present if merchant_id.blank? && name.blank? - errors.add(:base, "Either merchant or name must be present") + errors.add(:base, :merchant_or_name_required) end end @@ -34,10 +38,50 @@ class RecurringTransaction < ApplicationRecord end end + # When this row represents a recurring transfer, both endpoints must be + # present, belong to the same family, and not be the same account. + def transfer_endpoints_consistent + return if destination_account_id.blank? + + if account_id.blank? + errors.add(:account, "must be present on a recurring transfer") + elsif account.blank? + # account_id references a row that was destroyed. Mirror the + # destination_account.blank? branch so the source side surfaces a + # normal validation error too. + errors.add(:account, "must exist") + elsif destination_account.blank? + # destination_account_id references a row that was destroyed (or never + # existed). Surface as a normal validation error instead of letting + # the FK fire on save. + errors.add(:destination_account, "must exist") + elsif account_id == destination_account_id + errors.add(:destination_account, "cannot be the same as the source account") + elsif account.family_id != destination_account.family_id + errors.add(:destination_account, "must belong to the same family as the source account") + end + end + + def transfer? + destination_account_id.present? + end + scope :for_family, ->(family) { where(family: family) } scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) } scope :accessible_by, ->(user) { - where(account_id: Account.accessible_by(user).select(:id)).or(where(account_id: nil)) + accessible_account_ids = Account.accessible_by(user).select(:id) + # A recurring row is accessible when: + # * its account_id is in the user's accessible set or null (legacy rows + # with no account scoping survive), AND + # * its destination_account_id is also accessible OR null (so a recurring + # transfer never leaks into the list of a user without access to BOTH + # endpoints). + where(account_id: accessible_account_ids) + .or(where(account_id: nil)) + .merge( + where(destination_account_id: accessible_account_ids) + .or(where(destination_account_id: nil)) + ) } # Class methods for identification and cleanup @@ -56,6 +100,44 @@ class RecurringTransaction < ApplicationRecord Cleaner.new(family).cleanup_stale_transactions end + # Create a manual recurring transfer from an existing Transfer pair. + # Mirrors `create_from_transaction` but populates source + destination + # accounts and skips merchant / variance lookup -- transfers are + # account-pair-shaped, not merchant-shaped. + def self.create_from_transfer(transfer) + outflow_entry = transfer.outflow_transaction&.entry + inflow_entry = transfer.inflow_transaction&.entry + + raise ArgumentError, "transfer is missing one of its entries" unless outflow_entry && inflow_entry + + source_account = outflow_entry.account + destination_account = inflow_entry.account + family = source_account.family + + expected_day = outflow_entry.date.day + next_expected = calculate_next_expected_date_from_today(expected_day) + + create!( + family: family, + account: source_account, + destination_account: destination_account, + merchant_id: nil, + # Transfer#name yields "Payment to ..." for liability destinations + # and "Transfer to ..." otherwise, matching Transfer::Creator's + # name_prefix logic so the recurring row reads consistently with + # the originating Transfer. + name: transfer.name, + amount: outflow_entry.amount, # positive (outflow), per Sure sign convention + currency: outflow_entry.currency, + expected_day_of_month: expected_day, + last_occurrence_date: outflow_entry.date, + next_expected_date: next_expected, + status: "active", + occurrence_count: 1, + manual: true + ) + end + # Create a manual recurring transaction from an existing transaction # Automatically calculates amount variance from past 6 months of matching transactions def self.create_from_transaction(transaction, date_variance: 2) @@ -311,7 +393,10 @@ class RecurringTransaction < ApplicationRecord amount_min: expected_amount_min, amount_max: expected_amount_max, amount_avg: expected_amount_avg, - has_variance: has_amount_variance? + has_variance: has_amount_variance?, + transfer: transfer?, + source_account: account, + destination_account: destination_account ) end diff --git a/app/models/recurring_transaction/cleaner.rb b/app/models/recurring_transaction/cleaner.rb index 4aecbb343..dd22dcb89 100644 --- a/app/models/recurring_transaction/cleaner.rb +++ b/app/models/recurring_transaction/cleaner.rb @@ -7,11 +7,21 @@ class RecurringTransaction end # Mark recurring transactions as inactive if they haven't occurred recently - # Uses 2 months for automatic recurring, 6 months for manual recurring + # Uses 2 months for automatic recurring, 6 months for manual recurring. + # + # Transfer rows (destination_account_id present) are skipped: their + # `matching_transactions` helper looks at single-account name/amount + # which never matches a Transfer pair, so the Cleaner would + # incorrectly mark a still-recurring transfer inactive at the + # 6-month threshold. Issue #1590 tracks pair-detection-aware + # matching for recurring transfers. def cleanup_stale_transactions stale_count = 0 - family.recurring_transactions.active.find_each do |recurring_transaction| + family.recurring_transactions + .active + .where(destination_account_id: nil) + .find_each do |recurring_transaction| next unless recurring_transaction.should_be_inactive? # Determine threshold based on manual flag diff --git a/app/models/recurring_transaction/identifier.rb b/app/models/recurring_transaction/identifier.rb index 82834c26a..bcb6656d4 100644 --- a/app/models/recurring_transaction/identifier.rb +++ b/app/models/recurring_transaction/identifier.rb @@ -10,14 +10,20 @@ class RecurringTransaction def identify_recurring_patterns three_months_ago = 3.months.ago.to_date - # Get all transactions from the last 3 months + # Skip transfer-kind transactions: they're one half of a Transfer pair, so grouping them + # under their single account would produce incoherent recurring "patterns" that don't + # represent the underlying account-pair flow. Recurring transfers are tracked on a + # different shape (RecurringTransaction with destination_account_id). Filtering at the + # SQL level avoids loading and discarding transfer entries for a busy family. entries_with_transactions = family.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id") .where(entryable_type: "Transaction") .where("entries.date >= ?", three_months_ago) + .where.not("transactions.kind": Transaction::TRANSFER_KINDS) .includes(:entryable) .to_a - # Group by merchant (if present) or name, along with amount (preserve sign) and currency + # Group by merchant (if present) or name, along with amount (preserve sign) and currency. grouped_transactions = entries_with_transactions .select { |entry| entry.entryable.is_a?(Transaction) } .group_by do |entry| @@ -140,9 +146,17 @@ class RecurringTransaction recurring_patterns.size end - # Update variance for existing manual recurring transactions + # Update variance for existing manual recurring transactions. + # + # Transfer rows (destination_account_id present) are skipped: their + # variance / occurrence tracking would need pair-detection across + # both endpoints rather than the single-account name/merchant match + # the helper performs. Issue #1590 tracks the proper Cleaner-aware + # matching for recurring transfers. def update_manual_recurring_transactions(since_date) - family.recurring_transactions.where(manual: true, status: "active").find_each do |recurring| + family.recurring_transactions + .where(manual: true, status: "active", destination_account_id: nil) + .find_each do |recurring| # Find matching transactions in the recent period matching_entries = RecurringTransaction.find_matching_transaction_entries( family: family, diff --git a/app/models/rule.rb b/app/models/rule.rb index b53f80f83..d5b89eef0 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -140,14 +140,14 @@ class Rule < ApplicationRecord return if new_record? && !actions.empty? if actions.reject(&:marked_for_destruction?).empty? - errors.add(:base, "must have at least one action") + errors.add(:base, :min_actions) end end def no_duplicate_actions action_types = actions.reject(&:marked_for_destruction?).map(&:action_type) - errors.add(:base, "Rule cannot have duplicate actions #{action_types.inspect}") if action_types.uniq.count != action_types.count + errors.add(:base, :duplicate_actions, types: action_types.inspect) if action_types.uniq.count != action_types.count end # Validation: To keep rules simple and easy to understand, we don't allow nested compound conditions. @@ -157,7 +157,7 @@ class Rule < ApplicationRecord conditions.each do |condition| if condition.compound? if condition.sub_conditions.any? { |sub_condition| sub_condition.compound? } - errors.add(:base, "Compound conditions cannot be nested") + errors.add(:base, :nested_conditions) end end end diff --git a/app/models/rule_import.rb b/app/models/rule_import.rb index 48ae775fc..31cf4025a 100644 --- a/app/models/rule_import.rb +++ b/app/models/rule_import.rb @@ -52,10 +52,11 @@ class RuleImport < Import def generate_rows_from_csv rows.destroy_all - csv_rows.each do |row| + csv_rows.each.with_index(1) do |row, index| normalized_row = normalize_rule_row(row) rows.create!( + source_row_number: index, name: normalized_row[:name].to_s.strip, resource_type: normalized_row[:resource_type].to_s.strip, active: parse_boolean(normalized_row[:active]), @@ -114,7 +115,7 @@ class RuleImport < Import # Validate resource type unless resource_type == "transaction" - errors.add(:base, "Unsupported resource type: #{resource_type}") + errors.add(:base, :unsupported_resource_type, resource_type: resource_type) raise ActiveRecord::RecordInvalid.new(self) end @@ -123,13 +124,13 @@ class RuleImport < Import conditions_data = parse_json_safely(row.conditions, "conditions") actions_data = parse_json_safely(row.actions, "actions") rescue JSON::ParserError => e - errors.add(:base, "Invalid JSON in conditions or actions: #{e.message}") + errors.add(:base, :invalid_json, message: e.message) raise ActiveRecord::RecordInvalid.new(self) end # Validate we have at least one action if actions_data.empty? - errors.add(:base, "Rule must have at least one action") + errors.add(:base, :min_actions) raise ActiveRecord::RecordInvalid.new(self) end diff --git a/app/models/rule_run.rb b/app/models/rule_run.rb index 8f0ac759f..3028de44a 100644 --- a/app/models/rule_run.rb +++ b/app/models/rule_run.rb @@ -27,6 +27,10 @@ class RuleRun < ApplicationRecord status == "failed" end + def transactions_blocked + [ transactions_processed - transactions_modified, 0 ].max + end + # Thread-safe method to complete a job and update the run def complete_job!(modified_count: 0) with_lock do diff --git a/app/models/security.rb b/app/models/security.rb index 35b0e8bdd..b4268fc5e 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -1,6 +1,9 @@ class Security < ApplicationRecord include Provided, PlanRestrictionTracker + # Transient attribute for search results -- not persisted + attr_accessor :search_currency + # ISO 10383 MIC codes mapped to user-friendly exchange names # Source: https://www.iso20022.org/market-identifier-codes # Data stored in config/exchanges.yml @@ -8,8 +11,25 @@ class Security < ApplicationRecord KINDS = %w[standard cash].freeze + # Known securities provider keys — derived from the registry so adding a new + # provider to Registry#available_providers automatically allows it here. + # Evaluated at runtime (not boot) so runtime-enabled providers are accepted. + def self.valid_price_providers + Provider::Registry.for_concept(:securities).provider_keys.map(&:to_s) + end + + # Builds the Brandfetch crypto URL for a base asset (e.g. "BTC"). Returns + # nil when Brandfetch isn't configured. + def self.brandfetch_crypto_url(base_asset) + return nil if base_asset.blank? + return nil unless Setting.brand_fetch_client_id.present? + size = Setting.brand_fetch_logo_size + "https://cdn.brandfetch.io/crypto/#{base_asset}/icon/fallback/lettermark/w/#{size}/h/#{size}?c=#{Setting.brand_fetch_client_id}" + end + before_validation :upcase_symbols before_save :generate_logo_url_from_brandfetch, if: :should_generate_logo? + before_save :reset_first_provider_price_on_if_provider_changed has_many :trades, dependent: :nullify, class_name: "Trade" has_many :prices, dependent: :destroy @@ -17,10 +37,17 @@ class Security < ApplicationRecord validates :ticker, presence: true validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } validates :kind, inclusion: { in: KINDS } + validates :price_provider, inclusion: { in: ->(_) { Security.valid_price_providers } }, allow_nil: true scope :online, -> { where(offline: false) } scope :standard, -> { where(kind: "standard") } + # Parses the combobox ID format "SYMBOL|EXCHANGE|PROVIDER" into a hash. + def self.parse_combobox_id(value) + parts = value.to_s.split("|", 3) + { ticker: parts[0].presence, exchange_operating_mic: parts[1].presence, price_provider: parts[2].presence } + end + # Lazily finds or creates a synthetic cash security for an account. # Used as fallback when creating an interest Trade without a user-selected security. def self.cash_for(account) @@ -35,6 +62,38 @@ class Security < ApplicationRecord kind == "cash" end + # True when this security represents a crypto asset. Today the only signal + # is the Binance ISO MIC — when we add a second crypto provider, extend + # this check rather than duplicating the test at every call site. + def crypto? + exchange_operating_mic == Provider::BinancePublic::BINANCE_MIC + end + + # Strips the display-currency suffix from a crypto ticker (BTCUSD -> BTC, + # ETHEUR -> ETH). Returns nil for non-crypto securities or when the ticker + # doesn't end in a supported quote. + def crypto_base_asset + return nil unless crypto? + Provider::BinancePublic::QUOTE_TO_CURRENCY.each_value do |suffix| + next unless ticker.end_with?(suffix) + base = ticker.delete_suffix(suffix) + return base unless base.empty? + end + nil + end + + # Single source of truth for which logo URL the UI should render. Crypto + # and stocks share the same shape: prefer a freshly computed Brandfetch + # URL (honors current client_id + size) and fall back to any stored + # logo_url for the provider-returns-its-own-URL case (e.g. Tiingo S3). + def display_logo_url + if crypto? + self.class.brandfetch_crypto_url(crypto_base_asset).presence || logo_url.presence + else + brandfetch_icon_url.presence || logo_url.presence + end + end + # Returns user-friendly exchange name for a MIC code def self.exchange_name_for(mic) return nil if mic.blank? @@ -57,7 +116,9 @@ class Security < ApplicationRecord name: name, logo_url: logo_url, exchange_operating_mic: exchange_operating_mic, - country_code: country_code + country_code: country_code, + price_provider: price_provider, + currency: search_currency ) end @@ -92,8 +153,7 @@ class Security < ApplicationRecord def should_generate_logo? return false if cash? - url = brandfetch_icon_url - return false unless url.present? + return false unless Setting.brand_fetch_client_id.present? return true if logo_url.blank? return false unless logo_url.include?("cdn.brandfetch.io") @@ -102,6 +162,22 @@ class Security < ApplicationRecord end def generate_logo_url_from_brandfetch - self.logo_url = brandfetch_icon_url + self.logo_url = if crypto? + self.class.brandfetch_crypto_url(crypto_base_asset) + else + brandfetch_icon_url + end + end + + # When a user remaps a security to a different provider (via the holdings + # remap combobox or Security::Resolver), the previously-discovered + # first_provider_price_on belongs to the OLD provider and may no longer + # reflect what the new provider can serve. Reset it so the next sync's + # fallback rediscovers the correct earliest date for the new provider. + # Skip when the caller explicitly set both columns in the same save. + def reset_first_provider_price_on_if_provider_changed + return unless price_provider_changed? + return if first_provider_price_on_changed? + self.first_provider_price_on = nil end end diff --git a/app/models/security/combobox_option.rb b/app/models/security/combobox_option.rb index 0123023f4..18a491427 100644 --- a/app/models/security/combobox_option.rb +++ b/app/models/security/combobox_option.rb @@ -1,10 +1,10 @@ class Security::ComboboxOption include ActiveModel::Model - attr_accessor :symbol, :name, :logo_url, :exchange_operating_mic, :country_code + attr_accessor :symbol, :name, :logo_url, :exchange_operating_mic, :country_code, :price_provider, :currency def id - "#{symbol}|#{exchange_operating_mic}" + "#{symbol}|#{exchange_operating_mic}|#{price_provider}" end def exchange_name diff --git a/app/models/security/health_checker.rb b/app/models/security/health_checker.rb index 74e5a8d50..c24428c46 100644 --- a/app/models/security/health_checker.rb +++ b/app/models/security/health_checker.rb @@ -66,11 +66,21 @@ class Security::HealthChecker attr_reader :security def provider - Security.provider + security.price_data_provider + end + + # Some providers (e.g., Alpha Vantage) have very low daily limits and no + # lightweight endpoint — each health check burns a full API call that + # fetches ~100 data points. Skip health checks for those providers to + # avoid exhausting their quota on monitoring alone. + def skip_health_check? + provider.present? && provider.respond_to?(:max_history_days) && + provider.is_a?(Provider::AlphaVantage) end def latest_provider_price return nil unless provider.present? + return true if skip_health_check? # treat as healthy — quota too precious response = provider.fetch_security_price( symbol: security.ticker, @@ -111,6 +121,7 @@ class Security::HealthChecker Security.transaction do security.update!( offline: true, + offline_reason: "health_check_failed", failed_fetch_count: MAX_CONSECUTIVE_FAILURES + 1, failed_fetch_at: Time.current ) diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index 9d57332b6..082ef0262 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -30,21 +30,52 @@ class Security::Price::Importer prev_price_value = start_price_value prev_currency = prev_price_currency || db_price_currency || "USD" + # Fallback for holdings that predate the asset's listing on the provider + # (e.g. a 2018 BTCEUR trade vs. Binance's 2020-01-03 listing date, or a + # 2023 RDDT trade vs. the 2024-03-21 IPO on Yahoo/Twelve Data). We can't + # anchor a price on or before start_date, but provider_prices has real + # data later in the range — advance fill_start_date to the earliest + # available provider date and use that price as the LOCF anchor. Days + # before that are intentionally left out of the DB (honest gap) rather + # than backfilled from a future price. + advanced_first_price_on = nil + + if prev_price_value.blank? + # Filter for valid rows BEFORE picking the earliest — otherwise a + # single listing-day / halt-day row with a nil or zero price would + # cause us to fall through to the MissingStartPriceError bail even + # when plenty of valid prices exist later in the window. + earliest_provider_price = provider_prices.values + .select { |p| p.price.present? && p.price.to_f > 0 } + .min_by(&:date) + + if earliest_provider_price + Rails.logger.info( + "#{security.ticker}: no provider price on or before #{start_date}; " \ + "advancing gapfill start to earliest valid provider date #{earliest_provider_price.date}" + ) + prev_price_value = earliest_provider_price.price + prev_currency = earliest_provider_price.currency || prev_currency + @fill_start_date = earliest_provider_price.date + advanced_first_price_on = earliest_provider_price.date + end + end + unless prev_price_value.present? - Rails.logger.error("Could not find a start price for #{security.ticker} on or before #{start_date}") + Rails.logger.error("Could not find a start price for #{security.ticker} on or before #{fill_start_date}") Sentry.capture_exception(MissingStartPriceError.new("Could not determine start price for ticker")) do |scope| scope.set_tags(security_id: security.id) scope.set_context("security", { id: security.id, - start_date: start_date + start_date: fill_start_date }) end return 0 end - gapfilled_prices = effective_start_date.upto(end_date).map do |date| + gapfilled_prices = fill_start_date.upto(end_date).map do |date| db_price = db_prices[date] db_price_value = db_price&.price provider_price = provider_prices[date] @@ -95,21 +126,59 @@ class Security::Price::Importer } end - upsert_rows(gapfilled_prices) + result = upsert_rows(gapfilled_prices) + + # Persist the advanced start date so subsequent syncs can clamp + # expected_count and short-circuit via all_prices_exist? instead of + # re-iterating the full (start_date..end_date) range every time. + # + # Update when the column is currently blank, OR when we've discovered + # an EARLIER date than the stored one — the latter covers the + # clear_cache-driven case where a provider has extended its backward + # coverage (e.g. Binance backfilling older BTCEUR history) and we + # want subsequent syncs to reflect the new earlier clamp. We never + # move the column forward from a previously-discovered earlier value, + # since that would silently hide older rows already in the DB. + if advanced_first_price_on.present? && + (security.first_provider_price_on.blank? || + advanced_first_price_on < security.first_provider_price_on) + security.update_column(:first_provider_price_on, advanced_first_price_on) + end + + result end private attr_reader :security, :security_provider, :start_date, :end_date, :clear_cache + # The start date sent to the provider API, clamped to the provider's max + # lookback window when applicable. Computed independently of provider_prices + # so fill_start_date can reference it without relying on method call order. + def provider_fetch_start_date + @provider_fetch_start_date ||= begin + base = effective_start_date - PROVISIONAL_LOOKBACK_DAYS.days + max_days = security_provider.respond_to?(:max_history_days) ? security_provider.max_history_days : nil + + if max_days && (end_date - base).to_i > max_days + clamped = end_date - max_days.days + Rails.logger.info( + "#{security_provider.class.name} max history is #{max_days} days; " \ + "clamping #{security.ticker} start_date from #{base} to #{clamped}" + ) + clamped + else + base + end + end + end + def provider_prices @provider_prices ||= begin - provider_fetch_start_date = effective_start_date - PROVISIONAL_LOOKBACK_DAYS.days - response = security_provider.fetch_security_prices( symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic, start_date: provider_fetch_start_date, - end_date: end_date + end_date: end_date ) if response.success? @@ -127,10 +196,20 @@ class Security::Price::Importer ) end - Sentry.capture_exception(MissingSecurityPriceError.new("Could not fetch prices for ticker"), level: :warning) do |scope| - scope.set_tags(security_id: security.id) - scope.set_context("security", { id: security.id, start_date: start_date, end_date: end_date }) - end + DebugLogEntry.capture( + category: "security_price_fetch", + level: "warn", + message: "Could not fetch prices for ticker", + source: self.class.name, + provider: security_provider, + metadata: { + security_id: security.id, + ticker: security.ticker, + start_date: start_date, + end_date: end_date, + provider_error: error_message + } + ) @provider_error = error_message {} @@ -147,7 +226,16 @@ class Security::Price::Importer def all_prices_exist? return false if has_refetchable_provisional_prices? - db_prices.count == expected_count + + # Count only prices in the clamped range so pre-listing / pre-IPO gaps + # don't perpetually trip the "expected_count mismatch" re-sync. Query + # directly rather than via db_prices (which stays at the full range to + # preserve any user-entered rows pre-listing). + persisted_count = Security::Price + .where(security_id: security.id, date: clamped_start_date..end_date) + .count + + persisted_count == expected_count end def has_refetchable_provisional_prices? @@ -157,27 +245,53 @@ class Security::Price::Importer end def expected_count - (start_date..end_date).count + (clamped_start_date..end_date).count + end + + # Effective start date after clamping to the security's known first + # provider-available price date. Unlike start_date, this shrinks when the + # provider's history (e.g. Binance BTCEUR listed 2020-01-03, RDDT IPO + # 2024-03-21) begins after the user's original start_date. Falls through + # to start_date for any security that has never tripped the fallback. + def clamped_start_date + @clamped_start_date ||= begin + listed = security.first_provider_price_on + listed.present? && listed > start_date ? listed : start_date + end end # Skip over ranges that already exist unless clearing cache - # Also includes dates with refetchable provisional prices + # Also includes dates with refetchable provisional prices. + # + # Iterates from clamped_start_date (not start_date) so pre-listing / + # pre-IPO gaps don't perpetually trip "first missing date = start_date" + # and cause every incremental sync to re-fetch + re-upsert the full + # post-listing range. clear_cache bypasses the clamp so a user-triggered + # refresh can rediscover earlier provider history. def effective_start_date return start_date if clear_cache - refetchable_dates = Security::Price.where(security_id: security.id, date: start_date..end_date) + refetchable_dates = Security::Price.where(security_id: security.id, date: clamped_start_date..end_date) .refetchable_provisional(lookback_days: PROVISIONAL_LOOKBACK_DAYS) .pluck(:date) .to_set - (start_date..end_date).detect do |d| + (clamped_start_date..end_date).detect do |d| !db_prices.key?(d) || refetchable_dates.include?(d) end || end_date end + # The date the gap-fill loop starts from. When the provider's history was + # clamped (e.g. Alpha Vantage 140 days), we start from the clamped window + # instead of the original effective_start_date to avoid writing hundreds of + # LOCF-filled prices for dates the provider can't actually serve. + def fill_start_date + @fill_start_date ||= [ provider_fetch_start_date, effective_start_date ].max + end + def start_price_value # When processing full range (first sync), use original behavior - if effective_start_date == start_date + if fill_start_date == start_date provider_price_value = provider_prices.select { |date, _| date <= start_date } .max_by { |date, _| date } &.last&.price @@ -188,9 +302,8 @@ class Security::Price::Importer return nil end - # For partial range (effective_start_date > start_date), use recent data - # This prevents stale prices from old trade dates propagating to current gap-fills - cutoff_date = effective_start_date + # For partial range or clamped range, use the most recent data before fill_start_date + cutoff_date = fill_start_date # First try provider prices (most recent before cutoff) provider_price_value = provider_prices diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index e412244a9..1d7805422 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -4,50 +4,187 @@ module Security::Provided SecurityInfoMissingError = Class.new(StandardError) class_methods do - def provider - provider = ENV["SECURITIES_PROVIDER"].presence || Setting.securities_provider - registry = Provider::Registry.for_concept(:securities) - registry.get_provider(provider.to_sym) + # Returns all enabled and configured securities providers + def providers + Setting.enabled_securities_providers.filter_map do |name| + Provider::Registry.for_concept(:securities).get_provider(name.to_sym) + rescue Provider::Registry::Error + nil + end end + # Backward compat: first enabled provider + def provider + providers.first + end + + # Get a specific provider by key name (e.g., "finnhub", "twelve_data") + # Returns nil if the provider is disabled in settings or not configured. + def provider_for(name) + return nil if name.blank? + return nil unless Setting.enabled_securities_providers.include?(name.to_s) + Provider::Registry.for_concept(:securities).get_provider(name.to_sym) + rescue Provider::Registry::Error + nil + end + + # Cache duration for search results (avoids burning through provider rate limits) + SEARCH_CACHE_TTL = 5.minutes + + # Maximum number of results returned to the combobox dropdown + MAX_SEARCH_RESULTS = 30 + + # Per-provider timeout so one slow provider can't stall the entire search + PROVIDER_SEARCH_TIMEOUT = 8.seconds + def search_provider(symbol, country_code: nil, exchange_operating_mic: nil) - return [] if provider.nil? || symbol.blank? + return [] if symbol.blank? + + active_providers = providers.compact + return [] if active_providers.empty? params = { country_code: country_code, exchange_operating_mic: exchange_operating_mic }.compact_blank - response = provider.search_securities(symbol, **params) - - if response.success? - securities = response.data.map do |provider_security| - # Need to map to domain model so Combobox can display via to_combobox_option - Security.new( - ticker: provider_security.symbol, - name: provider_security.name, - logo_url: provider_security.logo_url, - exchange_operating_mic: provider_security.exchange_operating_mic, - country_code: provider_security.country_code - ) + # Query all providers concurrently so the total wall time is max(provider + # latencies) instead of sum. Each future runs in the concurrent-ruby thread + # pool, keeping Puma threads unblocked during individual provider sleeps. + futures = active_providers.map do |prov| + Concurrent::Promises.future(prov) do |provider| + fetch_provider_results(provider, symbol, params) end - - # Sort results to prioritize user's country if provided - if country_code.present? - user_country = country_code.upcase - securities.sort_by do |s| - [ - s.country_code&.upcase == user_country ? 0 : 1, # User's country first - s.ticker.upcase == symbol.upcase ? 0 : 1 # Exact ticker match second - ] - end - else - securities - end - else - [] end + + # Collect results from each future individually with a shared deadline. + # Unlike zip (which is all-or-nothing), this keeps results from fast + # providers even when a slow one times out. + deadline = Time.current + PROVIDER_SEARCH_TIMEOUT + results_array = futures.map do |future| + remaining = [ (deadline - Time.current), 0 ].max + future.value(remaining) + end + + all_results = [] + seen_keys = Set.new + + results_array.each_with_index do |provider_results, idx| + next if provider_results.nil? + + provider_key = provider_key_for(active_providers[idx]) + + provider_results.each do |ps| + # Dedup key includes provider so the same ticker on the same exchange can + # appear once per provider — the user picks which provider's price feed + # they want and that choice is stored in price_provider. + dedup_key = "#{ps[:symbol]}|#{ps[:exchange_operating_mic]}|#{provider_key}".upcase + next if seen_keys.include?(dedup_key) + seen_keys.add(dedup_key) + + security = Security.new( + ticker: ps[:symbol], + name: ps[:name], + logo_url: ps[:logo_url], + exchange_operating_mic: ps[:exchange_operating_mic], + country_code: ps[:country_code], + search_currency: ps[:currency], + price_provider: provider_key + ) + all_results << security + end + end + + if all_results.empty? && active_providers.any? + Rails.logger.warn("Security search: all #{active_providers.size} providers returned no results for '#{symbol}'") + end + + rank_search_results(all_results, symbol, country_code).first(MAX_SEARCH_RESULTS) end + + private + def provider_key_for(provider_instance) + provider_instance.class.name.demodulize.underscore + end + + # Fetches (or reads from cache) search results for a single provider. + # Designed to run inside a Concurrent::Promises.future. + def fetch_provider_results(prov, symbol, params) + provider_key = provider_key_for(prov) + cache_key = "security_search:#{provider_key}:#{symbol.upcase}:#{Digest::SHA256.hexdigest(params.sort_by { |k, _| k }.to_json)}" + + Rails.cache.fetch(cache_key, expires_in: SEARCH_CACHE_TTL, skip_nil: true) do + response = prov.search_securities(symbol, **params) + next nil unless response.success? + + response.data.map do |ps| + { symbol: ps.symbol, name: ps.name, logo_url: ps.logo_url, + exchange_operating_mic: ps.exchange_operating_mic, country_code: ps.country_code, + currency: ps.respond_to?(:currency) ? ps.currency : nil } + end + end + rescue => e + Rails.logger.warn("Security search failed for #{provider_key}: #{e.message}") + nil + end + + # Scores and sorts search results so the most relevant matches appear first. + # Scoring criteria (lower = better): + # 0: exact ticker match + # 1: ticker starts with query + # 2: name contains query + # 3: everything else + # Within the same relevance tier, user's country is preferred. + def rank_search_results(results, symbol, country_code) + query = symbol.upcase + user_country = country_code&.upcase + + results.sort_by do |s| + ticker_up = s.ticker.upcase + relevance = if ticker_up == query + 0 + elsif ticker_up.start_with?(query) + 1 + elsif s.name&.upcase&.include?(query) + 2 + else + 3 + end + + country_match = (user_country.present? && s.country_code&.upcase == user_country) ? 0 : 1 + + [ relevance, country_match, ticker_up ] + end + end + end + + # Public method: resolves the provider for this specific security. + # Uses the security's assigned price_provider if available and configured. + # Falls back to the first enabled provider only when no specific provider + # was ever assigned. When an assigned provider becomes unavailable, returns + # nil so the security is skipped rather than queried against an incompatible + # provider (e.g. MFAPI scheme codes sent to TwelveData). + def price_data_provider + if price_provider.present? + assigned = self.class.provider_for(price_provider) + return assigned if assigned.present? + return nil # assigned provider is unavailable — don't silently fall back + end + self.class.providers.first + end + + # Returns the health status of this security's provider link. + # Delegates to price_data_provider to avoid duplicating provider lookup logic. + def provider_status + resolved = price_data_provider + + # Had a specific provider assigned but it's now unavailable + return :provider_unavailable if resolved.nil? && price_provider.present? + + return :offline if offline? + return :no_provider if resolved.nil? + return :stale if failed_fetch_count.to_i > 0 + :ok end def find_or_fetch_price(date: Date.current, cache: true) @@ -59,8 +196,8 @@ module Security::Provided return nil if offline? # Make sure we have a data provider before fetching - return nil unless provider.present? - response = provider.fetch_security_price( + return nil unless price_data_provider.present? + response = price_data_provider.fetch_security_price( symbol: ticker, exchange_operating_mic: exchange_operating_mic, date: date @@ -79,7 +216,7 @@ module Security::Provided end def import_provider_details(clear_cache: false) - unless provider.present? + unless price_data_provider.present? Rails.logger.warn("No provider configured for Security.import_provider_details") return end @@ -88,44 +225,49 @@ module Security::Provided return end - response = provider.fetch_security_info( + response = price_data_provider.fetch_security_info( symbol: ticker, exchange_operating_mic: exchange_operating_mic ) if response.success? - update( - name: response.data.name, - logo_url: response.data.logo_url, - website_url: response.data.links - ) + # Only overwrite fields the provider actually returned, so providers that + # don't support metadata (e.g. Alpha Vantage) won't blank existing values. + attrs = {} + attrs[:name] = response.data.name if response.data.name.present? + attrs[:logo_url] = response.data.logo_url if response.data.logo_url.present? + attrs[:website_url] = response.data.links if response.data.links.present? + update(attrs) if attrs.any? else - Rails.logger.warn("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}") - Sentry.capture_exception(SecurityInfoMissingError.new("Failed to get security info"), level: :warning) do |scope| - scope.set_tags(security_id: self.id) - scope.set_context("security", { id: self.id, provider_error: response.error.message }) - end + Rails.logger.warn("Failed to fetch security info for #{ticker} from #{price_data_provider.class.name}: #{response.error.message}") + DebugLogEntry.capture( + category: "security_metadata_fetch", + level: "warn", + message: "Failed to get security info", + source: self.class.name, + provider: price_data_provider, + metadata: { + security_id: self.id, + ticker: ticker, + provider_error: response.error.message + } + ) end end def import_provider_prices(start_date:, end_date:, clear_cache: false) - unless provider.present? + unless price_data_provider.present? Rails.logger.warn("No provider configured for Security.import_provider_prices") return 0 end importer = Security::Price::Importer.new( security: self, - security_provider: provider, + security_provider: price_data_provider, start_date: start_date, end_date: end_date, clear_cache: clear_cache ) [ importer.import_provider_prices, importer.provider_error ] end - - private - def provider - self.class.provider - end end diff --git a/app/models/security/resolver.rb b/app/models/security/resolver.rb index 2ec7fb701..4d0521b2d 100644 --- a/app/models/security/resolver.rb +++ b/app/models/security/resolver.rb @@ -1,8 +1,9 @@ class Security::Resolver - def initialize(symbol, exchange_operating_mic: nil, country_code: nil) + def initialize(symbol, exchange_operating_mic: nil, country_code: nil, price_provider: nil) @symbol = validate_symbol!(symbol) @exchange_operating_mic = exchange_operating_mic @country_code = country_code + @price_provider = validated_price_provider(price_provider) end # Attempts several paths to resolve a security: @@ -20,13 +21,22 @@ class Security::Resolver end private - attr_reader :symbol, :exchange_operating_mic, :country_code + attr_reader :symbol, :exchange_operating_mic, :country_code, :price_provider def validate_symbol!(symbol) raise ArgumentError, "Symbol is required and cannot be blank" if symbol.blank? symbol.strip.upcase end + # Only accept price_provider values that are known and currently enabled. + # Prevents tampered combobox values from persisting invalid provider names. + def validated_price_provider(value) + return nil if value.blank? + return nil unless Security.valid_price_providers.include?(value.to_s) + return nil unless Setting.enabled_securities_providers.include?(value.to_s) + value.to_s + end + def offline_security security = Security.find_or_initialize_by( ticker: symbol, @@ -44,13 +54,26 @@ class Security::Resolver end def exact_match_from_db - Security.find_by( + security = Security.find_by( { ticker: symbol, exchange_operating_mic: exchange_operating_mic, country_code: country_code.presence }.compact ) + + return nil unless security + + # When the caller provides an explicit provider (e.g. user selected from + # search results), honor that choice. Automated syncs (Plaid, SimpleFIN) + # pass price_provider: nil and will not overwrite. + if price_provider.present? && security.price_provider != price_provider + security.update!(price_provider: price_provider) + end + + reactivate_if_provider_available!(security) + + security end # If provided a ticker + exchange (and optionally, a country code), we can find exact matches @@ -59,11 +82,11 @@ class Security::Resolver return nil unless exchange_operating_mic.present? match = provider_search_result.find do |s| - ticker_matches = s.ticker.upcase.to_s == symbol.upcase.to_s - exchange_matches = s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s + ticker_matches = s.ticker&.upcase.to_s == symbol.upcase.to_s + exchange_matches = s.exchange_operating_mic&.upcase.to_s == exchange_operating_mic.upcase.to_s if country_code && exchange_operating_mic - ticker_matches && exchange_matches && s.country_code&.upcase.to_s == country_code.upcase.to_s + ticker_matches && exchange_matches && country_matches?(s.country_code) else ticker_matches && exchange_matches end @@ -78,8 +101,10 @@ class Security::Resolver filtered_candidates = provider_search_result # If a country code is specified, we MUST find a match with the same code + # — but nil candidate country is treated as a wildcard (e.g. crypto from + # Binance, which isn't tied to a jurisdiction). if country_code.present? - filtered_candidates = filtered_candidates.select { |s| s.country_code&.upcase.to_s == country_code.upcase.to_s } + filtered_candidates = filtered_candidates.select { |s| country_matches?(s.country_code) } end # 1. Prefer exact ticker matches (MSTR before MSTRX when searching for "MSTR") @@ -88,8 +113,8 @@ class Security::Resolver # 4. Rank by exchange_operating_mic relevance (lower index in the list is more relevant) sorted_candidates = filtered_candidates.sort_by do |s| [ - s.ticker.upcase.to_s == symbol.upcase.to_s ? 0 : 1, - exchange_operating_mic.present? && s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1, + s.ticker&.upcase.to_s == symbol.upcase.to_s ? 0 : 1, + exchange_operating_mic.present? && s.exchange_operating_mic&.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1, sorted_country_codes_by_relevance.index(s.country_code&.upcase.to_s) || sorted_country_codes_by_relevance.length, sorted_exchange_operating_mics_by_relevance.index(s.exchange_operating_mic&.upcase.to_s) || sorted_exchange_operating_mics_by_relevance.length ] @@ -109,11 +134,44 @@ class Security::Resolver ) security.country_code = match.country_code + + # Set provider when explicitly provided (user selection) or when the + # record is new / has no provider yet. Automated syncs pass nil and + # will not overwrite an existing choice. + effective_provider = price_provider.presence || + (match.respond_to?(:price_provider) ? match.price_provider.presence : nil) + + if effective_provider.present? + security.price_provider = effective_provider + end + security.save! + reactivate_if_provider_available!(security) + security end + # If a security was marked offline (e.g. its provider was temporarily + # removed in settings) but now has a valid, enabled provider, bring it + # back online so the MarketDataImporter picks it up again. + def reactivate_if_provider_available!(security) + return unless security.offline? + return unless security.offline_reason == "provider_disabled" + return unless security.price_data_provider.present? + + security.update!(offline: false, offline_reason: nil, failed_fetch_count: 0, failed_fetch_at: nil) + end + + # Candidate country matches when it equals the resolver's country OR when + # the provider didn't report a country at all (e.g. crypto from Binance). + # A nil candidate country is a legitimate "no jurisdiction" signal, not a + # missing field, so we trust the user's provider + exchange pick. + def country_matches?(candidate_country) + return true if candidate_country.blank? + candidate_country.upcase == country_code.upcase + end + def provider_search_result params = { exchange_operating_mic: exchange_operating_mic, diff --git a/app/models/setting.rb b/app/models/setting.rb index b62d4073d..c5aa08d7e 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -10,6 +10,14 @@ class Setting < RailsSettings::Base field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"] field :openai_model, type: :string, default: ENV["OPENAI_MODEL"] field :openai_json_mode, type: :string, default: ENV["LLM_JSON_MODE"] + + # LLM token budget (applies to every outbound LLM call: chat, auto-categorize, + # merchant detection, enhance-merchants, PDF processing). Defaults track + # Ollama's historical 2048-token baseline so local small-context models work + # out of the box. ENV overrides Setting at read time in Provider::Openai. + field :llm_context_window, type: :integer, default: ENV["LLM_CONTEXT_WINDOW"]&.to_i + field :llm_max_response_tokens, type: :integer, default: ENV["LLM_MAX_RESPONSE_TOKENS"]&.to_i + field :llm_max_items_per_call, type: :integer, default: ENV["LLM_MAX_ITEMS_PER_CALL"]&.to_i field :external_assistant_url, type: :string field :external_assistant_token, type: :string field :external_assistant_agent_id, type: :string @@ -18,7 +26,11 @@ class Setting < RailsSettings::Base BRAND_FETCH_LOGO_SIZE_STANDARD = 40 BRAND_FETCH_LOGO_SIZE_HIGH_RES = 120 - BRAND_FETCH_URL_PATTERN = %r{(https://cdn\.brandfetch\.io/[^/]+/icon/fallback/lettermark/)w/\d+/h/\d+(\?c=.+)} + # Matches both legacy single-segment URLs (`/apple.com/icon/...`) and + # explicit type-routed URLs introduced 2026 (`/crypto/BTC/icon/...`, + # `/domain/apple.com/icon/...`). `[^?]+` reaches across the extra slash + # so transform_brand_fetch_url can rewrite the size params on both shapes. + BRAND_FETCH_URL_PATTERN = %r{(https://cdn\.brandfetch\.io/[^?]+/icon/fallback/lettermark/)w/\d+/h/\d+(\?c=.+)} def self.brand_fetch_logo_size brand_fetch_high_res_logos ? BRAND_FETCH_LOGO_SIZE_HIGH_RES : BRAND_FETCH_LOGO_SIZE_STANDARD @@ -36,6 +48,83 @@ class Setting < RailsSettings::Base field :exchange_rate_provider, type: :string, default: ENV.fetch("EXCHANGE_RATE_PROVIDER", "twelve_data") field :securities_provider, type: :string, default: ENV.fetch("SECURITIES_PROVIDER", "twelve_data") + # Multi-provider: comma-separated list of enabled securities providers + field :securities_providers, type: :string, default: ENV.fetch("SECURITIES_PROVIDERS", "") + + # New provider API keys (encrypted at rest — see EncryptedSettingFields below) + field :tiingo_api_key, type: :string, default: ENV["TIINGO_API_KEY"] + field :eodhd_api_key, type: :string, default: ENV["EODHD_API_KEY"] + field :alpha_vantage_api_key, type: :string, default: ENV["ALPHA_VANTAGE_API_KEY"] + + # Transparent encryption for API key fields. The `field` macro defines the + # raw getter/setter on the class. By prepending this module we intercept + # reads (decrypt) and writes (encrypt) while `super` delegates to the + # original getter/setter generated by rails-settings-cached. + # + # Backward-compatible: if decryption fails (e.g. the value was stored before + # encryption was enabled) the raw value is returned as-is. + module EncryptedSettingFields + ENCRYPTED_FIELDS = %i[ + twelve_data_api_key + tiingo_api_key + eodhd_api_key + alpha_vantage_api_key + openai_access_token + external_assistant_token + ].freeze + + ENCRYPTED_FIELDS.each do |field_name| + define_method(field_name) do + raw = super() + decrypt_setting(raw) + end + + define_method(:"#{field_name}=") do |value| + super(encrypt_setting(value)) + end + end + + private + + def setting_encryptor + @setting_encryptor ||= begin + key = ActiveSupport::KeyGenerator.new( + Rails.application.secret_key_base + ).generate_key("setting_encryption", 32) + ActiveSupport::MessageEncryptor.new(key) + end + end + + def encrypt_setting(value) + return value if value.blank? + setting_encryptor.encrypt_and_sign(value) + end + + def decrypt_setting(value) + return value if value.blank? + setting_encryptor.decrypt_and_verify(value) + rescue ActiveSupport::MessageVerifier::InvalidSignature, + ActiveSupport::MessageEncryptor::InvalidMessage + # Value was stored before encryption was enabled — return as-is. + # It will be re-encrypted on next write. + value + end + end + + class << self + prepend EncryptedSettingFields + end + + def self.enabled_securities_providers + plural = ENV["SECURITIES_PROVIDERS"].presence || securities_providers.presence + if plural.present? + plural.to_s.split(",").map(&:strip).reject(&:blank?) + else + # Backward compat: fall back to singular setting + [ ENV["SECURITIES_PROVIDER"].presence || securities_provider ].compact + end + end + # Sync settings - check both provider env vars for default # Only defaults to true if neither provider explicitly disables pending SYNCS_INCLUDE_PENDING_DEFAULT = begin diff --git a/app/models/simplefin_account.rb b/app/models/simplefin_account.rb index 8b89db432..049834667 100644 --- a/app/models/simplefin_account.rb +++ b/app/models/simplefin_account.rb @@ -26,6 +26,13 @@ class SimplefinAccount < ApplicationRecord linked_account || account end + # Summary of transaction activity derived from raw_transactions_payload. + # Used by the setup UI and ReplacementDetector to distinguish live vs dormant + # accounts without re-parsing the payload at every call site. + def activity_summary + ActivitySummary.new(raw_transactions_payload) + end + # Ensure there is an AccountProvider link for this SimpleFin account and its current Account. # Safe and idempotent; returns the AccountProvider or nil if no account is associated yet. def ensure_account_provider! @@ -130,6 +137,6 @@ class SimplefinAccount < ApplicationRecord end def has_balance return if current_balance.present? || available_balance.present? - errors.add(:base, "SimpleFin account must have either current or available balance") + errors.add(:base, :no_balance) end end diff --git a/app/models/simplefin_account/activity_summary.rb b/app/models/simplefin_account/activity_summary.rb new file mode 100644 index 000000000..7179593f6 --- /dev/null +++ b/app/models/simplefin_account/activity_summary.rb @@ -0,0 +1,67 @@ +class SimplefinAccount + # Value object summarising the activity state of a SimpleFIN account's raw + # transactions payload. Used by the setup UI to help users distinguish live + # from dormant accounts, and by the ReplacementDetector to spot cards that + # have likely been replaced. + class ActivitySummary + DEFAULT_WINDOW_DAYS = 60 + + def initialize(transactions) + @transactions = Array(transactions).compact + end + + def last_transacted_at + return @last_transacted_at if defined?(@last_transacted_at) + @last_transacted_at = @transactions.filter_map { |tx| transacted_at(tx) }.max + end + + def days_since_last_activity(now: Time.current) + return nil unless last_transacted_at + ((now.to_i - last_transacted_at.to_i) / 86_400).floor + end + + def recent_transaction_count(days: DEFAULT_WINDOW_DAYS) + cutoff = days.days.ago + @transactions.count { |tx| (ts = transacted_at(tx)) && ts >= cutoff } + end + + def recently_active?(days: DEFAULT_WINDOW_DAYS) + recent_transaction_count(days: days).positive? + end + + def dormant?(days: DEFAULT_WINDOW_DAYS) + !recently_active?(days: days) + end + + def transaction_count + @transactions.size + end + + private + # Extract a Time for sorting/windowing. Prefer transacted_at (SimpleFIN + # authored timestamp), fall back to posted. Zero values mean "unknown" + # in SimpleFIN (e.g., pending transactions have posted=0) and are ignored. + # Note: integer 0 is truthy in Ruby, so a plain `|| fallback` short-circuits + # and never falls back. Use explicit helper so transacted_at=0 properly + # yields to posted. + def transacted_at(tx) + return nil unless tx.is_a?(Hash) || tx.respond_to?(:[]) + value = timestamp_value(fetch(tx, "transacted_at")) || + timestamp_value(fetch(tx, "posted")) + return nil unless value + Time.at(value) + rescue StandardError + nil + end + + def timestamp_value(raw) + return nil if raw.blank? + value = raw.to_i + value.zero? ? nil : value + end + + def fetch(tx, key) + tx[key] || tx[key.to_sym] + end + end +end diff --git a/app/models/simplefin_account/investments/holdings_processor.rb b/app/models/simplefin_account/investments/holdings_processor.rb index 3274b4b9d..29bf62155 100644 --- a/app/models/simplefin_account/investments/holdings_processor.rb +++ b/app/models/simplefin_account/investments/holdings_processor.rb @@ -47,7 +47,8 @@ class SimplefinAccount::Investments::HoldingsProcessor # which would cause the system to display average cost as current price. (GH #1182) qty = parse_decimal(any_of(simplefin_holding, %w[shares quantity qty units])) market_value = parse_decimal(any_of(simplefin_holding, %w[market_value current_value])) - cost_basis = parse_decimal(any_of(simplefin_holding, %w[cost_basis basis total_cost value])) + raw_cost_basis, cost_basis_source_key = cost_basis_from(simplefin_holding) + cost_basis = normalize_cost_basis(raw_cost_basis, qty, cost_basis_source_key, institution_reports_total_basis?) # Derive price from market_value when possible; otherwise fall back to any price field fallback_price = parse_decimal(any_of(simplefin_holding, %w[purchase_price price unit_price average_cost avg_cost])) @@ -112,6 +113,63 @@ class SimplefinAccount::Investments::HoldingsProcessor simplefin_account.raw_holdings_payload || [] end + def cost_basis_from(simplefin_holding) + %w[cost_basis basis total_cost value].each do |key| + raw = simplefin_holding[key] + next if raw.nil? || raw.to_s.strip.empty? + + return [ parse_decimal(raw), key ] + end + + [ nil, nil ] + end + + # Sure stores holding cost_basis as per-share average cost. SimpleFIN + # brokerages are inconsistent about which field carries which shape: + # + # - total_cost / value: always a total position cost per the SimpleFIN + # spec and observed payloads; divide by qty unconditionally. + # - cost_basis / basis: the spec calls this per-share, and most + # brokerages comply. Keep these values unchanged by default. + # + # Exception: a small allowlist of brokerages (Vanguard, Fidelity) is + # known to populate cost_basis with the total position cost in violation + # of the spec (#1718, #1182). For those connections only, divide by qty. + # + # An earlier revision of this fix used a magnitude heuristic + # (share_price × √qty midpoint). It was withdrawn because a legitimate + # per-share basis on a holding with a large unrealized loss + # (e.g. 100 shares with basis $100 now worth $5) trips the midpoint and + # gets mis-divided to $1/share — corrupting compliant providers. The + # allowlist trades some manual maintenance for that safety. + def normalize_cost_basis(raw_cost_basis, qty, source_key, total_basis_institution = false) + return nil if raw_cost_basis.nil? + + if %w[total_cost value].include?(source_key) || + (total_basis_institution && %w[cost_basis basis].include?(source_key)) + return nil unless qty.to_d.positive? + return raw_cost_basis / qty + end + + raw_cost_basis + end + + # Institutions known to populate the SimpleFIN `cost_basis` / `basis` + # field with the total position cost rather than the per-share value the + # spec requires. Matched as case-insensitive substrings against the + # account's stored org name and domain. + TOTAL_BASIS_INSTITUTIONS = %w[vanguard fidelity].freeze + + def institution_reports_total_basis? + org = simplefin_account.respond_to?(:org_data) ? simplefin_account.org_data : nil + return false if org.blank? + + candidates = [ org["name"], org[:name], org["domain"], org[:domain] ].compact.map(&:to_s).map(&:downcase) + return false if candidates.empty? + + TOTAL_BASIS_INSTITUTIONS.any? { |needle| candidates.any? { |c| c.include?(needle) } } + end + def resolve_security(symbol, description) # Normalize crypto tickers to a distinct namespace so they don't collide with equities sym = symbol.to_s.upcase diff --git a/app/models/simplefin_account/processor.rb b/app/models/simplefin_account/processor.rb index f9ef87641..98cba6497 100644 --- a/app/models/simplefin_account/processor.rb +++ b/app/models/simplefin_account/processor.rb @@ -80,57 +80,90 @@ class SimplefinAccount::Processor balance = observed if is_liability - # 1) Try transaction-history heuristic when enabled - begin - result = SimplefinAccount::Liabilities::OverpaymentAnalyzer - .new(simplefin_account, observed_balance: observed) - .call + # Loans report principal outstanding as a positive balance from the + # bank's perspective. The OverpaymentAnalyzer is designed for credit- + # like liabilities (where charges/payments distinguish debt vs. + # credit) and returns :unknown for loans with no transaction + # history, which then falls through to a fallback that negates the + # observed value — inverting the meaning so the loan ends up + # _adding_ to net worth instead of subtracting from it. Trust the + # bank's sign for Loans and skip the heuristic. + if account.accountable_type == "Loan" + balance = observed.abs + Rails.logger.info( + "SimpleFIN liability sign: classification=loan sfa=#{simplefin_account.id}" + ) + Rails.logger.debug( + "SimpleFIN liability sign (loan) amounts: sfa=#{simplefin_account.id} " \ + "observed=#{observed.to_s('F')} stored=#{balance.to_s('F')}" + ) + Sentry.add_breadcrumb(Sentry::Breadcrumb.new( + category: "simplefin", + message: "liability_sign=loan", + data: { sfa_id: simplefin_account.id } + )) rescue nil + else + # 1) Try transaction-history heuristic when enabled + begin + result = SimplefinAccount::Liabilities::OverpaymentAnalyzer + .new(simplefin_account, observed_balance: observed) + .call - case result.classification - when :credit - balance = -observed.abs - Rails.logger.info( - "SimpleFIN overpayment heuristic: classified as credit for sfa=#{simplefin_account.id}, " \ - "observed=#{observed.to_s('F')} metrics=#{result.metrics.slice(:charges_total, :payments_total, :tx_count).inspect}" - ) - Sentry.add_breadcrumb(Sentry::Breadcrumb.new( - category: "simplefin", - message: "liability_sign=credit", - data: { sfa_id: simplefin_account.id, observed: observed.to_s("F") } - )) rescue nil - when :debt - balance = observed.abs - Rails.logger.info( - "SimpleFIN overpayment heuristic: classified as debt for sfa=#{simplefin_account.id}, " \ - "observed=#{observed.to_s('F')} metrics=#{result.metrics.slice(:charges_total, :payments_total, :tx_count).inspect}" - ) - Sentry.add_breadcrumb(Sentry::Breadcrumb.new( - category: "simplefin", - message: "liability_sign=debt", - data: { sfa_id: simplefin_account.id, observed: observed.to_s("F") } - )) rescue nil - else - # 2) Fall back to existing sign-only logic (log unknown for observability) - begin - obs = { - reason: result.reason, - tx_count: result.metrics[:tx_count], - charges_total: result.metrics[:charges_total], - payments_total: result.metrics[:payments_total], - observed: observed.to_s("F") - }.compact - Rails.logger.info("SimpleFIN overpayment heuristic: unknown; falling back #{obs.inspect}") - rescue - # no-op + case result.classification + when :credit + balance = -observed.abs + Rails.logger.info( + "SimpleFIN overpayment heuristic: classified as credit for sfa=#{simplefin_account.id} " \ + "tx_count=#{result.metrics[:tx_count]}" + ) + Rails.logger.debug( + "SimpleFIN overpayment heuristic (credit) amounts: sfa=#{simplefin_account.id} " \ + "observed=#{observed.to_s('F')} metrics=#{result.metrics.slice(:charges_total, :payments_total, :tx_count).inspect}" + ) + Sentry.add_breadcrumb(Sentry::Breadcrumb.new( + category: "simplefin", + message: "liability_sign=credit", + data: { sfa_id: simplefin_account.id } + )) rescue nil + when :debt + balance = observed.abs + Rails.logger.info( + "SimpleFIN overpayment heuristic: classified as debt for sfa=#{simplefin_account.id} " \ + "tx_count=#{result.metrics[:tx_count]}" + ) + Rails.logger.debug( + "SimpleFIN overpayment heuristic (debt) amounts: sfa=#{simplefin_account.id} " \ + "observed=#{observed.to_s('F')} metrics=#{result.metrics.slice(:charges_total, :payments_total, :tx_count).inspect}" + ) + Sentry.add_breadcrumb(Sentry::Breadcrumb.new( + category: "simplefin", + message: "liability_sign=debt", + data: { sfa_id: simplefin_account.id } + )) rescue nil + else + # 2) Fall back to existing sign-only logic (log unknown for observability) + begin + Rails.logger.info( + "SimpleFIN overpayment heuristic: unknown for sfa=#{simplefin_account.id} " \ + "reason=#{result.reason} tx_count=#{result.metrics[:tx_count]}; falling back" + ) + Rails.logger.debug( + "SimpleFIN overpayment heuristic (unknown) amounts: sfa=#{simplefin_account.id} " \ + "observed=#{observed.to_s('F')} " \ + "charges_total=#{result.metrics[:charges_total]} payments_total=#{result.metrics[:payments_total]}" + ) + rescue + # no-op + end + balance = normalize_liability_balance(observed, bal, avail) end + rescue NameError + # Analyzer not loaded; keep legacy behavior + balance = normalize_liability_balance(observed, bal, avail) + rescue => e + Rails.logger.warn("SimpleFIN overpayment heuristic error for sfa=#{simplefin_account.id}: #{e.class} - #{e.message}") balance = normalize_liability_balance(observed, bal, avail) end - rescue NameError - # Analyzer not loaded; keep legacy behavior - balance = normalize_liability_balance(observed, bal, avail) - rescue => e - Rails.logger.warn("SimpleFIN overpayment heuristic error for sfa=#{simplefin_account.id}: #{e.class} - #{e.message}") - balance = normalize_liability_balance(observed, bal, avail) end end diff --git a/app/models/simplefin_item/importer.rb b/app/models/simplefin_item/importer.rb index c1a567aae..450e7883e 100644 --- a/app/models/simplefin_item/importer.rb +++ b/app/models/simplefin_item/importer.rb @@ -50,6 +50,10 @@ class SimplefinItem::Importer # This allows the item to recover automatically when a bank's auth issue is resolved # in SimpleFIN Bridge, without requiring the user to manually reconnect. maybe_clear_requires_update_status + + # Detect likely card-replacement scenarios (e.g., fraud replacement). + # Persist suggestions on sync_stats so the UI can render a relink prompt. + detect_replacement_candidates rescue RateLimitedError => e stats["rate_limited"] = true stats["rate_limited_at"] = Time.current.iso8601 @@ -326,6 +330,29 @@ class SimplefinItem::Importer sync.update_columns(sync_stats: merged) # avoid callbacks/validations during tight loops end + # Run the replacement detector on the current simplefin_item and stash + # suggestions on sync_stats for the UI to render. The detector is best- + # effort; any error is logged but never fails the whole sync. + def detect_replacement_candidates + suggestions = SimplefinItem::ReplacementDetector.new(simplefin_item).call + return if suggestions.empty? + + stats["replacement_suggestions"] = suggestions + persist_stats! + Rails.logger.info( + "SimpleFIN: detected #{suggestions.size} replacement suggestion(s) for item ##{simplefin_item.id}" + ) + ActiveSupport::Notifications.instrument( + "simplefin.replacement_suggestions", + item_id: simplefin_item.id, + count: suggestions.size + ) + rescue => e + Rails.logger.warn( + "SimpleFIN: replacement detector failed for item ##{simplefin_item.id}: #{e.class} - #{e.message}" + ) + end + # Reset status to good if no auth errors occurred in this sync. # This allows automatic recovery when a bank's auth issue is resolved in SimpleFIN Bridge. def maybe_clear_requires_update_status @@ -937,33 +964,17 @@ class SimplefinItem::Importer # Record non-fatal provider errors into sync stats without raising, so the # rest of the accounts can continue to import. This is used when the # response contains both :accounts and :errors. + # + # NOTE: per-institution partial errors (e.g. one bank's auth expired inside + # a SimpleFIN Bridge connection that spans many institutions) are recorded + # for observability but must NOT flip the whole simplefin_item to + # requires_update - that would block sync for every other institution on + # the same connection. The top-level handle_errors path is the correct + # place to flag the item when the SimpleFIN token itself is dead. def record_errors(errors) arr = Array(errors) return if arr.empty? - # Determine if these errors indicate the item needs an update (e.g. 2FA) - needs_update = arr.any? do |error| - if error.is_a?(String) - down = error.downcase - down.include?("reauth") || down.include?("auth") || down.include?("two-factor") || down.include?("2fa") || down.include?("forbidden") || down.include?("unauthorized") - else - code = error[:code].to_s.downcase - type = error[:type].to_s.downcase - code.include?("auth") || code.include?("token") || type.include?("auth") - end - end - - if needs_update - Rails.logger.warn("SimpleFin: marking item ##{simplefin_item.id} requires_update due to auth-related provider errors") - simplefin_item.update!(status: :requires_update) - ActiveSupport::Notifications.instrument( - "simplefin.item_requires_update", - item_id: simplefin_item.id, - reason: "provider_errors_partial", - count: arr.size - ) - end - Rails.logger.info("SimpleFin: recording #{arr.size} non-fatal provider error(s) with partial data present") ActiveSupport::Notifications.instrument( "simplefin.provider_errors", diff --git a/app/models/simplefin_item/replacement_detector.rb b/app/models/simplefin_item/replacement_detector.rb new file mode 100644 index 000000000..0f0838aa0 --- /dev/null +++ b/app/models/simplefin_item/replacement_detector.rb @@ -0,0 +1,135 @@ +class SimplefinItem + # Detects cases where a linked SimpleFIN account looks like it has been + # replaced by a new unlinked SimpleFIN account at the same institution + # (typical for credit-card fraud replacement: the bank closes the old card + # and issues a new one, so SimpleFIN returns both for a transition window). + # + # Heuristic: + # * dormant_sfa: linked to a Sure account, no activity in 45+ days, + # AND near-zero current balance. + # * active_sfa: unlinked, recently active (transactions in last 30 days), + # belongs to the same simplefin_item, + # same account_type and same organisation name as dormant_sfa. + # * pair: exactly one active_sfa matches. Two or more candidates + # are considered ambiguous and skipped to avoid a wrong + # auto-suggestion. + # + # The detector does NOT mutate any records. It returns a plain array of + # suggestion hashes which the caller (Importer) persists on sync_stats so + # the UI can render a prompt. + class ReplacementDetector + DORMANCY_DAYS = 45 + ACTIVE_WINDOW_DAYS = 30 + NEAR_ZERO_BALANCE = BigDecimal("1.00") + + # Fraud-replacement is overwhelmingly a credit-card pattern (old card closed, + # new card issued with same institution/metadata). Checking/savings-account + # replacement exists but has very different UX cues (e.g., users get a new + # account number in advance). Scope narrowly for now; broaden later with + # account-type-aware copy if demand materialises. + SUPPORTED_ACCOUNT_TYPES = %w[credit credit_card creditcard].freeze + + def initialize(simplefin_item) + @simplefin_item = simplefin_item + end + + # @return [Array] suggestions. Empty when no replacements detected. + def call + sfas = @simplefin_item.simplefin_accounts + .includes(:linked_account, :account) + .to_a + .select { |sfa| supported_type?(sfa) } + active_unlinked = sfas.select { |sfa| unlinked?(sfa) && active?(sfa) } + return [] if active_unlinked.empty? + + # First pass: for each dormant candidate, find unambiguous matching actives + # (exactly one). Rejects "one dormant → many actives" collisions. + candidates = sfas.filter_map do |dormant| + next unless linked?(dormant) && dormant_with_zero_balance?(dormant) + matches = active_unlinked.select { |sfa| same_institution_and_type?(dormant, sfa) } + next if matches.size != 1 + [ dormant, matches.first ] + end + + # Second pass: reject "many dormants → one active" collisions. If two + # dormant accounts both claim the same active, we can't safely auto-suggest + # either — relinking both would move the provider away from the first. + active_counts = candidates.each_with_object(Hash.new(0)) { |(_d, a), h| h[a.id] += 1 } + candidates.filter_map do |dormant, active| + next if active_counts[active.id] > 1 + build_suggestion(dormant: dormant, active: active) + end + end + + private + def supported_type?(sfa) + SUPPORTED_ACCOUNT_TYPES.include?(canonical_account_type(sfa)) + end + + # Canonicalize for both gating (supported_type?) and matching + # (type_matches?) so variants like "credit card" and "credit_card" + # round-trip to the same key. + def canonical_account_type(sfa) + sfa.account_type.to_s.downcase.gsub(/\s+/, "_") + end + + def linked?(sfa) + sfa.current_account.present? + end + + def unlinked?(sfa) + sfa.current_account.blank? + end + + def dormant_with_zero_balance?(sfa) + # Require evidence of prior activity. An empty payload carries no signal + # (e.g., a brand-new card just linked) and must not trigger a replacement + # suggestion. Matches the likely-closed gate used by the setup UI. + return false if sfa.activity_summary.last_transacted_at.blank? + return false unless sfa.activity_summary.dormant?(days: DORMANCY_DAYS) + # Missing current_balance is "unknown," not "zero." Treat it as evidence + # against replacement rather than for it. + return false if sfa.current_balance.nil? + sfa.current_balance.to_d.abs <= NEAR_ZERO_BALANCE + end + + def active?(sfa) + sfa.activity_summary.recently_active?(days: ACTIVE_WINDOW_DAYS) + end + + def same_institution_and_type?(a, b) + type_matches?(a, b) && org_matches?(a, b) + end + + def type_matches?(a, b) + canonical_account_type(a) == canonical_account_type(b) + end + + # Require BOTH sides to have a non-blank org name. SimpleFIN sometimes omits + # org_data.name; "" casecmp? "" would otherwise treat unrelated accounts as + # co-institutional, producing false replacement suggestions. + def org_matches?(a, b) + name_a = org_name(a) + name_b = org_name(b) + return false if name_a.blank? || name_b.blank? + name_a.casecmp?(name_b) + end + + def org_name(sfa) + name = sfa.org_data.is_a?(Hash) ? (sfa.org_data["name"] || sfa.org_data[:name]) : nil + name.to_s.strip + end + + def build_suggestion(dormant:, active:) + { + "dormant_sfa_id" => dormant.id, + "active_sfa_id" => active.id, + "sure_account_id" => dormant.current_account&.id, + "institution_name" => org_name(dormant), + "dormant_account_name" => dormant.name, + "active_account_name" => active.name, + "confidence" => "high" + } + end + end +end diff --git a/app/models/snaptrade_account/activities_processor.rb b/app/models/snaptrade_account/activities_processor.rb index 141a24496..96b35cbb4 100644 --- a/app/models/snaptrade_account/activities_processor.rb +++ b/app/models/snaptrade_account/activities_processor.rb @@ -247,9 +247,9 @@ class SnaptradeAccount::ActivitiesProcessor def normalize_cash_amount(amount, activity_type) case activity_type when "WITHDRAWAL", "TRANSFER_OUT", "FEE", "TAX" - -amount.abs # These should be negative (money out) + amount.abs # Money out should be positive in Sure when "CONTRIBUTION", "TRANSFER_IN", "DIVIDEND", "DIV", "INTEREST", "CASH" - amount.abs # These should be positive (money in) + -amount.abs # Money in should be negative in Sure else amount end diff --git a/app/models/snaptrade_account/processor.rb b/app/models/snaptrade_account/processor.rb index b5edcb5e8..a20893e63 100644 --- a/app/models/snaptrade_account/processor.rb +++ b/app/models/snaptrade_account/processor.rb @@ -71,6 +71,11 @@ class SnaptradeAccount::Processor end def calculate_total_balance + if use_api_total_balance? + Rails.logger.debug "SnaptradeAccount::Processor - Using API total for multi-currency holdings for snaptrade_account=#{snaptrade_account.id}" + return snaptrade_account.current_balance || 0 + end + # Calculate total from holdings + cash for accuracy # SnapTrade's current_balance can sometimes be stale or just the cash value holdings_value = calculate_holdings_value @@ -109,4 +114,24 @@ class SnaptradeAccount::Processor units * price end end + + def use_api_total_balance? + return false unless snaptrade_account.current_balance.present? + + holdings_currencies.any? { |currency| currency.present? && currency != snaptrade_account.currency } + end + + def holdings_currencies + Array(snaptrade_account.raw_holdings_payload).filter_map do |holding| + data = holding.respond_to?(:with_indifferent_access) ? holding.with_indifferent_access : {} + extract_currency(data, extract_symbol_data(data), snaptrade_account.currency) + end.uniq + end + + def extract_symbol_data(data) + symbol_wrapper = data[:symbol].is_a?(Hash) ? data[:symbol].with_indifferent_access : {} + raw_symbol_data = symbol_wrapper[:symbol] + + raw_symbol_data.is_a?(Hash) ? raw_symbol_data.with_indifferent_access : {} + end end diff --git a/app/models/snaptrade_item.rb b/app/models/snaptrade_item.rb index f99c763f6..367836e42 100644 --- a/app/models/snaptrade_item.rb +++ b/app/models/snaptrade_item.rb @@ -35,6 +35,7 @@ class SnaptradeItem < ApplicationRecord has_many :linked_accounts, through: :snaptrade_accounts scope :active, -> { where(scheduled_for_deletion: false) } + scope :credentials_configured, -> { active.where.not(client_id: [ nil, "" ]).where.not(consumer_key: [ nil, "" ]) } # Syncable = active + fully configured (user registered with SnapTrade API) # Items without user registration will fail sync, so exclude them from auto-sync scope :syncable, -> { active.where.not(snaptrade_user_id: [ nil, "" ]).where.not(snaptrade_user_secret: [ nil, "" ]) } diff --git a/app/models/snaptrade_item/provided.rb b/app/models/snaptrade_item/provided.rb index 5ffb43906..c8d104f43 100644 --- a/app/models/snaptrade_item/provided.rb +++ b/app/models/snaptrade_item/provided.rb @@ -160,13 +160,14 @@ module SnaptradeItem::Provided return [] unless credentials_configured? && user_registered? all_users = list_all_users - all_users.reject { |uid| uid == snaptrade_user_id } + all_users.select { |uid| uid != snaptrade_user_id && uid.start_with?("family_#{family_id}_") } end # Delete an orphaned SnapTrade user and all their connections def delete_orphaned_user(user_id) return false unless credentials_configured? return false if user_id == snaptrade_user_id # Don't delete current user + return false unless user_id.start_with?("family_#{family_id}_") snaptrade_provider.delete_user(user_id: user_id) true diff --git a/app/models/sophtron_account.rb b/app/models/sophtron_account.rb new file mode 100644 index 000000000..8179b7b84 --- /dev/null +++ b/app/models/sophtron_account.rb @@ -0,0 +1,179 @@ +# Represents a single bank account from Sophtron. +# +# A SophtronAccount stores account-level data fetched from the Sophtron API, +# including balances, account type, and raw transaction data. It can be linked +# to a Maybe Account through the account_provider association. +# +# @attr [String] name Account name from Sophtron +# @attr [String] account_id Sophtron's unique identifier for this account +# @attr [String] customer_id Sophtron customer ID this account belongs to +# @attr [String] member_id Sophtron member ID +# @attr [String] currency Three-letter currency code (e.g., 'USD') +# @attr [Decimal] balance Current account balance +# @attr [Decimal] available_balance Available balance (for credit accounts) +# @attr [String] account_type Type of account (e.g., 'checking', 'savings') +# @attr [String] account_sub_type Detailed account subtype +# @attr [JSONB] raw_payload Raw account data from Sophtron API +# @attr [JSONB] raw_transactions_payload Raw transaction data from Sophtron API +# @attr [DateTime] last_updated When Sophtron last updated this account +class SophtronAccount < ApplicationRecord + include CurrencyNormalizable + + belongs_to :sophtron_item + + # Association to link this Sophtron account to a Maybe Account + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + scope :requires_manual_sync, -> { where(manual_sync: true) } + scope :automatic_sync, -> { where(manual_sync: false) } + + validates :name, :currency, presence: true + validate :has_balance + # Returns the linked Maybe Account for this Sophtron account. + # + # @return [Account, nil] The linked Maybe Account, or nil if not linked + def current_account + account + end + + def institution_name + institution_metadata.to_h["name"].presence || sophtron_item&.institution_name + end + + def institution_user_institution_id + institution_metadata.to_h["user_institution_id"].presence || sophtron_item&.user_institution_id + end + + def institution_key + institution_user_institution_id.presence || institution_name + end + + # Updates this SophtronAccount with fresh data from the Sophtron API. + # + # Maps Sophtron field names to our database schema and saves the changes. + # Stores the complete raw payload for reference. + # + # @param account_snapshot [Hash] Raw account data from Sophtron API + # @return [Boolean] true if save was successful + # @raise [ActiveRecord::RecordInvalid] if validation fails + def upsert_sophtron_snapshot!(account_snapshot) + # Convert to symbol keys or handle both string and symbol keys + snapshot = account_snapshot.with_indifferent_access + account_id = first_present(snapshot, :account_id, :id, :AccountID) + account_name = first_present(snapshot, :account_name, :name, :AccountName) + account_number = first_present(snapshot, :account_number, :AccountNumber) + currency = first_present(snapshot, :balance_currency, :currency, :BalanceCurrency, :Currency) + balance = first_present(snapshot, :balance, :account_balance, :AccountBalance, :Balance) + available_balance = first_present(snapshot, :"available-balance", :available_balance, :AvailableBalance) + account_type = first_present(snapshot, :account_type, :type, :AccountType) + account_sub_type = first_present(snapshot, :sub_type, :account_sub_type, :AccountSubType, :SubType) + last_updated = first_present(snapshot, :last_updated, :LastUpdated) + institution_name = first_present(snapshot, :institution_name, :InstitutionName).presence || sophtron_item&.institution_name + user_institution_id = first_present(snapshot, :user_institution_id, :UserInstitutionID).presence || sophtron_item&.user_institution_id + + # Map Sophtron field names to our field names + assign_attributes( + name: account_name, + account_id: account_id, + currency: parse_currency(currency) || "USD", + balance: parse_balance(balance), + available_balance: parse_balance(available_balance), + account_type: account_type.presence || "unknown", + account_sub_type: account_sub_type.presence || "unknown", + last_updated: parse_balance_date(last_updated), + account_status: first_present(snapshot, :account_status, :status, :AccountStatus, :Status), + account_number_mask: snapshot[:account_number_mask].presence || mask_account_number(account_number), + institution_metadata: { + name: institution_name, + user_institution_id: user_institution_id + }.compact, + raw_payload: account_snapshot, + customer_id: first_present(snapshot, :customer_id, :CustomerID) || customer_id, + member_id: first_present(snapshot, :member_id, :MemberID) || member_id + ) + self.manual_sync = true if new_record? && sophtron_item&.manual_sync? + + save! + end + + # Stores raw transaction data from the Sophtron API. + # + # This method saves the raw transaction payload which will later be + # processed by SophtronAccount::Transactions::Processor to create + # actual Transaction records. + # + # @param transactions_snapshot [Array] Array of raw transaction data + # @return [Boolean] true if save was successful + # @raise [ActiveRecord::RecordInvalid] if validation fails + def upsert_sophtron_transactions_snapshot!(transactions_snapshot) + assign_attributes( + raw_transactions_payload: transactions_snapshot + ) + + save! + end + + private + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for Sophtron account #{id}, defaulting to USD") + end + + + def parse_balance(balance_value) + return nil if balance_value.nil? + + case balance_value + when String + BigDecimal(balance_value) + when Numeric + BigDecimal(balance_value.to_s) + else + nil + end + rescue ArgumentError + nil + end + + def parse_balance_date(balance_date_value) + return nil if balance_date_value.nil? + + case balance_date_value + when String + Time.parse(balance_date_value) + when Numeric + t = balance_date_value + t = (t / 1000.0) if t > 1_000_000_000_000 # likely ms epoch + Time.at(t) + when Time, DateTime + balance_date_value + else + nil + end + rescue ArgumentError, TypeError + Rails.logger.warn("Invalid balance date for Sophtron account: #{balance_date_value}") + nil + end + def has_balance + return if balance.present? || available_balance.present? + errors.add(:base, :no_balance) + end + + def first_present(hash, *keys) + keys.each do |key| + value = hash[key] + return value if value.present? + end + + nil + end + + def mask_account_number(account_number) + return nil if account_number.blank? + + last_four = account_number.to_s.gsub(/\s+/, "").last(4) + last_four.present? ? "****#{last_four}" : nil + end +end diff --git a/app/models/sophtron_account/processor.rb b/app/models/sophtron_account/processor.rb new file mode 100644 index 000000000..fcd88fedb --- /dev/null +++ b/app/models/sophtron_account/processor.rb @@ -0,0 +1,119 @@ +# Processes a SophtronAccount to update Maybe Account and Transaction records. +# +# This processor is responsible for: +# 1. Updating the linked Maybe Account's balance from Sophtron data +# 2. Processing stored transactions to create Maybe Transaction records +# +# The processor handles currency normalization and sign conventions for +# different account types (e.g., credit cards use inverted signs). +class SophtronAccount::Processor + include CurrencyNormalizable + + attr_reader :sophtron_account + + # Initializes a new processor for a Sophtron account. + # + # @param sophtron_account [SophtronAccount] The account to process + def initialize(sophtron_account) + @sophtron_account = sophtron_account + end + + # Processes the account to update balances and transactions. + # + # This method: + # - Validates that the account is linked to a Maybe Account + # - Updates the Maybe Account's balance from Sophtron data + # - Processes all stored transactions to create Transaction records + # + # @return [Hash, nil] Transaction processing result hash or nil if no linked account + # @raise [StandardError] if processing fails (errors are logged and reported to Sentry) + def process + unless sophtron_account.current_account.present? + Rails.logger.info "SophtronAccount::Processor - No linked account for sophtron_account #{sophtron_account.id}, skipping processing" + return + end + + Rails.logger.info "SophtronAccount::Processor - Processing sophtron_account #{sophtron_account.id} (account #{sophtron_account.account_id})" + begin + process_account! + rescue StandardError => e + Rails.logger.error "SophtronAccount::Processor - Failed to process account #{sophtron_account.id}: #{e.message}" + Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}" + report_exception(e, "account") + raise + end + + process_transactions + end + + private + + # Updates the linked Maybe Account's balance from Sophtron data. + # + # Handles sign conventions for different account types: + # - CreditCard and Loan accounts use inverted signs (negated) + # - Other account types use Sophtron's native sign convention + # + # @return [void] + # @raise [ActiveRecord::RecordInvalid] if the account update fails + def process_account! + if sophtron_account.current_account.blank? + Rails.logger.error("Sophtron account #{sophtron_account.id} has no associated Account") + return + end + + # Update account balance from latest Sophtron data + account = sophtron_account.current_account + balance = sophtron_account.balance || sophtron_account.available_balance || 0 + + # Sophtron balance convention matches our app convention: + # - Positive balance = debt (you owe money) + # - Negative balance = credit balance (bank owes you, e.g., overpayment) + # No sign conversion needed - pass through as-is (same as Plaid) + # + # Exception: CreditCard and Loan accounts return inverted signs + # Provider returns negative for positive balance, so we negate it + if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" + balance = -balance + end + + # Normalize currency with fallback chain: parsed sophtron currency -> existing account currency -> USD + currency = parse_currency(sophtron_account.currency) || account.currency || "USD" + # Update account balance + account.update!( + balance: balance, + cash_balance: balance, + currency: currency + ) + end + + # Processes all stored transactions for this account. + # + # Delegates to SophtronAccount::Transactions::Processor to convert + # raw transaction data into Maybe Transaction records. + # + # @return [void] + # @raise [StandardError] if transaction processing fails + def process_transactions + SophtronAccount::Transactions::Processor.new(sophtron_account).process + rescue StandardError => e + Rails.logger.error "SophtronAccount::Processor - Failed to process transactions for sophtron_account #{sophtron_account.id}: #{e.message}" + Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}" + report_exception(e, "transactions") + raise + end + + # Reports an exception to Sentry with Sophtron account context. + # + # @param error [Exception] The error to report + # @param context [String] Additional context (e.g., 'account', 'transactions') + # @return [void] + def report_exception(error, context) + Sentry.capture_exception(error) do |scope| + scope.set_tags( + sophtron_account_id: sophtron_account.id, + context: context + ) + end + end +end diff --git a/app/models/sophtron_account/transactions/processor.rb b/app/models/sophtron_account/transactions/processor.rb new file mode 100644 index 000000000..20ca58eb7 --- /dev/null +++ b/app/models/sophtron_account/transactions/processor.rb @@ -0,0 +1,98 @@ +# Processes raw transaction data to create Maybe Transaction records. +# +# This processor takes the raw transaction payload stored in a SophtronAccount +# and converts each transaction into a Maybe Transaction record using +# SophtronEntry::Processor. It processes transactions individually to avoid +# database lock issues when handling large transaction volumes. +# +# The processor is resilient to errors - if one transaction fails, it logs +# the error and continues processing the remaining transactions. +class SophtronAccount::Transactions::Processor + attr_reader :sophtron_account + + # Initializes a new transaction processor. + # + # @param sophtron_account [SophtronAccount] The account whose transactions to process + def initialize(sophtron_account) + @sophtron_account = sophtron_account + end + + # Processes all transactions in the raw_transactions_payload. + # + # Each transaction is processed individually to avoid database lock contention. + # Errors are caught and logged, allowing the process to continue with remaining + # transactions. + # + # @return [Hash] Processing results with the following keys: + # - :success [Boolean] true if all transactions processed successfully + # - :total [Integer] Total number of transactions found + # - :imported [Integer] Number of transactions successfully imported + # - :failed [Integer] Number of transactions that failed + # - :errors [Array] Details of any errors encountered + # @example + # result = processor.process + # # => { success: true, total: 100, imported: 98, failed: 2, errors: [...] } + def process + unless sophtron_account.raw_transactions_payload.present? + Rails.logger.info "SophtronAccount::Transactions::Processor - No transactions in raw_transactions_payload for sophtron_account #{sophtron_account.id}" + return { success: true, total: 0, imported: 0, failed: 0, errors: [] } + end + + total_count = sophtron_account.raw_transactions_payload.count + Rails.logger.info "SophtronAccount::Transactions::Processor - Processing #{total_count} transactions for sophtron_account #{sophtron_account.id}" + + imported_count = 0 + failed_count = 0 + errors = [] + + # Each entry is processed inside a transaction, but to avoid locking up the DB when + # there are hundreds or thousands of transactions, we process them individually. + sophtron_account.raw_transactions_payload.each_with_index do |transaction_data, index| + begin + result = SophtronEntry::Processor.new( + transaction_data, + sophtron_account: sophtron_account + ).process + + if result.nil? + # Transaction was skipped (e.g., no linked account) + failed_count += 1 + errors << { index: index, transaction_id: transaction_data[:id], error: "No linked account" } + else + imported_count += 1 + end + rescue ArgumentError => e + # Validation error - log and continue + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + error_message = "Validation error: #{e.message}" + Rails.logger.error "SophtronAccount::Transactions::Processor - #{error_message} (transaction #{transaction_id})" + errors << { index: index, transaction_id: transaction_id, error: error_message } + rescue => e + # Unexpected error - log with full context and continue + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + error_message = "#{e.class}: #{e.message}" + Rails.logger.error "SophtronAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}" + Rails.logger.error e.backtrace.join("\n") + errors << { index: index, transaction_id: transaction_id, error: error_message } + end + end + + result = { + success: failed_count == 0, + total: total_count, + imported: imported_count, + failed: failed_count, + errors: errors + } + + if failed_count > 0 + Rails.logger.warn "SophtronAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions" + else + Rails.logger.info "SophtronAccount::Transactions::Processor - Successfully processed #{imported_count} transactions" + end + + result + end +end diff --git a/app/models/sophtron_entry/processor.rb b/app/models/sophtron_entry/processor.rb new file mode 100644 index 000000000..2ca30c124 --- /dev/null +++ b/app/models/sophtron_entry/processor.rb @@ -0,0 +1,229 @@ +require "digest/md5" + +# Processes a single Sophtron transaction and creates/updates a Maybe Transaction. +# +# This processor takes raw transaction data from the Sophtron API and converts it +# into a Maybe Transaction record using the Account::ProviderImportAdapter. +# It handles currency normalization, merchant matching, and data validation. +# +# Expected transaction structure from Sophtron: +# { +# id: String, +# accountId: String, +# amount: Numeric, +# currency: String, +# date: String/Date, +# merchant: String, +# description: String +# } +class SophtronEntry::Processor + include CurrencyNormalizable + + # Initializes a new processor for a Sophtron transaction. + # + # @param sophtron_transaction [Hash] Raw transaction data from Sophtron API + # @param sophtron_account [SophtronAccount] The account this transaction belongs to + def initialize(sophtron_transaction, sophtron_account:) + @sophtron_transaction = sophtron_transaction + @sophtron_account = sophtron_account + end + + # Processes the transaction and creates/updates a Maybe Transaction record. + # + # This method validates the transaction data, creates or finds a merchant, + # and uses the ProviderImportAdapter to import the transaction into Maybe. + # It respects user overrides through the enrichment pattern. + # + # @return [Entry, nil] The created/updated Entry, or nil if account not linked + # @raise [ArgumentError] if required transaction fields are missing + # @raise [StandardError] if the transaction cannot be saved + def process + # Validate that we have a linked account before processing + unless account.present? + Rails.logger.warn "SophtronEntry::Processor - No linked account for sophtron_account #{sophtron_account.id}, skipping transaction #{external_id}" + return nil + end + + # Wrap import in error handling to catch validation and save errors + begin + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "sophtron", + merchant: merchant, + notes: notes + ) + rescue ArgumentError => e + # Re-raise validation errors (missing required fields, invalid data) + Rails.logger.error "SophtronEntry::Processor - Validation error for transaction #{external_id}: #{e.message}" + raise + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + # Handle database save errors + Rails.logger.error "SophtronEntry::Processor - Failed to save transaction #{external_id}: #{e.message}" + raise StandardError.new("Failed to import transaction: #{e.message}") + rescue => e + # Catch unexpected errors with full context + Rails.logger.error "SophtronEntry::Processor - Unexpected error processing transaction #{external_id}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + raise StandardError.new("Unexpected error importing transaction: #{e.message}") + end + end + + private + attr_reader :sophtron_transaction, :sophtron_account + + # Returns the import adapter for this transaction's account. + # + # @return [Account::ProviderImportAdapter] Adapter for importing transactions + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + # Returns the linked Maybe Account for this transaction. + # + # @return [Account, nil] The linked account + def account + @account ||= sophtron_account.current_account + end + + # Returns the transaction data with indifferent access. + # + # @return [ActiveSupport::HashWithIndifferentAccess] Normalized transaction data + def data + @data ||= sophtron_transaction.with_indifferent_access + end + + # Generates a unique external ID for this transaction. + # + # Prefixes the Sophtron transaction ID with 'sophtron_' to avoid conflicts + # with other providers. + # + # @return [String] The external ID (e.g., 'sophtron_12345') + # @raise [ArgumentError] if the transaction ID is missing + def external_id + id = data[:id].presence + raise ArgumentError, "Sophtron transaction missing required field 'id'" unless id + "sophtron_#{id}" + end + + # Extracts the transaction name from the data. + # + # Falls back to "Unknown transaction" if merchant is not present. + # + # @return [String] The transaction name + def name + data[:merchant].presence || t("sophtron_items.sophtron_entry.processor.unknown_transaction") + end + + # Extracts optional notes/description from the transaction. + # + # @return [String, nil] Transaction description + def notes + data[:description].presence + end + + # Finds or creates a merchant for this transaction. + # + # Creates a deterministic merchant ID using MD5 hash of the merchant name. + # This ensures the same merchant name always maps to the same merchant record. + # + # @return [Merchant, nil] The merchant object, or nil if merchant data is missing + def merchant + return nil unless data[:merchant].present? + + # Create a stable merchant ID from the merchant name + # Using digest to ensure uniqueness while keeping it deterministic + merchant_name = data[:merchant].to_s.strip + return nil if merchant_name.blank? + + merchant_id = Digest::MD5.hexdigest(merchant_name.downcase) + + @merchant ||= begin + import_adapter.find_or_create_merchant( + provider_merchant_id: "sophtron_merchant_#{merchant_id}", + name: merchant_name, + source: "sophtron" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "SophtronEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + nil + end + end + + # Parses and converts the transaction amount. + # + # Sophtron uses standard banking convention (negative = expense, positive = income) + # while Maybe uses inverted signs (positive = expense, negative = income). + # This method negates the amount to convert between conventions. + # + # @return [BigDecimal] The converted amount + # @raise [ArgumentError] if the amount cannot be parsed + def amount + parsed_amount = case data[:amount] + when String + BigDecimal(data[:amount]) + when Numeric + BigDecimal(data[:amount].to_s) + else + BigDecimal("0") + end + + # Sophtron likely uses standard convention where negative is expense, positive is income + # Maybe expects opposite convention (expenses positive, income negative) + # So we negate the amount to convert from Sophtron to Maybe format + -parsed_amount + rescue ArgumentError => e + Rails.logger.error "Failed to parse Sophtron transaction amount: #{data[:amount].inspect} - #{e.message}" + raise + end + + # Extracts and normalizes the currency code. + # + # Falls back to the account currency, then USD if not specified. + # + # @return [String] Three-letter currency code (e.g., 'USD') + def currency + parse_currency(data[:currency]) || account&.currency || "USD" + end + + # Logs invalid currency codes. + # + # @param currency_value [String] The invalid currency code + # @return [void] + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' in Sophtron transaction #{external_id}, falling back to account currency") + end + + # Parses the transaction date from various formats. + # + # Handles: + # - String dates (ISO format) + # - Unix timestamps (Integer/Float) + # - Time/DateTime objects + # - Date objects + # + # @return [Date] The parsed transaction date + # @raise [ArgumentError] if the date cannot be parsed + def date + case data[:date] + when String + Date.parse(data[:date]) + when Integer, Float + # Unix timestamp + Time.at(data[:date]).to_date + when Time, DateTime + data[:date].to_date + when Date + data[:date] + else + Rails.logger.error("Sophtron transaction has invalid date value: #{data[:date].inspect}") + raise ArgumentError, "Invalid date format: #{data[:date].inspect}" + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Sophtron transaction date '#{data[:date]}': #{e.message}") + raise ArgumentError, "Unable to parse transaction date: #{data[:date].inspect}" + end +end diff --git a/app/models/sophtron_item.rb b/app/models/sophtron_item.rb new file mode 100644 index 000000000..aa6194681 --- /dev/null +++ b/app/models/sophtron_item.rb @@ -0,0 +1,416 @@ +# Represents a Sophtron integration item for a family. +# +# A SophtronItem stores Sophtron API credentials and manages the connection +# to a family's Sophtron account. It can have multiple associated SophtronAccounts, +# which represent individual bank accounts linked through Sophtron. +# +# @attr [String] name The display name for this Sophtron connection +# @attr [String] user_id Sophtron User ID (encrypted if encryption is configured) +# @attr [String] access_key Sophtron Access Key (encrypted if encryption is configured) +# @attr [String] base_url Base URL for Sophtron API (optional, defaults to production) +# @attr [String] status Current status: 'good' or 'requires_update' +# @attr [Boolean] scheduled_for_deletion Whether the item is scheduled for deletion +# @attr [DateTime] last_synced_at When the last successful sync occurred +class SophtronItem < ApplicationRecord + include Syncable, Provided, Unlinking + + INITIAL_LOAD_LOOKBACK_DAYS = 120 + MAX_TRANSACTION_HISTORY_YEARS = 3 + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + # Helper to detect if ActiveRecord Encryption is configured for this app. + # + # Checks both Rails credentials and environment variables for encryption keys. + # + # @return [Boolean] true if encryption is properly configured + def self.encryption_ready? + creds_ready = Rails.application.credentials.active_record_encryption.present? + env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? + creds_ready || env_ready + end + + # Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars) + if encryption_ready? + encrypts :user_id, deterministic: true + encrypts :access_key, deterministic: true + end + + validates :name, presence: true + validates :user_id, presence: true, on: :create + validates :access_key, presence: true, on: :create + + belongs_to :family + belongs_to :current_job_sophtron_account, class_name: "SophtronAccount", optional: true + has_one_attached :logo + + has_many :sophtron_accounts, dependent: :destroy + has_many :accounts, through: :sophtron_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + # Imports the latest account and transaction data from Sophtron. + # + # This method fetches all accounts and transactions from the Sophtron API + # and updates the local database accordingly. It will: + # - Fetch all accounts associated with the Sophtron connection + # - Create new SophtronAccount records for newly discovered accounts + # - Update existing linked accounts with latest data + # - Fetch and store transactions for all linked accounts + # + # @return [Hash] Import results with counts of accounts and transactions imported + # @raise [StandardError] if the Sophtron provider is not configured + # @raise [Provider::Sophtron::Error] if the Sophtron API returns an error + def import_latest_sophtron_data(sync: nil) + provider = sophtron_provider + unless provider + Rails.logger.error "SophtronItem #{id} - Cannot import: Sophtron provider is not configured (missing API key)" + raise StandardError.new("Sophtron provider is not configured") + end + + SophtronItem::Importer.new(self, sophtron_provider: provider, sync: sync).import + rescue => e + Rails.logger.error "SophtronItem #{id} - Failed to import data: #{e.message}" + raise + end + + def linked_visible_sophtron_accounts + sophtron_accounts.joins(:account).merge(Account.visible) + end + + def automatic_sync_sophtron_accounts + return linked_visible_sophtron_accounts.none if manual_sync? + + linked_visible_sophtron_accounts.automatic_sync + end + + def manual_sync_required? + manual_sync? || sophtron_accounts.requires_manual_sync.exists? + end + + def manual_sync_sophtron_accounts + linked_accounts = sophtron_accounts.joins(:account_provider).order(:created_at, :id) + manual_accounts = linked_accounts.requires_manual_sync + + return manual_accounts if manual_accounts.exists? + + manual_sync? ? linked_accounts : linked_accounts.none + end + + def connected_institution_options + sophtron_accounts.order(:created_at, :id).filter_map do |sophtron_account| + institution_key = sophtron_account.institution_key + next if institution_key.blank? + + { + institution_key: institution_key, + name: sophtron_account.institution_name.presence || institution_display_name + } + end.uniq { |institution| institution[:institution_key].to_s } + end + + def manual_sync_required_for_institution?(institution_key) + institution_accounts = sophtron_accounts.select do |sophtron_account| + sophtron_account.institution_key.to_s == institution_key.to_s + end + + return manual_sync? if institution_accounts.empty? + + institution_accounts.any?(&:manual_sync?) || (manual_sync? && !sophtron_accounts.requires_manual_sync.exists?) + end + + def process_accounts(sophtron_accounts_scope: linked_visible_sophtron_accounts) + return [] if sophtron_accounts_scope.empty? + + results = [] + # Only process accounts that are linked and have active status + sophtron_accounts_scope.each do |sophtron_account| + begin + result = SophtronAccount::Processor.new(sophtron_account).process + results << { sophtron_account_id: sophtron_account.id, success: true, result: result } + rescue => e + Rails.logger.error "SophtronItem #{id} - Failed to process account #{sophtron_account.id}: #{e.message}" + results << { sophtron_account_id: sophtron_account.id, success: false, error: e.message } + # Continue processing other accounts even if one fails + end + end + + results + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil, sophtron_accounts_scope: linked_visible_sophtron_accounts) + linked_accounts = sophtron_accounts_scope.includes(:account_provider).filter_map(&:current_account) + return [] if linked_accounts.empty? + + results = [] + # Only schedule syncs for active accounts + linked_accounts.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error "SophtronItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + # Continue scheduling other accounts even if one fails + end + end + + results + end + + def start_initial_load_later + active_sync = syncs.visible.ordered.first + + sync_later(window_start_date: initial_load_window_start_date) + + return unless active_sync&.reload&.syncing? + + SophtronInitialLoadJob.set(wait: SophtronInitialLoadJob::RETRY_DELAY).perform_later(self) + end + + def initial_load_window_start_date + configured_start = sync_start_date&.to_date + default_start = INITIAL_LOAD_LOOKBACK_DAYS.days.ago.to_date + max_history_start = MAX_TRANSACTION_HISTORY_YEARS.years.ago.to_date + + [ configured_start || default_start, max_history_start ].max + end + + def upsert_sophtron_snapshot!(accounts_snapshot) + assign_attributes( + raw_payload: accounts_snapshot + ) + + save! + end + + def ensure_customer!(provider: sophtron_provider) + return customer_id if customer_id.present? + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + matching_customer = find_matching_customer(Provider::Sophtron.response_data!(provider.list_customers)) + customer_payload = matching_customer || Provider::Sophtron.response_data!( + provider.create_customer( + unique_id: generated_customer_unique_id, + name: generated_customer_name, + source: "Sure" + ) + ) + + # Some Sophtron endpoints may return an empty body on success; re-list to find + # the customer we just created if the create response does not include an id. + if extract_customer_id(customer_payload).blank? + customer_payload = find_matching_customer(Provider::Sophtron.response_data!(provider.list_customers)) + end + + extracted_customer_id = extract_customer_id(customer_payload) + raise Provider::Sophtron::Error.new("Sophtron customer response did not include CustomerID", :invalid_response) if extracted_customer_id.blank? + + update!( + customer_id: extracted_customer_id, + customer_name: extract_customer_name(customer_payload).presence || generated_customer_name, + raw_customer_payload: customer_payload + ) + + customer_id + end + + def connected_to_institution? + user_institution_id.present? && current_job_id.blank? && good? && !failed_connection_job? + end + + def failed_connection_job? + payload = raw_job_payload || {} + payload = payload.with_indifferent_access if payload.respond_to?(:with_indifferent_access) + + success_flag = if payload.respond_to?(:key?) && payload.key?(:SuccessFlag) + payload[:SuccessFlag] + elsif payload.respond_to?(:key?) + payload[:success_flag] + end + + last_status = job_status.presence || + (payload[:LastStatus] if payload.respond_to?(:[])) || + (payload[:last_status] if payload.respond_to?(:[])) + + success_flag == false && Provider::Sophtron.failure_job_status?(last_status) + end + + def upsert_job_snapshot!(job_payload) + job_payload = job_payload.with_indifferent_access + + update!( + job_status: job_payload[:LastStatus] || job_payload[:last_status], + raw_job_payload: job_payload + ) + end + + def fetch_remote_accounts(force: false) + cache_key = "sophtron_accounts_#{family.id}_#{id}_#{user_institution_id}" + cached = Rails.cache.read(cache_key) + return cached if cached.present? && !force + + accounts_data = Provider::Sophtron.response_data!(sophtron_provider.get_accounts(user_institution_id)) + accounts = accounts_data[:accounts] || [] + Rails.cache.write(cache_key, accounts, expires_in: 5.minutes) + persist_remote_sophtron_accounts(accounts) + accounts + end + + def persist_remote_sophtron_accounts(accounts) + Array(accounts).each do |account_data| + account_data = account_data.with_indifferent_access + next if account_data[:account_name].blank? + + upsert_sophtron_account(account_data) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn("Skipping Sophtron account #{self.class.external_account_id(account_data)}: #{e.message}") + end + end + + def reject_already_linked(accounts) + linked_account_ids = sophtron_accounts.joins(:account_provider).pluck(:account_id).map(&:to_s) + Array(accounts).reject { |account| linked_account_ids.include?(self.class.external_account_id(account).to_s) } + end + + def upsert_sophtron_account(account_data) + sophtron_accounts.find_or_initialize_by( + account_id: self.class.external_account_id(account_data).to_s + ).tap do |sophtron_account| + sophtron_account.upsert_sophtron_snapshot!(account_data) + end + end + + def build_mfa_challenge(job) + job = job.with_indifferent_access + { + security_questions: Provider::Sophtron.parse_json_array(job[:SecurityQuestion] || job[:security_question]), + token_methods: Provider::Sophtron.parse_json_array(job[:TokenMethod] || job[:token_method]), + token_sent: Provider::Sophtron.job_token_input_required?(job), + token_read: job[:TokenRead] || job[:token_read], + captcha_image: job[:CaptchaImage] || job[:captcha_image] + } + end + + def self.external_account_id(account_data) + account_data.with_indifferent_access[:account_id] || account_data.with_indifferent_access[:id] + end + + def has_completed_initial_setup? + # Setup is complete if we have any linked accounts + accounts.any? + end + + def sync_status_summary + # Use centralized count helper methods for consistency + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count + + if total_accounts == 0 + "No accounts found" + elsif unlinked_count == 0 + "#{linked_count} #{'account'.pluralize(linked_count)} synced" + else + "#{linked_count} synced, #{unlinked_count} need setup" + end + end + + def linked_accounts_count + sophtron_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + sophtron_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + sophtron_accounts.count + end + + def institution_display_name + # Try to get institution name from stored metadata + institution_name.presence || institution_domain.presence || name + end + + def provider_display_name + I18n.t("sophtron_items.defaults.name", default: "Sophtron Connection") + end + + def connected_institutions + # Get unique institutions from all accounts + sophtron_accounts.includes(:account) + .where.not(institution_metadata: nil) + .map { |acc| acc.institution_metadata } + .uniq { |inst| inst["name"] || inst["institution_name"] } + end + + def institution_summary + institutions = connected_institutions + case institutions.count + when 0 + "No institutions connected" + when 1 + institutions.first["name"] || institutions.first["institution_name"] || "1 institution" + else + "#{institutions.count} institutions" + end + end + + def credentials_configured? + user_id.present? && + access_key.present? + end + + def effective_base_url + base_url.presence || Provider::Sophtron::DEFAULT_BASE_URL + end + + def generated_customer_unique_id + "sure-family-#{family.id}" + end + + def generated_customer_name + "Sure family #{family.id}" + end + + private + + def find_matching_customer(customers) + customers = Array(customers) + + customers.find do |customer| + extract_customer_id(customer).to_s == generated_customer_unique_id + end || customers.find do |customer| + extract_customer_name(customer).to_s == generated_customer_name + end + end + + def extract_customer_id(customer_payload) + return nil unless customer_payload.respond_to?(:with_indifferent_access) + + customer_payload = customer_payload.with_indifferent_access + customer_payload[:CustomerID] || customer_payload[:customer_id] || customer_payload[:id] + end + + def extract_customer_name(customer_payload) + return nil unless customer_payload.respond_to?(:with_indifferent_access) + + customer_payload = customer_payload.with_indifferent_access + customer_payload[:CustomerName] || customer_payload[:customer_name] || customer_payload[:name] + end +end diff --git a/app/models/sophtron_item/importer.rb b/app/models/sophtron_item/importer.rb new file mode 100644 index 000000000..403024cf6 --- /dev/null +++ b/app/models/sophtron_item/importer.rb @@ -0,0 +1,460 @@ +require "set" + +# Imports account and transaction data from Sophtron API. +# +# This class orchestrates the complete import process for a SophtronItem: +# 1. Fetches all accounts from Sophtron +# 2. Updates existing linked accounts with latest data +# 3. Creates SophtronAccount records for newly discovered accounts +# 4. Fetches and stores transactions for all linked accounts +# 5. Updates account balances +# +# The importer maintains a separation between "discovered" accounts (any account +# returned by the Sophtron API) and "linked" accounts (accounts the user has +# explicitly connected to Maybe Accounts). This allows users to selectively +# import accounts of their choosing. +class SophtronItem::Importer + INCREMENTAL_SYNC_BUFFER_DAYS = 60 + + attr_reader :sophtron_item, :sophtron_provider, :sync + + # Initializes a new importer. + # + # @param sophtron_item [SophtronItem] The Sophtron item to import data for + # @param sophtron_provider [Provider::Sophtron] Configured Sophtron API client + # @param sync [Sync, nil] Optional sync record whose window should guide import scope + def initialize(sophtron_item, sophtron_provider:, sync: nil) + @sophtron_item = sophtron_item + @sophtron_provider = sophtron_provider + @sync = sync + end + + # Performs the complete import process for this Sophtron item. + # + # This method: + # - Fetches all accounts from Sophtron API + # - Stores raw account data snapshot + # - Updates existing linked accounts + # - Creates records for newly discovered accounts + # - Fetches transactions for all linked accounts + # - Updates account balances + # + # @return [Hash] Import results with the following keys: + # - :success [Boolean] Overall success status + # - :accounts_updated [Integer] Number of existing accounts updated + # - :accounts_created [Integer] Number of new account records created + # - :accounts_failed [Integer] Number of accounts that failed to import + # - :transactions_imported [Integer] Total number of transactions imported + # - :transactions_failed [Integer] Number of accounts with transaction import failures + # @example + # result = importer.import + # # => { success: true, accounts_updated: 2, accounts_created: 1, + # # accounts_failed: 0, transactions_imported: 150, transactions_failed: 0 } + def import + Rails.logger.info "SophtronItem::Importer - Starting import for item #{sophtron_item.id}" + unless sophtron_item.user_institution_id.present? + error_message = "Sophtron institution connection is incomplete" + Rails.logger.warn "SophtronItem::Importer - Item #{sophtron_item.id} has no Sophtron UserInstitutionID" + sophtron_item.update!( + status: :requires_update, + last_connection_error: error_message + ) + + return { + success: false, + error: error_message, + accounts_updated: 0, + accounts_created: 0, + accounts_failed: 0, + transactions_imported: 0, + transactions_failed: 0 + } + end + + # Step 1: Fetch all accounts from Sophtron + accounts_data = fetch_accounts_data + unless accounts_data + Rails.logger.error "SophtronItem::Importer - Failed to fetch accounts data for item #{sophtron_item.id}" + return { success: false, error: "Failed to fetch accounts data", accounts_imported: 0, transactions_imported: 0 } + end + + # Store raw payload + begin + sophtron_item.upsert_sophtron_snapshot!(accounts_data) + rescue => e + Rails.logger.error "SophtronItem::Importer - Failed to store accounts snapshot: #{e.message}" + # Continue with import even if snapshot storage fails + end + + # Step 2: Update linked accounts and create records for new accounts from API + accounts_updated = 0 + accounts_created = 0 + accounts_failed = 0 + + if accounts_data[:accounts].present? + # Get linked sophtron account IDs (ones actually imported/used by the user) + linked_account_ids = sophtron_item.sophtron_accounts + .joins(:account_provider) + .pluck(:account_id) + .map(&:to_s) + # Get all existing sophtron account IDs (linked or not) + all_existing_ids = sophtron_item.sophtron_accounts.pluck(:account_id).map(&:to_s) + accounts_data[:accounts].each do |account_data| + account_id = (account_data[:account_id] || account_data[:id])&.to_s + next unless account_id.present? + account_name = account_data[:account_name] || account_data[:name] + next if account_name.blank? + if linked_account_ids.include?(account_id) + # Update existing linked accounts + begin + import_account(account_data) + accounts_updated += 1 + rescue => e + accounts_failed += 1 + Rails.logger.error "SophtronItem::Importer - Failed to update account #{account_id}: #{e.message}" + end + elsif !all_existing_ids.include?(account_id) + # Create new unlinked sophtron_account records for accounts we haven't seen before + # This allows users to link them later via "Setup new accounts" + begin + sophtron_account = sophtron_item.sophtron_accounts.build( + account_id: account_id, + name: account_name, + currency: account_data[:currency] || "USD" + ) + sophtron_account.upsert_sophtron_snapshot!(account_data) + accounts_created += 1 + Rails.logger.info "SophtronItem::Importer - Created new unlinked account record for #{account_id}" + rescue => e + accounts_failed += 1 + Rails.logger.error "SophtronItem::Importer - Failed to create account #{account_id}: #{e.message}" + end + end + end + end + + Rails.logger.info "SophtronItem::Importer - Updated #{accounts_updated} accounts, created #{accounts_created} new (#{accounts_failed} failed)" + + # Step 3: Fetch transactions only for linked accounts with active status + transactions_imported = 0 + transactions_failed = 0 + + linked_accounts = sophtron_item.automatic_sync_sophtron_accounts + linked_accounts.each do |sophtron_account| + begin + result = fetch_and_store_transactions(sophtron_account) + if result[:success] + transactions_imported += result[:transactions_count] + else + transactions_failed += 1 + break if result[:requires_update] + end + rescue => e + transactions_failed += 1 + Rails.logger.error "SophtronItem::Importer - Failed to fetch/store transactions for account #{sophtron_account.account_id}: #{e.message}" + # Continue with other accounts even if one fails + end + end + + Rails.logger.info "SophtronItem::Importer - Completed import for item #{sophtron_item.id}: #{accounts_updated} accounts updated, #{accounts_created} new accounts discovered, #{transactions_imported} transactions" + + { + success: accounts_failed == 0 && transactions_failed == 0, + accounts_updated: accounts_updated, + accounts_created: accounts_created, + accounts_failed: accounts_failed, + transactions_imported: transactions_imported, + transactions_failed: transactions_failed + } + end + + def import_transactions_after_refresh(sophtron_account) + fetch_and_store_transactions(sophtron_account, refresh: false) + end + + private + + def fetch_accounts_data + begin + accounts_data = Provider::Sophtron.response_data!(sophtron_provider.get_accounts(sophtron_item.user_institution_id)) + rescue Provider::Sophtron::Error => e + # Handle authentication errors by marking item as requiring update + if e.error_type == :unauthorized || e.error_type == :access_forbidden + begin + sophtron_item.update!(status: :requires_update) + rescue => update_error + Rails.logger.error "SophtronItem::Importer - Failed to update item status: #{update_error.message}" + end + end + Rails.logger.error "SophtronItem::Importer - Sophtron API error: #{e.message}" + return nil + rescue JSON::ParserError => e + Rails.logger.error "SophtronItem::Importer - Failed to parse Sophtron API response: #{e.message}" + return nil + rescue => e + Rails.logger.error "SophtronItem::Importer - Unexpected error fetching accounts: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + return nil + end + + # Validate response structure + unless accounts_data.is_a?(Hash) + Rails.logger.error "SophtronItem::Importer - Invalid accounts_data format: expected Hash, got #{accounts_data.class}" + return nil + end + + # Handle errors if present in response + if accounts_data[:error].present? + handle_error(accounts_data[:error]) + return nil + end + + accounts_data + end + + # Imports and updates a single account from Sophtron data. + # + # This method only updates existing SophtronAccount records that were + # previously created. It does not create new accounts during sync. + # + # @param account_data [Hash] Raw account data from Sophtron API + # @return [void] + # @raise [ArgumentError] if account_data is invalid or account_id is missing + # @raise [StandardError] if the account cannot be saved + def import_account(account_data) + # Validate account data structure + unless account_data.is_a?(Hash) + Rails.logger.error "SophtronItem::Importer - Invalid account_data format: expected Hash, got #{account_data.class}" + raise ArgumentError, "Invalid account data format" + end + + account_id = (account_data[:account_id] || account_data[:id])&.to_s + + # Validate required account_id + if account_id.blank? + Rails.logger.warn "SophtronItem::Importer - Skipping account with missing ID" + raise ArgumentError, "Account ID is required" + end + + # Only find existing accounts, don't create new ones during sync + sophtron_account = sophtron_item.sophtron_accounts.find_by( + account_id: account_id + ) + + # Skip if account wasn't previously selected + unless sophtron_account + return + end + + begin + sophtron_account.upsert_sophtron_snapshot!(account_data) + sophtron_account.save! + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "SophtronItem::Importer - Failed to save sophtron_account: #{e.message}" + raise StandardError.new("Failed to save account: #{e.message}") + end + end + + # Fetches and stores transactions for a Sophtron account. + # + # This method: + # 1. Determines the appropriate sync start date + # 2. Fetches transactions from the Sophtron API + # 3. Deduplicates against existing transactions + # 4. Stores new transactions in raw_transactions_payload + # 5. Updates the account balance + # + # @param sophtron_account [SophtronAccount] The account to fetch transactions for + # @return [Hash] Result with keys: + # - :success [Boolean] Whether the fetch was successful + # - :transactions_count [Integer] Number of transactions fetched + # - :error [String, nil] Error message if failed + def fetch_and_store_transactions(sophtron_account, refresh: true) + start_date = determine_sync_start_date(sophtron_account) + Rails.logger.info "SophtronItem::Importer - Fetching transactions for account #{sophtron_account.account_id} from #{start_date}" + + begin + if refresh && !initial_transaction_fetch?(sophtron_account) + refresh_result = refresh_account_before_transaction_fetch(sophtron_account) + return refresh_result if refresh_result.present? + end + + # Fetch transactions + transactions_data = Provider::Sophtron.response_data!( + sophtron_provider.get_account_transactions( + sophtron_account.account_id, + start_date: start_date + ) + ) + + # Validate response structure + unless transactions_data.is_a?(Hash) + Rails.logger.error "SophtronItem::Importer - Invalid transactions_data format for account #{sophtron_account.account_id}" + return { success: false, transactions_count: 0, error: "Invalid response format" } + end + + transactions = transactions_data[:transactions] + unless transactions.is_a?(Array) + Rails.logger.error "SophtronItem::Importer - Missing transactions array for account #{sophtron_account.account_id}" + return { success: false, transactions_count: 0, error: "Missing transactions array" } + end + + transactions_count = transactions.count + Rails.logger.info "SophtronItem::Importer - Fetched #{transactions_count} transactions for account #{sophtron_account.account_id}" + + # Store transactions in the account + if transactions.any? + begin + existing_transactions = sophtron_account.raw_transactions_payload.to_a + + # Build set of existing transaction IDs for efficient lookup + existing_ids = existing_transactions.map do |tx| + tx.with_indifferent_access[:id] + end.to_set + + # Filter to ONLY truly new transactions (skip duplicates) + # Transactions are immutable on the bank side, so we don't need to update them + new_transactions = transactions.select do |tx| + next false unless tx.is_a?(Hash) + + tx_id = tx.with_indifferent_access[:id] + tx_id.present? && !existing_ids.include?(tx_id) + end + + if new_transactions.any? + Rails.logger.info "SophtronItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions.count - new_transactions.count} duplicates skipped) for account #{sophtron_account.account_id}" + sophtron_account.upsert_sophtron_transactions_snapshot!(existing_transactions + new_transactions) + else + Rails.logger.info "SophtronItem::Importer - No new transactions to store (all #{transactions.count} were duplicates) for account #{sophtron_account.account_id}" + sophtron_account.upsert_sophtron_transactions_snapshot!(existing_transactions) if sophtron_account.raw_transactions_payload.nil? + end + rescue => e + Rails.logger.error "SophtronItem::Importer - Failed to store transactions for account #{sophtron_account.account_id}: #{e.message}" + return { success: false, transactions_count: 0, error: "Failed to store transactions: #{e.message}" } + end + else + Rails.logger.info "SophtronItem::Importer - No transactions to store for account #{sophtron_account.account_id}" + sophtron_account.upsert_sophtron_transactions_snapshot!([]) if sophtron_account.raw_transactions_payload.nil? + end + + { success: true, transactions_count: transactions_count } + rescue Provider::Sophtron::Error => e + requires_update = e.error_type.in?([ :unauthorized, :access_forbidden ]) + sophtron_item.update!(status: :requires_update) if requires_update + Rails.logger.error "SophtronItem::Importer - Sophtron API error for account #{sophtron_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: e.message, requires_update: requires_update } + rescue JSON::ParserError => e + Rails.logger.error "SophtronItem::Importer - Failed to parse transaction response for account #{sophtron_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: "Failed to parse response" } + rescue => e + Rails.logger.error "SophtronItem::Importer - Unexpected error fetching transactions for account #{sophtron_account.id}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + { success: false, transactions_count: 0, error: "Unexpected error: #{e.message}" } + end + end + + def refresh_account_before_transaction_fetch(sophtron_account) + refresh_response = Provider::Sophtron.response_data!(sophtron_provider.refresh_account(sophtron_account.account_id)) + job_id = refresh_response.with_indifferent_access[:JobID] || refresh_response.with_indifferent_access[:job_id] + return nil if job_id.blank? + + job = Provider::Sophtron.response_data!(sophtron_provider.get_job_information(job_id)) + sophtron_item.upsert_job_snapshot!(job) + + if Provider::Sophtron.job_requires_input?(job) + sophtron_item.update!( + status: :requires_update, + current_job_id: job_id, + last_connection_error: "Sophtron refresh requires MFA" + ) + return { success: false, transactions_count: 0, error: "Sophtron refresh requires MFA", requires_update: true } + end + + if Provider::Sophtron.job_failed?(job) + return { success: false, transactions_count: 0, error: "Sophtron refresh failed" } + end + + unless Provider::Sophtron.job_success?(job) || Provider::Sophtron.job_completed?(job) + SophtronRefreshPollJob.set(wait: SophtronRefreshPollJob::POLL_INTERVAL).perform_later( + sophtron_account, + job_id: job_id, + sync: sync + ) + + return { success: true, transactions_count: 0, refresh_pending: true } + end + + nil + rescue Provider::Sophtron::Error => e + requires_update = e.error_type.in?([ :unauthorized, :access_forbidden ]) + sophtron_item.update!(status: :requires_update) if requires_update + Rails.logger.error "SophtronItem::Importer - Sophtron API error refreshing account #{sophtron_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: e.message, requires_update: requires_update } + end + + # Determines the appropriate start date for fetching transactions. + # + # Logic: + # - For accounts with stored transactions: uses last sync date minus a buffer + # - For new accounts: uses the sync window or provider default initial lookback + # + # This captures late-arriving transactions while keeping history bounded. + # + # @param sophtron_account [SophtronAccount] The account to determine start date for + # @return [Date] The start date for transaction sync + def determine_sync_start_date(sophtron_account) + configured_start = sync&.window_start_date || sophtron_item.sync_start_date&.to_date + max_history_start = SophtronItem::MAX_TRANSACTION_HISTORY_YEARS.years.ago.to_date + floor_start = configured_start ? [ configured_start, max_history_start ].max : nil + + if !initial_transaction_fetch?(sophtron_account) + # Account has been synced before, use item-level logic with buffer + # For subsequent syncs, fetch from last sync date with a buffer + if sophtron_item.last_synced_at + [ sophtron_item.last_synced_at.to_date - INCREMENTAL_SYNC_BUFFER_DAYS, floor_start ].compact.max + else + # Fallback if item hasn't been synced but account has transactions + floor_start || sophtron_item.initial_load_window_start_date + end + else + # Account has no stored transactions - this is a first sync for this account + # Use the configured sync window if present, otherwise the provider's default initial lookback. + floor_start || sophtron_item.initial_load_window_start_date + end + end + + def initial_transaction_fetch?(sophtron_account) + sophtron_account.raw_transactions_payload.nil? && sophtron_item.last_synced_at.blank? + end + + # Handles API errors and marks the item for re-authentication if needed. + # + # Authentication-related errors cause the item status to be set to + # :requires_update, prompting the user to re-enter credentials. + # + # @param error_message [String] The error message from the API + # @return [void] + # @raise [Provider::Sophtron::Error] Always raises an error with the message + def handle_error(error_message) + # Mark item as requiring update for authentication-related errors + error_msg_lower = error_message.to_s.downcase + needs_update = error_msg_lower.include?("authentication") || + error_msg_lower.include?("unauthorized") || + error_msg_lower.include?("user id") || + error_msg_lower.include?("access key") + + if needs_update + begin + sophtron_item.update!(status: :requires_update) + rescue => e + Rails.logger.error "SophtronItem::Importer - Failed to update item status: #{e.message}" + end + end + + Rails.logger.error "SophtronItem::Importer - API error: #{error_message}" + raise Provider::Sophtron::Error.new( + "Sophtron API error: #{error_message}", + :api_error + ) + end +end diff --git a/app/models/sophtron_item/provided.rb b/app/models/sophtron_item/provided.rb new file mode 100644 index 000000000..f18b0281f --- /dev/null +++ b/app/models/sophtron_item/provided.rb @@ -0,0 +1,9 @@ +module SophtronItem::Provided + extend ActiveSupport::Concern + + def sophtron_provider + return nil unless credentials_configured? + + Provider::Sophtron.new(user_id, access_key, base_url: effective_base_url) + end +end diff --git a/app/models/sophtron_item/sync_complete_event.rb b/app/models/sophtron_item/sync_complete_event.rb new file mode 100644 index 000000000..9bb83089c --- /dev/null +++ b/app/models/sophtron_item/sync_complete_event.rb @@ -0,0 +1,25 @@ +class SophtronItem::SyncCompleteEvent + attr_reader :sophtron_item + + def initialize(sophtron_item) + @sophtron_item = sophtron_item + end + + def broadcast + # Update UI with latest account data + sophtron_item.accounts.each do |account| + account.broadcast_sync_complete + end + + # Update the Sophtron item view + sophtron_item.broadcast_replace_to( + sophtron_item.family, + target: "sophtron_item_#{sophtron_item.id}", + partial: "sophtron_items/sophtron_item", + locals: { sophtron_item: sophtron_item } + ) + + # Let family handle sync notifications + sophtron_item.family.broadcast_sync_complete + end +end diff --git a/app/models/sophtron_item/syncer.rb b/app/models/sophtron_item/syncer.rb new file mode 100644 index 000000000..2c21be53f --- /dev/null +++ b/app/models/sophtron_item/syncer.rb @@ -0,0 +1,137 @@ +# Orchestrates the complete sync process for a SophtronItem. +# +# The syncer coordinates multiple phases: +# 1. Import accounts and transactions from Sophtron API +# 2. Check account setup status and collect statistics +# 3. Process transactions for linked accounts +# 4. Schedule balance calculations +# 5. Collect sync statistics and health metrics +# +# This follows the same pattern as other provider syncers (SimpleFIN, Plaid) +# and integrates with the Syncable concern. +class SophtronItem::Syncer + include SyncStats::Collector + + attr_reader :sophtron_item + + # Initializes a new syncer for a Sophtron item. + # + # @param sophtron_item [SophtronItem] The item to sync + def initialize(sophtron_item) + @sophtron_item = sophtron_item + end + + # Performs the complete sync process. + # + # This method orchestrates all phases of the sync: + # - Imports fresh data from Sophtron API + # - Updates linked accounts and creates new account records + # - Processes transactions for linked accounts only + # - Schedules balance calculations + # - Collects statistics and health metrics + # + # @param sync [Sync] The sync record to track progress and status + # @return [void] + # @raise [StandardError] if any phase of the sync fails + def perform_sync(sync) + # Phase 1: Import data from Sophtron API + sync.update!(status_text: t("sophtron_items.syncer.importing_accounts")) if sync.respond_to?(:status_text) + import_result = sophtron_item.import_latest_sophtron_data(sync: sync) + import_errors = import_errors_for(import_result) + + # Phase 2: Check account setup status and collect sync statistics + sync.update!(status_text: t("sophtron_items.syncer.checking_account_configuration")) if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: sophtron_item.sophtron_accounts) + + # Check for unlinked accounts + linked_accounts = sophtron_item.automatic_sync_sophtron_accounts + unlinked_accounts = sophtron_item.sophtron_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + + # Set pending_account_setup if there are unlinked accounts + unlinked_count = unlinked_accounts.count + if unlinked_count.positive? + sophtron_item.update!(pending_account_setup: true) + sync.update!(status_text: t("sophtron_items.syncer.accounts_need_setup", count: unlinked_count)) if sync.respond_to?(:status_text) + else + sophtron_item.update!(pending_account_setup: false) + end + + # Phase 3: Process transactions for linked accounts only + if linked_accounts.any? + sync.update!(status_text: t("sophtron_items.syncer.processing_transactions")) if sync.respond_to?(:status_text) + mark_import_started(sync) + Rails.logger.info "SophtronItem::Syncer - Processing #{linked_accounts.count} linked accounts" + sophtron_item.process_accounts(sophtron_accounts_scope: linked_accounts) + Rails.logger.info "SophtronItem::Syncer - Finished processing accounts" + + # Phase 4: Schedule balance calculations for linked accounts + sync.update!(status_text: t("sophtron_items.syncer.calculating_balances")) if sync.respond_to?(:status_text) + sophtron_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date, + sophtron_accounts_scope: linked_accounts + ) + + # Phase 5: Collect transaction statistics + account_ids = linked_accounts.includes(:account_provider).filter_map { |la| la.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "sophtron") + else + sync.update!(status_text: t("sophtron_items.syncer.manual_sync_required")) if sophtron_item.manual_sync_required? && sync.respond_to?(:status_text) + Rails.logger.info "SophtronItem::Syncer - No linked accounts to process" + end + + # Mark sync health + if import_errors.present? + collect_health_stats(sync, errors: import_errors) + raise StandardError.new(import_errors.map { |error| error[:message] }.join(", ")) + else + collect_health_stats(sync, errors: nil) + end + rescue => e + collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) unless sync_errors_recorded?(sync) + raise + end + + # Performs post-sync cleanup or actions. + # + # Currently a no-op for Sophtron items. Reserved for future use. + # + # @return [void] + def perform_post_sync + # no-op + end + + private + + def import_errors_for(import_result) + return [] if import_result.blank? || import_result[:success] + + if import_result[:error].present? + return [ { message: import_result[:error], category: "sync_error" } ] + end + + errors = [] + if import_result[:accounts_failed].to_i.positive? + errors << { + message: "#{import_result[:accounts_failed]} #{'account'.pluralize(import_result[:accounts_failed])} failed to import", + category: "account_import" + } + end + + if import_result[:transactions_failed].to_i.positive? + errors << { + message: "#{import_result[:transactions_failed]} #{'account'.pluralize(import_result[:transactions_failed])} failed to import transactions", + category: "transaction_import" + } + end + + errors.presence || [ { message: "Sophtron import failed", category: "sync_error" } ] + end + + def sync_errors_recorded?(sync) + return false unless sync.respond_to?(:sync_stats) + + sync.sync_stats.to_h["total_errors"].to_i.positive? + end +end diff --git a/app/models/sophtron_item/unlinking.rb b/app/models/sophtron_item/unlinking.rb new file mode 100644 index 000000000..998c31b15 --- /dev/null +++ b/app/models/sophtron_item/unlinking.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module SophtronItem::Unlinking + # Concern that encapsulates unlinking logic for a Sophtron item. + # Mirrors the SimplefinItem::Unlinking behavior. + extend ActiveSupport::Concern + + # Idempotently removes all connections between this Sophtron item and local accounts. + # + # This method: + # - Finds all AccountProvider links for each SophtronAccount + # - Detaches any Holdings associated with those links + # - Destroys the AccountProvider links + # - Returns detailed results for observability + # + # This mirrors the SimplefinItem::Unlinking behavior. + # + # @param dry_run [Boolean] If true, only report what would be unlinked without making changes + # @return [Array] Results for each account with keys: + # - :sfa_id [Integer] The SophtronAccount ID + # - :name [String] The account name + # - :provider_link_ids [Array] IDs of AccountProvider links found + # @example + # item.unlink_all!(dry_run: true) # Preview what would be unlinked + # item.unlink_all! # Actually unlink all accounts + def unlink_all!(dry_run: false) + results = [] + + sophtron_accounts.find_each do |sfa| + links = AccountProvider.where(provider_type: "SophtronAccount", provider_id: sfa.id).to_a + link_ids = links.map(&:id) + result = { + sfa_id: sfa.id, + name: sfa.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + # Detach holdings for any provider links found + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + # Destroy all provider links + links.each do |ap| + ap.destroy! + end + end + rescue => e + Rails.logger.warn( + "SophtronItem Unlinker: failed to fully unlink SophtronAccount ##{sfa.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + # Record error for observability; continue with other accounts + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb index f9f2ef7c4..b5c9fe25b 100644 --- a/app/models/sso_provider.rb +++ b/app/models/sso_provider.rb @@ -90,7 +90,7 @@ class SsoProvider < ApplicationRecord idp_sso_url = settings&.dig("idp_sso_url") if idp_metadata_url.blank? && idp_sso_url.blank? - errors.add(:settings, "Either IdP Metadata URL or IdP SSO URL is required for SAML providers") + errors.add(:settings, :saml_url_required) end # If using manual config, require certificate @@ -99,17 +99,17 @@ class SsoProvider < ApplicationRecord idp_fingerprint = settings&.dig("idp_cert_fingerprint") if idp_cert.blank? && idp_fingerprint.blank? - errors.add(:settings, "Either IdP Certificate or Certificate Fingerprint is required when not using metadata URL") + errors.add(:settings, :saml_cert_required) end end # Validate URL formats if provided if idp_metadata_url.present? && !valid_url?(idp_metadata_url) - errors.add(:settings, "IdP Metadata URL must be a valid URL") + errors.add(:settings, :metadata_url_invalid) end if idp_sso_url.present? && !valid_url?(idp_sso_url) - errors.add(:settings, "IdP SSO URL must be a valid URL") + errors.add(:settings, :sso_url_invalid) end end diff --git a/app/models/sso_provider_tester.rb b/app/models/sso_provider_tester.rb index 7b27f08b7..9a66cc003 100644 --- a/app/models/sso_provider_tester.rb +++ b/app/models/sso_provider_tester.rb @@ -63,11 +63,14 @@ class SsoProviderTester ) end - # Check if issuer matches - if discovery["issuer"] != provider.issuer && discovery["issuer"] != provider.issuer.chomp("/") + # Check if issuer matches exactly. OIDC discovery requires the configured + # issuer string to be identical to the issuer returned by the provider. + if discovery["issuer"] != provider.issuer + hint = trailing_slash_hint(provider.issuer, discovery["issuer"]) + return Result.new( success?: false, - message: "Issuer mismatch: expected #{provider.issuer}, got #{discovery["issuer"]}", + message: [ "Issuer mismatch: expected #{provider.issuer}, got #{discovery["issuer"]}", hint ].compact.join(". "), details: { expected: provider.issuer, actual: discovery["issuer"] } ) end @@ -204,4 +207,10 @@ class SsoProviderTester def faraday_client @faraday_client ||= Faraday.new(ssl: self.class.faraday_ssl_options) end + + def trailing_slash_hint(expected, actual) + return unless expected.to_s.chomp("/") == actual.to_s.chomp("/") + + "trailing slash mismatch. This usually means the issuer URL differs only by a trailing slash. Update the configured issuer to exactly match the discovery document" + end end diff --git a/app/models/sure_import.rb b/app/models/sure_import.rb index 05d927c4d..0fab88a27 100644 --- a/app/models/sure_import.rb +++ b/app/models/sure_import.rb @@ -1,5 +1,23 @@ class SureImport < Import MAX_NDJSON_SIZE = 10.megabytes + IMPORTABLE_NDJSON_TYPES = { + "Account" => :accounts, + "Balance" => :balances, + "Category" => :categories, + "Tag" => :tags, + "Merchant" => :merchants, + "RecurringTransaction" => :recurring_transactions, + "Transaction" => :transactions, + "Transfer" => :transfers, + "RejectedTransfer" => :rejected_transfers, + "Trade" => :trades, + "Holding" => :holdings, + "Valuation" => :valuations, + "Budget" => :budgets, + "BudgetCategory" => :budget_categories, + "Rule" => :rules + }.freeze + VERIFICATION_STATUSES = %w[not_verified matched mismatch failed reverted].freeze ALLOWED_NDJSON_CONTENT_TYPES = %w[ application/x-ndjson application/ndjson @@ -11,6 +29,14 @@ class SureImport < Import has_one_attached :ndjson_file, dependent: :purge_later class << self + def max_row_count + 100_000 + end + + def max_ndjson_size + MAX_NDJSON_SIZE + end + # Counts JSON lines by top-level "type" (used for dry-run summaries and row limits). def ndjson_line_type_counts(content) return {} unless content.present? @@ -21,7 +47,7 @@ class SureImport < Import begin record = JSON.parse(line) - counts[record["type"]] += 1 if record["type"] + counts[record["type"]] += 1 if record.is_a?(Hash) && record["type"] && record.key?("data") rescue JSON::ParserError # Skip invalid lines end @@ -30,19 +56,25 @@ class SureImport < Import end def dry_run_totals_from_ndjson(content) - counts = ndjson_line_type_counts(content) - { - accounts: counts["Account"] || 0, - categories: counts["Category"] || 0, - tags: counts["Tag"] || 0, - merchants: counts["Merchant"] || 0, - transactions: counts["Transaction"] || 0, - trades: counts["Trade"] || 0, - valuations: counts["Valuation"] || 0, - budgets: counts["Budget"] || 0, - budget_categories: counts["BudgetCategory"] || 0, - rules: counts["Rule"] || 0 - } + dry_run_totals_from_line_type_counts(ndjson_line_type_counts(content)) + end + + def dry_run_totals_from_line_type_counts(counts) + IMPORTABLE_NDJSON_TYPES.to_h do |record_type, entity_key| + [ entity_key, counts[record_type] || 0 ] + end + end + + def expected_record_counts_from_ndjson(content) + expected_record_counts_from_line_type_counts(ndjson_line_type_counts(content)) + end + + def expected_record_counts_from_line_type_counts(counts) + dry_run_totals_from_line_type_counts(counts).transform_keys(&:to_s) + end + + def importable_ndjson_types + IMPORTABLE_NDJSON_TYPES.keys end def valid_ndjson_first_line?(str) @@ -53,7 +85,7 @@ class SureImport < Import begin record = JSON.parse(first_line) - record.key?("type") && record.key?("data") + record.is_a?(Hash) && record.key?("type") && record.key?("data") rescue JSON::ParserError false end @@ -87,11 +119,18 @@ class SureImport < Import end def import! + sync_ndjson_counts! + before_counts = readback_count_snapshot importer = Family::DataImporter.new(family, ndjson_blob_string) result = importer.import! result[:accounts].each { |account| accounts << account } result[:entries].each { |entry| entries << entry } + + record_readback_verification!(before_counts:) + rescue => error + record_failed_readback_verification!(before_counts:, error:) + raise end def uploaded? @@ -112,21 +151,155 @@ class SureImport < Import cleaned? && dry_run.values.sum.positive? end + def cleaned_from_validation_stats?(invalid_rows_count:) + configured? && invalid_rows_count.zero? + end + + def publishable_from_validation_stats?(invalid_rows_count:) + cleaned_from_validation_stats?(invalid_rows_count: invalid_rows_count) && dry_run.values.sum.positive? + end + def max_row_count - 100_000 + self.class.max_row_count end # Row total for max-row enforcement (counts every parsed line with a "type", including unsupported types). def sync_ndjson_rows_count! return unless ndjson_file.attached? - total = self.class.ndjson_line_type_counts(ndjson_blob_string).values.sum - update_column(:rows_count, total) + sync_ndjson_counts! + end + + def verification_payload + { + expected_record_counts: normalized_expected_record_counts, + readback: normalized_readback_verification + } + end + + def verification_status + status = normalized_readback_verification["status"] + status.in?(VERIFICATION_STATUSES) ? status : "not_verified" + end + + def reset_readback_verification! + update_columns( + readback_verification: { + "status" => "reverted", + "checked_at" => Time.current.iso8601 + }, + updated_at: Time.current + ) + end + + def revert + super + reset_readback_verification! if pending? end private + def sync_ndjson_counts! + line_counts = self.class.ndjson_line_type_counts(ndjson_blob_string) + + update_columns( + rows_count: line_counts.values.sum, + expected_record_counts: self.class.expected_record_counts_from_line_type_counts(line_counts), + readback_verification: {}, + updated_at: Time.current + ) + end + + def record_readback_verification!(before_counts:) + update_columns( + readback_verification: build_readback_verification(before_counts:, status_for_mismatch: "mismatch"), + updated_at: Time.current + ) + end + + def record_failed_readback_verification!(before_counts:, error:) + return unless before_counts + + update_columns( + readback_verification: build_readback_verification(before_counts:, status_for_mismatch: "failed").merge( + "status" => "failed", + "error" => error.message + ), + updated_at: Time.current + ) + rescue => verification_error + Rails.logger.warn("Failed to record Sure import readback verification for import #{id}: #{verification_error.message}") + end + + def build_readback_verification(before_counts:, status_for_mismatch:) + after_counts = readback_count_snapshot + actual_delta_counts = delta_counts(before_counts, after_counts) + expected_counts = normalized_expected_record_counts + checked_counts = (actual_delta_counts.keys | expected_counts.keys).index_with do |key| + expected_counts.fetch(key, 0).to_i + end + mismatches = checked_counts.each_with_object({}) do |(key, expected_count), result| + actual_count = actual_delta_counts.fetch(key, 0) + next if actual_count == expected_count.to_i + + result[key] = { + "expected" => expected_count.to_i, + "actual" => actual_count + } + end + + { + "status" => mismatches.empty? ? "matched" : status_for_mismatch, + "checked_at" => Time.current.iso8601, + "expected_record_counts" => expected_counts, + "before_counts" => before_counts, + "after_counts" => after_counts, + "actual_delta_counts" => actual_delta_counts, + "checked_counts" => checked_counts, + "mismatches" => mismatches + } + end + + def readback_count_snapshot + { + accounts: family.accounts.count, + balances: Balance.joins(:account).where(accounts: { family_id: family.id }).count, + categories: family.categories.count, + tags: family.tags.count, + merchants: family.merchants.count, + recurring_transactions: family.recurring_transactions.count, + transactions: family.entries.where(entryable_type: "Transaction").count, + transfers: Transfer.joins(inflow_transaction: { entry: :account }).where(accounts: { family_id: family.id }).count, + rejected_transfers: RejectedTransfer.joins(inflow_transaction: { entry: :account }).where(accounts: { family_id: family.id }).count, + trades: family.entries.where(entryable_type: "Trade").count, + holdings: family.holdings.count, + valuations: family.entries.where(entryable_type: "Valuation").count, + budgets: family.budgets.count, + budget_categories: family.budget_categories.count, + rules: family.rules.count + }.transform_keys(&:to_s).transform_values(&:to_i) + end + + def delta_counts(before_counts, after_counts) + after_counts.each_with_object({}) do |(key, after_count), result| + result[key] = after_count.to_i - before_counts.fetch(key, 0).to_i + end + end + + def normalized_expected_record_counts + (expected_record_counts || {}).to_h.transform_keys(&:to_s).transform_values(&:to_i) + end + + def normalized_readback_verification + (readback_verification || {}).to_h.deep_stringify_keys + end + def ndjson_blob_string - ndjson_file.download.force_encoding(Encoding::UTF_8) + blob_id = ndjson_file.blob&.id + + return @ndjson_blob_string if defined?(@ndjson_blob_string) && @ndjson_blob_id == blob_id + + @ndjson_blob_id = blob_id + @ndjson_blob_string = ndjson_file.download.force_encoding(Encoding::UTF_8) end end diff --git a/app/models/sync.rb b/app/models/sync.rb index d1ba07a26..e2ec1f28a 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -15,7 +15,7 @@ class Sync < ApplicationRecord belongs_to :parent, class_name: "Sync", optional: true has_many :children, class_name: "Sync", foreign_key: :parent_id, dependent: :destroy - scope :ordered, -> { order(created_at: :desc) } + scope :ordered, -> { order(created_at: :desc, id: :desc) } scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) } scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) } @@ -57,6 +57,52 @@ class Sync < ApplicationRecord def clean incomplete.where("syncs.created_at < ?", STALE_AFTER.ago).find_each(&:mark_stale!) end + + def for_family(family, resource_owner: nil) + query = where(syncable_type: "Family", syncable_id: family.id) + query = query.or(where(syncable_type: "Account", syncable_id: account_syncable_ids(family, resource_owner))) + + family_syncable_associations.each do |association| + query = query.or( + where(syncable_type: association.klass.name, syncable_id: family.public_send(association.name).select(:id)) + ) + end + + query + end + + private + def account_syncable_ids(family, resource_owner) + (resource_owner ? resource_owner.accessible_accounts : family.accounts) + .where(family_id: family.id) + .select(:id) + end + + def family_syncable_associations + Family.reflect_on_all_associations(:has_many).select do |association| + association.name.to_s.end_with?("_items") && + association.klass.included_modules.include?(Syncable) + rescue NameError + false + end + end + end + + def in_progress? + pending? || syncing? + end + + def terminal? + completed? || failed? || stale? + end + + def api_error_payload + return unless failed? || stale? + return if stale? && error.blank? + + { + message: stale? ? "Sync became stale before completion" : "Sync failed" + } end def perform @@ -204,6 +250,8 @@ class Sync < ApplicationRecord end def update_family_sync_timestamp + return unless family.persisted? + family.touch(:latest_sync_activity_at) end diff --git a/app/models/trade.rb b/app/models/trade.rb index dbdb90f31..a0c6df58a 100644 --- a/app/models/trade.rb +++ b/app/models/trade.rb @@ -14,6 +14,27 @@ class Trade < ApplicationRecord validates :price, :currency, presence: true validates :investment_activity_label, inclusion: { in: ACTIVITY_LABELS }, allow_nil: true + def exchange_rate + extra&.dig("exchange_rate") + end + + def exchange_rate=(value) + if value.blank? + self.extra = (extra || {}).merge("exchange_rate" => nil, "exchange_rate_invalid" => false) + else + begin + normalized_value = Float(value) + raise ArgumentError unless normalized_value.finite? + + self.extra = (extra || {}).merge("exchange_rate" => normalized_value, "exchange_rate_invalid" => false) + rescue ArgumentError, TypeError + self.extra = (extra || {}).merge("exchange_rate" => value, "exchange_rate_invalid" => true) + end + end + end + + validate :exchange_rate_must_be_valid + # Trade types for categorization def buy? qty.positive? @@ -57,6 +78,17 @@ class Trade < ApplicationRecord private + def exchange_rate_must_be_valid + if extra&.dig("exchange_rate_invalid") + errors.add(:exchange_rate, "must be a number") + elsif exchange_rate.present? + numeric_rate = Float(exchange_rate) rescue nil + if numeric_rate.nil? || !numeric_rate.finite? || numeric_rate <= 0 + errors.add(:exchange_rate, "must be greater than 0") + end + end + end + def calculate_realized_gain_loss return nil unless sell? diff --git a/app/models/trade/create_form.rb b/app/models/trade/create_form.rb index 54c78111e..37cf3a44e 100644 --- a/app/models/trade/create_form.rb +++ b/app/models/trade/create_form.rb @@ -22,11 +22,13 @@ class Trade::CreateForm private # Users can either look up a ticker from a provider or enter a manual, "offline" ticker (that we won't fetch prices for) def security - ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ] + parsed = ticker.present? ? Security.parse_combobox_id(ticker) : { ticker: manual_ticker } + return nil if parsed[:ticker].blank? Security::Resolver.new( - ticker_symbol, - exchange_operating_mic: exchange_operating_mic + parsed[:ticker], + exchange_operating_mic: parsed[:exchange_operating_mic], + price_provider: parsed[:price_provider] ).resolve end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index e8372bed4..58ade4809 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -65,11 +65,11 @@ class TradeImport < Import end def csv_template - template = <<-CSV + template = <<~CSV date*,ticker*,exchange_operating_mic,currency,qty*,price*,account,name - 05/15/2024,AAPL,XNAS,USD,10,150.00,Trading Account,Apple Inc. Purchase - 05/16/2024,GOOGL,XNAS,USD,-5,2500.00,Investment Account,Alphabet Inc. Sale - 05/17/2024,TSLA,XNAS,USD,2,700.50,Retirement Account,Tesla Inc. Purchase + 2024-05-15,AAPL,XNAS,USD,10,150.00,Trading Account,Apple Inc. Purchase + 2024-05-16,GOOGL,XNAS,USD,-5,2500.00,Investment Account,Alphabet Inc. Sale + 2024-05-17,TSLA,XNAS,USD,2,700.50,Retirement Account,Tesla Inc. Purchase CSV csv = CSV.parse(template, headers: true) diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 75e36bbdc..0334298d1 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -28,6 +28,44 @@ class Transaction < ApplicationRecord after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed? + # Accessors for exchange_rate stored in extra jsonb field + def exchange_rate + extra&.dig("exchange_rate") + end + + def exchange_rate=(value) + if value.blank? + self.extra = (extra || {}).merge("exchange_rate" => nil, "exchange_rate_invalid" => false) + else + begin + normalized_value = Float(value) + raise ArgumentError unless normalized_value.finite? + + self.extra = (extra || {}).merge("exchange_rate" => normalized_value, "exchange_rate_invalid" => false) + rescue ArgumentError, TypeError + # Store the raw value for validation error reporting + self.extra = (extra || {}).merge("exchange_rate" => value, "exchange_rate_invalid" => true) + end + end + end + + validate :exchange_rate_must_be_valid + + private + + def exchange_rate_must_be_valid + if extra&.dig("exchange_rate_invalid") + errors.add(:exchange_rate, "must be a number") + elsif exchange_rate.present? + numeric_rate = Float(exchange_rate) rescue nil + if numeric_rate.nil? || !numeric_rate.finite? || numeric_rate <= 0 + errors.add(:exchange_rate, "must be greater than 0") + end + end + end + + public + enum :kind, { standard: "standard", # A regular transaction, included in budget analytics funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics @@ -56,7 +94,14 @@ class Transaction < ApplicationRecord INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze # Providers that support pending transaction flags - PENDING_PROVIDERS = %w[simplefin plaid lunchflow].freeze + PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking].freeze + + # Pre-computed SQL fragment for subqueries that check if a transaction (aliased as "t") is pending. + # Stored as a constant so static analysis can verify it contains no user input. + PENDING_CHECK_SQL = PENDING_PROVIDERS + .map { |p| "(t.extra -> '#{p}' ->> 'pending')::boolean = true" } + .join(" OR ") + .freeze # Pending transaction scopes - filter based on provider pending flags in extra JSONB # Works with any provider that stores pending status in extra["provider_name"]["pending"] @@ -70,6 +115,14 @@ class Transaction < ApplicationRecord where(conditions.join(" AND ")) } + # SQL snippet for raw queries that must exclude pending transactions. + # Use in income statements, balance sheets, and raw analytics. + def self.pending_providers_sql(table_alias = "t") + PENDING_PROVIDERS.map do |provider| + "AND (#{table_alias}.extra -> '#{provider}' ->> 'pending')::boolean IS DISTINCT FROM true" + end.join("\n") + end + # Family-scoped query for Enrichable#clear_ai_cache def self.family_scope(family) joins(entry: :account).where(accounts: { family_id: family.id }) @@ -95,10 +148,28 @@ class Transaction < ApplicationRecord PENDING_PROVIDERS.any? do |provider| ActiveModel::Type::Boolean.new.cast(extra_data.dig(provider, "pending")) end - rescue + rescue StandardError false end + def activity_security_id + extra&.dig("security_id").presence || extra&.dig("security", "id").presence + end + + def activity_security + security_id = activity_security_id.to_s + return @activity_security = nil if security_id.blank? + return @activity_security if defined?(@activity_security_id) && @activity_security_id == security_id + + @activity_security_id = security_id + @activity_security = Security.find_by(id: security_id) + end + + def set_preloaded_activity_security(security) + @activity_security_id = security&.id&.to_s + @activity_security = security + end + # Potential duplicate matching methods # These help users review and resolve fuzzy-matched pending/posted pairs @@ -131,22 +202,106 @@ class Transaction < ApplicationRecord potential_posted_match_data&.dig("dismissed") == true end - # Merge this pending transaction with its suggested posted match - # This DELETES the pending entry since the posted version is canonical + # Merge this pending transaction with its suggested posted match. + # The pending entry is destroyed; the posted entry survives with attributes inherited from both sides. + # Attribute inheritance: Date + Category from pending, Name + Merchant from posted (booked). def merge_with_duplicate! + return false unless pending? return false unless has_potential_duplicate? posted_entry = potential_duplicate_entry return false unless posted_entry - pending_entry_id = entry.id - pending_entry_name = entry.name + pending_entry = entry - # Delete this pending entry completely (no need to keep it around) - entry.destroy! + # Guard: cross-account merges are never valid + if posted_entry.account_id != pending_entry.account_id + Rails.logger.warn("merge_with_duplicate! rejected: posted_entry #{posted_entry.id} belongs to different account than pending entry #{pending_entry.id}") + return false + end - Rails.logger.info("User merged pending entry #{pending_entry_id} (#{pending_entry_name}) with posted entry #{posted_entry.id}") - true + pending_entry_id = pending_entry.id + merge_succeeded = false + + ApplicationRecord.transaction(requires_new: true) do + # Row-level locks prevent concurrent merges on the same pair of entries. + # If a concurrent request already destroyed the pending entry, lock! raises + # RecordNotFound — treat that as an idempotent success. + begin + pending_entry.lock! + rescue ActiveRecord::RecordNotFound + Rails.logger.info("Pending entry #{pending_entry_id} already destroyed (concurrent merge), skipping") + return true + end + + begin + posted_entry.lock! + rescue ActiveRecord::RecordNotFound + Rails.logger.info("Posted entry #{posted_entry.id} deleted concurrently; aborting merge") + raise ActiveRecord::Rollback + end + + # Capture after lock! (which reloads) to guarantee DB-fresh values and avoid + # stale in-memory cached associations (e.g., loaded via touch: true). + external_id = pending_entry.external_id + pending_entry_date = pending_entry.date + + # Batch all changes to the surviving posted Transaction into a single update! + # to avoid firing after_save callbacks twice on the same row. + # Lock the Transaction row so concurrent merges into the same posted entry + # cannot race on reading/writing extra (e.g., the manual_merge array). + posted_tx = posted_entry.entryable + posted_tx.lock! if posted_tx.is_a?(Transaction) + if posted_tx.is_a?(Transaction) + tx_attrs = {} + + # Merge metadata — always written so the sync engine can skip re-importing. + # Stored as an array so multiple pending entries merged into the same posted + # transaction each preserve their external_id for future sync exclusion. + # Legacy records written as a plain Hash are migrated to a single-element array + # on first append, maintaining backward compatibility. + if external_id.present? + new_record = { + "merged_from_entry_id" => pending_entry_id, + "merged_from_external_id" => external_id, + "merged_at" => Time.current.iso8601, + "source" => pending_entry.source + } + prior = case posted_tx.extra["manual_merge"] + when Array then posted_tx.extra["manual_merge"] + when Hash then [ posted_tx.extra["manual_merge"] ] + else [] + end + tx_attrs[:extra] = posted_tx.extra.merge("manual_merge" => prior + [ new_record ]) + end + + # Attribute inheritance — only when the posted entry is not already user-protected. + unless posted_entry.protected_from_sync? + pending_transaction = pending_entry.entryable + if pending_transaction.is_a?(Transaction) && pending_transaction.category_id.present? + tx_attrs[:category_id] = pending_transaction.category_id + end + end + + posted_tx.update!(tx_attrs) if tx_attrs.any? + end + + # Date inheritance on the Entry row — separate from the Transaction update above. + unless posted_entry.protected_from_sync? + # Date: pending dates reflect actual transaction initiation time + posted_entry.update!(date: pending_entry_date) if posted_entry.date != pending_entry_date + # Name + Merchant intentionally NOT inherited — booked values are canonical + end + + # Lock the posted entry so future syncs cannot overwrite the merged state + posted_entry.mark_user_modified! + + Rails.logger.info("User merged pending entry #{pending_entry_id} (ext: #{external_id}) into posted entry #{posted_entry.id}") + pending_entry.destroy! + merge_succeeded = true + end + + merge_succeeded end # Dismiss the duplicate suggestion - user says these are NOT the same transaction diff --git a/app/models/transaction/activity_security_preloader.rb b/app/models/transaction/activity_security_preloader.rb new file mode 100644 index 000000000..2939cf29d --- /dev/null +++ b/app/models/transaction/activity_security_preloader.rb @@ -0,0 +1,36 @@ +class Transaction::ActivitySecurityPreloader + def initialize(records) + @records = Array(records) + end + + def preload + transactions.each do |transaction| + transaction.set_preloaded_activity_security(securities_by_id[transaction.activity_security_id.to_s]) + end + + records + end + + private + attr_reader :records + + def transactions + @transactions ||= records.filter_map do |record| + case record + when Transaction + record + when Entry + record.transaction? ? record.entryable : nil + end + end + end + + def securities_by_id + @securities_by_id ||= begin + security_ids = transactions.filter_map(&:activity_security_id).uniq + return {} if security_ids.empty? + + Security.where(id: security_ids).index_by { |security| security.id.to_s } + end + end +end diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index d60e39fb4..d9a87c18e 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -29,8 +29,8 @@ class Transaction::Search # This already joins entries + accounts. To avoid expensive double-joins, don't join them again (causes full table scan) query = family.transactions.merge(Entry.excluding_split_parents) - # Scope to accessible accounts when provided - query = query.where(entries: { account_id: accessible_account_ids }) if accessible_account_ids + # Scope to accessible accounts when provided (including an empty array, which should yield no results) + query = query.where(entries: { account_id: accessible_account_ids }) unless accessible_account_ids.nil? query = apply_active_accounts_filter(query, active_accounts_only) query = apply_category_filter(query, categories) @@ -88,11 +88,11 @@ class Transaction::Search .take Totals.new( - count: result.transactions_count.to_i, - income_money: Money.new(result.income_total, family.currency), - expense_money: Money.new(result.expense_total, family.currency), - transfer_inflow_money: Money.new(result.transfer_inflow_total, family.currency), - transfer_outflow_money: Money.new(result.transfer_outflow_total, family.currency) + count: result&.transactions_count.to_i, + income_money: Money.new((result&.income_total || 0), family.currency), + expense_money: Money.new((result&.expense_total || 0), family.currency), + transfer_inflow_money: Money.new((result&.transfer_inflow_total || 0), family.currency), + transfer_outflow_money: Money.new((result&.transfer_outflow_total || 0), family.currency) ) end end diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index 229526046..8b93431e6 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -105,11 +105,11 @@ class TransactionImport < Import end def csv_template - template = <<-CSV + template = <<~CSV date*,amount*,name,currency,category,tags,account,notes - 05/15/2024,-45.99,Grocery Store,USD,Food,groceries|essentials,Checking Account,Monthly grocery run - 05/16/2024,1500.00,Salary,,Income,,Main Account, - 05/17/2024,-12.50,Coffee Shop,,,coffee,, + 2024-05-15,-45.99,Grocery Store,USD,Food,groceries|essentials,Checking Account,Monthly grocery run + 2024-05-16,1500.00,Salary,,Income,,Main Account, + 2024-05-17,-12.50,Coffee Shop,,,coffee,, CSV csv = CSV.parse(template, headers: true) diff --git a/app/models/transfer.rb b/app/models/transfer.rb index d2dcbf667..878e899be 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -107,12 +107,12 @@ class Transfer < ApplicationRecord private def transfer_has_different_accounts return unless inflow_transaction&.entry && outflow_transaction&.entry - errors.add(:base, "Must be from different accounts") if to_account == from_account + errors.add(:base, :different_accounts) if to_account == from_account end def transfer_has_same_family return unless inflow_transaction&.entry && outflow_transaction&.entry - errors.add(:base, "Must be from same family") unless to_account&.family == from_account&.family + errors.add(:base, :same_family) unless to_account&.family == from_account&.family end def transfer_has_opposite_amounts @@ -126,10 +126,10 @@ class Transfer < ApplicationRecord if inflow_entry.currency == outflow_entry.currency # For same currency, amounts must be exactly opposite - errors.add(:base, "Must have opposite amounts") if inflow_amount + outflow_amount != 0 + errors.add(:base, :opposite_amounts) if inflow_amount + outflow_amount != 0 else # For different currencies, just check the signs are opposite - errors.add(:base, "Must have opposite amounts") unless inflow_amount.negative? && outflow_amount.positive? + errors.add(:base, :opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive? end end @@ -138,6 +138,6 @@ class Transfer < ApplicationRecord date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs max_days = status == "confirmed" ? 30 : 4 - errors.add(:base, "Must be within #{max_days} days") if date_diff > max_days + errors.add(:base, :within_days, count: max_days) if date_diff > max_days end end diff --git a/app/models/transfer/creator.rb b/app/models/transfer/creator.rb index 1825e92d0..7aa8bc72c 100644 --- a/app/models/transfer/creator.rb +++ b/app/models/transfer/creator.rb @@ -1,10 +1,18 @@ class Transfer::Creator - def initialize(family:, source_account_id:, destination_account_id:, date:, amount:) + def initialize(family:, source_account_id:, destination_account_id:, date:, amount:, exchange_rate: nil) @family = family @source_account = family.accounts.find(source_account_id) # early throw if not found @destination_account = family.accounts.find(destination_account_id) # early throw if not found @date = date @amount = amount.to_d + + if exchange_rate.present? + rate_value = exchange_rate.to_d + raise ArgumentError, "exchange_rate must be greater than 0" unless rate_value > 0 + @exchange_rate = rate_value + else + @exchange_rate = nil + end end def create @@ -23,7 +31,7 @@ class Transfer::Creator end private - attr_reader :family, :source_account, :destination_account, :date, :amount + attr_reader :family, :source_account, :destination_account, :date, :amount, :exchange_rate def outflow_transaction name = "#{name_prefix} to #{destination_account.name}" @@ -62,13 +70,13 @@ class Transfer::Creator end # If destination account has different currency, its transaction should show up as converted - # Future improvement: instead of a 1:1 conversion fallback, add a UI/UX flow for missing rates + # Uses user-provided exchange rate if available, otherwise requires a provider rate def inflow_converted_money Money.new(amount.abs, source_account.currency) .exchange_to( destination_account.currency, date: date, - fallback_rate: 1.0 + custom_rate: exchange_rate ) end diff --git a/app/models/user.rb b/app/models/user.rb index 02320aa8d..b1c40bb76 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,9 +9,6 @@ class User < ApplicationRecord if encryption_ready? # MFA secrets encrypts :otp_secret, deterministic: true - # Note: otp_backup_codes is a PostgreSQL array column which doesn't support - # AR encryption. To encrypt it, a migration would be needed to change the - # column type from array to text/jsonb. # PII - emails (deterministic for lookups, downcase for case-insensitive) encrypts :email, deterministic: true, downcase: true @@ -28,6 +25,7 @@ class User < ApplicationRecord has_many :sessions, dependent: :destroy has_many :chats, dependent: :destroy has_many :api_keys, dependent: :destroy + has_many :webauthn_credentials, dependent: :destroy has_many :mobile_devices, dependent: :destroy has_many :invitations, foreign_key: :inviter_id, dependent: :destroy has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy @@ -39,6 +37,8 @@ class User < ApplicationRecord has_many :shared_accounts, through: :account_shares, source: :account accepts_nested_attributes_for :family, update_only: true + MFA_BACKUP_CODE_COUNT = 8 + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validate :ensure_valid_profile_image validates :default_period, inclusion: { in: Period::PERIODS.keys } @@ -220,24 +220,39 @@ class User < ApplicationRecord end def enable_mfa! + raise ArgumentError, "OTP secret must be set before enabling MFA" if otp_secret.blank? + + backup_codes = generate_backup_codes + + # Store bcrypt digests only; this Postgres array cannot use AR encryption. update!( otp_required: true, - otp_backup_codes: generate_backup_codes + otp_backup_codes: backup_codes.map { |code| digest_backup_code(code) } ) + + backup_codes end def disable_mfa! - update!( - otp_secret: nil, - otp_required: false, - otp_backup_codes: [] - ) + transaction do + update!( + otp_secret: nil, + otp_required: false, + otp_backup_codes: [] + ) + webauthn_credentials.destroy_all + end end def verify_otp?(code) return false if otp_secret.blank? - return true if verify_backup_code?(code) - totp.verify(code, drift_behind: 15) + + normalized_code = normalize_mfa_code(code) + return false if normalized_code.blank? + return true if totp.verify(normalized_code, drift_behind: 15) + return false unless backup_code_input?(normalized_code) + + consume_backup_code!(normalized_code) end def provisioning_uri @@ -245,6 +260,20 @@ class User < ApplicationRecord totp.provisioning_uri(email) end + def ensure_webauthn_id! + return webauthn_id if webauthn_id.present? + + with_lock do + update!(webauthn_id: WebAuthn.generate_user_id) unless webauthn_id.present? + end + + webauthn_id + end + + def webauthn_enabled? + otp_required? && webauthn_credentials.exists? + end + def onboarded? onboarded_at.present? end @@ -336,6 +365,10 @@ class User < ApplicationRecord preferences&.dig("dashboard_two_column") == true end + def preview_features_enabled? + preferences&.dig("preview_features_enabled") == true + end + def update_transactions_preferences(prefs) transaction do lock! @@ -379,6 +412,10 @@ class User < ApplicationRecord self.show_sidebar = true unless show_sidebar self.show_ai_sidebar = true unless show_ai_sidebar end + + if new_record? && member? && !ai_available? + self.show_ai_sidebar = false + end end def leaving_guest_role? @@ -442,21 +479,72 @@ class User < ApplicationRecord ROTP::TOTP.new(otp_secret, issuer: "Sure Finances") end - def verify_backup_code?(code) - return false if otp_backup_codes.blank? + def consume_backup_code!(normalized_code) + consumed = false - # Find and remove the used backup code - if (index = otp_backup_codes.index(code)) - remaining_codes = otp_backup_codes.dup - remaining_codes.delete_at(index) - update!(otp_backup_codes: remaining_codes) - true - else - false + transaction do + lock! + + if otp_backup_codes.present? + matching_index = otp_backup_codes.index do |stored_code| + backup_code_matches?(stored_code, normalized_code) + end + + if matching_index + remaining_codes = otp_backup_codes.dup + remaining_codes.delete_at(matching_index) + update!(otp_backup_codes: remaining_codes) + consumed = true + end + end end + + consumed end def generate_backup_codes - 8.times.map { SecureRandom.hex(4) } + MFA_BACKUP_CODE_COUNT.times.map { SecureRandom.hex(8) } + end + + def digest_backup_code(code) + BCrypt::Password.create(normalize_mfa_code(code), cost: backup_code_digest_cost).to_s + end + + def backup_code_matches?(stored_code, normalized_code) + if backup_code_digest?(stored_code) + return false unless backup_code_input?(normalized_code) + + BCrypt::Password.new(stored_code).is_password?(normalized_code) + else + # Legacy plaintext codes are accepted once so existing MFA users are + # not locked out after backup-code hashing ships. + ActiveSupport::SecurityUtils.secure_compare(stored_code.to_s, normalized_code) + end + rescue BCrypt::Errors::InvalidHash + false + end + + def backup_code_digest?(stored_code) + stored_code.to_s.start_with?("$2a$", "$2b$", "$2y$") + end + + def normalize_mfa_code(code) + code.to_s.strip.downcase + end + + def backup_code_input?(code) + backup_code_candidate?(code) || legacy_plaintext_backup_code_candidate?(code) + end + + def backup_code_candidate?(code) + code.to_s.match?(/\A[0-9a-f]{16}\z/) + end + + def legacy_plaintext_backup_code_candidate?(code) + code.to_s.match?(/\A[0-9a-f]{8}\z/) + end + + def backup_code_digest_cost + ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost end end diff --git a/app/models/user_message.rb b/app/models/user_message.rb index 5a123120d..865550a4a 100644 --- a/app/models/user_message.rb +++ b/app/models/user_message.rb @@ -11,7 +11,7 @@ class UserMessage < Message chat.ask_assistant_later(self) end - def request_response - chat.ask_assistant(self) + def request_response(assistant_message: nil) + chat.ask_assistant(self, assistant_message: assistant_message) end end diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb new file mode 100644 index 000000000..da7ebefe3 --- /dev/null +++ b/app/models/webauthn_credential.rb @@ -0,0 +1,15 @@ +class WebauthnCredential < ApplicationRecord + belongs_to :user + + before_validation :set_default_nickname + + validates :nickname, presence: true, length: { maximum: 80 } + validates :credential_id, presence: true, uniqueness: true + validates :public_key, presence: true + validates :sign_count, numericality: { greater_than_or_equal_to: 0, only_integer: true } + + private + def set_default_nickname + self.nickname = nickname.to_s.strip.presence || I18n.t("webauthn_credentials.default_name") + end +end diff --git a/app/views/account_sharings/show.html.erb b/app/views/account_sharings/show.html.erb index b53a6bdff..5700136ba 100644 --- a/app/views/account_sharings/show.html.erb +++ b/app/views/account_sharings/show.html.erb @@ -46,7 +46,7 @@ <% end %>
- <%= render DS::Button.new(text: t(".save"), class: "md:w-auto w-full justify-center") %> + <%= render DS::Button.new(text: t(".save"), type: :submit, class: "md:w-auto w-full justify-center") %>
<% end %> <% else %> @@ -83,7 +83,7 @@
- <%= render DS::Button.new(text: t(".save"), class: "md:w-auto w-full justify-center") %> + <%= render DS::Button.new(text: t(".save"), type: :submit, class: "md:w-auto w-full justify-center") %>
<% end %>
diff --git a/app/views/account_statements/index.html.erb b/app/views/account_statements/index.html.erb new file mode 100644 index 000000000..dba3f1677 --- /dev/null +++ b/app/views/account_statements/index.html.erb @@ -0,0 +1,170 @@ +<%= content_for :page_title, t(".title") %> + +<%= settings_section title: t(".title") do %> +
+
+
+
+

<%= t(".upload_title") %>

+

<%= t(".upload_description") %>

+
+
+

<%= t(".storage_used") %>

+

<%= number_to_human_size(@total_storage_bytes) %>

+
+
+ + <%= styled_form_with url: account_statements_path, scope: :account_statement, multipart: true, class: "grid gap-3 lg:grid-cols-[1fr_16rem_auto] lg:items-end" do |form| %> +
+ <%= form.label :files, t("account_statements.form.files_label"), class: "form-field__label" %> + <%= form.file_field :files, + multiple: true, + accept: AccountStatement::ACCEPTED_FILE_EXTENSIONS.join(","), + class: "form-field__input" %> +

<%= t("account_statements.form.files_hint", max_size: AccountStatement::MAX_FILE_SIZE / 1.megabyte) %>

+
+ <%= form.collection_select :account_id, + @accounts, + :id, + :name, + { include_blank: t(".leave_unmatched"), label: t(".account_label") }, + { data: { testid: "account-statement-account-select" } } %> +
+ <%= form.submit t("account_statements.form.inbox_upload") %> +
+ <% end %> +
+ +
+
+

<%= t(".unmatched_title") %>

+ · +

<%= @unmatched_pagy.count %>

+
+ +
+ <% if @unmatched_statements.any? %> + + + + + + + + + + + <% @unmatched_statements.each do |statement| %> + + + + + + + <% end %> + +
<%= t("account_statements.table.file") %><%= t("account_statements.table.period") %><%= t("account_statements.table.suggestion") %><%= t("account_statements.table.actions") %>
+ <%= link_to account_statement_path(statement), class: "flex items-center gap-2 min-w-0 text-sm font-medium text-primary hover:underline" do %> + <%= icon(account_statement_file_icon(statement), size: "sm") %> + <%= statement.filename %> + <% end %> +

<%= number_to_human_size(statement.byte_size) %>

+
<%= account_statement_period(statement) %> + <% if statement.suggested_account.present? %> +
+

<%= statement.suggested_account.name %>

+

<%= t(".confidence", confidence: number_to_percentage(statement.match_confidence.to_d * 100, precision: 0)) %>

+
+ <% else %> + <%= t(".no_suggestion") %> + <% end %> +
+
+ <% if statement.suggested_account.present? %> + <%= button_to link_account_statement_path(statement), + method: :patch, + params: { account_id: statement.suggested_account_id }, + class: "flex items-center", + aria: { label: t("account_statements.table.link_suggestion") } do %> + <%= icon("link", class: "w-5 h-5 text-primary") %> + <% end %> + <%= button_to reject_account_statement_path(statement), + method: :patch, + class: "flex items-center", + aria: { label: t("account_statements.table.reject") } do %> + <%= icon("x", class: "w-5 h-5 text-secondary") %> + <% end %> + <% end %> + <%= link_to account_statement_path(statement), aria: { label: t("account_statements.table.view") } do %> + <%= icon("eye", class: "w-5 h-5 text-primary") %> + <% end %> +
+
+ <% else %> +

<%= t(".empty_unmatched") %>

+ <% end %> +
+ <% if @unmatched_pagy.pages > 1 %> +
+ <%= render "shared/pagination", pagy: @unmatched_pagy %> +
+ <% end %> +
+ +
+
+

<%= t(".linked_title") %>

+ · +

<%= @linked_pagy.count %>

+
+ +
+ <% if @linked_statements.any? %> + + + + + + + + + + + <% @linked_statements.each do |statement| %> + + + + + + + <% end %> + +
<%= t("account_statements.table.file") %><%= t("account_statements.table.account") %><%= t("account_statements.table.period") %><%= t("account_statements.table.actions") %>
+ <%= link_to account_statement_path(statement), class: "flex items-center gap-2 min-w-0 text-sm font-medium text-primary hover:underline" do %> + <%= icon(account_statement_file_icon(statement), size: "sm") %> + <%= statement.filename %> + <% end %> + <%= statement.account&.name %><%= account_statement_period(statement) %> +
+ <%= link_to account_statement_path(statement), aria: { label: t("account_statements.table.view") } do %> + <%= icon("eye", class: "w-5 h-5 text-primary") %> + <% end %> + <% if statement.original_file.attached? %> + <%= link_to rails_blob_path(statement.original_file, disposition: "attachment"), aria: { label: t("account_statements.table.download") } do %> + <%= icon("download", class: "w-5 h-5 text-primary") %> + <% end %> + <% end %> +
+
+ <% else %> +

<%= t(".empty_linked") %>

+ <% end %> +
+ <% if @linked_pagy.pages > 1 %> +
+ <%= render "shared/pagination", pagy: @linked_pagy %> +
+ <% end %> +
+
+<% end %> diff --git a/app/views/account_statements/show.html.erb b/app/views/account_statements/show.html.erb new file mode 100644 index 000000000..e320fc79a --- /dev/null +++ b/app/views/account_statements/show.html.erb @@ -0,0 +1,180 @@ +<%= content_for :page_title, @statement.filename %> + +<%= settings_section title: t(".title") do %> +
+
+
+
+
+ <%= icon(account_statement_file_icon(@statement), size: "sm") %> +

<%= @statement.filename %>

+
+
+ <%= account_statement_status_badge(@statement) %> + <%= number_to_human_size(@statement.byte_size) %> + <%= @statement.content_type %> +
+
+ +
+ <% if @statement.original_file.attached? %> + <%= link_to rails_blob_path(@statement.original_file, disposition: "attachment"), + class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-primary-hover" do %> + <%= icon("download", size: "sm") %> + <%= t(".download") %> + <% end %> + <% end %> + <% if @can_manage_statement %> + <%= button_to account_statement_path(@statement), + method: :delete, + class: "inline-flex items-center gap-2 text-sm font-medium text-destructive", + data: { turbo_confirm: CustomConfirm.for_resource_deletion("statement") } do %> + <%= icon("trash-2", size: "sm", color: "destructive") %> + <%= t(".delete") %> + <% end %> + <% end %> +
+
+
+ +
+
+

<%= t(".metadata_title") %>

+ + <% if @can_manage_statement %> + <%= styled_form_with model: @statement, url: account_statement_path(@statement), method: :patch, class: "space-y-4" do |form| %> +
+ <%= form.text_field :institution_name_hint, label: t(".institution_name_hint") %> + <%= form.text_field :account_name_hint, label: t(".account_name_hint") %> + <%= form.text_field :account_last4_hint, label: t(".account_last4_hint") %> + <%= form.select :currency, + account_statement_currency_options(@statement), + { label: t(".currency"), selected: @statement.statement_currency } %> + <%= form.date_field :period_start_on, label: t(".period_start_on") %> + <%= form.date_field :period_end_on, label: t(".period_end_on") %> + <%= form.number_field :opening_balance, label: t(".opening_balance"), step: "0.01" %> + <%= form.number_field :closing_balance, label: t(".closing_balance"), step: "0.01" %> +
+ + <%= form.submit t(".save") %> + <% end %> + <% else %> +
+
+
<%= t(".account_label") %>
+
<%= @statement.account&.name || t(".unmatched_account") %>
+
+
+
<%= t(".institution_name_hint") %>
+
<%= @statement.institution_name_hint.presence || t(".unknown_value") %>
+
+
+
<%= t(".account_name_hint") %>
+
<%= @statement.account_name_hint.presence || t(".unknown_value") %>
+
+
+
<%= t(".account_last4_hint") %>
+
<%= @statement.account_last4_hint.presence || t(".unknown_value") %>
+
+
+
<%= t(".currency") %>
+
<%= @statement.statement_currency %>
+
+
+
<%= t("account_statements.table.period") %>
+
<%= account_statement_period(@statement) %>
+
+
+
<%= t(".opening_balance") %>
+
<%= account_statement_balance_label(@statement, :opening_balance) %>
+
+
+
<%= t(".closing_balance") %>
+
<%= account_statement_balance_label(@statement, :closing_balance) %>
+
+
+ <% end %> +
+ +
+
+

<%= t(".linking_title") %>

+ + <% if @statement.account.present? %> +

+ <%= t(".linked_to", account: @statement.account.name) %> +

+ <% if @can_manage_statement %> + <%= button_to unlink_account_statement_path(@statement), + method: :patch, + class: "inline-flex items-center gap-2 text-sm font-medium text-primary" do %> + <%= icon("unlink", size: "sm") %> + <%= t(".unlink") %> + <% end %> + <% end %> + <% elsif @statement.suggested_account.present? %> +

+ <%= t(".suggested_account", account: @statement.suggested_account.name, confidence: number_to_percentage(@statement.match_confidence.to_d * 100, precision: 0)) %> +

+ <% if @can_manage_statement %> +
+ <%= button_to link_account_statement_path(@statement), + method: :patch, + params: { account_id: @statement.suggested_account_id }, + class: "inline-flex items-center gap-2 text-sm font-medium text-primary" do %> + <%= icon("link", size: "sm") %> + <%= t(".link_suggestion") %> + <% end %> + <%= button_to reject_account_statement_path(@statement), + method: :patch, + class: "inline-flex items-center gap-2 text-sm font-medium text-secondary" do %> + <%= icon("x", size: "sm") %> + <%= t(".reject") %> + <% end %> +
+ <% end %> + <% else %> +

<%= t(".no_suggestion") %>

+ <% end %> +
+ +
+

<%= t(".reconciliation_title") %>

+ + <% if @reconciliation_checks.any? %> +
+ <% @reconciliation_checks.each do |check| %> +
+
+

<%= account_statement_reconciliation_label(check) %>

+ <% if check[:status] == "matched" %> + <%= render "shared/badge", color: "success" do %><%= t("account_statements.reconciliation.matched") %><% end %> + <% else %> + <%= render "shared/badge", color: "error" do %><%= t("account_statements.reconciliation.mismatched") %><% end %> + <% end %> +
+
+
+
<%= t(".statement_amount") %>
+
<%= Money.new(check[:statement_amount], @statement.statement_currency).format %>
+
+
+
<%= t(".ledger_amount") %>
+
<%= Money.new(check[:ledger_amount], @statement.statement_currency).format %>
+
+
+
<%= t(".difference") %>
+
<%= Money.new(check[:difference], @statement.statement_currency).format %>
+
+
+
+ <% end %> +
+ <% else %> +

<%= t(".reconciliation_unavailable") %>

+ <% end %> +
+
+
+
+<% end %> diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index 00341560b..bcc57a68d 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -54,7 +54,7 @@ <% if account.draft? %> <%= render DS::Link.new( - text: "Complete setup", + text: t(".complete_setup"), href: edit_account_path(account, return_to: return_to), variant: :outline, frame: :modal @@ -71,6 +71,17 @@ <% if !account.linked? && %w[Depository CreditCard Investment Crypto].include?(account.accountable_type) %> <% menu.with_item(variant: "link", text: t("accounts.account.link_provider"), href: select_provider_account_path(account), icon: "link", data: { turbo_frame: :modal }) %> <% elsif account.linked? %> + <%# Same-provider relink (e.g., card-replacement fraud). Only surfaced for + SimpleFIN-linked accounts today; other providers can be added later. %> + <% if account.linked_to?("SimplefinAccount") %> + <% menu.with_item( + variant: "link", + text: t("accounts.account.change_simplefin_account"), + href: select_existing_account_simplefin_items_path(account_id: account.id), + icon: "arrow-left-right", + data: { turbo_frame: :modal } + ) %> + <% end %> <% menu.with_item(variant: "link", text: t("accounts.account.unlink_provider"), href: confirm_unlink_account_path(account), icon: "unlink", data: { turbo_frame: :modal }) %> <% end %> <% end %> diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index ae6f793d8..653a64c85 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -1,5 +1,6 @@ <%# locals: (family:, active_tab:, mobile: false) %> +<% cache account_sidebar_tabs_cache_key(family: family, active_tab: active_tab, mobile: mobile), expires_in: 12.hours do %>
<% if family.missing_data_provider? %>
@@ -88,3 +89,4 @@ <% end %> <% end %>
+<% end %> diff --git a/app/views/accounts/_account_type.html.erb b/app/views/accounts/_account_type.html.erb index 741ace78b..d055156f1 100644 --- a/app/views/accounts/_account_type.html.erb +++ b/app/views/accounts/_account_type.html.erb @@ -1,11 +1,11 @@ <%# locals: (accountable:) %> <%= link_to new_polymorphic_path(accountable, step: "method_select", return_to: params[:return_to]), - class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %> + class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover text-primary border border-transparent block px-2 rounded-lg p-2" do %> <%= render DS::FilledIcon.new( icon: accountable.icon, hex_color: accountable.color, ) %> - <%= accountable.display_name.singularize %> + <%= accountable.singular_display_name %> <% end %> diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index 64f655797..28727dc85 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -56,7 +56,7 @@
<%= render DS::Link.new( href: new_polymorphic_path(account_group.key, step: "method_select"), - text: t("accounts.sidebar.new_account_group", account_group: account_group.name.downcase.singularize), + text: t("accounts.sidebar.new_account_group", account_group: account_group.accountable_type.singular_display_name.downcase), icon: "plus", full_width: true, variant: "ghost", diff --git a/app/views/accounts/_tax_treatment_badge.html.erb b/app/views/accounts/_tax_treatment_badge.html.erb index 070e870ba..b27da2381 100644 --- a/app/views/accounts/_tax_treatment_badge.html.erb +++ b/app/views/accounts/_tax_treatment_badge.html.erb @@ -1,17 +1,17 @@ <%# locals: (account:) %> <% treatment = account.tax_treatment - badge_classes = case treatment - when :tax_exempt - "bg-green-500/10 text-green-600 theme-dark:text-green-400" - when :tax_deferred - "bg-blue-500/10 text-blue-600 theme-dark:text-blue-400" - when :tax_advantaged - "bg-purple-500/10 text-purple-600 theme-dark:text-purple-400" - else - "bg-gray-500/10 text-secondary" - end + tone = case treatment + when :tax_exempt then :green + when :tax_deferred then :indigo # was raw blue-500/10 → maps to closest DS tone + when :tax_advantaged then :violet # was raw purple-500/10 → maps to closest DS tone + else :neutral + end %> -"> - <%= account.tax_treatment_label %> - +<%= render DS::Pill.new( + label: account.tax_treatment_label, + tone: tone, + marker: false, + show_dot: false, + title: t("accounts.tax_treatment_descriptions.#{treatment}") +) %> diff --git a/app/views/accounts/confirm_unlink.html.erb b/app/views/accounts/confirm_unlink.html.erb index 5b77c4808..e61cfd925 100644 --- a/app/views/accounts/confirm_unlink.html.erb +++ b/app/views/accounts/confirm_unlink.html.erb @@ -6,19 +6,15 @@ <%= t("accounts.confirm_unlink.description_html", account_name: @account.name, provider_name: @account.provider_name) %>

-
-
- <%= icon "alert-triangle", class: "w-4 h-4 text-yellow-600 mt-0.5 flex-shrink-0" %> -
-

<%= t("accounts.confirm_unlink.warning_title") %>

-
    -
  • <%= t("accounts.confirm_unlink.warning_no_sync") %>
  • -
  • <%= t("accounts.confirm_unlink.warning_manual_updates") %>
  • -
  • <%= t("accounts.confirm_unlink.warning_transactions_kept") %>
  • -
  • <%= t("accounts.confirm_unlink.warning_can_delete") %>
  • -
-
-
+
+ <%= render DS::Alert.new(title: t("accounts.confirm_unlink.warning_title"), variant: :warning) do %> +
    +
  • <%= t("accounts.confirm_unlink.warning_no_sync") %>
  • +
  • <%= t("accounts.confirm_unlink.warning_manual_updates") %>
  • +
  • <%= t("accounts.confirm_unlink.warning_transactions_kept") %>
  • +
  • <%= t("accounts.confirm_unlink.warning_can_delete") %>
  • +
+ <% end %>
<%= render DS::Button.new( diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index ec2fce04e..38ba4d888 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -9,7 +9,7 @@ frame: :_top ) %> <%= render DS::Link.new( - text: "New account", + text: t(".new_account"), href: new_account_path(return_to: accounts_path), variant: "primary", icon: "plus", @@ -17,7 +17,7 @@ ) %> <% end %> -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? %> <%= render "empty" %> <% else %>
@@ -41,10 +41,18 @@ <%= render @coinstats_items.sort_by(&:created_at) %> <% end %> + <% if @sophtron_items.any? %> + <%= render @sophtron_items.sort_by(&:created_at) %> + <% end %> + <% if @mercury_items.any? %> <%= render @mercury_items.sort_by(&:created_at) %> <% end %> + <% if @brex_items.any? %> + <%= render @brex_items.sort_by(&:created_at) %> + <% end %> + <% if @coinbase_items.any? %> <%= render @coinbase_items.sort_by(&:created_at) %> <% end %> @@ -53,9 +61,13 @@ <%= render @snaptrade_items.sort_by(&:created_at) %> <% end %> + <% if @ibkr_items.any? %> + <%= render @ibkr_items.sort_by(&:created_at) %> + <% end %> + <% if @indexa_capital_items.any? %> - <%= render @indexa_capital_items.sort_by(&:created_at) %> -<% end %> + <%= render @indexa_capital_items.sort_by(&:created_at) %> + <% end %> <% if @manual_accounts.any? %>
diff --git a/app/views/accounts/index/_account_groups.erb b/app/views/accounts/index/_account_groups.erb index a66ddf5e3..79709142e 100644 --- a/app/views/accounts/index/_account_groups.erb +++ b/app/views/accounts/index/_account_groups.erb @@ -1,6 +1,11 @@ <%# locals: (accounts:) %> -<% ActiveRecord::Associations::Preloader.new(records: accounts, associations: :account_shares).call if accounts.any? %> +<% if accounts.any? %> + <% ActiveRecord::Associations::Preloader.new( + records: accounts, + associations: [ :account_shares, :accountable, :plaid_account, :simplefin_account, { account_providers: :provider } ] + ).call %> +<% end %> <% accounts.group_by(&:accountable_type).sort_by { |group, _| group }.each do |group, accounts| %>
@@ -9,7 +14,7 @@

<%= accounts.count %>

<% unless accounts.any?(&:syncing?) %> -

<%= totals_by_currency(collection: accounts, money_method: :balance_money) %>

+

<%= totals_by_currency(collection: accounts, money_method: :balance_money) %>

<% end %>
diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index 01b02a575..7caa4dda3 100644 --- a/app/views/accounts/new.html.erb +++ b/app/views/accounts/new.html.erb @@ -27,7 +27,7 @@ <% unless params[:return_to].present? %> <%= button_to imports_path(import: { type: "AccountImport" }), data: { turbo_frame: :_top }, - class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %> + class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover text-primary border border-transparent block px-2 rounded-lg p-2" do %> <%= render DS::FilledIcon.new( icon: "download", hex_color: "#F79009", diff --git a/app/views/accounts/new/_container.html.erb b/app/views/accounts/new/_container.html.erb index 67697da50..6c1c16112 100644 --- a/app/views/accounts/new/_container.html.erb +++ b/app/views/accounts/new/_container.html.erb @@ -2,7 +2,7 @@ <%= render DS::Dialog.new do |dialog| %>
-
+
<% if back_path %> <%= render DS::Link.new( @@ -29,13 +29,13 @@ diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index f98fef36c..ac3c31909 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -10,7 +10,7 @@ <% end %> <% end %>> <%# Manual entry option %> - <%= link_to path, class: "flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2" do %> + <%= link_to path, class: "flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-secondary px-2 hover:bg-surface rounded-lg p-2" do %> <%= icon("keyboard") %> diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index 170f12986..a2c663b6b 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -2,7 +2,11 @@ account: @account, chart_view: @chart_view, chart_period: @period, - active_tab: @tab + active_tab: @tab, + statement_coverage: @statement_coverage, + statements: @account_statements, + reconciliation_statuses: @statement_reconciliation_statuses, + can_manage_statements: @can_manage_statements ) do |account_page| %> <%= account_page.with_activity_feed(feed_data: @activity_feed_data, pagy: @pagy, search: @q[:search]) %> <% end %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 76de75102..5660695c9 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -7,11 +7,11 @@ <% unless @account.linked? %> <% if @account.permission_for(Current.user).in?([ :owner, :full_control ]) %> <%= render DS::Menu.new(variant: "button") do |menu| %> - <% menu.with_button(text: "New", variant: "secondary", icon: "plus") %> + <% menu.with_button(text: t(".new"), variant: "secondary", icon: "plus") %> <% menu.with_item( variant: "link", - text: "New balance", + text: t(".new_balance"), icon: "circle-dollar-sign", href: new_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }) %> @@ -44,11 +44,11 @@ data: { controller: "auto-submit-form" } do |form| %>
-
+
<%= icon("search") %> <%= hidden_field_tag :account_id, @account.id %> <%= form.search_field :search, - placeholder: "Search entries by name", + placeholder: t(".search_placeholder"), value: @q[:search], class: "form-field__input placeholder:text-sm placeholder:text-secondary", "data-auto-submit-form-target": "auto" %> diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index cafd97811..ddbad5a2b 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -17,7 +17,7 @@ <% end %> <% if account.draft? %> <%= render DS::Link.new( - text: "Complete setup", + text: t(".complete_setup"), href: edit_account_path(account), variant: :outline, size: :sm, diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index 45a39fe18..de1b77626 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -3,9 +3,10 @@ <% permission = account.permission_for(Current.user) %> <%= render DS::Menu.new(testid: "account-menu") do |menu| %> <% if permission.in?([ :owner, :full_control ]) %> - <% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %> + <% menu.with_item(variant: "link", text: t(".edit"), href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %> <% end %> - <% menu.with_item(variant: "link", text: "Sharing", href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %> + <% menu.with_item(variant: "link", text: t(".sharing"), href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %> + <% menu.with_item(variant: "link", text: t(".statements"), href: account_path(account, tab: "statements"), icon: "archive") %> <% if permission.in?([ :owner, :full_control ]) %> <% if account.supports_trades? %> @@ -30,7 +31,7 @@ <% if account.owned_by?(Current.user) && !account.linked? %> <% menu.with_item( variant: "button", - text: "Delete account", + text: t(".delete_account"), href: account_path(account), method: :delete, icon: "trash-2", diff --git a/app/views/accounts/show/_statements.html.erb b/app/views/accounts/show/_statements.html.erb new file mode 100644 index 000000000..3a5d4a11a --- /dev/null +++ b/app/views/accounts/show/_statements.html.erb @@ -0,0 +1,124 @@ +<%# locals: (account:, coverage:, statements:, reconciliation_statuses:, can_manage_statements:) %> + +
+
+
+
+

<%= t("account_statements.account_tab.coverage_title") %>

+

<%= t("account_statements.account_tab.coverage_description") %>

+

<%= account_statement_coverage_range(coverage) %>

+
+
+ <%= form_with url: account_path(account), method: :get, data: { controller: "auto-submit-form" } do |form| %> + <%= form.hidden_field :tab, value: "statements" %> + <%= form.select :statement_year, + coverage.available_years.map { |year| [ year, year ] }, + { selected: coverage.selected_year }, + class: "bg-container border border-secondary rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0", + data: { "auto-submit-form-target": "auto" }, + aria: { label: t("account_statements.account_tab.year_label") } %> + <% end %> + <%= link_to account_statements_path, + class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-primary-hover" do %> + <%= icon("inbox", size: "sm") %> + <%= t("account_statements.account_tab.open_inbox") %> + <% end %> +
+
+ +
+ <% coverage.months.each do |month| %> + + <% end %> +
+
+ + <% if can_manage_statements %> +
+ <%= styled_form_with url: account_statements_path, scope: :account_statement, multipart: true, class: "space-y-3" do |form| %> + <%= form.hidden_field :account_id, value: account.id %> +
+ <%= form.label :files, t("account_statements.form.files_label"), class: "form-field__label" %> + <%= form.file_field :files, + multiple: true, + accept: AccountStatement::ACCEPTED_FILE_EXTENSIONS.join(","), + class: "form-field__input" %> +

<%= t("account_statements.form.files_hint", max_size: AccountStatement::MAX_FILE_SIZE / 1.megabyte) %>

+
+ <%= form.submit t("account_statements.form.account_upload") %> + <% end %> +
+ <% end %> + +
+
+

<%= t("account_statements.account_tab.statements_title") %>

+ · +

<%= statements.size %>

+
+ +
+ <% if statements.any? %> + + + + + + + + + + + <% statements.each do |statement| %> + + + + + + + <% end %> + +
<%= t("account_statements.table.file") %><%= t("account_statements.table.period") %><%= t("account_statements.table.reconciliation") %><%= t("account_statements.table.actions") %>
+ <%= link_to account_statement_path(statement), class: "flex items-center gap-2 min-w-0 text-sm font-medium text-primary hover:underline" do %> + <%= icon(account_statement_file_icon(statement), size: "sm") %> + <%= statement.filename %> + <% end %> +

<%= number_to_human_size(statement.byte_size) %>

+
<%= account_statement_period(statement) %> + <% case reconciliation_statuses[statement.id] %> + <% when "matched" %> + <%= render "shared/badge", color: "success" do %><%= t("account_statements.reconciliation.matched") %><% end %> + <% when "mismatched" %> + <%= render "shared/badge", color: "error" do %><%= t("account_statements.reconciliation.mismatched") %><% end %> + <% else %> + <%= render "shared/badge" do %><%= t("account_statements.reconciliation.unavailable") %><% end %> + <% end %> + +
+ <%= link_to account_statement_path(statement), aria: { label: t("account_statements.table.view") } do %> + <%= icon("eye", class: "w-5 h-5 text-primary") %> + <% end %> + <% if statement.original_file.attached? %> + <%= link_to rails_blob_path(statement.original_file, disposition: "attachment"), aria: { label: t("account_statements.table.download") } do %> + <%= icon("download", class: "w-5 h-5 text-primary") %> + <% end %> + <% end %> + <% if can_manage_statements %> + <%= button_to unlink_account_statement_path(statement), + method: :patch, + class: "flex items-center", + aria: { label: t("account_statements.table.unlink") } do %> + <%= icon("unlink", class: "w-5 h-5 text-secondary") %> + <% end %> + <% end %> +
+
+ <% else %> +

<%= t("account_statements.account_tab.empty") %>

+ <% end %> +
+
+
diff --git a/app/views/accounts/show/_statements_frame.html.erb b/app/views/accounts/show/_statements_frame.html.erb new file mode 100644 index 000000000..acccc4190 --- /dev/null +++ b/app/views/accounts/show/_statements_frame.html.erb @@ -0,0 +1,10 @@ +<%# locals: (account:, coverage:, statements:, reconciliation_statuses:, can_manage_statements:) %> + +<%= turbo_frame_tag dom_id(account, :statements_tab) do %> + <%= render "accounts/show/statements", + account: account, + coverage: coverage, + statements: statements, + reconciliation_statuses: reconciliation_statuses, + can_manage_statements: can_manage_statements %> +<% end %> diff --git a/app/views/admin/sso_providers/_form.html.erb b/app/views/admin/sso_providers/_form.html.erb index 5ccdc3b87..9845bab1a 100644 --- a/app/views/admin/sso_providers/_form.html.erb +++ b/app/views/admin/sso_providers/_form.html.erb @@ -6,7 +6,7 @@ <%= icon "alert-circle", class: "w-5 h-5 text-destructive mr-2 shrink-0" %>

- <%= pluralize(sso_provider.errors.count, "error") %> prohibited this provider from being saved: + <%= t("admin.sso_providers.form.errors_title", count: sso_provider.errors.count) %>

    <% sso_provider.errors.full_messages.each do |message| %> @@ -20,38 +20,38 @@ <%= styled_form_with model: [:admin, sso_provider], class: "space-y-6", data: { controller: "admin-sso-form" } do |form| %>
    -

    Basic Information

    +

    <%= t("admin.sso_providers.form.basic_information") %>

    <%= form.select :strategy, options_for_select([ - ["OpenID Connect", "openid_connect"], - ["SAML 2.0", "saml"], - ["Google OAuth2", "google_oauth2"], - ["GitHub", "github"] + [t("admin.sso_providers.form.strategy_openid_connect"), "openid_connect"], + [t("admin.sso_providers.form.strategy_saml"), "saml"], + [t("admin.sso_providers.form.strategy_google_oauth2"), "google_oauth2"], + [t("admin.sso_providers.form.strategy_github"), "github"] ], sso_provider.strategy), - { label: "Strategy" }, + { label: t("admin.sso_providers.form.strategy_label") }, { data: { action: "change->admin-sso-form#toggleFields" } } %> <%= form.text_field :name, - label: "Name", - placeholder: "e.g., keycloak, authentik", + label: t("admin.sso_providers.form.name_label"), + placeholder: t("admin.sso_providers.form.name_placeholder"), required: true, data: { action: "input->admin-sso-form#updateCallbackUrl" } %>
    -

    Unique identifier (lowercase, numbers, underscores only)

    +

    <%= t("admin.sso_providers.form.name_help") %>

    <%= form.text_field :label, - label: "Button Label", - placeholder: "e.g., Sign in with Keycloak", + label: t("admin.sso_providers.form.label_label"), + placeholder: t("admin.sso_providers.form.label_placeholder"), required: true %>
    <%= form.text_field :icon, - label: "Icon (optional)", - placeholder: "e.g., key, shield" %> -

    Lucide icon name for the login button

    + label: t("admin.sso_providers.form.icon_label"), + placeholder: t("admin.sso_providers.form.icon_placeholder") %> +

    <%= t("admin.sso_providers.form.icon_help") %>

    @@ -65,42 +65,42 @@
    -

    OAuth/OIDC Configuration

    +

    <%= t("admin.sso_providers.form.oauth_configuration") %>

    "> <%= form.text_field :issuer, - label: "Issuer URL", - placeholder: "https://your-idp.example.com/realms/your-realm", + label: t("admin.sso_providers.form.issuer_label"), + placeholder: t("admin.sso_providers.form.issuer_placeholder"), data: { action: "blur->admin-sso-form#validateIssuer" } %> -

    OIDC issuer URL (validates .well-known/openid-configuration)

    +

    <%= t("admin.sso_providers.form.issuer_help") %>

    <%= form.text_field :client_id, - label: "Client ID", - placeholder: "your-client-id", + label: t("admin.sso_providers.form.client_id_label"), + placeholder: t("admin.sso_providers.form.client_id_placeholder"), required: true %> <%= form.password_field :client_secret, - label: "Client Secret", - placeholder: sso_provider.persisted? ? "••••••••" : "your-client-secret", + label: t("admin.sso_providers.form.client_secret_label"), + placeholder: sso_provider.persisted? ? t("admin.sso_providers.form.client_secret_placeholder_existing") : t("admin.sso_providers.form.client_secret_placeholder_new"), required: !sso_provider.persisted? %> <% if sso_provider.persisted? %> -

    Leave blank to keep existing secret

    +

    <%= t("admin.sso_providers.form.client_secret_help_existing") %>

    <% end %>
    "> - +
    <%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %>
    -

    Configure this URL in your identity provider

    +

    <%= t("admin.sso_providers.form.redirect_uri_help") %>

    @@ -119,7 +119,7 @@
    <%= t("admin.sso_providers.form.manual_saml_config") %> -
    +

    <%= t("admin.sso_providers.form.manual_saml_help") %>

    @@ -172,18 +172,18 @@
    - +
    <%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %>
    -

    Configure this URL as the Assertion Consumer Service URL in your IdP

    +

    <%= t("admin.sso_providers.form.saml_sp_callback_url_help") %>

@@ -202,7 +202,7 @@
<%= t("admin.sso_providers.form.role_mapping_title") %> -
+

<%= t("admin.sso_providers.form.role_mapping_help") %>

@@ -282,9 +282,9 @@
- <%= link_to "Cancel", admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %> - <%= form.submit sso_provider.persisted? ? "Update Provider" : "Create Provider", - class: "px-4 py-2 bg-primary text-inverse rounded-lg text-sm font-medium hover:bg-primary/90" %> + <%= link_to t("admin.sso_providers.form.cancel"), admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %> + <%= form.submit sso_provider.persisted? ? t("admin.sso_providers.form.update_provider") : t("admin.sso_providers.form.create_provider"), + class: "px-4 py-2 button-bg-primary text-inverse rounded-lg text-sm font-medium hover:button-bg-primary-hover" %>
<% end %> diff --git a/app/views/admin/sso_providers/index.html.erb b/app/views/admin/sso_providers/index.html.erb index 8709fbdb1..c799d374d 100644 --- a/app/views/admin/sso_providers/index.html.erb +++ b/app/views/admin/sso_providers/index.html.erb @@ -1,16 +1,16 @@ -<%= content_for :page_title, "SSO Providers" %> +<%= content_for :page_title, t(".page_title") %>

- Manage single sign-on authentication providers for your instance. + <%= t(".description") %> <% unless FeatureFlags.db_sso_providers? %> - Changes require a server restart to take effect. + <%= t(".restart_required") %> <% end %>

- <%= settings_section title: "Configured Providers" do %> + <%= settings_section title: t(".configured_providers") do %> <% if @sso_providers.any? %> -
+
<% @sso_providers.each do |provider| %>
@@ -27,20 +27,20 @@
<% if provider.enabled? %> - Enabled + <%= t(".enabled") %> <% else %> - Disabled + <%= t(".disabled") %> <% end %> - <%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: "Edit" do %> + <%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: t(".edit") do %> <%= icon "pencil", class: "w-4 h-4" %> <% end %> - <%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? "Disable" : "Enable", form: { data: { turbo_confirm: "Are you sure you want to #{provider.enabled? ? 'disable' : 'enable'} this provider?" } } do %> + <%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? t(".disable") : t(".enable"), form: { data: { turbo_confirm: provider.enabled? ? t("admin.sso_providers.toggle.confirm_disable") : t("admin.sso_providers.toggle.confirm_enable") } } do %> <%= icon provider.enabled? ? "toggle-right" : "toggle-left", class: "w-4 h-4" %> <% end %> - <%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: "Delete", form: { data: { turbo_confirm: "Are you sure you want to delete this provider? This action cannot be undone." } } do %> + <%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: t(".delete"), form: { data: { turbo_confirm: t("admin.sso_providers.destroy.confirm") } } do %> <%= icon "trash-2", class: "w-4 h-4" %> <% end %>
@@ -50,14 +50,14 @@ <% else %>
<%= icon "key", class: "w-12 h-12 mx-auto text-secondary mb-3" %> -

No SSO providers configured yet.

+

<%= t(".no_providers_message") %>

<% end %>
<%= link_to new_admin_sso_provider_path, class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-secondary" do %> <%= icon "plus", class: "w-4 h-4" %> - Add Provider + <%= t(".add_provider") %> <% end %>
<% end %> @@ -73,7 +73,7 @@
-
+
<% @legacy_providers.each do |provider| %>
@@ -100,26 +100,25 @@ <% end %> <% end %> - <%= settings_section title: "Configuration Mode", collapsible: true, open: false do %> + <%= settings_section title: t(".configuration_mode"), collapsible: true, open: false do %>
-

Database-backed providers

-

Load providers from database instead of YAML config

+

<%= t(".db_backed_providers") %>

+

<%= t(".db_backed_providers_description") %>

<% if FeatureFlags.db_sso_providers? %> - Enabled + <%= t(".enabled") %> <% else %> - Disabled + <%= t(".disabled") %> <% end %>

- Set AUTH_PROVIDERS_SOURCE=db to enable database-backed providers. - This allows changes without server restarts. + <%= t(".db_backed_providers_help_html") %>

<% end %> diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 6d10e0570..c31a1fb61 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,12 +1,12 @@ <%= content_for :page_title, t(".title") %> -
-
+
+

<%= t(".description") %>

-
+
<%= form_with url: admin_users_path, method: :get, class: "flex gap-4 items-end flex-wrap" do |f| %>
<%= f.label :role, t(".filters.role"), class: "block text-sm font-medium text-primary mb-1" %> @@ -33,7 +33,7 @@
-
+
<%= icon "calendar-clock", class: "w-5 h-5 text-secondary" %> @@ -95,7 +95,7 @@ <%= t(".table.role") %> - + <% users.each do |user| %> @@ -137,7 +137,7 @@ <% end %> <% if pending_invitations.any? %> - + <% pending_invitations.each do |invitation| %> diff --git a/app/views/api/v1/accounts/_account.json.jbuilder b/app/views/api/v1/accounts/_account.json.jbuilder new file mode 100644 index 000000000..b38c3b1a6 --- /dev/null +++ b/app/views/api/v1/accounts/_account.json.jbuilder @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +balance_money = account.balance_money +cash_balance_money = account.cash_balance_money + +json.id account.id +json.name account.name +json.balance balance_money.format +json.balance_cents((balance_money.amount * balance_money.currency.minor_unit_conversion).round(0).to_i) +json.cash_balance cash_balance_money.format +json.cash_balance_cents((cash_balance_money.amount * cash_balance_money.currency.minor_unit_conversion).round(0).to_i) +json.currency account.currency +json.classification account.classification +json.account_type account.accountable_type&.underscore +json.subtype account.subtype +json.status account.status +json.institution_name account.institution_name +json.institution_domain account.institution_domain +json.created_at account.created_at.iso8601 +json.updated_at account.updated_at.iso8601 diff --git a/app/views/api/v1/accounts/index.json.jbuilder b/app/views/api/v1/accounts/index.json.jbuilder index e6e8bfeec..889e2e5d4 100644 --- a/app/views/api/v1/accounts/index.json.jbuilder +++ b/app/views/api/v1/accounts/index.json.jbuilder @@ -1,12 +1,7 @@ # frozen_string_literal: true json.accounts @accounts do |account| - json.id account.id - json.name account.name - json.balance account.balance_money.format - json.currency account.currency - json.classification account.classification - json.account_type account.accountable_type.underscore + json.partial! "account", account: account end json.pagination do diff --git a/app/views/api/v1/accounts/show.json.jbuilder b/app/views/api/v1/accounts/show.json.jbuilder new file mode 100644 index 000000000..8a29ac174 --- /dev/null +++ b/app/views/api/v1/accounts/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "account", account: @account diff --git a/app/views/api/v1/balances/_balance.json.jbuilder b/app/views/api/v1/balances/_balance.json.jbuilder new file mode 100644 index 000000000..05b92d98e --- /dev/null +++ b/app/views/api/v1/balances/_balance.json.jbuilder @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +json.id balance.id +json.date balance.date +json.currency balance.currency +json.flows_factor balance.flows_factor + +json.balance format_money(balance.balance_money) +json.balance_cents money_to_minor_units(balance.balance_money) +json.cash_balance format_money(balance.cash_balance_money) +json.cash_balance_cents money_to_minor_units(balance.cash_balance_money) + +json.start_cash_balance format_money(balance.start_cash_balance_money) +json.start_cash_balance_cents money_to_minor_units(balance.start_cash_balance_money) +json.start_non_cash_balance format_money(balance.start_non_cash_balance_money) +json.start_non_cash_balance_cents money_to_minor_units(balance.start_non_cash_balance_money) +json.start_balance format_money(balance.start_balance_money) +json.start_balance_cents money_to_minor_units(balance.start_balance_money) + +json.cash_inflows format_money(balance.cash_inflows_money) +json.cash_inflows_cents money_to_minor_units(balance.cash_inflows_money) +json.cash_outflows format_money(balance.cash_outflows_money) +json.cash_outflows_cents money_to_minor_units(balance.cash_outflows_money) +json.non_cash_inflows format_money(balance.non_cash_inflows_money) +json.non_cash_inflows_cents money_to_minor_units(balance.non_cash_inflows_money) +json.non_cash_outflows format_money(balance.non_cash_outflows_money) +json.non_cash_outflows_cents money_to_minor_units(balance.non_cash_outflows_money) +json.net_market_flows format_money(balance.net_market_flows_money) +json.net_market_flows_cents money_to_minor_units(balance.net_market_flows_money) +json.cash_adjustments format_money(balance.cash_adjustments_money) +json.cash_adjustments_cents money_to_minor_units(balance.cash_adjustments_money) +json.non_cash_adjustments format_money(balance.non_cash_adjustments_money) +json.non_cash_adjustments_cents money_to_minor_units(balance.non_cash_adjustments_money) + +json.end_cash_balance format_money(balance.end_cash_balance_money) +json.end_cash_balance_cents money_to_minor_units(balance.end_cash_balance_money) +json.end_non_cash_balance format_money(balance.end_non_cash_balance_money) +json.end_non_cash_balance_cents money_to_minor_units(balance.end_non_cash_balance_money) +json.end_balance format_money(balance.end_balance_money) +json.end_balance_cents money_to_minor_units(balance.end_balance_money) + +json.account do + json.id balance.account.id + json.name balance.account.name + json.account_type balance.account.accountable_type&.underscore +end + +json.created_at balance.created_at.iso8601 +json.updated_at balance.updated_at.iso8601 diff --git a/app/views/api/v1/balances/index.json.jbuilder b/app/views/api/v1/balances/index.json.jbuilder new file mode 100644 index 000000000..ddd8fe389 --- /dev/null +++ b/app/views/api/v1/balances/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.balances @balances do |balance| + json.partial! "balance", balance: balance +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/balances/show.json.jbuilder b/app/views/api/v1/balances/show.json.jbuilder new file mode 100644 index 000000000..3c31c5911 --- /dev/null +++ b/app/views/api/v1/balances/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "balance", balance: @balance diff --git a/app/views/api/v1/budget_categories/_budget_category.json.jbuilder b/app/views/api/v1/budget_categories/_budget_category.json.jbuilder new file mode 100644 index 000000000..a0418f78a --- /dev/null +++ b/app/views/api/v1/budget_categories/_budget_category.json.jbuilder @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +money_to_minor_units = lambda do |money| + (money.amount * money.currency.minor_unit_conversion).round(0).to_i if money +end +include_derived_amounts = local_assigns.fetch(:include_derived_amounts, true) + +json.id budget_category.id +json.budget_id budget_category.budget_id +json.currency budget_category.currency +json.subcategory budget_category.subcategory? +json.inherits_parent_budget budget_category.inherits_parent_budget? + +json.budgeted_spending budget_category.budgeted_spending_money.format +json.budgeted_spending_cents money_to_minor_units.call(budget_category.budgeted_spending_money) +json.display_budgeted_spending Money.new(budget_category.display_budgeted_spending, budget_category.currency).format +json.display_budgeted_spending_cents money_to_minor_units.call(Money.new(budget_category.display_budgeted_spending, budget_category.currency)) +if include_derived_amounts + json.actual_spending budget_category.actual_spending_money.format + json.actual_spending_cents money_to_minor_units.call(budget_category.actual_spending_money) + json.available_to_spend budget_category.available_to_spend_money.format + json.available_to_spend_cents money_to_minor_units.call(budget_category.available_to_spend_money) +end + +json.category do + json.id budget_category.category.id + json.name budget_category.category.name + json.color budget_category.category.color + json.lucide_icon budget_category.category.lucide_icon + json.parent_id budget_category.category.parent_id +end + +json.created_at budget_category.created_at.iso8601 +json.updated_at budget_category.updated_at.iso8601 diff --git a/app/views/api/v1/budget_categories/index.json.jbuilder b/app/views/api/v1/budget_categories/index.json.jbuilder new file mode 100644 index 000000000..14ef80310 --- /dev/null +++ b/app/views/api/v1/budget_categories/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.budget_categories @budget_categories do |budget_category| + json.partial! "budget_category", budget_category: budget_category, include_derived_amounts: false +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/budget_categories/show.json.jbuilder b/app/views/api/v1/budget_categories/show.json.jbuilder new file mode 100644 index 000000000..84a92e5c0 --- /dev/null +++ b/app/views/api/v1/budget_categories/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "budget_category", budget_category: @budget_category diff --git a/app/views/api/v1/budgets/_budget.json.jbuilder b/app/views/api/v1/budgets/_budget.json.jbuilder new file mode 100644 index 000000000..173df442b --- /dev/null +++ b/app/views/api/v1/budgets/_budget.json.jbuilder @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +money_to_minor_units = lambda do |money| + (money.amount * money.currency.minor_unit_conversion).round(0).to_i if money +end + +include_derived_amounts = local_assigns.fetch(:include_derived_amounts, true) + +json.id budget.id +json.start_date budget.start_date +json.end_date budget.end_date +json.name budget.name +json.currency budget.currency +json.initialized budget.initialized? +json.current budget.current? + +json.budgeted_spending budget.budgeted_spending_money&.format +json.budgeted_spending_cents money_to_minor_units.call(budget.budgeted_spending_money) +json.expected_income budget.expected_income_money&.format +json.expected_income_cents money_to_minor_units.call(budget.expected_income_money) +json.allocated_spending budget.allocated_spending_money.format +json.allocated_spending_cents money_to_minor_units.call(budget.allocated_spending_money) + +if include_derived_amounts + json.actual_spending budget.actual_spending_money.format + json.actual_spending_cents money_to_minor_units.call(budget.actual_spending_money) + json.actual_income budget.actual_income_money.format + json.actual_income_cents money_to_minor_units.call(budget.actual_income_money) + json.available_to_spend budget.available_to_spend_money.format + json.available_to_spend_cents money_to_minor_units.call(budget.available_to_spend_money) + json.available_to_allocate budget.available_to_allocate_money.format + json.available_to_allocate_cents money_to_minor_units.call(budget.available_to_allocate_money) +end + +json.created_at budget.created_at.iso8601 +json.updated_at budget.updated_at.iso8601 diff --git a/app/views/api/v1/budgets/index.json.jbuilder b/app/views/api/v1/budgets/index.json.jbuilder new file mode 100644 index 000000000..ebf1b3018 --- /dev/null +++ b/app/views/api/v1/budgets/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.budgets @budgets do |budget| + json.partial! "budget", budget: budget, include_derived_amounts: false +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/budgets/show.json.jbuilder b/app/views/api/v1/budgets/show.json.jbuilder new file mode 100644 index 000000000..3e88d7669 --- /dev/null +++ b/app/views/api/v1/budgets/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "budget", budget: @budget diff --git a/app/views/api/v1/chats/_chat.json.jbuilder b/app/views/api/v1/chats/_chat.json.jbuilder index a1b520ebf..259e2c5f4 100644 --- a/app/views/api/v1/chats/_chat.json.jbuilder +++ b/app/views/api/v1/chats/_chat.json.jbuilder @@ -2,6 +2,6 @@ json.id chat.id json.title chat.title -json.error chat.error.present? ? chat.error : nil +json.error chat.presentable_error_message json.created_at chat.created_at.iso8601 json.updated_at chat.updated_at.iso8601 diff --git a/app/views/api/v1/chats/index.json.jbuilder b/app/views/api/v1/chats/index.json.jbuilder index c251b4161..9f65a069a 100644 --- a/app/views/api/v1/chats/index.json.jbuilder +++ b/app/views/api/v1/chats/index.json.jbuilder @@ -5,7 +5,7 @@ json.chats @chats do |chat| json.title chat.title json.last_message_at chat.messages.ordered.first&.created_at&.iso8601 json.message_count chat.messages.count - json.error chat.error.present? ? chat.error : nil + json.error chat.presentable_error_message json.created_at chat.created_at.iso8601 json.updated_at chat.updated_at.iso8601 end diff --git a/app/views/api/v1/family_exports/_family_export.json.jbuilder b/app/views/api/v1/family_exports/_family_export.json.jbuilder new file mode 100644 index 000000000..f8f0d304a --- /dev/null +++ b/app/views/api/v1/family_exports/_family_export.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +json.id family_export.id +json.status family_export.status +json.filename family_export.filename +json.downloadable family_export.downloadable? +json.download_path family_export.downloadable? ? download_api_v1_family_export_path(family_export) : nil +attached = family_export.export_file.attached? +json.file do + json.attached attached + json.byte_size attached ? family_export.export_file.byte_size : nil + json.content_type attached ? family_export.export_file.content_type : nil +end +json.created_at family_export.created_at +json.updated_at family_export.updated_at diff --git a/app/views/api/v1/family_exports/index.json.jbuilder b/app/views/api/v1/family_exports/index.json.jbuilder new file mode 100644 index 000000000..d3d3b9d22 --- /dev/null +++ b/app/views/api/v1/family_exports/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.data do + json.array! @family_exports, partial: "api/v1/family_exports/family_export", as: :family_export +end + +json.meta do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/family_exports/show.json.jbuilder b/app/views/api/v1/family_exports/show.json.jbuilder new file mode 100644 index 000000000..b9c2ecd12 --- /dev/null +++ b/app/views/api/v1/family_exports/show.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.data do + json.partial! "api/v1/family_exports/family_export", family_export: @family_export +end diff --git a/app/views/api/v1/family_settings/show.json.jbuilder b/app/views/api/v1/family_settings/show.json.jbuilder new file mode 100644 index 000000000..025e82dfe --- /dev/null +++ b/app/views/api/v1/family_settings/show.json.jbuilder @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +json.id @family.id +json.name @family.name +json.currency @family.currency +json.locale @family.locale +json.date_format @family.date_format +json.country @family.country +json.timezone @family.timezone +json.month_start_day @family.month_start_day +json.moniker @family.moniker +json.default_account_sharing @family.default_account_sharing +json.custom_enabled_currencies @family.custom_enabled_currencies? +json.enabled_currencies @family.enabled_currency_codes +json.created_at @family.created_at.iso8601 +json.updated_at @family.updated_at.iso8601 diff --git a/app/views/api/v1/imports/_status_detail.json.jbuilder b/app/views/api/v1/imports/_status_detail.json.jbuilder new file mode 100644 index 000000000..7535dcada --- /dev/null +++ b/app/views/api/v1/imports/_status_detail.json.jbuilder @@ -0,0 +1,22 @@ +uploaded = local_assigns[:uploaded] +uploaded = import.uploaded? if uploaded.nil? +configured = local_assigns[:configured] +configured = import.configured_for_status_detail? if configured.nil? + +json.uploaded uploaded +json.configured configured +json.terminal import.complete? || import.failed? || import.revert_failed? + +if include_validation_stats + valid_rows_count = local_assigns.fetch(:valid_rows_count) + invalid_rows_count = local_assigns.fetch(:invalid_rows_count) + + cleaned = local_assigns[:cleaned] + publishable = local_assigns[:publishable] + cleaned = import.cleaned_from_validation_stats?(invalid_rows_count: invalid_rows_count) if cleaned.nil? + publishable = import.publishable_from_validation_stats?(invalid_rows_count: invalid_rows_count) if publishable.nil? + + json.cleaned cleaned + json.publishable publishable + json.revertable import.revertable? +end diff --git a/app/views/api/v1/imports/index.json.jbuilder b/app/views/api/v1/imports/index.json.jbuilder index eff1c6414..3359d704a 100644 --- a/app/views/api/v1/imports/index.json.jbuilder +++ b/app/views/api/v1/imports/index.json.jbuilder @@ -8,6 +8,9 @@ json.data do json.account_id import.account_id json.rows_count import.rows_count json.error import.error if import.error.present? + json.status_detail do + json.partial! "status_detail", import: import, include_validation_stats: false + end end end diff --git a/app/views/api/v1/imports/rows.json.jbuilder b/app/views/api/v1/imports/rows.json.jbuilder new file mode 100644 index 000000000..75d1ad5ac --- /dev/null +++ b/app/views/api/v1/imports/rows.json.jbuilder @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +mapping_summary = lambda do |type, key| + mapping = @row_mapping_lookup[[ type, key.to_s ]] + + if mapping + mappable = if mapping.mappable + { + id: mapping.mappable.id, + type: mapping.mappable_type, + name: mapping.mappable.try(:name) + } + end + + { + key: mapping.key, + type: mapping.type, + value: mapping.value, + create_when_empty: mapping.create_when_empty, + creatable: mapping.creatable?, + mappable: mappable + } + else + { + key: key, + type: type, + value: nil, + create_when_empty: false, + creatable: false, + mappable: nil + } + end +end + +json.data do + json.array! @rows do |row| + json.id row.id + json.row_number row.source_row_number + json.valid row.errors.empty? + json.errors row.errors.full_messages + + json.fields do + json.account row.account + json.date row.date + json.qty row.qty + json.ticker row.ticker + json.exchange_operating_mic row.exchange_operating_mic + json.price row.price + json.amount row.amount + json.currency row.currency + json.name row.name + json.category row.category + json.tags row.tags + json.entity_type row.entity_type + json.notes row.notes + json.active row.active + json.effective_date row.effective_date + json.conditions row.conditions + json.actions row.actions + end + + json.mappings do + json.account mapping_summary.call("Import::AccountMapping", row.account) if row.account.present? + json.category mapping_summary.call("Import::CategoryMapping", row.category) if row.category.present? + json.account_type mapping_summary.call("Import::AccountTypeMapping", row.entity_type) if row.entity_type.present? + json.tags row.tags_list.reject(&:blank?).map { |tag| mapping_summary.call("Import::TagMapping", tag) } + end + end +end + +json.meta do + json.current_page @pagy.page + json.next_page @pagy.next + json.prev_page @pagy.prev + json.total_pages @pagy.pages + json.total_count @pagy.count + json.per_page @per_page +end diff --git a/app/views/api/v1/imports/show.json.jbuilder b/app/views/api/v1/imports/show.json.jbuilder index 18509062e..fd8526428 100644 --- a/app/views/api/v1/imports/show.json.jbuilder +++ b/app/views/api/v1/imports/show.json.jbuilder @@ -1,3 +1,10 @@ +rows = @import.rows.to_a +valid_rows_count = rows.count(&:valid?) +invalid_rows_count = rows.length - valid_rows_count +cleaned = @import.cleaned_from_validation_stats?(invalid_rows_count: invalid_rows_count) +publishable = @import.publishable_from_validation_stats?(invalid_rows_count: invalid_rows_count) +mapping_counts = @import.mapping_status_counts + json.data do json.id @import.id json.type @import.type @@ -6,6 +13,15 @@ json.data do json.updated_at @import.updated_at json.account_id @import.account_id json.error @import.error if @import.error.present? + json.status_detail do + json.partial! "status_detail", + import: @import, + include_validation_stats: true, + valid_rows_count: valid_rows_count, + invalid_rows_count: invalid_rows_count, + cleaned: cleaned, + publishable: publishable + end json.configuration do json.date_col_label @import.date_col_label @@ -22,9 +38,14 @@ json.data do json.stats do json.rows_count @import.rows_count - json.valid_rows_count @import.rows.select(&:valid?).count if @import.rows.loaded? + json.valid_rows_count valid_rows_count + json.invalid_rows_count invalid_rows_count + json.mappings_count mapping_counts[:mappings_count] + json.unassigned_mappings_count mapping_counts[:unassigned_mappings_count] end + json.verification @import.verification_payload if @import.is_a?(SureImport) + # Only show a subset of rows for preview if needed, or link to a separate rows endpoint # json.sample_rows @import.rows.limit(5) end diff --git a/app/views/api/v1/provider_connections/_provider_connection.json.jbuilder b/app/views/api/v1/provider_connections/_provider_connection.json.jbuilder new file mode 100644 index 000000000..dc233c6f6 --- /dev/null +++ b/app/views/api/v1/provider_connections/_provider_connection.json.jbuilder @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +json.extract! provider_connection, + :id, + :provider, + :provider_type, + :name, + :status, + :requires_update, + :credentials_configured, + :scheduled_for_deletion, + :pending_account_setup, + :institution, + :accounts, + :sync, + :created_at, + :updated_at diff --git a/app/views/api/v1/provider_connections/index.json.jbuilder b/app/views/api/v1/provider_connections/index.json.jbuilder new file mode 100644 index 000000000..2fe69b2f9 --- /dev/null +++ b/app/views/api/v1/provider_connections/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.data do + json.array! @provider_connections, partial: "api/v1/provider_connections/provider_connection", as: :provider_connection +end diff --git a/app/views/api/v1/recurring_transactions/_recurring_transaction.json.jbuilder b/app/views/api/v1/recurring_transactions/_recurring_transaction.json.jbuilder new file mode 100644 index 000000000..c103ed53b --- /dev/null +++ b/app/views/api/v1/recurring_transactions/_recurring_transaction.json.jbuilder @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +json.id recurring_transaction.id +json.amount recurring_transaction.amount_money.format +money_to_minor_units = lambda do |money| + (money.amount * money.currency.minor_unit_conversion).round(0).to_i if money +end +json.amount_cents money_to_minor_units.call(recurring_transaction.amount_money) +json.currency recurring_transaction.currency +json.expected_day_of_month recurring_transaction.expected_day_of_month +json.last_occurrence_date recurring_transaction.last_occurrence_date +json.next_expected_date recurring_transaction.next_expected_date +json.status recurring_transaction.status +json.occurrence_count recurring_transaction.occurrence_count +json.name recurring_transaction.name +json.manual recurring_transaction.manual +json.expected_amount_min recurring_transaction.expected_amount_min_money&.format +json.expected_amount_min_cents money_to_minor_units.call(recurring_transaction.expected_amount_min_money) +json.expected_amount_max recurring_transaction.expected_amount_max_money&.format +json.expected_amount_max_cents money_to_minor_units.call(recurring_transaction.expected_amount_max_money) +json.expected_amount_avg recurring_transaction.expected_amount_avg_money&.format +json.expected_amount_avg_cents money_to_minor_units.call(recurring_transaction.expected_amount_avg_money) +json.created_at recurring_transaction.created_at.iso8601 +json.updated_at recurring_transaction.updated_at.iso8601 + +if recurring_transaction.account.present? + json.account do + json.id recurring_transaction.account.id + json.name recurring_transaction.account.name + json.account_type recurring_transaction.account.accountable_type&.underscore + end +else + json.account nil +end + +if recurring_transaction.merchant.present? + json.merchant do + json.id recurring_transaction.merchant.id + json.name recurring_transaction.merchant.name + end +else + json.merchant nil +end diff --git a/app/views/api/v1/recurring_transactions/index.json.jbuilder b/app/views/api/v1/recurring_transactions/index.json.jbuilder new file mode 100644 index 000000000..98a9c795d --- /dev/null +++ b/app/views/api/v1/recurring_transactions/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.recurring_transactions @recurring_transactions do |recurring_transaction| + json.partial! "recurring_transaction", recurring_transaction: recurring_transaction +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/recurring_transactions/show.json.jbuilder b/app/views/api/v1/recurring_transactions/show.json.jbuilder new file mode 100644 index 000000000..f96ab77de --- /dev/null +++ b/app/views/api/v1/recurring_transactions/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "recurring_transaction", recurring_transaction: @recurring_transaction diff --git a/app/views/api/v1/rejected_transfers/_rejected_transfer.json.jbuilder b/app/views/api/v1/rejected_transfers/_rejected_transfer.json.jbuilder new file mode 100644 index 000000000..d8efa4425 --- /dev/null +++ b/app/views/api/v1/rejected_transfers/_rejected_transfer.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +json.id rejected_transfer.id + +json.inflow_transaction do + json.partial! "api/v1/transfers/transaction_side", transaction: rejected_transfer.inflow_transaction +end + +json.outflow_transaction do + json.partial! "api/v1/transfers/transaction_side", transaction: rejected_transfer.outflow_transaction +end + +json.created_at rejected_transfer.created_at.iso8601 +json.updated_at rejected_transfer.updated_at.iso8601 diff --git a/app/views/api/v1/rejected_transfers/index.json.jbuilder b/app/views/api/v1/rejected_transfers/index.json.jbuilder new file mode 100644 index 000000000..08c070f9b --- /dev/null +++ b/app/views/api/v1/rejected_transfers/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.rejected_transfers @rejected_transfers do |rejected_transfer| + json.partial! "rejected_transfer", rejected_transfer: rejected_transfer +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/rejected_transfers/show.json.jbuilder b/app/views/api/v1/rejected_transfers/show.json.jbuilder new file mode 100644 index 000000000..60edba7cf --- /dev/null +++ b/app/views/api/v1/rejected_transfers/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "rejected_transfer", rejected_transfer: @rejected_transfer diff --git a/app/views/api/v1/rule_runs/_rule_run.json.jbuilder b/app/views/api/v1/rule_runs/_rule_run.json.jbuilder new file mode 100644 index 000000000..e61677714 --- /dev/null +++ b/app/views/api/v1/rule_runs/_rule_run.json.jbuilder @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +json.id rule_run.id +json.rule_id rule_run.rule_id +json.rule_name rule_run.rule_name +json.execution_type rule_run.execution_type +json.status rule_run.status +json.transactions_queued rule_run.transactions_queued +json.transactions_processed rule_run.transactions_processed +json.transactions_modified rule_run.transactions_modified +json.pending_jobs_count rule_run.pending_jobs_count +json.executed_at rule_run.executed_at.iso8601 +json.error_message rule_run.error_message + +if rule_run.rule + json.rule do + json.id rule_run.rule.id + json.name rule_run.rule.name + json.resource_type rule_run.rule.resource_type + json.active rule_run.rule.active + end +else + json.rule nil +end + +json.created_at rule_run.created_at.iso8601 +json.updated_at rule_run.updated_at.iso8601 diff --git a/app/views/api/v1/rule_runs/index.json.jbuilder b/app/views/api/v1/rule_runs/index.json.jbuilder new file mode 100644 index 000000000..6b1e1802e --- /dev/null +++ b/app/views/api/v1/rule_runs/index.json.jbuilder @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +json.data do + json.array! @rule_runs do |rule_run| + json.partial! "rule_run", rule_run: rule_run + end +end + +json.meta do + json.current_page @pagy.page + json.next_page @pagy.next + json.prev_page @pagy.prev + json.total_pages @pagy.pages + json.total_count @pagy.count + json.per_page @per_page +end diff --git a/app/views/api/v1/rule_runs/show.json.jbuilder b/app/views/api/v1/rule_runs/show.json.jbuilder new file mode 100644 index 000000000..c6f555d4d --- /dev/null +++ b/app/views/api/v1/rule_runs/show.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.data do + json.partial! "rule_run", rule_run: @rule_run +end diff --git a/app/views/api/v1/rules/_action.json.jbuilder b/app/views/api/v1/rules/_action.json.jbuilder new file mode 100644 index 000000000..3dfbab6a1 --- /dev/null +++ b/app/views/api/v1/rules/_action.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +json.id action.id +json.action_type action.action_type +json.value action.value +json.created_at action.created_at.iso8601 +json.updated_at action.updated_at.iso8601 diff --git a/app/views/api/v1/rules/_condition.json.jbuilder b/app/views/api/v1/rules/_condition.json.jbuilder new file mode 100644 index 000000000..088028b71 --- /dev/null +++ b/app/views/api/v1/rules/_condition.json.jbuilder @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +json.id condition.id +json.condition_type condition.condition_type +json.operator condition.operator +json.value condition.value + +if condition.compound? + json.sub_conditions condition.sub_conditions do |sub_condition| + json.partial! "api/v1/rules/condition", condition: sub_condition + end +else + json.sub_conditions [] +end + +json.created_at condition.created_at.iso8601 +json.updated_at condition.updated_at.iso8601 diff --git a/app/views/api/v1/rules/_rule.json.jbuilder b/app/views/api/v1/rules/_rule.json.jbuilder new file mode 100644 index 000000000..1009a3737 --- /dev/null +++ b/app/views/api/v1/rules/_rule.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +json.id rule.id +json.name rule.name +json.resource_type rule.resource_type +json.active rule.active +json.effective_date rule.effective_date&.iso8601 +json.conditions rule.conditions.select { |condition| condition.parent_id.nil? } do |condition| + json.partial! "api/v1/rules/condition", condition: condition +end +json.actions rule.actions do |action| + json.partial! "api/v1/rules/action", action: action +end +json.created_at rule.created_at.iso8601 +json.updated_at rule.updated_at.iso8601 diff --git a/app/views/api/v1/rules/index.json.jbuilder b/app/views/api/v1/rules/index.json.jbuilder new file mode 100644 index 000000000..97432d8b6 --- /dev/null +++ b/app/views/api/v1/rules/index.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +json.data @rules do |rule| + json.partial! "api/v1/rules/rule", rule: rule +end + +json.meta do + json.current_page @pagy.page + json.next_page @pagy.next + json.prev_page @pagy.prev + json.total_pages @pagy.pages + json.total_count @pagy.count + json.per_page @per_page +end diff --git a/app/views/api/v1/rules/show.json.jbuilder b/app/views/api/v1/rules/show.json.jbuilder new file mode 100644 index 000000000..0c4de976c --- /dev/null +++ b/app/views/api/v1/rules/show.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.data do + json.partial! "api/v1/rules/rule", rule: @rule +end diff --git a/app/views/api/v1/securities/_security.json.jbuilder b/app/views/api/v1/securities/_security.json.jbuilder new file mode 100644 index 000000000..3fcc31b31 --- /dev/null +++ b/app/views/api/v1/securities/_security.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +json.id security.id +json.ticker security.ticker +json.name security.name +json.kind security.kind +json.country_code security.country_code +json.exchange_mic security.exchange_mic +json.exchange_acronym security.exchange_acronym +json.exchange_operating_mic security.exchange_operating_mic +json.exchange_name security.exchange_name +json.offline security.offline +json.offline_reason security.offline_reason +json.website_url security.website_url +json.logo_url security.display_logo_url +json.first_provider_price_on security.first_provider_price_on +json.created_at security.created_at.iso8601 +json.updated_at security.updated_at.iso8601 diff --git a/app/views/api/v1/securities/index.json.jbuilder b/app/views/api/v1/securities/index.json.jbuilder new file mode 100644 index 000000000..f7ffdd22d --- /dev/null +++ b/app/views/api/v1/securities/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.securities @securities do |security| + json.partial! "security", security: security +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/securities/show.json.jbuilder b/app/views/api/v1/securities/show.json.jbuilder new file mode 100644 index 000000000..7ed519050 --- /dev/null +++ b/app/views/api/v1/securities/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "security", security: @security diff --git a/app/views/api/v1/security_prices/_security_price.json.jbuilder b/app/views/api/v1/security_prices/_security_price.json.jbuilder new file mode 100644 index 000000000..9804493bb --- /dev/null +++ b/app/views/api/v1/security_prices/_security_price.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +json.id security_price.id +json.date security_price.date +json.price Money.new(security_price.price, security_price.currency).format +json.price_amount format("%.4f", security_price.price.to_d) +json.currency security_price.currency +json.provisional security_price.provisional + +json.security do + json.id security_price.security.id + json.ticker security_price.security.ticker + json.name security_price.security.name + json.exchange_operating_mic security_price.security.exchange_operating_mic +end + +json.created_at security_price.created_at.iso8601 +json.updated_at security_price.updated_at.iso8601 diff --git a/app/views/api/v1/security_prices/index.json.jbuilder b/app/views/api/v1/security_prices/index.json.jbuilder new file mode 100644 index 000000000..8c534edfc --- /dev/null +++ b/app/views/api/v1/security_prices/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.security_prices @security_prices do |security_price| + json.partial! "security_price", security_price: security_price +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/security_prices/show.json.jbuilder b/app/views/api/v1/security_prices/show.json.jbuilder new file mode 100644 index 000000000..e3a997f70 --- /dev/null +++ b/app/views/api/v1/security_prices/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "security_price", security_price: @security_price diff --git a/app/views/api/v1/syncs/_sync.json.jbuilder b/app/views/api/v1/syncs/_sync.json.jbuilder new file mode 100644 index 000000000..9550aeca1 --- /dev/null +++ b/app/views/api/v1/syncs/_sync.json.jbuilder @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +syncable = sync.syncable + +json.id sync.id +json.status sync.status +json.in_progress sync.in_progress? +json.terminal sync.terminal? +json.syncable do + json.type sync.syncable_type + json.id sync.syncable_id + json.name syncable&.try(:name) +end +json.parent_id sync.parent_id +json.children_count sync.children.size +json.window_start_date sync.window_start_date +json.window_end_date sync.window_end_date +json.pending_at sync.pending_at +json.syncing_at sync.syncing_at +json.completed_at sync.completed_at +json.failed_at sync.failed_at +json.error sync.api_error_payload +json.created_at sync.created_at +json.updated_at sync.updated_at diff --git a/app/views/api/v1/syncs/index.json.jbuilder b/app/views/api/v1/syncs/index.json.jbuilder new file mode 100644 index 000000000..70496f7ff --- /dev/null +++ b/app/views/api/v1/syncs/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.data do + json.array! @syncs, partial: "api/v1/syncs/sync", as: :sync +end + +json.meta do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/syncs/show.json.jbuilder b/app/views/api/v1/syncs/show.json.jbuilder new file mode 100644 index 000000000..621b5638a --- /dev/null +++ b/app/views/api/v1/syncs/show.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +json.data do + if @sync + json.partial! "api/v1/syncs/sync", sync: @sync + else + json.nil! + end +end diff --git a/app/views/api/v1/transactions/_transaction.json.jbuilder b/app/views/api/v1/transactions/_transaction.json.jbuilder index 9f3a47a98..488ecbe5b 100644 --- a/app/views/api/v1/transactions/_transaction.json.jbuilder +++ b/app/views/api/v1/transactions/_transaction.json.jbuilder @@ -17,6 +17,8 @@ json.signed_amount_cents(transaction.entry.classification == "income" ? amount_c json.currency transaction.entry.currency json.name transaction.entry.name json.notes transaction.entry.notes +json.external_id transaction.entry.external_id +json.source transaction.entry.source json.classification transaction.entry.classification # Account information diff --git a/app/views/api/v1/transfers/_transaction_side.json.jbuilder b/app/views/api/v1/transfers/_transaction_side.json.jbuilder new file mode 100644 index 000000000..99b3099b3 --- /dev/null +++ b/app/views/api/v1/transfers/_transaction_side.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +entry = transaction.entry + +json.id transaction.id +json.entry_id entry.id +json.date entry.date +json.amount entry.amount_money.format +json.amount_cents money_to_minor_units(entry.amount_money) +json.currency entry.currency +json.name entry.name +json.kind transaction.kind + +json.account do + json.id entry.account.id + json.name entry.account.name + json.account_type entry.account.accountable_type.underscore +end diff --git a/app/views/api/v1/transfers/_transfer.json.jbuilder b/app/views/api/v1/transfers/_transfer.json.jbuilder new file mode 100644 index 000000000..1fb27815f --- /dev/null +++ b/app/views/api/v1/transfers/_transfer.json.jbuilder @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +json.id transfer.id +json.status transfer.status +json.date transfer.date +json.amount transfer.amount_abs.format +json.amount_cents money_to_minor_units(transfer.amount_abs) +json.currency transfer.inflow_transaction.entry.currency +json.transfer_type transfer.transfer_type +json.notes transfer.notes + +json.inflow_transaction do + json.partial! "api/v1/transfers/transaction_side", transaction: transfer.inflow_transaction +end + +json.outflow_transaction do + json.partial! "api/v1/transfers/transaction_side", transaction: transfer.outflow_transaction +end + +json.created_at transfer.created_at.iso8601 +json.updated_at transfer.updated_at.iso8601 diff --git a/app/views/api/v1/transfers/index.json.jbuilder b/app/views/api/v1/transfers/index.json.jbuilder new file mode 100644 index 000000000..fdb49b15d --- /dev/null +++ b/app/views/api/v1/transfers/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.transfers @transfers do |transfer| + json.partial! "transfer", transfer: transfer +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/transfers/show.json.jbuilder b/app/views/api/v1/transfers/show.json.jbuilder new file mode 100644 index 000000000..d5c0710f3 --- /dev/null +++ b/app/views/api/v1/transfers/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "transfer", transfer: @transfer diff --git a/app/views/api/v1/valuations/_valuation.json.jbuilder b/app/views/api/v1/valuations/_valuation.json.jbuilder index 1e63fb537..3341fa0e6 100644 --- a/app/views/api/v1/valuations/_valuation.json.jbuilder +++ b/app/views/api/v1/valuations/_valuation.json.jbuilder @@ -1,17 +1,19 @@ # frozen_string_literal: true -json.id valuation.entry.id -json.date valuation.entry.date -json.amount valuation.entry.amount_money.format -json.currency valuation.entry.currency -json.notes valuation.entry.notes +entry = local_assigns[:entry] || valuation.entry + +json.id entry.id +json.date entry.date +json.amount entry.amount_money.format +json.currency entry.currency +json.notes entry.notes json.kind valuation.kind # Account information json.account do - json.id valuation.entry.account.id - json.name valuation.entry.account.name - json.account_type valuation.entry.account.accountable_type.underscore + json.id entry.account.id + json.name entry.account.name + json.account_type entry.account.accountable_type.underscore end # Additional metadata diff --git a/app/views/api/v1/valuations/index.json.jbuilder b/app/views/api/v1/valuations/index.json.jbuilder new file mode 100644 index 000000000..a7b93c0d2 --- /dev/null +++ b/app/views/api/v1/valuations/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.valuations @entries do |entry| + json.partial! "valuation", valuation: entry.entryable, entry: entry +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/assistant_messages/_assistant_message.html.erb b/app/views/assistant_messages/_assistant_message.html.erb index 59356a788..6b00a3c2d 100644 --- a/app/views/assistant_messages/_assistant_message.html.erb +++ b/app/views/assistant_messages/_assistant_message.html.erb @@ -1,10 +1,15 @@ <%# locals: (assistant_message:) %>
- <% if assistant_message.reasoning? %> + <% if assistant_message.pending? %> +
+ <%= render "chats/ai_avatar" %> +

<%= t("chats.thinking") %>

+
+ <% elsif assistant_message.reasoning? %>
-

Assistant reasoning

+

<%= t(".assistant_reasoning") %>

<%= icon("chevron-down", class: "group-open:transform group-open:rotate-180") %>
diff --git a/app/views/assistant_messages/_tool_calls.html.erb b/app/views/assistant_messages/_tool_calls.html.erb index 59c149225..d2adace6a 100644 --- a/app/views/assistant_messages/_tool_calls.html.erb +++ b/app/views/assistant_messages/_tool_calls.html.erb @@ -3,15 +3,15 @@
<%= icon("chevron-right", class: "group-open:transform group-open:rotate-90") %> -

Tool Calls

+

<%= t(".tool_calls") %>

<% message.tool_calls.each do |tool_call| %>
-

Function:

+

<%= t(".function") %>

<%= tool_call.function_name %>

-

Arguments:

+

<%= t(".arguments") %>

<%= tool_call.function_arguments %>
<% end %> diff --git a/app/views/binance_items/_binance_item.html.erb b/app/views/binance_items/_binance_item.html.erb index d08e6d34d..5a1374fc7 100644 --- a/app/views/binance_items/_binance_item.html.erb +++ b/app/views/binance_items/_binance_item.html.erb @@ -1,49 +1,51 @@ <%# locals: (binance_item:, unlinked_count: binance_item.unlinked_accounts_count) %> <%= tag.div id: dom_id(binance_item) do %> -
- - <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+ <%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> -
-
- <%= icon "coins", size: "sm", class: "text-[#F0B90B]" %> +
+
+ <%= icon "coins", size: "sm", class: "text-[#F0B90B]" %> +
-
-
-
- <%= tag.p binance_item.institution_display_name, class: "font-medium text-primary" %> - <% if binance_item.scheduled_for_deletion? %> -

<%= t(".deletion_in_progress") %>

+
+
+ <%= tag.p binance_item.institution_display_name, class: "font-medium text-primary" %> + <% if binance_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+

<%= t(".provider_name") %>

+ <% if binance_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif binance_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".reconnect") %> +
+ <% else %> +

+ <% if binance_item.last_synced_at %> + <% if binance_item.sync_status_summary %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(binance_item.last_synced_at), summary: binance_item.sync_status_summary) %> + <% else %> + <%= t(".status", timestamp: time_ago_in_words(binance_item.last_synced_at)) %> + <% end %> + <% else %> + <%= t(".status_never") %> + <% end %> +

<% end %>
-

<%= t(".provider_name") %>

- <% if binance_item.syncing? %> -
- <%= icon "loader", size: "sm", class: "animate-spin" %> - <%= tag.span t(".syncing") %> -
- <% elsif binance_item.requires_update? %> -
- <%= icon "alert-triangle", size: "sm", color: "warning" %> - <%= tag.span t(".reconnect") %> -
- <% else %> -

- <% if binance_item.last_synced_at %> - <% if binance_item.sync_status_summary %> - <%= t(".status_with_summary", timestamp: time_ago_in_words(binance_item.last_synced_at), summary: binance_item.sync_status_summary) %> - <% else %> - <%= t(".status", timestamp: time_ago_in_words(binance_item.last_synced_at)) %> - <% end %> - <% else %> - <%= t(".status_never") %> - <% end %> -

- <% end %>
-
+ <% end %> <% if Current.user&.admin? %>
@@ -128,5 +130,5 @@ <% end %>
<% end %> -
+ <% end %> <% end %> diff --git a/app/views/binance_items/select_existing_account.html.erb b/app/views/binance_items/select_existing_account.html.erb index 8f12e628a..a980fca99 100644 --- a/app/views/binance_items/select_existing_account.html.erb +++ b/app/views/binance_items/select_existing_account.html.erb @@ -17,7 +17,7 @@ <%= hidden_field_tag :account_id, @account.id %>
<% @available_binance_accounts.each do |ba| %> -
@@ -61,15 +61,26 @@ <%= format_money(budget_category.actual_spending_money) %>
+ <% if show_budget_meta %>
<%= t("reports.budget_performance.budgeted") %>: <%= format_money(budget_category.budgeted_spending_money) %> <% if budget_category.inherits_parent_budget? %> - <%= t("reports.budget_performance.shared") %> + <%= t("reports.budget_performance.shared") %> <% end %>
+ <% if budget_category.suggested_daily_spending.present? %> + <% daily_info = budget_category.suggested_daily_spending %> +
+ <%= t("reports.budget_performance.suggested_daily", + amount: daily_info[:amount].format, + days: daily_info[:days_remaining]) + %> +
+ <% end %> + <% end %>
<% if budget_category.available_to_spend >= 0 %> <%= t("reports.budget_performance.remaining") %>: @@ -85,18 +96,6 @@
- <%# Suggested Daily Limit (if remaining days in month) %> - <% if budget_category.suggested_daily_spending.present? %> - <% daily_info = budget_category.suggested_daily_spending %> -
-

- <%= t("reports.budget_performance.suggested_daily", - amount: daily_info[:amount].format, - days: daily_info[:days_remaining]) %> -

-
- <% end %> - <% else %> <%# Uninitialized budget - show simple view %>
diff --git a/app/views/budget_categories/_budget_category_form.html.erb b/app/views/budget_categories/_budget_category_form.html.erb index 59a564a8b..855a0cda8 100644 --- a/app/views/budget_categories/_budget_category_form.html.erb +++ b/app/views/budget_categories/_budget_category_form.html.erb @@ -8,22 +8,22 @@

<%= budget_category.category.name %>

-

<%= budget_category.median_monthly_expense_money.format(precision: 0) %>/m avg

+

<%= t("budget_categories.budget_category_form.monthly_average", amount: budget_category.median_monthly_expense_money.format(precision: 0)) %>

<%= form_with model: [budget_category.budget, budget_category], data: { controller: "auto-submit-form preserve-focus" } do |f| %>
-
+
<%= currency.symbol %> <%= f.number_field :budgeted_spending, class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", - placeholder: budget_category.subcategory? ? "Shared" : "0", + placeholder: budget_category.subcategory? ? t("budget_categories.budget_category_form.shared_placeholder") : "0", step: currency.step, id: dom_id(budget_category, :budgeted_spending), min: 0, data: { auto_submit_form_target: "auto" }, - title: budget_category.subcategory? ? "Leave empty to share parent's budget" : nil %> + title: budget_category.subcategory? ? t("budget_categories.budget_category_form.shared_title") : nil %>
<% end %> diff --git a/app/views/budget_categories/_confirm_button.html.erb b/app/views/budget_categories/_confirm_button.html.erb index a9d983317..730ebf1e3 100644 --- a/app/views/budget_categories/_confirm_button.html.erb +++ b/app/views/budget_categories/_confirm_button.html.erb @@ -1,6 +1,6 @@
<%= render DS::Button.new( - text: "Confirm", + text: t(".confirm"), variant: "primary", full_width: true, href: budget_path(budget), diff --git a/app/views/budget_categories/_no_categories.html.erb b/app/views/budget_categories/_no_categories.html.erb index 44d8915b1..ed949d2d9 100644 --- a/app/views/budget_categories/_no_categories.html.erb +++ b/app/views/budget_categories/_no_categories.html.erb @@ -1,18 +1,18 @@
-

Oops!

+

<%= t(".oops") %>

- You have not created or assigned any expense categories to your transactions yet. + <%= t(".no_categories_message") %>

<%= render DS::Button.new( - text: "Use defaults (recommended)", + text: t(".use_defaults"), href: bootstrap_categories_path, ) %> <%= render DS::Link.new( - text: "New category", + text: t(".new_category"), variant: "outline", icon: "plus", href: new_category_path, diff --git a/app/views/budget_categories/_uncategorized_budget_category_form.html.erb b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb index 0ed955d34..49e545cb0 100644 --- a/app/views/budget_categories/_uncategorized_budget_category_form.html.erb +++ b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb @@ -7,12 +7,12 @@

<%= budget_category.category.name %>

-

<%= budget_category.avg_monthly_expense_money.format(precision: 0) %>/m avg

+

<%= t("budget_categories.budget_category_form.monthly_average", amount: budget_category.avg_monthly_expense_money.format(precision: 0)) %>

-
+
<%= budget_category.budgeted_spending_money.currency.symbol %> <%= text_field_tag :uncategorized, budget_category.budgeted_spending_money.amount, autocomplete: "off", class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", disabled: true %>
diff --git a/app/views/budget_categories/index.html.erb b/app/views/budget_categories/index.html.erb index d5ded43d7..049d5ede8 100644 --- a/app/views/budget_categories/index.html.erb +++ b/app/views/budget_categories/index.html.erb @@ -8,9 +8,9 @@
-

Edit your category budgets

+

<%= t(".title") %>

- Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized. + <%= t(".description") %>

diff --git a/app/views/budget_categories/show.html.erb b/app/views/budget_categories/show.html.erb index 154a7958f..6d8d64ed9 100644 --- a/app/views/budget_categories/show.html.erb +++ b/app/views/budget_categories/show.html.erb @@ -1,7 +1,7 @@ <%= render DS::Dialog.new(variant: :drawer) do |dialog| %> <% dialog.with_header do %>
-

Category

+

<%= t(".category") %>

<%= @budget_category.name %>

@@ -26,12 +26,12 @@ <% end %> <% dialog.with_body do %> - <% dialog.with_section(title: "Overview", open: true) do %> + <% dialog.with_section(title: t(".overview"), open: true) do %>
- <%= @budget_category.budget.start_date.strftime("%b %Y") %> spending + <%= t(".spending", date: @budget_category.budget.start_date.strftime("%b %Y")) %>
<%= format_money @budget_category.actual_spending_money %> @@ -40,30 +40,30 @@ <% if @budget_category.budget.initialized? %>
-
Status
+
<%= t(".status") %>
<% if @budget_category.available_to_spend.negative? %>
<%= icon "alert-circle", size: "sm", color: "destructive" %> <%= format_money @budget_category.available_to_spend_money.abs %> - overspent + <%= t(".overspent") %>
<% elsif @budget_category.available_to_spend.zero? %>
<%= icon "x-circle", size: "sm", color: "warning" %> <%= format_money @budget_category.available_to_spend_money %> - left + <%= t(".left") %>
<% else %>
<%= icon "check-circle", size: "sm", color: "success" %> <%= format_money @budget_category.available_to_spend_money %> - left + <%= t(".left") %>
<% end %>
-
Budgeted
+
<%= t(".budgeted") %>
<%= format_money @budget_category.budgeted_spending_money %>
@@ -71,14 +71,14 @@ <% end %>
-
Monthly average spending
+
<%= t(".monthly_average_spending") %>
<%= @budget_category.avg_monthly_expense_money.format %>
-
Monthly median spending
+
<%= t(".monthly_median_spending") %>
<%= @budget_category.median_monthly_expense_money.format %>
@@ -87,7 +87,7 @@
<% end %> - <% dialog.with_section(title: "Recent Transactions", open: true) do %> + <% dialog.with_section(title: t(".recent_transactions"), open: true) do %>
<% if @recent_transactions.any? %> @@ -120,7 +120,7 @@ <%= render DS::Link.new( - text: "View all category transactions", + text: t(".view_all_transactions"), variant: "outline", full_width: true, href: transactions_path(q: { @@ -132,7 +132,7 @@ ) %> <% else %>

- No transactions found for this budget period. + <%= t(".no_transactions") %>

<% end %>
diff --git a/app/views/budgets/_actuals_summary.html.erb b/app/views/budgets/_actuals_summary.html.erb index fe9509d9a..524fb0006 100644 --- a/app/views/budgets/_actuals_summary.html.erb +++ b/app/views/budgets/_actuals_summary.html.erb @@ -2,7 +2,7 @@
-

Income

+

<%= t(".income") %>

<%= budget.actual_income_money.format %> @@ -30,7 +30,7 @@
-

Expenses

+

<%= t(".expenses") %>

<%= budget.actual_spending_money.format %> diff --git a/app/views/budgets/_budget_categories.html.erb b/app/views/budgets/_budget_categories.html.erb index 46824bac3..addb349f3 100644 --- a/app/views/budgets/_budget_categories.html.erb +++ b/app/views/budgets/_budget_categories.html.erb @@ -1,44 +1,34 @@ <%# locals: (budget:) %> -
-
-

Categories

- · -

<%= budget.budget_categories.count %>

+<% categories_state = budget_categories_view_state(budget) %> +<% uncategorized_budget_category = categories_state[:uncategorized_budget_category] %> +<% visible_expenses_empty = categories_state[:visible_expenses_empty] %> +<% over_budget_groups = categories_state[:over_budget_groups] %> +<% show_over_budget_uncategorized = categories_state[:show_over_budget_uncategorized] %> +<% over_budget_count = categories_state[:over_budget_count] %> +<% on_track_groups = categories_state[:on_track_groups] %> +<% show_on_track_uncategorized = categories_state[:show_on_track_uncategorized] %> +<% on_track_count = categories_state[:on_track_count] %> -

Amount

-
+
-
- <% if budget.family.categories.expenses.empty? %> -
- <%= render "budget_categories/no_categories" %> -
- <% else %> - <% category_groups = BudgetCategory::Group.for(budget.budget_categories) %> + <% if over_budget_count.positive? %> + <%= render "budgets/category_section", + budget: budget, + count: over_budget_count, + groups: over_budget_groups, + uncategorized: uncategorized_budget_category, + show_uncategorized: show_over_budget_uncategorized, + over_budget_mode: true %> + <% end %> - <% category_groups.each_with_index do |group, index| %> -
- <%= render "budget_categories/budget_category", budget_category: group.budget_category %> + <%= render "budgets/category_section", + budget: budget, + count: on_track_count, + groups: on_track_groups, + uncategorized: uncategorized_budget_category, + show_uncategorized: show_on_track_uncategorized, + over_budget_mode: false + %> -
- <% group.budget_subcategories.each do |budget_subcategory| %> -
-
- <%= icon "corner-down-right" %> -
- - <%= render "budget_categories/budget_category", budget_category: budget_subcategory %> -
- <% end %> -
-
- - <%= render "shared/ruler" %> - <% end %> -
- <%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %> -
- <% end %> -
-
+
\ No newline at end of file diff --git a/app/views/budgets/_budget_donut.html.erb b/app/views/budgets/_budget_donut.html.erb index 8879d9bfc..15c88046f 100644 --- a/app/views/budgets/_budget_donut.html.erb +++ b/app/views/budgets/_budget_donut.html.erb @@ -4,8 +4,8 @@
<% if budget.initialized? %> -
- Spent +
+ <%= t(".spent") %>
"> @@ -13,7 +13,7 @@
<%= render DS::Link.new( - text: "of #{budget.budgeted_spending_money.format}", + text: t(".of_budget", amount: budget.budgeted_spending_money.format), variant: "secondary", icon: "pencil", icon_position: "right", @@ -26,7 +26,7 @@
<%= render DS::Link.new( - text: "New budget", + text: t(".new_budget"), size: "sm", icon: "plus", href: edit_budget_path(budget) @@ -47,7 +47,7 @@

<%= render DS::Link.new( - text: "of #{bc.budgeted_spending_money.format(precision: 0)}", + text: t(".of_budget", amount: bc.budgeted_spending_money.format(precision: 0)), variant: "secondary", icon: "pencil", icon_position: "right", @@ -59,7 +59,7 @@ <% end %> - <%= render DS::Menu.new(variant: "button") do |menu| %> - <% menu.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %> + <%= render DS::Popover.new(variant: "button") do |popover| %> + <% popover.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %> <%= @budget.name %> <%= icon("chevron-down") %> <% end %> - <% menu.with_custom_content do %> + <% popover.with_custom_content do %> <%= render "budgets/picker", family: Current.family, year: budget.start_date.year %> <% end %> <% end %>
<%= render DS::Link.new( - text: "Today", + text: t(".today"), variant: "outline", href: budget_path(Budget.date_to_param(Date.current)), ) %> diff --git a/app/views/budgets/_budget_nav.html.erb b/app/views/budgets/_budget_nav.html.erb index 41d81a614..45c8f9e8d 100644 --- a/app/views/budgets/_budget_nav.html.erb +++ b/app/views/budgets/_budget_nav.html.erb @@ -16,7 +16,7 @@ step[:is_complete] ? "text-green-600" : "text-secondary" end %> <% step_class = if is_current - "bg-primary text-primary" + "bg-inverse text-inverse" else step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-container-inset" end %> diff --git a/app/views/budgets/_budget_tabs.html.erb b/app/views/budgets/_budget_tabs.html.erb new file mode 100644 index 000000000..cdb82ba31 --- /dev/null +++ b/app/views/budgets/_budget_tabs.html.erb @@ -0,0 +1,31 @@ +
+
+
+ + + + + + + +
+
+
\ No newline at end of file diff --git a/app/views/budgets/_budgeted_summary.html.erb b/app/views/budgets/_budgeted_summary.html.erb index 07905d0d3..86a9f1210 100644 --- a/app/views/budgets/_budgeted_summary.html.erb +++ b/app/views/budgets/_budgeted_summary.html.erb @@ -2,7 +2,7 @@
-

Expected income

+

<%= t(".expected_income") %>

<%= format_money(budget.expected_income_money) %> @@ -19,12 +19,12 @@ <% end %>
-

<%= format_money(budget.actual_income_money) %> earned

+

<%= t(".earned", amount: format_money(budget.actual_income_money)) %>

<% if budget.remaining_expected_income.negative? %> - <%= format_money(budget.remaining_expected_income_money.abs) %> over + <%= t(".over", amount: format_money(budget.remaining_expected_income_money.abs)) %> <% else %> - <%= format_money(budget.remaining_expected_income_money) %> left + <%= t(".left", amount: format_money(budget.remaining_expected_income_money)) %> <% end %>

@@ -32,7 +32,7 @@
-

Budgeted

+

<%= t(".budgeted") %>

<%= format_money(budget.budgeted_spending_money) %> @@ -49,12 +49,12 @@ <% end %>
-

<%= format_money(budget.actual_spending_money) %> spent

+

<%= t(".spent", amount: format_money(budget.actual_spending_money)) %>

<% if budget.available_to_spend.negative? %> - <%= format_money(budget.available_to_spend_money.abs) %> over + <%= t(".over", amount: format_money(budget.available_to_spend_money.abs)) %> <% else %> - <%= format_money(budget.available_to_spend_money) %> left + <%= t(".left", amount: format_money(budget.available_to_spend_money)) %> <% end %>

diff --git a/app/views/budgets/_category_group.html.erb b/app/views/budgets/_category_group.html.erb new file mode 100644 index 000000000..7ef0ad347 --- /dev/null +++ b/app/views/budgets/_category_group.html.erb @@ -0,0 +1,28 @@ +<%# locals: (group:, parent_visible:, over_budget_mode: false) %> + +<% if parent_visible %> +
+ <%= render "budget_categories/budget_category", + budget_category: group.budget_category, + show_budget_meta: (over_budget_mode ? group.budget_category.over_budget_with_budget? : true) %> +
+<% end %> + +<% group.budget_subcategories.each do |budget_subcategory| %> + <% if parent_visible %> +
+
+ <%= icon "corner-down-right" %> +
+ <%= render "budget_categories/budget_category", + budget_category: budget_subcategory, + show_budget_meta: (over_budget_mode ? budget_subcategory.over_budget_with_budget? : true) %> +
+ <% else %> +
+ <%= render "budget_categories/budget_category", + budget_category: budget_subcategory, + show_budget_meta: (over_budget_mode ? budget_subcategory.over_budget_with_budget? : true) %> +
+ <% end %> +<% end %> diff --git a/app/views/budgets/_category_section.html.erb b/app/views/budgets/_category_section.html.erb new file mode 100644 index 000000000..52d4c4980 --- /dev/null +++ b/app/views/budgets/_category_section.html.erb @@ -0,0 +1,56 @@ +<%# locals: (budget:, count:, groups:, uncategorized:, show_uncategorized:, over_budget_mode:) %> + +<%# derive display config from over_budget_mode %> +<% + if over_budget_mode + target = "overBudget" + title = t("budgets.show.over_budget_categories.short_title") + else + target = "onTrack" + title = t("budgets.show.on_track_categories.short_title") + end +%> + +
+ + +
+

<%= title %>

+ · +

<%= count %>

+

<%= t("budgets.show.categories.amount") %>

+
+ + +
+ + <% groups.each_with_index do |group, index| %> + + <%# derive parent visibility based on mode %> + <% + parent_visible = + if over_budget_mode + group.budget_category.any_over_budget? + else + budget.initialized? ? group.budget_category.visible_on_track? : true + end + %> + <%= render "shared/ruler" unless index == 0 %> + <%= render "budgets/category_group", + group: group, + parent_visible: parent_visible, + over_budget_mode: over_budget_mode %> + + <% end %> + + <% if show_uncategorized %> + <%= render "shared/ruler" unless groups.size == 0 %> +
+ <%= render "budget_categories/budget_category", + budget_category: uncategorized, + show_budget_meta: (over_budget_mode ? uncategorized.over_budget_with_budget? : true) %> +
+ <% end %> + +
+
\ No newline at end of file diff --git a/app/views/budgets/_over_allocation_warning.html.erb b/app/views/budgets/_over_allocation_warning.html.erb index bd75fba28..16ebf72e6 100644 --- a/app/views/budgets/_over_allocation_warning.html.erb +++ b/app/views/budgets/_over_allocation_warning.html.erb @@ -2,10 +2,10 @@
<%= icon "alert-triangle", size: "lg", color: "destructive" %> -

You have over-allocated your budget. Please fix your allocations.

+

<%= t(".over_allocated_message") %>

<%= render DS::Link.new( - text: "Fix allocations", + text: t(".fix_allocations"), variant: "secondary", size: "sm", icon: "pencil", diff --git a/app/views/budgets/_picker.html.erb b/app/views/budgets/_picker.html.erb index a1deaea03..47e2ba43f 100644 --- a/app/views/budgets/_picker.html.erb +++ b/app/views/budgets/_picker.html.erb @@ -15,7 +15,7 @@ <% end %> - + <%= year %> diff --git a/app/views/budgets/edit.html.erb b/app/views/budgets/edit.html.erb index 3ec00b220..abe757af6 100644 --- a/app/views/budgets/edit.html.erb +++ b/app/views/budgets/edit.html.erb @@ -8,24 +8,24 @@
-

Setup your budget

+

<%= t(".setup_title") %>

- Enter your monthly earnings and planned spending below to setup your budget. + <%= t(".setup_description") %>

<%= styled_form_with model: @budget, class: "space-y-3", data: { controller: "budget-form" } do |f| %> - <%= f.money_field :budgeted_spending, label: "Budgeted spending", required: true, disable_currency: true %> - <%= f.money_field :expected_income, label: "Expected income", required: true, disable_currency: true %> + <%= f.money_field :budgeted_spending, label: t(".budgeted_spending"), required: true, disable_currency: true %> + <%= f.money_field :expected_income, label: t(".expected_income"), required: true, disable_currency: true %> <% if @budget.estimated_income && @budget.estimated_spending %>
<%= icon "sparkles" %>
-

Autosuggest income & spending budget

+

<%= t(".autosuggest_title") %>

- This will be based on transaction history. AI can make mistakes, verify before continuing. + <%= t(".autosuggest_description") %>

@@ -40,7 +40,7 @@
<% end %> - <%= f.submit "Continue" %> + <%= f.submit t(".continue") %> <% end %>
diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb index 768cf7727..0df039ba5 100644 --- a/app/views/budgets/show.html.erb +++ b/app/views/budgets/show.html.erb @@ -47,23 +47,39 @@
<%# Bottom Section: Categories full width %> -
-
-

Categories

+ <% has_over_budget = budget_has_over_budget?(@budget) %> + <%= content_tag :div, + class: "w-full bg-container rounded-xl shadow-border-xs p-4", + data: (has_over_budget ? { controller: "budget-filter" } : {}) do %> +
+

+ <%= t("budgets.show.categories.title") %> +

- <% if @budget.initialized? %> - <%= render DS::Link.new( - text: "Edit", - variant: "secondary", - icon: "settings-2", - href: budget_budget_categories_path(@budget) - ) %> + <% if has_over_budget %> + <% end %> -
-
- <%= render "budgets/budget_categories", budget: @budget %> +
"> + <% if @budget.initialized? %> + <%= render DS::Link.new( + text: t("budgets.show.categories.edit"), + variant: "secondary", + icon: "settings-2", + href: budget_budget_categories_path(@budget) + ) %> + <% end %> +
-
+ <% if has_over_budget %> +
+ <%= render "budgets/budget_tabs" %> +
+ <% end %> + + <%= render "budgets/budget_categories", budget: @budget %> + <% end %>
diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb index 10135c251..5570f2c70 100644 --- a/app/views/categories/_badge.html.erb +++ b/app/views/categories/_badge.html.erb @@ -1,15 +1,17 @@ <%# locals: (category:) %> <% category ||= Category.uncategorized %> -
- + <% if category.lucide_icon.present? %> - <%= icon category.lucide_icon, size: "sm", color: "current" %> + + <%= icon category.lucide_icon, size: "sm", color: "current" %> + <% end %> - <%= category.name %> + <%= category.name %>
diff --git a/app/views/categories/_category.html.erb b/app/views/categories/_category.html.erb index dbc9b80fe..1460fe5d6 100644 --- a/app/views/categories/_category.html.erb +++ b/app/views/categories/_category.html.erb @@ -1,7 +1,7 @@ <%# locals: (category:) %> -
<%= "pb-4" unless category.subcategories.any? %> bg-container"> -
+
<%= "pb-4" unless category.subcategories.any? %> bg-container"> +
<% if category.subcategory? %> <%= icon "corner-down-right", size: "sm", color: "current", class: "ml-2" %> @@ -11,7 +11,7 @@ <%= render partial: "categories/badge", locals: { category: category } %>
-
+
<%= render DS::Menu.new do |menu| %> <% menu.with_item(variant: "link", text: t(".edit"), icon: "pencil", href: edit_category_path(category), data: { turbo_frame: :modal }) %> diff --git a/app/views/categories/_category_name_mobile.html.erb b/app/views/categories/_category_name_mobile.html.erb index 2f9bd07cb..36c13021c 100644 --- a/app/views/categories/_category_name_mobile.html.erb +++ b/app/views/categories/_category_name_mobile.html.erb @@ -1,4 +1,4 @@ - + <% if transaction.transfer&.categorizable? || transaction.transfer.nil? %> <%= transaction.category&.name || Category.uncategorized.name %> <% else %> diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 1f3e5fbeb..8cc890885 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -14,7 +14,7 @@
">
-

Color

+

<%= t(".color") %>

<% Category::COLORS.each do |color| %>
-

Icon

+

<%= t(".icon") %>

<% Category.icon_codes.each do |icon| %>
-

Enable AI Chats

+

<%= t(".title") %>

-

+

<% if Current.user.ai_available? %> - AI chat can answer financial questions and provide insights based on your data. To use this feature you'll need to explicitly enable it. + <%= t(".available_description") %> <% else %> - To use the AI assistant, you need to set the OPENAI_ACCESS_TOKEN - environment variable or configure it in the Self-Hosting settings of your instance. + <%= t(".unavailable_description_html") %> <% end %>

@@ -18,9 +17,9 @@ <%= form_with url: user_path(Current.user), method: :patch, class: "w-full", data: { turbo: false } do |form| %> <%= form.hidden_field "user[ai_enabled]", value: true %> <%= form.hidden_field "user[redirect_to]", value: "home" %> - <%= form.submit "Enable AI Chats", class: "cursor-pointer hover:bg-inverse-hover w-full py-2 px-4 bg-inverse fg-inverse rounded-lg text-sm font-medium" %> + <%= form.submit t(".enable_button"), class: "cursor-pointer hover:bg-inverse-hover w-full py-2 px-4 bg-inverse text-inverse rounded-lg text-sm font-medium" %> <% end %> <% end %> -

Disable anytime. All data sent to our LLM providers is anonymized.

+

<%= t(".disable_note") %>

diff --git a/app/views/chats/_ai_greeting.html.erb b/app/views/chats/_ai_greeting.html.erb index e04b9bc51..f5baa52c6 100644 --- a/app/views/chats/_ai_greeting.html.erb +++ b/app/views/chats/_ai_greeting.html.erb @@ -2,27 +2,27 @@ <%= render "chats/ai_avatar" %>
-

Hey <%= Current.user&.first_name || "there" %>! I'm an AI/large-language-model that can help with your finances. I have access to the web and your account data.

+

<%= t(".greeting", name: Current.user&.first_name || t(".there")) %>

- You can use / to access commands + <%= t(".commands_hint_html") %>

-

Here's a few questions you can ask:

+

<%= t(".questions_intro") %>

<% questions = [ { icon: "chart-area", - text: "Evaluate investment portfolio" + text: t(".evaluate_portfolio") }, { icon: "wallet-minimal", - text: "Show spending insights" + text: t(".spending_insights") }, { icon: "alert-triangle", - text: "Find unusual patterns" + text: t(".unusual_patterns") } ] %> @@ -30,7 +30,7 @@ <% questions.each do |question| %> <% end %> diff --git a/app/views/chats/_chat.html.erb b/app/views/chats/_chat.html.erb index fc082f377..d10d9f771 100644 --- a/app/views/chats/_chat.html.erb +++ b/app/views/chats/_chat.html.erb @@ -14,14 +14,14 @@ <%= render DS::Menu.new(icon_vertical: true) do |menu| %> <% menu.with_item( variant: "link", - text: "Edit chat title", + text: t(".edit_chat_title"), href: edit_chat_path(chat, ctx: "list"), icon: "pencil", frame: dom_id(chat, "title")) %> <% menu.with_item( variant: "button", - text: "Delete chat", + text: t(".delete_chat"), href: chat_path(chat), icon: "trash-2", method: :delete, diff --git a/app/views/chats/_chat_nav.html.erb b/app/views/chats/_chat_nav.html.erb index 6cf7d28f2..ea093fc61 100644 --- a/app/views/chats/_chat_nav.html.erb +++ b/app/views/chats/_chat_nav.html.erb @@ -10,7 +10,7 @@ icon: "menu", href: path, frame: chat_frame, - text: "All chats" + text: t(".all_chats") ) %>
@@ -19,19 +19,19 @@
<%= render DS::Menu.new(icon_vertical: true) do |menu| %> - <% menu.with_item(variant: "link", text: "Start new chat", href: new_chat_path, icon: "plus") %> + <% menu.with_item(variant: "link", text: t(".start_new_chat"), href: new_chat_path, icon: "plus") %> <% unless chat.new_record? %> <% menu.with_item( variant: "link", - text: "Edit chat title", + text: t(".edit_chat_title"), href: edit_chat_path(chat, ctx: "chat"), icon: "pencil", frame: dom_id(chat, "title")) %> <% menu.with_item( variant: "button", - text: "Delete chat", + text: t(".delete_chat"), href: chat_path(chat), icon: "trash-2", method: :delete, diff --git a/app/views/chats/_error.html.erb b/app/views/chats/_error.html.erb index bbcb75818..c4ea7b912 100644 --- a/app/views/chats/_error.html.erb +++ b/app/views/chats/_error.html.erb @@ -1,17 +1,17 @@ <%# locals: (chat:) %> -
+
<% if chat.debug_mode? %>
- <%= chat.error %> + <%= chat.technical_error_message %>
<% end %>
-

Failed to generate response. Please try again.

+

<%= chat.presentable_error_message %>

<%= render DS::Button.new( - text: "Retry", + text: t(".retry"), href: retry_chat_path(chat), ) %>
diff --git a/app/views/chats/_thinking_indicator.html.erb b/app/views/chats/_thinking_indicator.html.erb deleted file mode 100644 index e1ba89217..000000000 --- a/app/views/chats/_thinking_indicator.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -<%# locals: (chat:, message: "Thinking ...") -%> - -
- <%= render "chats/ai_avatar" %> -

<%= message %>

-
diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb index 801865e31..8afa67c57 100644 --- a/app/views/chats/index.html.erb +++ b/app/views/chats/index.html.erb @@ -10,14 +10,14 @@ <% if @chats.any? %>
-

Chats

+

<%= t(".chats") %>

<%= render DS::Link.new( id: "new-chat", icon: "plus", variant: "icon", href: new_chat_path, frame: chat_frame, - text: "New chat" + text: t(".new_chat") ) %>
@@ -26,7 +26,7 @@
<% else %>
-

Chats

+

<%= t(".chats") %>

<%= render "chats/ai_greeting" %>
diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb index 496d4ac65..2b69c1f0d 100644 --- a/app/views/chats/show.html.erb +++ b/app/views/chats/show.html.erb @@ -15,7 +15,7 @@ <% end %>
-
+
<% if @chat.conversation_messages.any? %> <% @chat.conversation_messages.ordered.each do |message| %> <%= render message %> @@ -26,10 +26,6 @@
<% end %> - <% if params[:thinking].present? %> - <%= render "chats/thinking_indicator", chat: @chat %> - <% end %> - <% if @chat.error.present? && @chat.needs_assistant_response? %> <%= render "chats/error", chat: @chat %> <% end %> diff --git a/app/views/coinbase_items/_coinbase_item.html.erb b/app/views/coinbase_items/_coinbase_item.html.erb index 0694c261d..5d1262888 100644 --- a/app/views/coinbase_items/_coinbase_item.html.erb +++ b/app/views/coinbase_items/_coinbase_item.html.erb @@ -16,92 +16,94 @@ end end %> -
- -
- <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+ <%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> -
-
- <%= icon "bitcoin", size: "sm", class: "text-[#0052FF]" %> +
+
+ <%= icon "bitcoin", size: "sm", class: "text-[#0052FF]" %> +
-
-
-
- <%= tag.p coinbase_item.institution_display_name, class: "font-medium text-primary" %> - <% if coinbase_item.scheduled_for_deletion? %> -

<%= t(".deletion_in_progress") %>

+
+
+ <%= tag.p coinbase_item.institution_display_name, class: "font-medium text-primary" %> + <% if coinbase_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+

<%= t(".provider_name") %>

+ <% if coinbase_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif coinbase_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".reconnect") %> +
+ <% else %> +

+ <% if coinbase_item.last_synced_at %> + <% if coinbase_item.sync_status_summary %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(coinbase_item.last_synced_at), summary: coinbase_item.sync_status_summary) %> + <% else %> + <%= t(".status", timestamp: time_ago_in_words(coinbase_item.last_synced_at)) %> + <% end %> + <% else %> + <%= t(".status_never") %> + <% end %> +

<% end %>
-

<%= t(".provider_name") %>

- <% if coinbase_item.syncing? %> -
- <%= icon "loader", size: "sm", class: "animate-spin" %> - <%= tag.span t(".syncing") %> -
- <% elsif coinbase_item.requires_update? %> -
- <%= icon "alert-triangle", size: "sm", color: "warning" %> - <%= tag.span t(".reconnect") %> -
- <% else %> -

- <% if coinbase_item.last_synced_at %> - <% if coinbase_item.sync_status_summary %> - <%= t(".status_with_summary", timestamp: time_ago_in_words(coinbase_item.last_synced_at), summary: coinbase_item.sync_status_summary) %> - <% else %> - <%= t(".status", timestamp: time_ago_in_words(coinbase_item.last_synced_at)) %> - <% end %> - <% else %> - <%= t(".status_never") %> - <% end %> -

- <% end %>
-
- <% if Current.user&.admin? %> -
- <% if coinbase_item.requires_update? %> - <%= render DS::Link.new( - text: t(".update_credentials"), - icon: "refresh-cw", - variant: "secondary", - href: settings_providers_path, - frame: "_top" - ) %> - <% else %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_coinbase_item_path(coinbase_item), - disabled: coinbase_item.syncing? - ) %> - <% end %> - - <%= render DS::Menu.new do |menu| %> - <% if unlinked_count.to_i > 0 %> - <% menu.with_item( - variant: "link", - text: t(".import_wallets_menu"), - icon: "plus", - href: setup_accounts_coinbase_item_path(coinbase_item), - frame: :modal + <% if Current.user&.admin? %> +
+ <% if coinbase_item.requires_update? %> + <%= render DS::Link.new( + text: t(".update_credentials"), + icon: "refresh-cw", + variant: "secondary", + href: settings_providers_path, + frame: "_top" + ) %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_coinbase_item_path(coinbase_item), + disabled: coinbase_item.syncing? ) %> <% end %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - icon: "trash-2", - href: coinbase_item_path(coinbase_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(coinbase_item.institution_display_name, high_severity: true) - ) %> - <% end %> -
- <% end %> -
+ + <%= render DS::Menu.new do |menu| %> + <% if unlinked_count.to_i > 0 %> + <% menu.with_item( + variant: "link", + text: t(".import_wallets_menu"), + icon: "plus", + href: setup_accounts_coinbase_item_path(coinbase_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: coinbase_item_path(coinbase_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(coinbase_item.institution_display_name, high_severity: true) + ) %> + <% end %> +
+ <% end %> +
+ <% end %> <% unless coinbase_item.scheduled_for_deletion? %>
@@ -142,5 +144,5 @@ <% end %>
<% end %> -
+ <% end %> <% end %> diff --git a/app/views/coinbase_items/select_existing_account.html.erb b/app/views/coinbase_items/select_existing_account.html.erb index 86b9d3840..aee144bdc 100644 --- a/app/views/coinbase_items/select_existing_account.html.erb +++ b/app/views/coinbase_items/select_existing_account.html.erb @@ -17,7 +17,7 @@ <%= hidden_field_tag :account_id, @account.id %>
<% @available_coinbase_accounts.each do |ca| %> -
<% end %> diff --git a/app/views/credit_cards/_overview.html.erb b/app/views/credit_cards/_overview.html.erb index 1dfcbb0b7..317191a14 100644 --- a/app/views/credit_cards/_overview.html.erb +++ b/app/views/credit_cards/_overview.html.erb @@ -28,7 +28,7 @@
<%= render DS::Link.new( - text: "Edit account details", + text: t(".edit_account_details"), variant: "ghost", href: edit_credit_card_path(account), frame: :modal diff --git a/app/views/doorkeeper/applications/_form.html.erb b/app/views/doorkeeper/applications/_form.html.erb index 4cc71b3d8..cb9bc6be6 100644 --- a/app/views/doorkeeper/applications/_form.html.erb +++ b/app/views/doorkeeper/applications/_form.html.erb @@ -4,7 +4,7 @@ <% end %>
- <%= f.label :name, class: "col-sm-2 col-form-label font-weight-bold" %> + <%= f.label :name, class: "col-sm-2 col-form-label font-bold" %>
<%= f.text_field :name, class: "form-control #{ 'is-invalid' if application.errors[:name].present? }", required: true %> <%= doorkeeper_errors_for application, :name %> @@ -12,7 +12,7 @@
- <%= f.label :redirect_uri, class: "col-sm-2 col-form-label font-weight-bold" %> + <%= f.label :redirect_uri, class: "col-sm-2 col-form-label font-bold" %>
<%= f.text_area :redirect_uri, class: "form-control #{ 'is-invalid' if application.errors[:redirect_uri].present? }" %> <%= doorkeeper_errors_for application, :redirect_uri %> @@ -29,7 +29,7 @@
- <%= f.label :confidential, class: "col-sm-2 form-check-label font-weight-bold" %> + <%= f.label :confidential, class: "col-sm-2 form-check-label font-bold" %>
<%= f.check_box :confidential, class: "checkbox #{ 'is-invalid' if application.errors[:confidential].present? }" %> <%= doorkeeper_errors_for application, :confidential %> @@ -40,7 +40,7 @@
- <%= f.label :scopes, class: "col-sm-2 col-form-label font-weight-bold" %> + <%= f.label :scopes, class: "col-sm-2 col-form-label font-bold" %>
<%= f.text_field :scopes, class: "form-control #{ 'has-error' if application.errors[:scopes].present? }" %> <%= doorkeeper_errors_for application, :scopes %> diff --git a/app/views/doorkeeper/applications/show.html.erb b/app/views/doorkeeper/applications/show.html.erb index 53e84c785..fcd32a692 100644 --- a/app/views/doorkeeper/applications/show.html.erb +++ b/app/views/doorkeeper/applications/show.html.erb @@ -5,14 +5,14 @@

<%= t(".application_id") %>:

-

<%= @application.uid %>

+

<%= @application.uid %>

<%= t(".secret") %>:

- + <% secret = flash[:application_secret].presence || @application.plaintext_secret %> <% if secret.blank? && Doorkeeper.config.application_secret_hashed? %> - <%= t(".secret_hashed") %> + <%= t(".secret_hashed") %> <% else %> <%= secret %> <% end %> @@ -21,17 +21,17 @@

<%= t(".scopes") %>:

- + <% if @application.scopes.present? %> <%= @application.scopes %> <% else %> - <%= t(".not_defined") %> + <%= t(".not_defined") %> <% end %>

<%= t(".confidential") %>:

-

<%= @application.confidential? %>

+

<%= @application.confidential? %>

<%= t(".callback_urls") %>:

@@ -40,7 +40,7 @@ <% @application.redirect_uri.split.each do |uri| %> - <%= uri %> + <%= uri %> <%= link_to t("doorkeeper.applications.buttons.authorize"), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: "code", scope: @application.scopes), class: "btn btn-success", target: "_blank" %> @@ -49,7 +49,7 @@ <% end %> <% else %> - <%= t(".not_defined") %> + <%= t(".not_defined") %> <% end %>
diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb index 401e191f7..033430030 100644 --- a/app/views/doorkeeper/authorizations/error.html.erb +++ b/app/views/doorkeeper/authorizations/error.html.erb @@ -14,7 +14,7 @@
<%= render DS::Link.new( - text: "Go back", + text: t("doorkeeper.authorizations.error.go_back"), href: "javascript:history.back()", variant: :secondary ) %> diff --git a/app/views/doorkeeper/authorizations/show.html.erb b/app/views/doorkeeper/authorizations/show.html.erb index 4653af2a1..43552339a 100644 --- a/app/views/doorkeeper/authorizations/show.html.erb +++ b/app/views/doorkeeper/authorizations/show.html.erb @@ -7,11 +7,11 @@
-

Authorization Code:

+

<%= t(".authorization_code_label") %>

<%= params[:code] %>

- Copy this code and paste it into the application. + <%= t(".copy_instructions") %>

diff --git a/app/views/enable_banking_items/_enable_banking_item.html.erb b/app/views/enable_banking_items/_enable_banking_item.html.erb index 6d53b0c6a..2e3af279b 100644 --- a/app/views/enable_banking_items/_enable_banking_item.html.erb +++ b/app/views/enable_banking_items/_enable_banking_item.html.erb @@ -1,85 +1,87 @@ <%# locals: (enable_banking_item:) %> <%= tag.div id: dom_id(enable_banking_item) do %> -
- -
- <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+ <%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> -
- <% if enable_banking_item.logo.attached? %> - <%= image_tag enable_banking_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> - <% else %> -
- <%= tag.p enable_banking_item.institution_display_name.first.upcase, class: "text-success text-xs font-medium" %> -
- <% end %> -
- -
-
- <%= tag.p enable_banking_item.institution_display_name, class: "font-medium text-primary" %> - <% if enable_banking_item.scheduled_for_deletion? %> -

Deletion in progress

+
+ <% if enable_banking_item.logo.attached? %> + <%= image_tag enable_banking_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> + <% else %> +
+ <%= tag.p enable_banking_item.institution_display_name.first.upcase, class: "text-success text-xs font-medium" %> +
<% end %>
-

Enable Banking

- <% if enable_banking_item.syncing? %> -
- <%= icon "loader", size: "sm", class: "animate-spin" %> - <%= tag.span "Syncing..." %> -
- <% elsif enable_banking_item.requires_update? %> -
- <%= icon "alert-triangle", size: "sm", color: "warning" %> - <%= tag.span "Reconnect" %> -
- <% else %> -

- <% if enable_banking_item.last_synced_at %> - Last synced <%= time_ago_in_words(enable_banking_item.last_synced_at) %> ago - <% if enable_banking_item.sync_status_summary %> - · <%= enable_banking_item.sync_status_summary %> - <% end %> - <% else %> - Never synced + +

+
+ <%= tag.p enable_banking_item.institution_display_name, class: "font-medium text-primary" %> + <% if enable_banking_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

<% end %> -

- <% end %> -
-
- - <% if Current.user&.admin? %> -
- <% if enable_banking_item.requires_update? %> - <%= button_to reauthorize_enable_banking_item_path(enable_banking_item), - method: :post, - class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-white bg-warning hover:opacity-90 transition-colors", - data: { turbo: false } do %> - <%= icon "refresh-cw", size: "sm" %> - Update +
+

<%= t(".provider_name") %>

+ <% if enable_banking_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif enable_banking_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".reconnect") %> +
+ <% else %> +

+ <% if enable_banking_item.last_synced_at %> + <%= t(".last_synced", time: time_ago_in_words(enable_banking_item.last_synced_at)) %> + <% if enable_banking_item.sync_status_summary %> + · <%= enable_banking_item.sync_status_summary %> + <% end %> + <% else %> + <%= t(".never_synced") %> + <% end %> +

<% end %> - <% elsif Rails.env.development? %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_enable_banking_item_path(enable_banking_item) - ) %> - <% end %> - - <%= render DS::Menu.new do |menu| %> - <% menu.with_item( - variant: "button", - text: "Delete", - icon: "trash-2", - href: enable_banking_item_path(enable_banking_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(enable_banking_item.institution_display_name, high_severity: true) - ) %> - <% end %> +
- <% end %> -
+ + <% if Current.user&.admin? %> +
+ <% if enable_banking_item.requires_update? %> + <%= button_to reauthorize_enable_banking_item_path(enable_banking_item), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-inverse bg-warning hover:opacity-90 transition-colors", + data: { turbo: false } do %> + <%= icon "refresh-cw", size: "sm" %> + <%= t(".update") %> + <% end %> + <% elsif Rails.env.development? %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_enable_banking_item_path(enable_banking_item) + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: enable_banking_item_path(enable_banking_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(enable_banking_item.institution_display_name, high_severity: true) + ) %> + <% end %> +
+ <% end %> +
+ <% end %> <% unless enable_banking_item.scheduled_for_deletion? %>
@@ -109,10 +111,10 @@ <% if enable_banking_item.unlinked_accounts_count > 0 %>
-

Setup needed

-

<%= pluralize(enable_banking_item.unlinked_accounts_count, "account") %> imported from Enable Banking need to be set up

+

<%= t(".setup_needed") %>

+

<%= t(".setup_needed_description", count: enable_banking_item.unlinked_accounts_count) %>

<%= render DS::Link.new( - text: "Set up accounts", + text: t(".set_up_accounts"), icon: "settings", variant: "primary", href: setup_accounts_enable_banking_item_path(enable_banking_item), @@ -121,11 +123,11 @@
<% elsif enable_banking_item.accounts.empty? && enable_banking_item.enable_banking_accounts.empty? %>
-

No accounts found

-

No accounts were found from Enable Banking. Try syncing again.

+

<%= t(".no_accounts_found") %>

+

<%= t(".no_accounts_found_description") %>

<% end %>
<% end %> -
+ <% end %> <% end %> diff --git a/app/views/enable_banking_items/_subtype_select.html.erb b/app/views/enable_banking_items/_subtype_select.html.erb index a9bab3848..c5969396c 100644 --- a/app/views/enable_banking_items/_subtype_select.html.erb +++ b/app/views/enable_banking_items/_subtype_select.html.erb @@ -2,9 +2,7 @@ <% if subtype_config[:options].present? %> <%= label_tag "account_subtypes[#{enable_banking_account.id}]", subtype_config[:label], class: "block text-sm font-medium text-primary mb-2" %> - <% selected_value = account_type == "Depository" ? - (enable_banking_account.name.downcase.include?("checking") ? "checking" : - enable_banking_account.name.downcase.include?("savings") ? "savings" : "") : "" %> + <% selected_value = enable_banking_account.suggested_subtype.presence || "" %> <%= select_tag "account_subtypes[#{enable_banking_account.id}]", options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value), { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full" } %> diff --git a/app/views/enable_banking_items/new.html.erb b/app/views/enable_banking_items/new.html.erb index f7e99cf3d..2f7b481e9 100644 --- a/app/views/enable_banking_items/new.html.erb +++ b/app/views/enable_banking_items/new.html.erb @@ -17,22 +17,22 @@ <% if item.session_valid? %>
-

<%= item.aspsp_name || "Connected Bank" %>

+

<%= item.aspsp_name || t(".connected_bank") %>

- Session expires: <%= item.session_expires_at&.strftime("%b %d, %Y") || "Unknown" %> + <%= t(".session_expires") %>: <%= item.session_expires_at&.strftime("%b %d, %Y") || t(".unknown") %>

<% elsif item.session_expired? %>
-

<%= item.aspsp_name || "Connection" %>

-

Session expired - re-authorization required

+

<%= item.aspsp_name || t(".connection") %>

+

<%= t(".session_expired") %>

<% else %>
-

Configured

-

Ready to connect a bank

+

<%= t(".configured") %>

+

<%= t(".ready_to_connect") %>

<% end %>
@@ -41,30 +41,32 @@ <% if item.session_valid? %> <%= button_to sync_enable_banking_item_path(item), method: :post, - class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-primary bg-container border border-primary hover:bg-gray-50 transition-colors", + class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-primary bg-container border border-primary hover:bg-surface-inset transition-colors", data: { turbo: false } do %> - Sync + <%= t(".sync") %> <% end %> <% elsif item.session_expired? %> <%= button_to reauthorize_enable_banking_item_path(item), method: :post, class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-warning hover:opacity-90 transition-colors", data: { turbo: false } do %> - Reconnect + <%= t(".reconnect") %> <% end %> <% else %> - <%= link_to select_bank_enable_banking_item_path(item), - class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-gray-900 hover:bg-gray-800 transition-colors", - data: { turbo_frame: "modal" } do %> - Connect Bank - <% end %> + <%= render DS::Link.new( + text: t(".connect_bank"), + href: select_bank_enable_banking_item_path(item), + variant: :primary, + size: :sm, + data: { turbo_frame: "modal" } + ) %> <% end %> <%= button_to enable_banking_item_path(item), method: :delete, class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors", - data: { turbo_confirm: "Are you sure you want to remove this connection?" } do %> - Remove + data: { turbo_confirm: t(".remove_confirm") } do %> + <%= t(".remove") %> <% end %> @@ -73,13 +75,13 @@ <%# Add Connection button below the list - only show if we have a valid session to copy credentials from %> <% if item_for_new_connection %>
- <%= button_to new_connection_enable_banking_item_path(item_for_new_connection), - method: :post, - class: "inline-flex items-center gap-2 justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 transition-colors", - data: { turbo_frame: "modal" } do %> - <%= icon "plus", size: "sm" %> - Add Connection - <% end %> + <%= render DS::Button.new( + text: t(".add_connection"), + icon: "plus", + href: new_connection_enable_banking_item_path(item_for_new_connection), + variant: :primary, + data: { turbo_method: :post, turbo_frame: "modal" } + ) %>
<% end %> @@ -88,27 +90,29 @@
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
-

Enable Banking connection not configured

-

Before you can link Enable Banking accounts, you need to configure your Enable Banking connection.

+

<%= t(".not_configured") %>

+

<%= t(".not_configured_description") %>

-

Setup Steps:

+

<%= t(".setup_steps_title") %>

    -
  1. Go to Settings → Providers
  2. -
  3. Find the Enable Banking section
  4. -
  5. Enter your Enable Banking credentials
  6. -
  7. Return here to link your accounts
  8. +
  9. <%= t(".setup_step_1_html") %>
  10. +
  11. <%= t(".setup_step_2_html") %>
  12. +
  13. <%= t(".setup_step_3") %>
  14. +
  15. <%= t(".setup_step_4") %>
- <%= link_to settings_providers_path, - class: "w-full inline-flex items-center justify-center rounded-lg font-medium whitespace-nowrap rounded-lg hidden md:inline-flex px-3 py-2 text-sm text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400", - data: { turbo: false } do %> - Go to Provider Settings - <% end %> + <%= render DS::Link.new( + text: t(".go_to_provider_settings"), + href: settings_providers_path, + variant: :primary, + full_width: true, + data: { turbo: false } + ) %>
<% end %> diff --git a/app/views/enable_banking_items/select_bank.html.erb b/app/views/enable_banking_items/select_bank.html.erb index 561639dd8..124c8e3ec 100644 --- a/app/views/enable_banking_items/select_bank.html.erb +++ b/app/views/enable_banking_items/select_bank.html.erb @@ -3,7 +3,7 @@ <% dialog.with_header(title: t(".title", default: "Select Your Bank")) %> <% dialog.with_body do %> -
+

<%= t(".description", default: "Choose the bank you want to connect to your account.") %>

@@ -15,29 +15,52 @@ <% end %> <% if @aspsps.present? %> + <%# Search input — filters list client-side via Stimulus %> + " + data-bank-search-target="input" + data-action="input->bank-search#filter" + class="w-full px-3 py-2 text-sm rounded-md border border-primary bg-container-inset text-primary placeholder:text-secondary focus:outline-none focus:ring-1 focus:ring-primary" + autocomplete="off" + aria-label="<%= t(".search_label", default: "Search for your bank") %>" + autofocus> +
<% @aspsps.each do |aspsp| %> - <%= button_to authorize_enable_banking_item_path(@enable_banking_item), - method: :post, - params: { aspsp_name: aspsp[:name], new_connection: @new_connection }, - class: "w-full flex items-center gap-4 p-3 rounded-lg border border-primary bg-container hover:bg-subtle transition-colors text-left", - data: { turbo: false } do %> - <% if aspsp[:logo].present? %> - <%= aspsp[:name] %> - <% else %> -
- <%= icon "building-bank", class: "w-5 h-5 text-gray-400" %> -
- <% end %> -
-

<%= aspsp[:name] %>

- <% if aspsp[:bic].present? %> -

BIC: <%= aspsp[:bic] %>

+
+ <%= button_to authorize_enable_banking_item_path(@enable_banking_item), + method: :post, + params: { aspsp_name: aspsp[:name], new_connection: @new_connection }, + class: "w-full flex items-center gap-4 p-3 rounded-lg border border-primary bg-container hover:bg-subtle transition-colors text-left", + data: { turbo: false } do %> + <% if aspsp[:logo].present? %> + <%= aspsp[:name] %> + <% else %> +
+ <%= icon "building-bank", class: "w-5 h-5 text-tertiary" %> +
<% end %> -
- <%= icon "chevron-right", class: "w-5 h-5 text-secondary" %> - <% end %> +
+
+

<%= aspsp[:name] %>

+ <% if aspsp[:beta] %> + + <%= t(".beta_label", default: "Beta") %> + + <% end %> +
+ <% if aspsp[:bic].present? %> +

BIC: <%= aspsp[:bic] %>

+ <% end %> +
+ <%= icon "chevron-right", class: "w-5 h-5 text-secondary flex-shrink-0" %> + <% end %> +
<% end %> +
<% else %>
diff --git a/app/views/enable_banking_items/select_existing_account.html.erb b/app/views/enable_banking_items/select_existing_account.html.erb index 7ff7aedad..9d77b7071 100644 --- a/app/views/enable_banking_items/select_existing_account.html.erb +++ b/app/views/enable_banking_items/select_existing_account.html.erb @@ -1,15 +1,15 @@ <%# Modal: Link an existing manual account to a Enable Banking account %> <%= turbo_frame_tag "modal" do %> <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Link Enable Banking account") %> + <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %> <% if @available_enable_banking_accounts.blank? %>
-

All Enable Banking accounts appear to be linked already.

+

<%= t(".all_linked") %>

    -
  • If you just connected or synced, try again after the sync completes.
  • -
  • To link a different account, first unlink it from the account’s actions menu.
  • +
  • <%= t(".try_after_sync") %>
  • +
  • <%= t(".unlink_to_move") %>
<% else %> @@ -17,12 +17,12 @@ <%= hidden_field_tag :account_id, @account.id %>
<% @available_enable_banking_accounts.each do |eba| %> -
- <%= render DS::Button.new(text: "Link", variant: :primary, icon: "link-2", type: :submit) %> - <%= render DS::Link.new(text: "Cancel", variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %> + <%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %> + <%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
<% end %> <% end %> diff --git a/app/views/enable_banking_items/setup_accounts.html.erb b/app/views/enable_banking_items/setup_accounts.html.erb index 2db41d72d..97060d2c2 100644 --- a/app/views/enable_banking_items/setup_accounts.html.erb +++ b/app/views/enable_banking_items/setup_accounts.html.erb @@ -1,8 +1,8 @@ <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Set Up Your Enable Banking Accounts") do %> + <% dialog.with_header(title: t(".title")) do %>
<%= icon "building-2", class: "text-primary" %> - Choose the correct account types for your imported accounts + <%= t(".header_subtitle") %>
<% end %> @@ -13,7 +13,7 @@ data: { controller: "loading-button", action: "submit->loading-button#showLoading", - loading_button_loading_text_value: "Creating Accounts...", + loading_button_loading_text_value: t(".creating_accounts"), turbo_frame: "_top" }, class: "space-y-6" do |form| %> @@ -24,7 +24,7 @@ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>

- Choose the correct account type for each Enable Banking account: + <%= t(".choose_account_type") %>

    <% @account_type_options.reject { |_, type| type == "skip" }.each do |label, _| %> @@ -35,21 +35,33 @@
+ +
+
+ <%= icon "alert-triangle", size: "sm", class: "text-warning mt-0.5 flex-shrink-0" %> +
+

+ <%= t("enable_banking_items.setup_accounts.psd2_savings_notice") %> +

+
+
+
+
<%= icon "calendar", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>

- Historical Data Range: + <%= t(".historical_data_range") %>

<%= form.date_field :sync_start_date, - label: "Start syncing transactions from:", + label: t(".sync_start_date_label"), value: @enable_banking_item.sync_start_date || 3.months.ago.to_date, - min: 1.year.ago.to_date, + min: 2.years.ago.to_date, max: Date.current, class: "w-full max-w-xs rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary", - help_text: "Select how far back you want to sync transaction history. Maximum 1 year of history available." %> + help_text: t(".sync_start_date_help") %>
@@ -69,18 +81,21 @@

<%= enable_banking_account.account_type_display %>

<% end %> <% if enable_banking_account.current_balance.present? %> -

Balance: <%= number_to_currency(enable_banking_account.current_balance, unit: enable_banking_account.currency) %>

+

<%= t(".balance") %>: <%= number_to_currency(enable_banking_account.current_balance, unit: enable_banking_account.currency) %>

<% end %>
-
+
- <%= label_tag "account_types[#{enable_banking_account.id}]", "Account Type:", + <%= label_tag "account_types[#{enable_banking_account.id}]", t(".account_type_label"), class: "block text-sm font-medium text-primary mb-2" %> <%= select_tag "account_types[#{enable_banking_account.id}]", - options_for_select(@account_type_options, "skip"), + options_for_select(@account_type_options, enable_banking_account.suggested_account_type || "skip"), { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full", data: { action: "change->account-type-selector#updateSubtype" @@ -100,7 +115,7 @@
<%= render DS::Button.new( - text: "Create Accounts", + text: t(".create_accounts"), variant: "primary", icon: "plus", type: "submit", @@ -108,7 +123,7 @@ data: { loading_button_target: "button" } ) %> <%= render DS::Link.new( - text: "Cancel", + text: t(".cancel"), variant: "secondary", href: accounts_path ) %> diff --git a/app/views/entries/_entry.html.erb b/app/views/entries/_entry.html.erb index 3c3f83fa3..5ba08622e 100644 --- a/app/views/entries/_entry.html.erb +++ b/app/views/entries/_entry.html.erb @@ -1,6 +1,6 @@ -<%# locals: (entry:, balance_trend: nil, view_ctx: "global") %> +<%# locals: (entry:, balance_trend: nil, view_ctx: "global", in_split_group: false) %> <% if entry.entryable.present? %> <%= render partial: entry.entryable.to_partial_path, - locals: { entry: entry, balance_trend: balance_trend, view_ctx: view_ctx } %> + locals: { entry: entry, balance_trend: balance_trend, view_ctx: view_ctx, in_split_group: in_split_group } %> <% end %> diff --git a/app/views/entries/_selection_bar.html.erb b/app/views/entries/_selection_bar.html.erb index 5bd4b5788..19033d78e 100644 --- a/app/views/entries/_selection_bar.html.erb +++ b/app/views/entries/_selection_bar.html.erb @@ -7,6 +7,17 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %> + + <%= link_to new_transaction_path, + class: "p-1.5 group/duplicate hover:bg-inverse flex items-center justify-center rounded-md hidden", + title: t("transactions.selection_bar.duplicate"), + data: { + turbo_frame: "modal", + bulk_select_target: "duplicateLink" + } do %> + <%= icon "copy", class: "group-hover/duplicate:text-inverse" %> + <% end %> + <%= link_to new_transactions_bulk_update_path, class: "p-1.5 group/edit hover:bg-inverse flex items-center justify-center rounded-md", title: "Edit", diff --git a/app/views/family_exports/new.html.erb b/app/views/family_exports/new.html.erb index 7c8f3ac98..9433eee2f 100644 --- a/app/views/family_exports/new.html.erb +++ b/app/views/family_exports/new.html.erb @@ -1,40 +1,40 @@ <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Export your data", subtitle: "Download all your financial data") %> + <% dialog.with_header(title: t(".dialog_title"), subtitle: t(".dialog_subtitle")) %> <% dialog.with_body do %>
-

What's included:

+

<%= t(".whats_included") %>

  • <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> - All accounts and balances + <%= t(".accounts_and_balances") %>
  • <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> - Transaction history + <%= t(".transaction_history") %>
  • <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> - Investment trades + <%= t(".investment_trades") %>
  • <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %> - Categories, tags and rules + <%= t(".categories_tags_rules") %>

- Note: This export includes all of your data, but only some of the data can be imported back via the CSV import feature. We support account, transaction (with category and tags), and trade imports. Other account data cannot be imported and is for your records only. + <%= t(".note_label") %>: <%= t(".note_description") %>

<%= form_with url: family_exports_path, method: :post, class: "space-y-4" do |form| %>
- <%= link_to "Cancel", "#", class: "flex-1 text-center px-4 py-2 text-primary bg-surface border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %> - <%= form.submit "Export data", class: "flex-1 bg-inverse fg-inverse rounded-lg px-4 py-2 cursor-pointer hover:bg-inverse-hover" %> + <%= link_to t(".cancel"), "#", class: "flex-1 text-center px-4 py-2 text-primary bg-surface border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %> + <%= form.submit t(".export_data"), class: "flex-1 bg-inverse text-inverse rounded-lg px-4 py-2 cursor-pointer hover:bg-inverse-hover" %>
<% end %>
diff --git a/app/views/family_merchants/_family_merchant.html.erb b/app/views/family_merchants/_family_merchant.html.erb index 8e3306df7..c0df9dfec 100644 --- a/app/views/family_merchants/_family_merchant.html.erb +++ b/app/views/family_merchants/_family_merchant.html.erb @@ -18,10 +18,10 @@ <%= render DS::Menu.new do |menu| %> - <% menu.with_item(variant: "link", text: "Edit", href: edit_family_merchant_path(family_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %> + <% menu.with_item(variant: "link", text: t(".edit"), href: edit_family_merchant_path(family_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %> <% menu.with_item( variant: "button", - text: "Delete", + text: t(".delete"), href: family_merchant_path(family_merchant), icon: "trash-2", method: :delete, diff --git a/app/views/family_merchants/merge.html.erb b/app/views/family_merchants/merge.html.erb index 77d6ba84f..879118d56 100644 --- a/app/views/family_merchants/merge.html.erb +++ b/app/views/family_merchants/merge.html.erb @@ -13,7 +13,7 @@
<% @merchants.each do |merchant| %>
- <%= tag.p format_money account.cash_balance_money %> + <%= tag.p format_money(account.cash_balance_money), class: "privacy-sensitive" %>
diff --git a/app/views/holdings/_cost_basis_cell.html.erb b/app/views/holdings/_cost_basis_cell.html.erb index f482d0097..4ae265df4 100644 --- a/app/views/holdings/_cost_basis_cell.html.erb +++ b/app/views/holdings/_cost_basis_cell.html.erb @@ -12,29 +12,29 @@ <% if holding.cost_basis_locked? && !editable %> <%# Locked and not editable (from holdings list) - just show value, right-aligned %>
- <%= tag.span format_money(holding.avg_cost) %> + <%= tag.span format_money(holding.avg_cost), class: "privacy-sensitive" %> <%= icon "lock", size: "xs", class: "text-secondary" %>
<% else %> <%# Unlocked OR editable context (drawer) - show clickable menu %> - <%= render DS::Menu.new(variant: :button, placement: "bottom-end") do |menu| %> - <% menu.with_button(class: "hover:text-primary cursor-pointer group") do %> + <%= render DS::Popover.new(variant: :button, placement: "bottom-end") do |popover| %> + <% popover.with_button(class: "hover:text-primary cursor-pointer group") do %> <% if holding.avg_cost %>
- <%= tag.span format_money(holding.avg_cost) %> + <%= tag.span format_money(holding.avg_cost), class: "privacy-sensitive" %> <% if holding.cost_basis_locked? %> <%= icon "lock", size: "xs", class: "text-secondary" %> <% end %> <%= icon "pencil", size: "xs", class: "text-secondary opacity-0 group-hover:opacity-100 transition-opacity" %>
<% else %> -
+
<%= icon "pencil", size: "xs" %> - Set + <%= t(".set") %>
<% end %> <% end %> - <% menu.with_custom_content do %> + <% popover.with_custom_content do %>
@@ -95,8 +95,8 @@
<%= f.submit t(".save"), class: "inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %> diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index c0ba88e64..533b2a7a6 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -3,10 +3,8 @@ <%= turbo_frame_tag dom_id(holding) do %>
- <% if holding.security.brandfetch_icon_url.present? %> - <%= image_tag holding.security.brandfetch_icon_url, class: "w-9 h-9 rounded-full", loading: "lazy" %> - <% elsif holding.security.logo_url.present? %> - <%= image_tag holding.security.logo_url, class: "w-9 h-9 rounded-full", loading: "lazy" %> + <% if (logo = holding.security.display_logo_url).present? %> + <%= image_tag logo, class: "w-9 h-9 rounded-full", loading: "lazy" %> <% else %> <%= render DS::FilledIcon.new(variant: :text, text: holding.name, size: "md", rounded: true) %> <% end %> @@ -41,7 +39,7 @@ <% else %> <%= tag.p "--", class: "text-secondary" %> <% end %> - <%= tag.p t(".shares", qty: format_quantity(holding.qty)), class: "font-normal text-secondary" %> + <%= tag.p t(".shares", qty: format_quantity(holding.qty)), class: "font-normal text-secondary privacy-sensitive" %>
diff --git a/app/views/holdings/_missing_price_tooltip.html.erb b/app/views/holdings/_missing_price_tooltip.html.erb index f2f592011..94793a882 100644 --- a/app/views/holdings/_missing_price_tooltip.html.erb +++ b/app/views/holdings/_missing_price_tooltip.html.erb @@ -3,8 +3,8 @@ <%= icon "info", size: "sm", color: "current" %> <%= tag.span t(".missing_data"), class: "font-normal text-xs" %>
-
diff --git a/app/views/imports/_importing.html.erb b/app/views/imports/_importing.html.erb index 43d61f540..7fb8ed100 100644 --- a/app/views/imports/_importing.html.erb +++ b/app/views/imports/_importing.html.erb @@ -2,18 +2,18 @@
-
+
<%= icon "loader", class: "animate-pulse" %>
-

Import in progress

-

Your import is in progress. Check the imports menu for status updates or click 'Check Status' to refresh the page for updates. Feel free to continue using the app.

+

<%= t(".title") %>

+

<%= t(".description") %>

- <%= render DS::Link.new(text: "Check status", href: import_path(import), variant: "primary", full_width: true) %> - <%= render DS::Link.new(text: "Back to dashboard", href: root_path, variant: "secondary", full_width: true) %> + <%= render DS::Link.new(text: t(".check_status"), href: import_path(import), variant: "primary", full_width: true) %> + <%= render DS::Link.new(text: t(".back_to_dashboard"), href: root_path, variant: "secondary", full_width: true) %>
diff --git a/app/views/imports/_pdf_import.html.erb b/app/views/imports/_pdf_import.html.erb index f565e8274..068d55da9 100644 --- a/app/views/imports/_pdf_import.html.erb +++ b/app/views/imports/_pdf_import.html.erb @@ -16,14 +16,14 @@

<%= t("imports.pdf_import.document_type_label") %>

-

+

<%= t("imports.document_types.#{import.document_type}", default: import.document_type&.humanize || t("imports.pdf_import.unknown_document_type", default: "Unknown")) %>

<%= t("imports.pdf_import.transactions_extracted", default: "Transactions Extracted") %>

-

+

<%= t("imports.pdf_import.transactions_extracted_count", count: import.rows_count, default: "%{count} transactions") %>

@@ -63,7 +63,7 @@
<% elsif import.importing? || import.pending? %> -
+
<%= icon "loader", class: "animate-pulse" %>
@@ -92,7 +92,7 @@
<%= render DS::Link.new(text: t("imports.pdf_import.try_again"), href: new_import_path, variant: "primary", full_width: true) %> - <%= button_to t("imports.pdf_import.delete_import"), import_path(import), method: :delete, class: "w-full font-medium text-sm px-3 py-2 rounded-lg text-primary bg-gray-200 hover:bg-gray-300" %> + <%= button_to t("imports.pdf_import.delete_import"), import_path(import), method: :delete, class: "w-full font-medium text-sm px-3 py-2 rounded-lg text-primary bg-surface-inset hover:bg-surface-inset-hover" %>
<% elsif import.complete? && import.ai_processed? %> @@ -108,14 +108,14 @@

<%= t("imports.pdf_import.document_type_label") %>

-

+

<%= t("imports.document_types.#{import.document_type}", default: import.document_type&.humanize || t("imports.pdf_import.unknown_document_type", default: "Unknown")) %>

<%= t("imports.pdf_import.summary_label") %>

-

+

<%= import.ai_summary %>

@@ -127,7 +127,7 @@
<%= render DS::Link.new(text: t("imports.pdf_import.back_to_imports"), href: imports_path, variant: "primary", full_width: true) %> - <%= button_to t("imports.pdf_import.delete_import"), import_path(import), method: :delete, class: "w-full font-medium text-sm px-3 py-2 rounded-lg text-primary bg-gray-200 hover:bg-gray-300" %> + <%= button_to t("imports.pdf_import.delete_import"), import_path(import), method: :delete, class: "w-full font-medium text-sm px-3 py-2 rounded-lg text-primary bg-surface-inset hover:bg-surface-inset-hover" %>
<% else %> diff --git a/app/views/imports/_revert_failure.html.erb b/app/views/imports/_revert_failure.html.erb index 9d64e7a76..6c4872d06 100644 --- a/app/views/imports/_revert_failure.html.erb +++ b/app/views/imports/_revert_failure.html.erb @@ -7,12 +7,12 @@
-

Reverting import failed

-

Please try again

+

<%= t(".title") %>

+

<%= t(".description") %>

<%= render DS::Button.new( - text: "Try again", + text: t(".try_again"), full_width: true, href: revert_import_path(import) ) %> diff --git a/app/views/imports/_success.html.erb b/app/views/imports/_success.html.erb index c0849e94b..329977a91 100644 --- a/app/views/imports/_success.html.erb +++ b/app/views/imports/_success.html.erb @@ -2,17 +2,50 @@
-
+
<%= icon "check", color: "success" %>
-

Import successful

-

Your imported data has been successfully added to the app and is now ready for use.

+

<%= t(".title") %>

+

<%= t(".description") %>

+ <% if import.is_a?(SureImport) %> + <% verification = import_verification_view(import) %> + +
+
+

<%= t(".verification.title") %>

+ <%= t(".verification.status.#{verification.status}", default: verification.status.humanize) %> +
+ +
+
+

<%= t(".verification.checked") %>

+

<%= number_with_delimiter(verification.checked_total) %>

+
+
+

<%= t(".verification.mismatches") %>

+

<%= number_with_delimiter(verification.mismatches_count) %>

+
+
+ + <% if verification.mismatches? %> +
+ <% verification.mismatches_preview.each do |key, counts| %> +
+ <%= key.humanize %> + <%= number_with_delimiter(counts["actual"]) %> / <%= number_with_delimiter(counts["expected"]) %> +
+ <% end %> +
+ <% end %> +
+ <% end %> + <%= render DS::Link.new( - text: "Back to dashboard", + text: t(".back_to_dashboard"), variant: "primary", full_width: true, href: root_path diff --git a/app/views/imports/_table.html.erb b/app/views/imports/_table.html.erb index 8de7cea31..87978afeb 100644 --- a/app/views/imports/_table.html.erb +++ b/app/views/imports/_table.html.erb @@ -2,10 +2,10 @@
<% if caption %>
-
+
<%= inline_svg_tag "icon-csv.svg", class: "w-4 h-4" %>
-

<%= caption %>

+

<%= caption %>

<% end %>
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index 33d07ddfd..e71d84b6f 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -31,7 +31,7 @@ <% import_type = params[:type].presence || @pending_import&.type - active_tab = import_type.present? && !import_type.in?(%w[MintImport QifImport SureImport DocumentImport PdfImport]) ? "raw_data" : "financial_tools" + active_tab = import_type.present? && !import_type.in?(%w[MintImport ActualImport QifImport SureImport DocumentImport PdfImport]) ? "raw_data" : "financial_tools" %> <%= render DS::Tabs.new(active_tab: active_tab) do |tabs| %> <% tabs.with_nav do |nav| %> @@ -42,7 +42,7 @@ <% tabs.with_panel(tab_id: "financial_tools") do %>
    - <% if @pending_import.present? && params[:type].present? && params[:type].in?(%w[MintImport QifImport SureImport DocumentImport PdfImport]) %> + <% if @pending_import.present? && params[:type].present? && params[:type].in?(%w[MintImport ActualImport QifImport SureImport DocumentImport PdfImport]) %>
  • <%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
    @@ -99,6 +99,16 @@ enabled: true %> <% end %> + <% if params[:type].nil? || params[:type] == "ActualImport" %> + <%= render "imports/import_option", + type: "ActualImport", + icon_name: "wallet", + icon_bg_class: "bg-emerald-500/5", + icon_text_class: "text-emerald-500", + label: t(".import_actual"), + enabled: true %> + <% end %> + <% if params[:type].nil? || params[:type] == "QifImport" %> <%= render "imports/import_option", type: "QifImport", @@ -112,8 +122,8 @@ <%= render "imports/import_option", type: "TransactionImport", icon_name: "bar-chart-2", - icon_bg_class: "bg-gray-500/5", - icon_text_class: "text-gray-400", + icon_bg_class: "bg-gray-tint-5", + icon_text_class: "text-subdued", label: t(".import_ynab"), enabled: false, disabled_message: t(".coming_soon") %> @@ -153,7 +163,7 @@ <% tabs.with_panel(tab_id: "raw_data") do %>
      - <% if @pending_import.present? && params[:type].present? && !params[:type].in?(%w[MintImport QifImport SureImport DocumentImport PdfImport]) %> + <% if @pending_import.present? && params[:type].present? && !params[:type].in?(%w[MintImport ActualImport QifImport SureImport DocumentImport PdfImport]) %>
    • <%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
      diff --git a/app/views/indexa_capital_items/_indexa_capital_item.html.erb b/app/views/indexa_capital_items/_indexa_capital_item.html.erb index 915a68650..d03c2ffb1 100644 --- a/app/views/indexa_capital_items/_indexa_capital_item.html.erb +++ b/app/views/indexa_capital_items/_indexa_capital_item.html.erb @@ -1,101 +1,103 @@ <%# locals: (indexa_capital_item:) %> <%= tag.div id: dom_id(indexa_capital_item) do %> -
      - -
      - <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + <% unlinked_count = indexa_capital_item.unlinked_accounts_count %> -
      -
      - <%= tag.p indexa_capital_item.name.first.upcase, class: "text-primary text-xs font-medium" %> + <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> + <% disclosure.with_summary_content do %> +
      +
      + <%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> + +
      +
      + <%= tag.p indexa_capital_item.name.first.upcase, class: "text-primary text-xs font-medium" %> +
      -
      - <% unlinked_count = indexa_capital_item.unlinked_accounts_count %> - -
      -
      - <%= tag.p indexa_capital_item.name, class: "font-medium text-primary" %> - <% if indexa_capital_item.scheduled_for_deletion? %> -

      <%= t(".deletion_in_progress") %>

      +
      +
      + <%= tag.p indexa_capital_item.name, class: "font-medium text-primary" %> + <% if indexa_capital_item.scheduled_for_deletion? %> +

      <%= t(".deletion_in_progress") %>

      + <% end %> +
      +

      <%= t(".provider_name") %>

      + <% if indexa_capital_item.syncing? %> +
      + <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
      + <% elsif indexa_capital_item.requires_update? %> +
      + <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".requires_update") %> +
      + <% elsif indexa_capital_item.sync_error.present? %> +
      + <%= render DS::Tooltip.new(text: indexa_capital_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> + <%= tag.span t(".error"), class: "text-destructive" %> +
      + <% else %> +

      + <% if indexa_capital_item.last_synced_at %> + <%= t(".status", timestamp: time_ago_in_words(indexa_capital_item.last_synced_at), summary: indexa_capital_item.sync_status_summary) %> + <% else %> + <%= t(".status_never") %> + <% end %> +

      <% end %>
      -

      <%= t(".provider_name") %>

      - <% if indexa_capital_item.syncing? %> -
      - <%= icon "loader", size: "sm", class: "animate-spin" %> - <%= tag.span t(".syncing") %> -
      - <% elsif indexa_capital_item.requires_update? %> -
      - <%= icon "alert-triangle", size: "sm", color: "warning" %> - <%= tag.span t(".requires_update") %> -
      - <% elsif indexa_capital_item.sync_error.present? %> -
      - <%= render DS::Tooltip.new(text: indexa_capital_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> - <%= tag.span t(".error"), class: "text-destructive" %> -
      - <% else %> -

      - <% if indexa_capital_item.last_synced_at %> - <%= t(".status", timestamp: time_ago_in_words(indexa_capital_item.last_synced_at), summary: indexa_capital_item.sync_status_summary) %> - <% else %> - <%= t(".status_never") %> - <% end %> -

      - <% end %>
      -
      - <% if Current.user&.admin? %> -
      - <% if indexa_capital_item.requires_update? %> - <%= render DS::Link.new( - text: t(".update_credentials"), - icon: "refresh-cw", - variant: "secondary", - href: settings_providers_path, - frame: "_top" - ) %> - <% else %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_indexa_capital_item_path(indexa_capital_item), - disabled: indexa_capital_item.syncing? - ) %> - <% end %> - - <%= render DS::Menu.new do |menu| %> - <% if unlinked_count > 0 %> - <% menu.with_item( - variant: "link", - text: t(".setup_action"), - icon: "settings", - href: setup_accounts_indexa_capital_item_path(indexa_capital_item), - frame: :modal + <% if Current.user&.admin? %> +
      + <% if indexa_capital_item.requires_update? %> + <%= render DS::Link.new( + text: t(".update_credentials"), + icon: "refresh-cw", + variant: "secondary", + href: settings_providers_path, + frame: "_top" + ) %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_indexa_capital_item_path(indexa_capital_item), + disabled: indexa_capital_item.syncing? ) %> <% end %> - <% menu.with_item( - variant: "link", - text: t(".update_credentials"), - icon: "cable", - href: settings_providers_path(manage: "1") - ) %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - icon: "trash-2", - href: indexa_capital_item_path(indexa_capital_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(indexa_capital_item.name, high_severity: true) - ) %> - <% end %> -
      - <% end %> -
      + + <%= render DS::Menu.new do |menu| %> + <% if unlinked_count > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_action"), + icon: "settings", + href: setup_accounts_indexa_capital_item_path(indexa_capital_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "link", + text: t(".update_credentials"), + icon: "cable", + href: settings_providers_path(manage: "1") + ) %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: indexa_capital_item_path(indexa_capital_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(indexa_capital_item.name, high_severity: true) + ) %> + <% end %> +
      + <% end %> +
    + <% end %> <% unless indexa_capital_item.scheduled_for_deletion? %>
    @@ -144,5 +146,5 @@ <% end %>
    <% end %> - + <% end %> <% end %> diff --git a/app/views/indexa_capital_items/select_existing_account.html.erb b/app/views/indexa_capital_items/select_existing_account.html.erb index 7b0f46764..bb6535672 100644 --- a/app/views/indexa_capital_items/select_existing_account.html.erb +++ b/app/views/indexa_capital_items/select_existing_account.html.erb @@ -14,7 +14,13 @@ <%= icon "alert-circle", class: "text-warning mx-auto mb-4", size: "lg" %>

    <%= t("indexa_capital_items.select_existing_account.no_accounts") %>

    <%= t("indexa_capital_items.select_existing_account.connect_hint") %>

    - <%= link_to t("indexa_capital_items.select_existing_account.settings_link"), settings_providers_path, class: "btn btn--primary btn--sm mt-4" %> + <%= render DS::Link.new( + text: t("indexa_capital_items.select_existing_account.settings_link"), + href: settings_providers_path, + variant: :primary, + size: :sm, + class: "mt-4" + ) %>
    <% else %>
    diff --git a/app/views/investment_activity/_badge.html.erb b/app/views/investment_activity/_badge.html.erb index 549d5b33f..646e04d4d 100644 --- a/app/views/investment_activity/_badge.html.erb +++ b/app/views/investment_activity/_badge.html.erb @@ -1,33 +1,14 @@ <%# locals: (label:) %> -<%# Simple non-interactive badge for displaying activity labels %> +<%# Simple non-interactive badge for displaying activity labels. %> <% - # Color mapping for different investment activity labels - color = case label - when "Buy" - "rgb(59 130 246)" # blue - when "Sell" - "rgb(239 68 68)" # red - when "Dividend", "Interest" - "rgb(34 197 94)" # green - when "Contribution" - "rgb(168 85 247)" # purple - when "Withdrawal" - "rgb(249 115 22)" # orange - when "Fee" - "rgb(107 114 128)" # gray - when "Transfer", "Sweep In", "Sweep Out", "Exchange" - "rgb(107 114 128)" # gray - when "Reinvestment" - "rgb(59 130 246)" # blue - else - "rgb(107 114 128)" # gray for "Other" - end + tone = case label + when "Buy", "Reinvestment" then :indigo # was raw blue + when "Sell" then :red + when "Dividend", "Interest" then :green + when "Contribution" then :violet # was raw purple + when "Withdrawal" then :amber # was raw orange + when "Fee", "Transfer", "Sweep In", "Sweep Out", "Exchange" then :gray + else :gray + end %> - - - <%= label %> - +<%= render DS::Pill.new(label: label, tone: tone, marker: false, show_dot: false) %> diff --git a/app/views/investments/_value_tooltip.html.erb b/app/views/investments/_value_tooltip.html.erb index 0cdeee278..55619f9ab 100644 --- a/app/views/investments/_value_tooltip.html.erb +++ b/app/views/investments/_value_tooltip.html.erb @@ -2,34 +2,34 @@
    <%= icon("info", size: "sm") %> -
<%= render DS::Button.new( - text: "Add condition", + text: t(".add_condition"), leading_icon: "plus", variant: "ghost", type: "button", diff --git a/app/views/rules/_category_rule_cta.html.erb b/app/views/rules/_category_rule_cta.html.erb index ff3481f4d..3c7f2ed41 100644 --- a/app/views/rules/_category_rule_cta.html.erb +++ b/app/views/rules/_category_rule_cta.html.erb @@ -13,7 +13,7 @@ <%= f.hidden_field :rule_prompt_dismissed_at, value: Time.current %> <%= tag.div class:"flex gap-2 justify-end" do %> - <%= render DS::Button.new(text: "Dismiss", variant: "secondary") %> + <%= render DS::Button.new(text: "Dismiss", variant: "secondary", type: :submit) %> <% rule_href = new_rule_path(resource_type: "transaction", action_type: "set_transaction_category", action_value: cta[:category_id], name: cta[:merchant_name]) %> <%= render DS::Link.new(text: "Create rule", variant: "primary", href: rule_href, frame: :modal) %> <% end %> diff --git a/app/views/rules/_form.html.erb b/app/views/rules/_form.html.erb index 2f4f1fe18..7c942a35f 100644 --- a/app/views/rules/_form.html.erb +++ b/app/views/rules/_form.html.erb @@ -12,10 +12,10 @@
<%= icon "tag", size: "sm" %> -

Rule name (optional)

+

<%= t(".rule_name_label") %>

- <%= f.text_field :name, placeholder: "Enter a name for this rule", class: "form-field__input" %> + <%= f.text_field :name, placeholder: t(".rule_name_placeholder"), class: "form-field__input" %>
@@ -50,15 +50,15 @@
- <%= render DS::Button.new(text: "Add condition", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %> - <%= render DS::Button.new(text: "Add condition group", icon: "copy-plus", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %> + <%= render DS::Button.new(text: t(".add_condition"), icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %> + <%= render DS::Button.new(text: t(".add_condition_group"), icon: "copy-plus", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %>
-

THEN

+

<%= t(".then") %>

<%# Action template, used by Stimulus controller to add new actions dynamically %> @@ -75,7 +75,7 @@ <% end %> - <%= render DS::Button.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %> + <%= render DS::Button.new(text: t(".add_action"), icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %>
@@ -87,13 +87,13 @@
<%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: "rules#clearEffectiveDate" } %> - <%= f.label :effective_date_enabled_false, "All past and future #{rule.resource_type}s", class: "text-sm text-primary" %> + <%= f.label :effective_date_enabled_false, t(".all_past_and_future", resource: rule.resource_type.pluralize), class: "text-sm text-primary" %>
<%= f.radio_button :effective_date_enabled, true, checked: rule.effective_date.present? %> - <%= f.label :effective_date_enabled_true, "Starting from", class: "text-sm text-primary" %> + <%= f.label :effective_date_enabled_true, t(".starting_from"), class: "text-sm text-primary" %>
<%= f.date_field :effective_date, container_class: "w-fit", data: { rules_target: "effectiveDateInput" } %> diff --git a/app/views/rules/_rule.html.erb b/app/views/rules/_rule.html.erb index 2e882e4ba..3106e8b4f 100644 --- a/app/views/rules/_rule.html.erb +++ b/app/views/rules/_rule.html.erb @@ -21,14 +21,14 @@ <% end %> <% if additional_condition_count.positive? %> - and <%= additional_condition_count %> more <%= additional_condition_count == 1 ? "condition" : "conditions" %> + <%= t(".and_more_conditions", count: additional_condition_count) %> <% end %>

<% end %>
- THEN + <%= t(".then") %>

@@ -36,14 +36,14 @@ <%= t("rules.no_action") %> <% else %> <% if rule.actions.first.value && rule.actions.first.options %> - <%= rule.actions.first.executor.label %> to <%= rule.actions.first.value_display %> + <%= t(".action_label_to", label: rule.actions.first.executor.label, value: rule.actions.first.value_display) %> <% else %> <%= rule.actions.first.executor.label %> <% end %> <% end %> <% if rule.actions.count > 1 %> - and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %> + <%= t(".and_more_actions", count: rule.actions.count - 1) %> <% end %>

@@ -54,9 +54,9 @@

<% if rule.effective_date.nil? %> - All past and future <%= rule.resource_type.pluralize %> + <%= t(".all_past_and_future", resource: rule.resource_type.pluralize) %> <% else %> - <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime("%b %-d, %Y") %> + <%= t(".on_or_after", resource: rule.resource_type.pluralize, date: rule.effective_date.strftime("%b %-d, %Y")) %> <% end %>

@@ -67,11 +67,11 @@ <%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %> <% end %> <%= render DS::Menu.new do |menu| %> - <% menu.with_item(variant: "link", text: "Edit", href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %> - <% menu.with_item(variant: "link", text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %> + <% menu.with_item(variant: "link", text: t(".edit"), href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %> + <% menu.with_item(variant: "link", text: t(".re_apply_rule"), href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %> <% menu.with_item( variant: "button", - text: "Delete", + text: t(".delete"), href: rule_path(rule), icon: "trash-2", method: :delete, diff --git a/app/views/rules/confirm.html.erb b/app/views/rules/confirm.html.erb index cf505c8f9..775846f6a 100644 --- a/app/views/rules/confirm.html.erb +++ b/app/views/rules/confirm.html.erb @@ -1,53 +1,44 @@ <%= render DS::Dialog.new(reload_on_close: params[:reload_on_close].present?) do |dialog| %> <% title = if @rule.name.present? - "Confirm changes to \"#{@rule.name}\"" + t(".title_with_name", name: @rule.name) else - "Confirm changes" + t(".title") end %> <% dialog.with_header(title: title) %> <% dialog.with_body do %>

- You are about to apply this rule to - <%= @rule.affected_resource_count %> <%= @rule.resource_type.pluralize %> - that meet the specified rule criteria. Please confirm if you wish to proceed with this change. + <%= t(".apply_notice_html", count: @rule.affected_resource_count, resource: @rule.resource_type.pluralize) %>

<% if @rule.actions.any? { |a| a.action_type == "auto_categorize" } %> <% affected_count = @rule.affected_resource_count %> -
-
- <%= icon "info", class: "w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" %> -
-

AI Cost Estimation

- <% if @estimated_cost.present? %> -

- This will use AI to categorize <%= affected_count %> transaction<%= "s" if affected_count != 1 %>. - Estimated cost: ~$<%= sprintf("%.4f", @estimated_cost) %> -

- <% else %> -

- This will use AI to categorize <%= affected_count %> transaction<%= "s" if affected_count != 1 %>. - <% if @selected_model.present? %> - Cost estimation unavailable for model "<%= @selected_model %>". - <% else %> - Cost estimation unavailable (no LLM provider configured). - <% end %> - You may incur costs, please check with the model provider for the most up-to-date prices. -

- <% end %> -

- <%= link_to "View usage history", settings_llm_usage_path, class: "underline hover:text-blue-800" %> +

+ <%= render DS::Alert.new(title: t(".ai_cost_title"), variant: :info) do %> + <% if @estimated_cost.present? %> +

<%= t(".ai_cost_with_estimate_html", count: affected_count, cost: sprintf("%.4f", @estimated_cost)) %>

+ <% else %> +

+ <%= t(".ai_cost_no_estimate_html", count: affected_count) %> + <% if @selected_model.present? %> + <%= t(".cost_unavailable_model", model: @selected_model) %> + <% else %> + <%= t(".cost_unavailable_no_provider") %> + <% end %> + <%= t(".cost_warning") %>

-
-
+ <% end %> +

+ <%= link_to t(".view_usage_history"), settings_llm_usage_path, class: "text-link underline" %> +

+ <% end %>
<% end %> <%= render DS::Button.new( - text: "Confirm changes", + text: t(".confirm_changes"), href: apply_rule_path(@rule), method: :post, full_width: true, diff --git a/app/views/rules/confirm_all.html.erb b/app/views/rules/confirm_all.html.erb index c88778767..d3b230766 100644 --- a/app/views/rules/confirm_all.html.erb +++ b/app/views/rules/confirm_all.html.erb @@ -9,32 +9,28 @@

<% if @rules.any? { |r| r.actions.any? { |a| a.action_type == "auto_categorize" } } %> -
-
- <%= icon "info", class: "w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" %> -
-

<%= t("rules.apply_all.ai_cost_title") %>

- <% if @estimated_cost.present? %> -

- <%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %> - <%= t("rules.apply_all.estimated_cost", cost: sprintf("%.4f", @estimated_cost)) %> -

- <% else %> -

- <%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %> - <% if @selected_model.present? %> - <%= t("rules.apply_all.cost_unavailable_model", model: @selected_model) %> - <% else %> - <%= t("rules.apply_all.cost_unavailable_no_provider") %> - <% end %> - <%= t("rules.apply_all.cost_warning") %> -

- <% end %> -

- <%= link_to t("rules.apply_all.view_usage"), settings_llm_usage_path, class: "underline hover:text-blue-800" %> +

+ <%= render DS::Alert.new(title: t("rules.apply_all.ai_cost_title"), variant: :info) do %> + <% if @estimated_cost.present? %> +

+ <%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %> + <%= t("rules.apply_all.estimated_cost", cost: sprintf("%.4f", @estimated_cost)) %>

-
-
+ <% else %> +

+ <%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %> + <% if @selected_model.present? %> + <%= t("rules.apply_all.cost_unavailable_model", model: @selected_model) %> + <% else %> + <%= t("rules.apply_all.cost_unavailable_no_provider") %> + <% end %> + <%= t("rules.apply_all.cost_warning") %> +

+ <% end %> +

+ <%= link_to t("rules.apply_all.view_usage"), settings_llm_usage_path, class: "text-link underline" %> +

+ <% end %>
<% end %> diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 49282af42..05c5361dd 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -1,4 +1,4 @@ -<%= content_for :page_title, "Rules" %> +<%= content_for :page_title, t(".page_title") %> <%= content_for :page_actions do %> <% if @rules.any? %> <%= render DS::Menu.new do |menu| %> @@ -15,7 +15,7 @@ )) %> <% menu.with_item( variant: "button", - text: "Delete all rules", + text: t(".delete_all_rules"), href: destroy_all_rules_path, icon: "trash-2", method: :delete, @@ -30,7 +30,7 @@ ) %> <% end %> <%= render DS::Link.new( - text: "New rule", + text: t(".new_rule"), variant: "primary", href: new_rule_path(resource_type: "transaction"), icon: "plus", @@ -42,7 +42,7 @@
<%= icon("circle-alert", size: "sm") %>

- AI-enabled rule actions will cost money. Be sure to filter as narrowly as possible to avoid unnecessary costs. + <%= t(".ai_cost_warning") %>

<% end %> @@ -51,15 +51,15 @@
-

Rules

+

<%= t(".rules_heading") %>

·

<%= @rules.count %>

- Sort by: + <%= t(".sort_by") %> <%= form_with url: rules_path, method: :get, local: true, class: "flex items-center", data: { controller: "auto-submit-form" } do |form| %> <%= form.select :sort_by, - options_for_select([["Name", "name"], ["Updated At", "updated_at"]], @sort_by), + options_for_select([[t(".sort_name"), "name"], [t(".sort_updated_at"), "updated_at"]], @sort_by), {}, class: "min-w-[120px] bg-transparent rounded border-none cursor-pointer text-primary uppercase text-xs w-auto", data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %> @@ -70,7 +70,7 @@ variant: "icon", icon: "arrow-up-down", size: :sm, - title: "Toggle sort direction" + title: t(".toggle_sort_direction") ) %>
@@ -83,11 +83,11 @@ <% else %>
-

No rules yet

-

Set up rules to perform actions to your transactions and other data on every account sync.

+

<%= t(".no_rules_title") %>

+

<%= t(".no_rules_description") %>

<%= render DS::Link.new( - text: "New rule", + text: t(".new_rule"), variant: "primary", href: new_rule_path(resource_type: "transaction"), icon: "plus", @@ -128,6 +128,7 @@
<%= t("rules.recent_runs.columns.transactions_counts.queued") %>
<%= t("rules.recent_runs.columns.transactions_counts.processed") %>
<%= t("rules.recent_runs.columns.transactions_counts.modified") %>
+
<%= t("rules.recent_runs.columns.transactions_counts.blocked", default: "Blocked") %>
@@ -169,7 +170,7 @@ <%= run.rule_name.presence || run.rule&.name.presence || t("rules.recent_runs.unnamed_rule") %> - <%= "#{number_with_delimiter(run.transactions_queued)} / #{number_with_delimiter(run.transactions_processed)} / #{number_with_delimiter(run.transactions_modified)}" %> + <%= "#{number_with_delimiter(run.transactions_queued)} / #{number_with_delimiter(run.transactions_processed)} / #{number_with_delimiter(run.transactions_modified)} / #{number_with_delimiter(run.transactions_blocked)}" %> <% end %> diff --git a/app/views/securities/_combobox_security.turbo_stream.erb b/app/views/securities/_combobox_security.turbo_stream.erb index 50d35b9bb..79f8263e0 100644 --- a/app/views/securities/_combobox_security.turbo_stream.erb +++ b/app/views/securities/_combobox_security.turbo_stream.erb @@ -9,18 +9,28 @@ <%= t("securities.combobox.exchange_label", symbol: combobox_security.symbol, exchange: combobox_security.exchange_name) %> + <% if combobox_security.price_provider.present? %> + · <%= t("securities.providers.#{combobox_security.price_provider}", default: combobox_security.price_provider.humanize) %> + <% end %>
- <% if combobox_security.country_code.present? %> -
- <%= image_tag("https://hatscripts.github.io/circle-flags/flags/#{combobox_security.country_code.downcase}.svg", - class: "h-4 rounded-sm", - alt: "#{combobox_security.country_code.upcase} flag", - title: combobox_security.country_code.upcase) %> - - <%= combobox_security.country_code.upcase %> +
+ <% if combobox_security.currency.present? %> + + <%= combobox_security.currency %> -
- <% end %> + <% end %> + <% if combobox_security.country_code.present? %> +
+ <%= image_tag("https://hatscripts.github.io/circle-flags/flags/#{combobox_security.country_code.downcase}.svg", + class: "h-4 rounded-sm", + alt: "#{combobox_security.country_code.upcase} flag", + title: combobox_security.country_code.upcase) %> + + <%= combobox_security.country_code.upcase %> + +
+ <% end %> +
diff --git a/app/views/sessions/mobile_sso_start.html.erb b/app/views/sessions/mobile_sso_start.html.erb index 74fa9ebfd..123aca3a7 100644 --- a/app/views/sessions/mobile_sso_start.html.erb +++ b/app/views/sessions/mobile_sso_start.html.erb @@ -4,5 +4,5 @@ - + diff --git a/app/views/settings/_section.html.erb b/app/views/settings/_section.html.erb index bb5873839..14ef45a61 100644 --- a/app/views/settings/_section.html.erb +++ b/app/views/settings/_section.html.erb @@ -1,23 +1,39 @@ -<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil) %> +<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil) %> <% if collapsible %> -
- class="group bg-container shadow-border-xs rounded-xl p-4" - <%= "data-controller=\"auto-open\" data-auto-open-param-value=\"#{h(auto_open_param)}\"".html_safe if auto_open_param.present? %>> - -
- <%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %> -
-

<%= title %>

- <% if subtitle.present? %> -

<%= subtitle %>

- <% end %> + <%= render DS::Disclosure.new( + variant: :card, + open: open, + data: auto_open_param.present? ? { controller: "auto-open", auto_open_param_value: auto_open_param } : nil + ) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+ <%= icon "chevron-right", class: "text-secondary group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> +
+
+

<%= title %>

+ <%= badge if badge.present? %> +
+ <% if subtitle.present? %> +

<%= subtitle %>

+ <% end %> +
+ <% if status.present? %> +
+ <% if meta.present? %> + <%= meta %> + <% end %> + <%= status %> + <%= actions if actions.present? %> +
+ <% end %>
-
+ <% end %>
<%= content %>
-
+ <% end %> <% else %>
diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index bc4f9dcd0..f9e5e975b 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -4,7 +4,7 @@ nav_sections = [ header: t(".general_section_title"), items: [ { label: t(".accounts_label"), path: accounts_path, icon: "layers" }, - { label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" }, + { label: t(".bank_sync_label"), path: settings_providers_path, icon: "banknote", if: Current.user&.admin? }, { label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" }, { label: t(".appearance_label"), path: settings_appearance_path, icon: "palette" }, { label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" }, @@ -19,7 +19,8 @@ nav_sections = [ { label: t(".tags_label"), path: tags_path, icon: "tags" }, { label: t(".rules_label"), path: rules_path, icon: "git-branch" }, { label: t(".merchants_label"), path: family_merchants_path, icon: "store" }, - { label: t(".recurring_transactions_label"), path: recurring_transactions_path, icon: "repeat" } + { label: t(".recurring_transactions_label"), path: recurring_transactions_path, icon: "repeat" }, + { label: t(".statement_vault_label"), path: account_statements_path, icon: "archive", if: Current.user&.admin? } ] }, ( @@ -27,14 +28,14 @@ nav_sections = [ header: t(".advanced_section_title"), items: [ { label: t(".ai_prompts_label"), path: settings_ai_prompts_path, icon: "bot" }, - { label: "LLM Usage", path: settings_llm_usage_path, icon: "activity" }, + { label: t(".llm_usage_label"), path: settings_llm_usage_path, icon: "activity" }, { label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" }, + { label: t(".debug_label", default: "Debug"), path: settings_debug_path, icon: "bug", if: Current.user&.super_admin? }, { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, - { label: "Providers", path: settings_providers_path, icon: "plug" }, { label: t(".imports_label"), path: imports_path, icon: "download" }, { label: t(".exports_label"), path: family_exports_path, icon: "upload" }, - { label: "SSO Providers", path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? }, - { label: "Users", path: admin_users_path, icon: "users", if: Current.user&.super_admin? } + { label: t(".sso_providers_label"), path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? }, + { label: t(".users_label"), path: admin_users_path, icon: "users", if: Current.user&.super_admin? } ] } : nil ), diff --git a/app/views/settings/_user_avatar_field.html.erb b/app/views/settings/_user_avatar_field.html.erb index 1bedac90e..64f23d9b1 100644 --- a/app/views/settings/_user_avatar_field.html.erb +++ b/app/views/settings/_user_avatar_field.html.erb @@ -5,7 +5,7 @@ @@ -52,7 +52,7 @@ <%= form.file_field :profile_image, accept: "image/png, image/jpeg", - class: "hidden px-3 py-2 bg-gray-50 text-primary rounded-md text-sm font-medium", + class: "hidden px-3 py-2 bg-surface-inset text-primary rounded-md text-sm font-medium", data: { profile_image_preview_target: "input", action: "change->profile-image-preview#showFileInputPreview" diff --git a/app/views/settings/ai_prompts/show.html.erb b/app/views/settings/ai_prompts/show.html.erb index 6847b0481..60e11c9bb 100644 --- a/app/views/settings/ai_prompts/show.html.erb +++ b/app/views/settings/ai_prompts/show.html.erb @@ -24,7 +24,7 @@
-
+
<%= icon "message-circle" %>
@@ -52,7 +52,7 @@
-
+
<%= icon "brain" %>
@@ -80,7 +80,7 @@
-
+
<%= icon "store" %>
diff --git a/app/views/settings/api_keys/created.html.erb b/app/views/settings/api_keys/created.html.erb index 5e7eb833e..d0c4e7bca 100644 --- a/app/views/settings/api_keys/created.html.erb +++ b/app/views/settings/api_keys/created.html.erb @@ -1,6 +1,6 @@ -<%= content_for :page_title, "API Key Created" %> +<%= content_for :page_title, t(".page_title") %> -<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %> +<%= settings_section title: t(".success_title"), subtitle: t(".success_description") do %>
@@ -11,21 +11,21 @@ variant: :success ) %>
-

API Key Created Successfully!

-

Your new API key "<%= @api_key.name %>" has been created and is ready to use.

+

<%= t(".success_title") %>

+

<%= t(".key_ready", name: @api_key.name) %>

-

Your API Key

-

Copy and store this key securely. You'll need it to authenticate your API requests.

+

<%= t(".your_api_key") %>

+

<%= t(".copy_store_securely") %>

<%= @api_key.plain_key %> <%= render DS::Button.new( - text: "Copy API Key", + text: t(".copy_key"), variant: "ghost", icon: "copy", data: { action: "clipboard#copy" } @@ -35,49 +35,42 @@
-

Key Details

+

<%= t(".key_details_title") %>

- Name: + <%= t(".key_name_label") %> <%= @api_key.name %>
- Permissions: + <%= t(".permissions_label") %> <%= @api_key.scopes.map { |scope| case scope - when "read_accounts" then "View Accounts" - when "read_transactions" then "View Transactions" - when "read_balances" then "View Balances" - when "write_transactions" then "Create Transactions" + when "read_accounts" then t("settings.api_keys_controller.scope_descriptions.read_accounts") + when "read_transactions" then t("settings.api_keys_controller.scope_descriptions.read_transactions") + when "read_balances" then t("settings.api_keys_controller.scope_descriptions.read_balances") + when "write_transactions" then t("settings.api_keys_controller.scope_descriptions.write_transactions") else scope.humanize end }.join(", ") %>
- Created: + <%= t(".created_label") %> <%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %>
-
-
- <%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %> -
-

Important Security Note

-

- This is the only time your API key will be displayed. Make sure to copy it now and store it securely. - If you lose this key, you'll need to generate a new one. -

-
-
-
+ <%= render DS::Alert.new(title: t(".security_note_title"), variant: :warning) do %> +

+ <%= t(".security_note_body") %> +

+ <% end %>
-

How to use your API key

-

Include your API key in the X-Api-Key header when making requests:

+

<%= t(".usage_instructions_title") %>

+

<%= t("settings.api_keys.show.current_api_key.usage_instructions", product_name: product_name) %>

curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
@@ -85,7 +78,7 @@
<%= render DS::Link.new( - text: "Continue to API Key Settings", + text: t(".continue"), href: settings_api_key_path, variant: "primary" ) %> diff --git a/app/views/settings/api_keys/created.turbo_stream.erb b/app/views/settings/api_keys/created.turbo_stream.erb index 89dab090b..f187a6d31 100644 --- a/app/views/settings/api_keys/created.turbo_stream.erb +++ b/app/views/settings/api_keys/created.turbo_stream.erb @@ -2,10 +2,10 @@

- API Key Created + <%= t("settings.api_keys.created.page_title") %>

- <%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %> + <%= settings_section title: t("settings.api_keys.created.success_title"), subtitle: t("settings.api_keys.created.success_description") do %>
@@ -16,21 +16,21 @@ variant: :success ) %>
-

API Key Created Successfully!

-

Your new API key "<%= @api_key.name %>" has been created and is ready to use.

+

<%= t("settings.api_keys.created.success_title") %>

+

<%= t("settings.api_keys.created.key_ready", name: @api_key.name) %>

-

Your API Key

-

Copy and store this key securely. You'll need it to authenticate your API requests.

+

<%= t("settings.api_keys.created.your_api_key") %>

+

<%= t("settings.api_keys.created.copy_store_securely") %>

<%= @api_key.plain_key %> <%= render DS::Button.new( - text: "Copy API Key", + text: t("settings.api_keys.created.copy_key"), variant: "ghost", icon: "copy", data: { action: "clipboard#copy" } @@ -40,49 +40,42 @@
-

Key Details

+

<%= t("settings.api_keys.created.key_details_title") %>

- Name: + <%= t("settings.api_keys.created.key_name_label") %> <%= @api_key.name %>
- Permissions: + <%= t("settings.api_keys.created.permissions_label") %> <%= @api_key.scopes.map { |scope| case scope - when "read_accounts" then "View Accounts" - when "read_transactions" then "View Transactions" - when "read_balances" then "View Balances" - when "write_transactions" then "Create Transactions" + when "read_accounts" then t("settings.api_keys_controller.scope_descriptions.read_accounts") + when "read_transactions" then t("settings.api_keys_controller.scope_descriptions.read_transactions") + when "read_balances" then t("settings.api_keys_controller.scope_descriptions.read_balances") + when "write_transactions" then t("settings.api_keys_controller.scope_descriptions.write_transactions") else scope.humanize end }.join(", ") %>
- Created: + <%= t("settings.api_keys.created.created_label") %> <%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %>
-
-
- <%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %> -
-

Important Security Note

-

- This is the only time your API key will be displayed. Make sure to copy it now and store it securely. - If you lose this key, you'll need to generate a new one. -

-
-
-
+ <%= render DS::Alert.new(title: t("settings.api_keys.created.security_note_title"), variant: :warning) do %> +

+ <%= t("settings.api_keys.created.security_note_body") %> +

+ <% end %>
-

How to use your API key

-

Include your API key in the X-Api-Key header when making requests:

+

<%= t("settings.api_keys.created.usage_instructions_title") %>

+

<%= t("settings.api_keys.show.current_api_key.usage_instructions", product_name: product_name) %>

curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
@@ -90,7 +83,7 @@
<%= render DS::Link.new( - text: "Continue to API Key Settings", + text: t("settings.api_keys.created.continue"), href: settings_api_key_path, variant: "primary" ) %> diff --git a/app/views/settings/api_keys/new.html.erb b/app/views/settings/api_keys/new.html.erb index 20e322981..d37a24f70 100644 --- a/app/views/settings/api_keys/new.html.erb +++ b/app/views/settings/api_keys/new.html.erb @@ -1,20 +1,20 @@ -<%= content_for :page_title, "Create New API Key" %> +<%= content_for :page_title, t(".create_new_api_key") %> -<%= settings_section title: nil, subtitle: "Generate a new API key to access your Sure data programmatically." do %> +<%= settings_section title: nil, subtitle: t(".subtitle") do %> <%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %> <%= form.text_field :name, - placeholder: "e.g., My Budget App, Portfolio Tracker", - label: "API Key Name", - help_text: "Choose a descriptive name to help you identify this key later." %> + placeholder: t(".name_placeholder"), + label: t(".name_label"), + help_text: t(".name_help_text") %>
- <%= form.label :scopes, "Permissions", class: "block text-sm font-medium text-primary mb-2" %> -

Select the permissions this API key should have:

+ <%= form.label :scopes, t(".permissions_label"), class: "block text-sm font-medium text-primary mb-2" %> +

<%= t(".permissions_help") %>

<% [ - ["read", "Read Only", "View your accounts, transactions, and balances"], - ["read_write", "Read/Write", "View your data and create new transactions"] + ["read", t(".scope_read_only"), t(".scope_read_only_description")], + ["read_write", t(".scope_read_write"), t(".scope_read_write_description")] ].each do |value, label, description| %>
-
-
- <%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %> -
-

Security Warning

-

- Your API key will be displayed only once after creation. Make sure to copy and store it securely. - Anyone with access to this key can access your data according to the permissions you select. -

-
-
-
+ <%= render DS::Alert.new(title: t(".security_warning_title"), variant: :warning) do %> +

+ <%= t(".security_warning_body") %> +

+ <% end %>
<%= render DS::Link.new( - text: "Cancel", + text: t(".cancel"), href: settings_api_key_path, variant: "ghost" ) %> <%= render DS::Button.new( - text: "Save API Key", + text: t(".save_api_key"), variant: "primary", type: "submit" ) %> diff --git a/app/views/settings/api_keys/show.html.erb b/app/views/settings/api_keys/show.html.erb index 91e1bd322..c32e87349 100644 --- a/app/views/settings/api_keys/show.html.erb +++ b/app/views/settings/api_keys/show.html.erb @@ -1,5 +1,5 @@ <% if @newly_created && @plain_key %> - <%= content_for :page_title, "API Key Created Successfully" %> + <%= content_for :page_title, t(".newly_created.page_title") %>
@@ -12,21 +12,21 @@ variant: :success ) %>
-

API Key Created Successfully!

-

Your new API key "<%= @current_api_key.name %>" has been created and is ready to use.

+

<%= t(".newly_created.heading") %>

+

<%= t(".newly_created.key_ready", name: @current_api_key.name) %>

-

Your API Key

-

Copy and store this key securely. You'll need it to authenticate your API requests.

+

<%= t(".newly_created.your_api_key") %>

+

<%= t(".newly_created.copy_store_securely") %>

<%= @current_api_key.plain_key %> <%= render DS::Button.new( - text: "Copy API Key", + text: t(".newly_created.copy_api_key"), variant: "ghost", icon: "copy", data: { action: "clipboard#copy" } @@ -36,8 +36,8 @@
-

How to use your API key

-

Include your API key in the X-Api-Key header when making requests:

+

<%= t(".newly_created.how_to_use") %>

+

<%= t(".current_api_key.usage_instructions", product_name: product_name) %>

curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
@@ -45,7 +45,7 @@
<%= render DS::Link.new( - text: "Continue to API Key Settings", + text: t(".newly_created.continue"), href: settings_api_key_path, variant: "primary" ) %> @@ -53,10 +53,10 @@
<% elsif @current_api_key %> - <%= content_for :page_title, "Your API Key" %> + <%= content_for :page_title, t(".current_api_key.title") %> <%= content_for :page_actions do %> <%= render DS::Link.new( - text: "Create New Key", + text: t(".current_api_key.regenerate_key"), href: new_settings_api_key_path(regenerate: true), variant: "secondary" ) %> @@ -75,30 +75,30 @@

<%= @current_api_key.name %>

- Created <%= time_ago_in_words(@current_api_key.created_at) %> ago + <%= t(".current_api_key.created_ago", time: time_ago_in_words(@current_api_key.created_at)) %> <% if @current_api_key.last_used_at %> - • Last used <%= time_ago_in_words(@current_api_key.last_used_at) %> ago + • <%= t(".current_api_key.last_used_ago", time: time_ago_in_words(@current_api_key.last_used_at)) %> <% else %> - • Never used + • <%= t(".current_api_key.never_used") %> <% end %>

-

Active

+

<%= t(".current_api_key.active") %>

-

Permissions

+

<%= t(".current_api_key.permissions") %>

<% @current_api_key.scopes.each do |scope| %> - + <%= icon("shield-check", class: "w-3 h-3") %> <%= case scope - when "read" then "Read Only" - when "read_write" then "Read/Write" + when "read" then t(".current_api_key.scope_read_only") + when "read_write" then t(".current_api_key.scope_read_write") else scope.humanize end %> @@ -107,14 +107,14 @@
-

Your API Key

-

Copy and store this key securely. You'll need it to authenticate your API requests.

+

<%= t(".current_api_key.title") %>

+

<%= t(".current_api_key.copy_store_securely") %>

<%= @current_api_key.plain_key %> <%= render DS::Button.new( - text: "Copy API Key", + text: t(".current_api_key.copy_api_key"), variant: "ghost", icon: "copy", data: { action: "clipboard#copy" } @@ -124,8 +124,8 @@
-

How to use your API key

-

Include your API key in the X-Api-Key header when making requests:

+

<%= t(".current_api_key.usage_instructions_title") %>

+

<%= t(".current_api_key.usage_instructions", product_name: product_name) %>

curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
@@ -133,12 +133,12 @@
<%= render DS::Button.new( - text: "Revoke Key", + text: t(".current_api_key.revoke_key"), href: settings_api_key_path, method: :delete, variant: "destructive", data: { - turbo_confirm: "Are you sure you want to revoke this API key?" + turbo_confirm: t(".current_api_key.revoke_confirmation") } ) %>
diff --git a/app/views/settings/bank_sync/_provider_link.html.erb b/app/views/settings/bank_sync/_provider_link.html.erb deleted file mode 100644 index 32a2caf59..000000000 --- a/app/views/settings/bank_sync/_provider_link.html.erb +++ /dev/null @@ -1,32 +0,0 @@ -<%# locals: (provider_link:) %> - -<%# Assign distinct colors to each provider %> -<% provider_colors = { - "Lunch Flow" => "#6471eb", - "Plaid" => "#4da568", - "SimpleFin" => "#e99537", - "Enable Banking" => "#6471eb", - "CoinStats" => "#FF9332" # https://coinstats.app/press-kit/ -} %> -<% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %> - -<%= link_to provider_link[:path], - target: provider_link[:target], - rel: provider_link[:rel], - class: "flex justify-between items-center p-4 bg-container hover:bg-container-hover transition-colors" do %> -
- <%= render partial: "shared/color_avatar", locals: { name: provider_link[:name], color: provider_color } %> - -
-

- <%= provider_link[:name] %> -

-

- <%= provider_link[:description] %> -

-
-
-
- <%= icon("arrow-right", size: "sm", class: "text-secondary") %> -
-<% end %> diff --git a/app/views/settings/bank_sync/show.html.erb b/app/views/settings/bank_sync/show.html.erb deleted file mode 100644 index 51c42bfcb..000000000 --- a/app/views/settings/bank_sync/show.html.erb +++ /dev/null @@ -1,26 +0,0 @@ -<%= content_for :page_title, "Bank Sync" %> - -
- <% if @providers.any? %> -
-
-

PROVIDERS

- · -

<%= @providers.count %>

-
- -
-
- <%= render partial: "provider_link", collection: @providers, spacer_template: "shared/ruler" %> -
-
-
- <% else %> -
-
-

No providers configured

-

Configure providers to link your bank accounts.

-
-
- <% end %> -
diff --git a/app/views/settings/debugs/show.html.erb b/app/views/settings/debugs/show.html.erb new file mode 100644 index 000000000..d55c4ce46 --- /dev/null +++ b/app/views/settings/debugs/show.html.erb @@ -0,0 +1,115 @@ +<%= content_for :page_title, t(".page_title") %> + +
+
+

<%= t(".title") %>

+

<%= t(".subtitle") %>

+
+ +
+ <%= form_with url: settings_debug_path, method: :get, class: "grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 items-end" do |f| %> +
+ <%= f.label :category, t(".filters.category"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.select :category, options_for_select([[t(".filters.all"), ""]] + @categories.map { |value| [value, value] }, params[:category]), {}, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %> +
+
+ <%= f.label :level, t(".filters.level"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.select :level, options_for_select([[t(".filters.all"), ""]] + @levels.map { |value| [value, value] }, params[:level]), {}, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %> +
+
+ <%= f.label :source, t(".filters.source"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.select :source, options_for_select([[t(".filters.all"), ""]] + @sources.map { |value| [value, value] }, params[:source]), {}, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %> +
+
+ <%= f.label :provider_key, t(".filters.provider"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.select :provider_key, options_for_select([[t(".filters.all"), ""]] + @provider_keys.map { |value| [value, value] }, params[:provider_key]), {}, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %> +
+
+ <%= f.label :start_date, t(".filters.start_date"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.date_field :start_date, value: @start_date || params[:start_date], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %> +
+
+ <%= f.label :end_date, t(".filters.end_date"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.date_field :end_date, value: @end_date || params[:end_date], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %> +
+
+ <%= f.label :family_id, t(".filters.family_id"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.text_field :family_id, value: params[:family_id], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full font-mono" %> +
+
+ <%= f.label :account_id, t(".filters.account_id"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.text_field :account_id, value: params[:account_id], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full font-mono" %> +
+
+ <%= f.label :user_id, t(".filters.user_id"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.text_field :user_id, value: params[:user_id], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full font-mono" %> +
+
+ <%= f.label :account_provider_id, t(".filters.account_provider_id"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.text_field :account_provider_id, value: params[:account_provider_id], class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full font-mono" %> +
+
+ <%= render DS::Button.new(variant: :primary, size: :md, type: "submit", text: t(".filters.submit"), class: "justify-center") %> + <%= render DS::Link.new(text: t(".filters.reset"), href: settings_debug_path, variant: "ghost") %> +
+ <% end %> +
+ +
+ <% if @debug_log_entries.any? %> +
+ + + + + + + + + + + + + + <% @debug_log_entries.each do |entry| %> + + + + + + + + + + <% end %> + +
<%= t(".table.time") %><%= t(".table.level") %><%= t(".table.category") %><%= t(".table.source") %><%= t(".table.message") %><%= t(".table.context") %><%= t(".table.metadata") %>
<%= l(entry.created_at, format: :long) %><%= entry.level %><%= entry.category %><%= entry.source %><%= entry.message %> +
<%= t(".context.provider", value: entry.provider_key.presence || t(".missing_value")) %>
+
<%= t(".context.family", value: entry.family_id || t(".missing_value")) %>
+
<%= t(".context.account", value: entry.account_id || t(".missing_value")) %>
+
<%= t(".context.user", value: entry.user_id || t(".missing_value")) %>
+
<%= t(".context.account_provider", value: entry.account_provider_id || t(".missing_value")) %>
+
+ <% if entry.metadata.present? %> + <%= render DS::Disclosure.new(variant: :inline) do |disclosure| %> + <% disclosure.with_summary_content do %> + <%= t(".table.view_metadata") %> + <% end %> +
<%= JSON.pretty_generate(entry.metadata) %>
+ <% end %> + <% else %> + <%= t(".missing_value") %> + <% end %> +
+
+ <% else %> +
<%= t(".empty") %>
+ <% end %> +
+ + <% if @pagy.pages > 1 %> +
+ <%= render "shared/pagination", pagy: @pagy %> +
+ <% end %> +
diff --git a/app/views/settings/hostings/_alpha_vantage_settings.html.erb b/app/views/settings/hostings/_alpha_vantage_settings.html.erb new file mode 100644 index 000000000..8a9249541 --- /dev/null +++ b/app/views/settings/hostings/_alpha_vantage_settings.html.erb @@ -0,0 +1,41 @@ +
+
+

<%= t(".title") %>

+ <% if ENV["ALPHA_VANTAGE_API_KEY"].present? %> +

<%= t(".env_configured_message") %>

+ <% else %> +
+ <%= t(".description") %> +
+ <%= t(".show_details") %> +
    +
  1. <%= t(".step_1_html") %>
  2. +
  3. <%= t(".step_2") %>
  4. +
+
+
+ <% end %> +
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "blur" + } do |form| %> + <% has_key = ENV["ALPHA_VANTAGE_API_KEY"].present? || Setting.alpha_vantage_api_key.present? %> + <%= form.text_field :alpha_vantage_api_key, + label: t(".label"), + type: "password", + placeholder: t(".placeholder"), + value: has_key ? "********" : "", + disabled: ENV["ALPHA_VANTAGE_API_KEY"].present?, + data: { "auto-submit-form-target": "auto" } %> + <% end %> + + <%= render DS::Alert.new(variant: :warning) do %> +

<%= t(".rate_limit_warning") %>

+

<%= t(".no_health_check_note") %>

+ <% end %> +
diff --git a/app/views/settings/hostings/_assistant_settings.html.erb b/app/views/settings/hostings/_assistant_settings.html.erb index 082fddde7..37e5bbc65 100644 --- a/app/views/settings/hostings/_assistant_settings.html.erb +++ b/app/views/settings/hostings/_assistant_settings.html.erb @@ -54,12 +54,12 @@ <%= button_to t(".disconnect_button"), disconnect_external_assistant_settings_hosting_path, method: :delete, - class: "bg-red-600 fg-inverse text-sm font-medium rounded-lg px-4 py-2 whitespace-nowrap", + class: "bg-red-600 text-inverse text-sm font-medium rounded-lg px-4 py-2 whitespace-nowrap", data: { turbo_confirm: { title: t(".confirm_disconnect.title"), body: t(".confirm_disconnect.body"), accept: t(".disconnect_button"), - acceptClass: "w-full bg-red-600 fg-inverse rounded-xl text-center p-[10px] border mb-2" + acceptClass: "w-full bg-red-600 text-inverse rounded-xl text-center p-[10px] border mb-2" }} %>
<% end %> diff --git a/app/views/settings/hostings/_brand_fetch_settings.html.erb b/app/views/settings/hostings/_brand_fetch_settings.html.erb index ea83248e4..03c52701f 100644 --- a/app/views/settings/hostings/_brand_fetch_settings.html.erb +++ b/app/views/settings/hostings/_brand_fetch_settings.html.erb @@ -2,21 +2,21 @@

<%= t(".title") %>

<% if ENV["BRAND_FETCH_CLIENT_ID"].present? %> -

You have successfully configured your Brand Fetch Client ID through the BRAND_FETCH_CLIENT_ID environment variable.

+

<%= t(".env_configured_message") %>

<% else %>
<%= t(".description") %>
- (show details) + <%= t(".show_details") %>
  1. - Visit brandfetch.com and create a free Brand Fetch Developer account. + <%= t(".setup_step_1_html") %>
  2. - Go to the Logo API page. + <%= t(".setup_step_2_html") %>
  3. - Tap the eye icon under the "Your Client ID" section to reveal your Client ID and paste it below. + <%= t(".setup_step_3") %>
diff --git a/app/views/settings/hostings/_danger_zone_settings.html.erb b/app/views/settings/hostings/_danger_zone_settings.html.erb index 18cfd4d03..23e26644e 100644 --- a/app/views/settings/hostings/_danger_zone_settings.html.erb +++ b/app/views/settings/hostings/_danger_zone_settings.html.erb @@ -7,12 +7,12 @@
<%= button_to t("settings.hostings.show.clear_cache"), clear_cache_settings_hosting_path, method: :delete, - class: "w-full md:w-auto bg-yellow-600 fg-inverse text-sm font-medium rounded-lg px-4 py-2", + class: "w-full md:w-auto bg-yellow-600 text-inverse text-sm font-medium rounded-lg px-4 py-2", data: { turbo_confirm: { title: t("settings.hostings.show.confirm_clear_cache.title"), body: t("settings.hostings.show.confirm_clear_cache.body"), accept: t("settings.hostings.show.clear_cache"), - acceptClass: "w-full bg-yellow-600 fg-inverse rounded-xl text-center p-[10px] border mb-2" + acceptClass: "w-full bg-yellow-600 text-inverse rounded-xl text-center p-[10px] border mb-2" }} %>
diff --git a/app/views/settings/hostings/_eodhd_settings.html.erb b/app/views/settings/hostings/_eodhd_settings.html.erb new file mode 100644 index 000000000..0636da678 --- /dev/null +++ b/app/views/settings/hostings/_eodhd_settings.html.erb @@ -0,0 +1,39 @@ +
+
+

<%= t(".title") %>

+ <% if ENV["EODHD_API_KEY"].present? %> +

<%= t(".env_configured_message") %>

+ <% else %> +
+ <%= t(".description") %> +
+ <%= t(".show_details") %> +
    +
  1. <%= t(".step_1_html") %>
  2. +
  3. <%= t(".step_2_html") %>
  4. +
  5. <%= t(".step_3") %>
  6. +
+
+
+ <% end %> +
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "blur" + } do |form| %> + <% has_key = ENV["EODHD_API_KEY"].present? || Setting.eodhd_api_key.present? %> + <%= form.text_field :eodhd_api_key, + label: t(".label"), + type: "password", + placeholder: t(".placeholder"), + value: has_key ? "********" : "", + disabled: ENV["EODHD_API_KEY"].present?, + data: { "auto-submit-form-target": "auto" } %> + <% end %> + + <%= render DS::Alert.new(message: t(".rate_limit_warning"), variant: :warning) %> +
diff --git a/app/views/settings/hostings/_openai_settings.html.erb b/app/views/settings/hostings/_openai_settings.html.erb index 6691d9556..b54dd7671 100644 --- a/app/views/settings/hostings/_openai_settings.html.erb +++ b/app/views/settings/hostings/_openai_settings.html.erb @@ -63,5 +63,37 @@ { disabled: ENV["LLM_JSON_MODE"].present?, data: { "auto-submit-form-target": "auto" } } %>

<%= t(".json_mode_help") %>

+ +
+

<%= t(".budget_heading") %>

+

<%= t(".budget_description") %>

+ + <%= form.number_field :llm_context_window, + label: t(".context_window_label"), + placeholder: "2048", + value: Setting.llm_context_window, + min: 256, + disabled: ENV["LLM_CONTEXT_WINDOW"].present?, + data: { "auto-submit-form-target": "auto" } %> +

<%= t(".context_window_help") %>

+ + <%= form.number_field :llm_max_response_tokens, + label: t(".max_response_tokens_label"), + placeholder: "512", + value: Setting.llm_max_response_tokens, + min: 64, + disabled: ENV["LLM_MAX_RESPONSE_TOKENS"].present?, + data: { "auto-submit-form-target": "auto" } %> +

<%= t(".max_response_tokens_help") %>

+ + <%= form.number_field :llm_max_items_per_call, + label: t(".max_items_per_call_label"), + placeholder: "25", + value: Setting.llm_max_items_per_call, + min: 1, + disabled: ENV["LLM_MAX_ITEMS_PER_CALL"].present?, + data: { "auto-submit-form-target": "auto" } %> +

<%= t(".max_items_per_call_help") %>

+
<% end %>
diff --git a/app/views/settings/hostings/_provider_selection.html.erb b/app/views/settings/hostings/_provider_selection.html.erb index 55dde1e88..4e38bb38f 100644 --- a/app/views/settings/hostings/_provider_selection.html.erb +++ b/app/views/settings/hostings/_provider_selection.html.erb @@ -1,17 +1,16 @@ -
-
-

<%= t(".title") %>

-

<%= t(".description") %>

-
+
+ <%# Exchange Rate Provider - single dropdown %> +
+

<%= t(".exchange_rate_title") %>

+

<%= t(".exchange_rate_description") %>

- <%= styled_form_with model: Setting.new, - url: settings_hosting_path, - method: :patch, - data: { - controller: "auto-submit-form", - "auto-submit-form-trigger-event-value": "change" - } do |form| %> -
+ <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "change" + } do |form| %> <%= form.select :exchange_rate_provider, [ [t(".providers.twelve_data"), "twelve_data"], @@ -23,29 +22,55 @@ disabled: ENV["EXCHANGE_RATE_PROVIDER"].present?, data: { "auto-submit-form-target": "auto" } } %> + <% end %> +
- <%= form.select :securities_provider, - [ - [t(".providers.twelve_data"), "twelve_data"], - [t(".providers.yahoo_finance"), "yahoo_finance"] - ], - { label: t(".securities_provider_label") }, - { - value: ENV.fetch("SECURITIES_PROVIDER", Setting.securities_provider), - disabled: ENV["SECURITIES_PROVIDER"].present?, - data: { "auto-submit-form-target": "auto" } - } %> -
+ <%# Securities Providers - multiple checkboxes %> +
+

<%= t(".securities_title") %>

+

<%= t(".securities_description") %>

- <% if ENV["EXCHANGE_RATE_PROVIDER"].present? || ENV["SECURITIES_PROVIDER"].present? %> -
-
- <%= icon("alert-circle", class: "w-5 h-5 text-warning-600 mt-0.5 shrink-0") %> -

- <%= t(".env_configured_message") %> -

-
+ <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { + controller: "auto-submit-form" + } do |form| %> + <% disabled = ENV["SECURITIES_PROVIDERS"].present? || ENV["SECURITIES_PROVIDER"].present? %> + <% enabled_providers = Setting.enabled_securities_providers %> + +
+ <%# Hidden field to ensure empty array is submitted when all unchecked %> + + + <% [ + ["twelve_data", t(".providers.twelve_data"), t(".twelve_data_hint")], + ["yahoo_finance", t(".providers.yahoo_finance"), t(".yahoo_finance_hint")], + ["tiingo", t(".providers.tiingo"), t(".requires_api_key")], + ["eodhd", t(".providers.eodhd"), t(".requires_api_key_eodhd")], + ["alpha_vantage", t(".providers.alpha_vantage"), t(".requires_api_key_alpha_vantage")], + ["mfapi", t(".providers.mfapi"), t(".mfapi_hint")], + ["binance_public", t(".providers.binance_public"), t(".binance_public_hint")], + ].each do |value, label, hint| %> + + <% end %>
<% end %> +
+ + <% if ENV["EXCHANGE_RATE_PROVIDER"].present? || ENV["SECURITIES_PROVIDERS"].present? || ENV["SECURITIES_PROVIDER"].present? %> + <%= render DS::Alert.new(message: t(".env_configured_message"), variant: :warning) %> <% end %>
diff --git a/app/views/settings/hostings/_tiingo_settings.html.erb b/app/views/settings/hostings/_tiingo_settings.html.erb new file mode 100644 index 000000000..af2cf0ff8 --- /dev/null +++ b/app/views/settings/hostings/_tiingo_settings.html.erb @@ -0,0 +1,37 @@ +
+
+

<%= t(".title") %>

+ <% if ENV["TIINGO_API_KEY"].present? %> +

<%= t(".env_configured_message") %>

+ <% else %> +
+ <%= t(".description") %> +
+ <%= t(".show_details") %> +
    +
  1. <%= t(".step_1_html") %>
  2. +
  3. <%= t(".step_2_html") %>
  4. +
  5. <%= t(".step_3") %>
  6. +
+
+
+ <% end %> +
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "blur" + } do |form| %> + <% has_key = ENV["TIINGO_API_KEY"].present? || Setting.tiingo_api_key.present? %> + <%= form.text_field :tiingo_api_key, + label: t(".label"), + type: "password", + placeholder: t(".placeholder"), + value: has_key ? "********" : "", + disabled: ENV["TIINGO_API_KEY"].present?, + data: { "auto-submit-form-target": "auto" } %> + <% end %> +
diff --git a/app/views/settings/hostings/_twelve_data_settings.html.erb b/app/views/settings/hostings/_twelve_data_settings.html.erb index b6275491a..9dbb77289 100644 --- a/app/views/settings/hostings/_twelve_data_settings.html.erb +++ b/app/views/settings/hostings/_twelve_data_settings.html.erb @@ -7,17 +7,11 @@
<%= t(".description") %>
- (show details) + <%= t(".show_details") %>
    -
  1. - Visit twelvedata.com and create a free Twelve Data Developer account. -
  2. -
  3. - Go to the API Keys page. -
  4. -
  5. - Reveal your Secret Key and paste it below. -
  6. +
  7. <%= t(".step_1_html") %>
  8. +
  9. <%= t(".step_2_html") %>
  10. +
  11. <%= t(".step_3") %>
@@ -31,11 +25,12 @@ controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "blur" } do |form| %> + <% has_key = ENV["TWELVE_DATA_API_KEY"].present? || Setting.twelve_data_api_key.present? %> <%= form.text_field :twelve_data_api_key, label: t(".label"), type: "password", placeholder: t(".placeholder"), - value: ENV.fetch("TWELVE_DATA_API_KEY", Setting.twelve_data_api_key), + value: has_key ? "********" : "", disabled: ENV["TWELVE_DATA_API_KEY"].present?, container_class: @twelve_data_usage.present? && !@twelve_data_usage.success? ? "border-red-500" : "", data: { "auto-submit-form-target": "auto" } %> @@ -50,42 +45,38 @@ limit: number_with_delimiter(@twelve_data_usage.data.limit), percentage: number_to_percentage(@twelve_data_usage.data.utilization, precision: 1)) %>

-
+
-
+

<%= t(".plan", plan: @twelve_data_usage.data.plan) %>

<% if @plan_restricted_securities.present? %> -
-
- <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5") %> -
-

<%= t(".plan_upgrade_warning_title") %>

-

<%= t(".plan_upgrade_warning_description") %>

-
    - <% @plan_restricted_securities.each do |security| %> -
  • - <%= security[:ticker] %> - <% if security[:name].present? %> - (<%= security[:name] %>) - <% end %> - — <%= t(".requires_plan", plan: security[:required_plan]) %> -
  • - <% end %> -
-

- - <%= t(".view_pricing") %> - -

-
-
+
+ <%= render DS::Alert.new(title: t(".plan_upgrade_warning_title"), variant: :warning) do %> +

<%= t(".plan_upgrade_warning_description") %>

+
    + <% @plan_restricted_securities.each do |security| %> +
  • + <%= security[:ticker] %> + <% if security[:name].present? %> + (<%= security[:name] %>) + <% end %> + — <%= t(".requires_plan", plan: security[:required_plan]) %> +
  • + <% end %> +
+

+ + <%= t(".view_pricing") %> + +

+ <% end %>
<% end %>
diff --git a/app/views/settings/hostings/_yahoo_finance_settings.html.erb b/app/views/settings/hostings/_yahoo_finance_settings.html.erb index 33676f547..1ed8044b6 100644 --- a/app/views/settings/hostings/_yahoo_finance_settings.html.erb +++ b/app/views/settings/hostings/_yahoo_finance_settings.html.erb @@ -20,19 +20,11 @@ <%= t(".status_inactive") %>

-
-
- <%= icon("alert-circle", class: "w-5 h-5 text-destructive-600 mt-0.5 shrink-0") %> -
-

- <%= t(".connection_failed") %> -

-
-

<%= t(".troubleshooting") %>

-
-
-
-
+ <%= render DS::Alert.new( + title: t(".connection_failed"), + message: t(".troubleshooting"), + variant: :warning + ) %>
<% end %>
diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index 354cf86a4..6312f900e 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -17,6 +17,15 @@ <% if @show_twelve_data_settings %> <%= render "settings/hostings/twelve_data_settings" %> <% end %> + <% if @show_tiingo_settings %> + <%= render "settings/hostings/tiingo_settings" %> + <% end %> + <% if @show_eodhd_settings %> + <%= render "settings/hostings/eodhd_settings" %> + <% end %> + <% if @show_alpha_vantage_settings %> + <%= render "settings/hostings/alpha_vantage_settings" %> + <% end %>
<% end %> <%= settings_section title: t(".sync_settings") do %> diff --git a/app/views/settings/llm_usages/show.html.erb b/app/views/settings/llm_usages/show.html.erb index c2d4660af..39929a55b 100644 --- a/app/views/settings/llm_usages/show.html.erb +++ b/app/views/settings/llm_usages/show.html.erb @@ -1,22 +1,22 @@ -<%= content_for :page_title, "LLM Usage & Costs" %> +<%= content_for :page_title, t(".page_title") %>
-

Track your AI usage and estimated costs

+

<%= t(".subtitle") %>

<%= form_with url: settings_llm_usage_path, method: :get, class: "flex gap-4 items-end flex-wrap" do |f| %>
- <%= f.label :start_date, "Start Date", class: "block text-sm font-medium text-primary mb-1" %> + <%= f.label :start_date, t(".start_date"), class: "block text-sm font-medium text-primary mb-1" %> <%= f.date_field :start_date, value: @start_date, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %>
- <%= f.label :end_date, "End Date", class: "block text-sm font-medium text-primary mb-1" %> + <%= f.label :end_date, t(".end_date"), class: "block text-sm font-medium text-primary mb-1" %> <%= f.date_field :end_date, value: @end_date, class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %>
- <%= render DS::Button.new(variant: :primary, size: :md, type: "submit", text: "Filter", class: "md:w-auto w-full justify-center") %> + <%= render DS::Button.new(variant: :primary, size: :md, type: "submit", text: t(".filter"), class: "md:w-auto w-full justify-center") %> <% end %>
@@ -25,7 +25,7 @@
<%= icon "activity", class: "w-5 h-5 text-secondary" %> -

Total Requests

+

<%= t(".total_requests") %>

<%= number_with_delimiter(@statistics[:total_requests]) %>

@@ -33,19 +33,19 @@
<%= icon "hash", class: "w-5 h-5 text-secondary" %> -

Total Tokens

+

<%= t(".total_tokens") %>

<%= number_with_delimiter(@statistics[:total_tokens]) %>

- <%= number_with_delimiter(@statistics[:total_prompt_tokens]) %> prompt / - <%= number_with_delimiter(@statistics[:total_completion_tokens]) %> completion + <%= number_with_delimiter(@statistics[:total_prompt_tokens]) %> <%= t(".prompt") %> / + <%= number_with_delimiter(@statistics[:total_completion_tokens]) %> <%= t(".completion") %>

<%= icon "dollar-sign", class: "w-5 h-5 text-secondary" %> -

Total Cost

+

<%= t(".total_cost") %>

$<%= sprintf("%.2f", @statistics[:total_cost]) %>

@@ -53,15 +53,14 @@
<%= icon "trending-up", class: "w-5 h-5 text-secondary" %> -

Avg Cost/Request

+

<%= t(".avg_cost_per_request") %>

$<%= sprintf("%.4f", @statistics[:avg_cost]) %>

<% if @statistics[:requests_with_cost] < @statistics[:total_requests] %>

- Based on <%= number_with_delimiter(@statistics[:requests_with_cost]) %> of - <%= number_with_delimiter(@statistics[:total_requests]) %> requests with cost data + <%= t(".based_on_requests", with_cost: number_with_delimiter(@statistics[:requests_with_cost]), total: number_with_delimiter(@statistics[:total_requests])) %>

<% end %>
@@ -70,7 +69,7 @@ <% if @statistics[:by_operation].any? %>
-

Cost by Operation

+

<%= t(".cost_by_operation") %>

<% @statistics[:by_operation].each do |operation, cost| %> @@ -87,7 +86,7 @@ <% if @statistics[:by_model].any? %>
-

Cost by Model

+

<%= t(".cost_by_model") %>

<% @statistics[:by_model].each do |model, cost| %> @@ -103,17 +102,17 @@
-

Recent Usage

+

<%= t(".recent_usage") %>

<% if @llm_usages.any? %> - - - - - + + + + + @@ -133,12 +132,12 @@
<%= icon "alert-circle", class: "w-4 h-4 text-red-600 theme-dark:text-red-400" %> - Failed + <%= t(".failed") %>
-
DateOperationModelTokensCost<%= t(".col_date") %><%= t(".col_operation") %><%= t(".col_model") %><%= t(".col_tokens") %><%= t(".col_cost") %>
<% else %>
-

No usage data found for the selected period

+

<%= t(".no_usage_data") %>

<% end %>
- -
-
- <%= icon "info", class: "w-5 h-5 text-blue-600 mt-0.5" %> -
-

About Cost Estimates

-

- Costs are estimated based on OpenAI's pricing as of 2025. Actual costs may vary. - Pricing is per 1 million tokens and varies by model. - Custom or self-hosted models will show "N/A" and are not included in cost totals. -

-
-
+
+ <%= render DS::Alert.new( + title: t(".cost_estimates_title"), + message: t(".cost_estimates_description"), + variant: :info + ) %>
diff --git a/app/views/settings/payments/show.html.erb b/app/views/settings/payments/show.html.erb index ac0184cea..0768440cd 100644 --- a/app/views/settings/payments/show.html.erb +++ b/app/views/settings/payments/show.html.erb @@ -13,7 +13,7 @@
<% if @family.has_active_subscription? %>

- Currently on the <%= @family.subscription.name %>.
+ <%= t(".currently_on_plan") %> <%= @family.subscription.name %>.
<% if @family.next_payment_date %> <% if @family.subscription_pending_cancellation? %> @@ -25,21 +25,21 @@

<% elsif @family.trialing? %>

- Currently using the open demo of <%= product_name %>
+ <%= t(".trialing", product_name: product_name) %>
- (Data will be deleted in <%= @family.days_left_in_trial %> days) + (<%= t(".trial_days_left", count: @family.days_left_in_trial) %>)

<% else %> -

You are currently not contributing

-

Contributions to <%= product_name %> will show here.

+

<%= t(".not_contributing_prefix") %> <%= t(".not_contributing_emphasis") %>

+

<%= t(".contributions_note", product_name: product_name) %>

<% end %>
<% if @family.has_active_subscription? %> <%= render DS::Link.new( - text: "Manage", + text: t(".manage"), icon: "external-link", variant: "primary", icon_position: "right", @@ -48,7 +48,7 @@ ) %> <% else %> <%= render DS::Link.new( - text: "Choose level", + text: t(".choose_level"), variant: "primary", icon: "plus", icon_position: "right", @@ -59,7 +59,7 @@
<%= image_tag "stripe-logo.svg", class: "w-5 h-5 shrink-0" %> -

Payment via Stripe

+

<%= t(".payment_via_stripe") %>

<% end %> diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 22faaf010..80b767bd1 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -1,63 +1,189 @@ <%= content_for :page_title, t(".page_title") %> - <%= settings_section title: t(".general_title"), subtitle: t(".general_subtitle") do %>
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> <%= form.hidden_field :redirect_to, value: "preferences" %> - <%= form.select :locale, language_options, { label: t(".language"), include_blank: t(".language_auto") }, { data: { auto_submit_form_target: "auto" } } %> - <%= form.fields_for :family do |family_form| %> - <%= family_form.select :currency, - Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, - { label: t(".currency") }, disabled: true %> - <%= family_form.select :timezone, timezone_options, { label: t(".timezone") }, { data: { auto_submit_form_target: "auto" } } %> - <%= family_form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format") }, { data: { auto_submit_form_target: "auto" } } %> - - <%= form.select :default_period, - Period.all.map { |period| [ period.label, period.key ] }, - { label: t(".default_period") }, - { data: { auto_submit_form_target: "auto" } } %> - <%= form.select :default_account_order, AccountOrder.all.map { |order| [ order.label, order.key ] }, { label: t(".default_account_order") }, { data: { auto_submit_form_target: "auto" } } %> - <%= family_form.select :country, country_options, { label: t(".country") }, { data: { auto_submit_form_target: "auto" } } %> - <%= family_form.select :month_start_day, - (1..28).map { |day| [day.ordinalize, day] }, + (1..28).map { |day| [localized_ordinal(day), day] }, { label: t(".month_start_day"), hint: t(".month_start_day_hint") }, { data: { auto_submit_form_target: "auto" } } %> - <% if @user.family.uses_custom_month_start? %>
<%= t(".month_start_day_warning") %>
<% end %> - -

Please note, we are still working on translations for various languages.

+

<%= t(".translations_notice") %>

<% end %> <% end %>
<% end %> - <% if Current.user.admin? %> + <%= settings_section title: t(".currencies_title", moniker: family_moniker), subtitle: t(".currencies_subtitle", moniker: family_moniker_downcase) do %> + <% selected_count_translations = t(".selected_currencies_count") %> +
+ <% additional_preview_currencies = @user.family.secondary_enabled_currency_objects.first(6) %> + <% additional_currency_count = @user.family.secondary_enabled_currency_objects.count %> +
+
+
+

<%= t(".base_currency_label") %>

+
+

<%= @user.family.primary_currency_code %>

+

<%= currency_label(@user.family.primary_currency_code) %>

+
+
+
+

<%= t(".additional_currencies_label") %>

+ <% if additional_preview_currencies.any? %> +
+ <% additional_preview_currencies.each do |currency| %> + + <%= currency.iso_code %> + + <% end %> + <% if additional_currency_count > additional_preview_currencies.count %> + + <%= t(".currencies_more", count: additional_currency_count - additional_preview_currencies.count) %> + + <% end %> +
+ <% else %> +

<%= t(".no_additional_currencies") %>

+ <% end %> +
+
+
+
+ <%= render DS::Button.new( + text: t(".manage_currencies"), + type: :button, + class: "md:w-auto w-full justify-center", + data: { action: "currency-preferences#open" } + ) %> +
+ <%= render DS::Dialog.new( + id: "currency-preferences-dialog", + auto_open: false, + disable_frame: true, + width: "md", + data: { currency_preferences_target: "dialog" } + ) do |dialog| %> + <% dialog.with_header(title: t(".manage_currencies"), subtitle: t(".manage_currencies_subtitle")) %> + <% dialog.with_body do %> + <% primary_currency_code = @user.family.primary_currency_code %> + <% selected_currency_codes = @user.family.enabled_currency_codes %> + <% base_currency_rows, other_currency_rows = Money::Currency.as_options.partition { |currency| currency.iso_code == primary_currency_code } %> + <% currency_rows = base_currency_rows + other_currency_rows %> + <%= styled_form_with model: @user, url: user_path(@user), class: "space-y-4", data: { action: "turbo:submit-end->currency-preferences#handleSubmitEnd" } do |form| %> + <%= form.hidden_field :redirect_to, value: "preferences" %> + <%= hidden_field_tag "user[family_attributes][id]", @user.family.id %> + <%= hidden_field_tag "user[family_attributes][enabled_currencies][]", primary_currency_code, id: nil %> +
+
+ <%= render DS::Button.new( + text: t(".select_all_currencies"), + type: :button, + variant: :ghost, + size: :sm, + data: { action: "currency-preferences#selectAll" } + ) %> + <%= render DS::Button.new( + text: t(".select_base_only"), + type: :button, + variant: :ghost, + size: :sm, + data: { action: "currency-preferences#selectBaseOnly" } + ) %> +
+

+
+
+ <%= render DS::SearchInput.new( + placeholder: t(".currency_search_placeholder"), + data: { + list_filter_target: "input", + action: "input->list-filter#filter" + } + ) %> +
+ + <% currency_rows.each do |currency| %> + <% checked = selected_currency_codes.include?(currency.iso_code) %> + <% base_currency = currency.iso_code == primary_currency_code %> + + <% end %> +
+
+
+ <%= render DS::Button.new(text: t("shared.cancel"), type: :button, variant: :ghost, data: { action: "DS--dialog#close" }) %> + <%= render DS::Button.new(text: t(".save_currencies"), type: :submit) %> +
+ <% end %> + <% end %> + <% end %> +
+ <% end %> <%= settings_section title: t(".sharing_title", moniker: family_moniker), subtitle: t(".sharing_subtitle", moniker: family_moniker_downcase) do %>
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> @@ -75,3 +201,29 @@
<% end %> <% end %> + +<%# Preview features toggle — visible to all users, not just admins. Lives at + the bottom of Preferences as a standalone card (no section header) so the + toggle row is the entire surface. Posts directly to + settings#preferences#update via the Settings::PreferencesController, + matching the auto-submit pattern used on the Appearance page. %> +
+ <%= form_with url: settings_preferences_path, method: :patch, + data: { controller: "auto-submit-form" } do |f| %> + <%# Wrapping the row in
diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index c2f2dc158..5c431449d 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -8,7 +8,7 @@ <%= form.email_field :email, placeholder: t(".email"), label: t(".email") %> <% if @user.unconfirmed_email.present? %> -

+

You have requested to change your email to <%= @user.unconfirmed_email %>. Please go to your email and confirm for the change to take effect. If you haven't received the email, please check your spam folder, or <%= link_to "request a new confirmation email", resend_confirmation_email_user_path(@user), class: "hover:underline text-secondary" %>.

<% end %> @@ -19,19 +19,19 @@
- <%= render DS::Button.new(text: t(".save"), class: "md:w-auto w-full justify-center") %> + <%= render DS::Button.new(text: t(".save"), type: :submit, class: "md:w-auto w-full justify-center") %>
<% end %> <% end %> <% unless Current.user.ui_layout_intro? %> - <%= settings_section title: family_moniker == "Group" ? t(".group_title", default: "Group") : t(".household_title"), subtitle: t(".household_subtitle", moniker_plural: family_moniker_plural_downcase, moniker: family_moniker_downcase) do %> + <%= settings_section title: Current.family&.moniker == "Group" ? t(".group_title", default: "Group") : t(".household_title"), subtitle: t(".household_subtitle", moniker_plural: family_moniker_plural_downcase, moniker: family_moniker_downcase) do %>
<%= styled_form_with model: Current.user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> <%= form.fields_for :family do |family_fields| %> - <% name_label = family_moniker == "Group" ? t(".group_form_label", default: "Group name") : t(".household_form_label") %> - <% name_placeholder = family_moniker == "Group" ? t(".group_form_input_placeholder", default: "Enter group name") : t(".household_form_input_placeholder") %> + <% name_label = Current.family&.moniker == "Group" ? t(".group_form_label", default: "Group name") : t(".household_form_label") %> + <% name_placeholder = Current.family&.moniker == "Group" ? t(".group_form_input_placeholder", default: "Enter group name") : t(".household_form_input_placeholder") %> <%= family_fields.text_field :name, placeholder: name_placeholder, label: name_label, @@ -50,7 +50,7 @@

<%= user.display_name %>

-

<%= user.role %>

+

<%= t("users.roles.#{user.role}", default: user.role.humanize) %>

<% if Current.user.admin? && user != Current.user %>
@@ -70,7 +70,7 @@
-
<%= invitation.email[0] %>
+
<%= invitation.email[0] %>

<%= invitation.email %>

@@ -88,8 +88,8 @@ readonly autocomplete="off" value="<%= accept_invitation_url(invitation.token) %>" - class="text-sm bg-gray-50 px-2 py-1 rounded border border-secondary w-72"> - + <% end %> +
+
diff --git a/app/views/settings/providers/_setup_steps.html.erb b/app/views/settings/providers/_setup_steps.html.erb new file mode 100644 index 000000000..e3537b3bb --- /dev/null +++ b/app/views/settings/providers/_setup_steps.html.erb @@ -0,0 +1,26 @@ +<%# locals: (steps:, help: nil, eyebrow: nil) %> +<%# steps: array of strings (or html_safe strings; caller is responsible for safety). + help: optional hash { url:, text: } rendered under the steps with a small divider + book icon. + eyebrow: optional override for the localized "SETUP" eyebrow label. %> +
+

+ <%= eyebrow.presence || t("settings.providers.setup_steps.eyebrow") %> +

+
    + <% steps.each_with_index do |step, i| %> +
  1. + <%= i + 1 %> + <%= step %> +
  2. + <% end %> +
+ <% if help %> +
+ <%= icon "book-open", size: "sm", class: "!w-3 !h-3" %> + + <%= t("settings.providers.setup_steps.need_help") %> + <%= link_to help[:text], help[:url], class: "text-primary font-medium", target: "_blank", rel: "noopener noreferrer" %> + +
+ <% end %> +
diff --git a/app/views/settings/providers/_simplefin_panel.html.erb b/app/views/settings/providers/_simplefin_panel.html.erb index 1e50a611c..f5125a2a1 100644 --- a/app/views/settings/providers/_simplefin_panel.html.erb +++ b/app/views/settings/providers/_simplefin_panel.html.erb @@ -1,22 +1,16 @@
-
-

Setup instructions:

-
    -
  1. Visit SimpleFIN Bridge to get your one-time setup token
  2. -
  3. Paste the token below and click the Save button to enable SimpleFIN bank data sync
  4. -
  5. After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones
  6. -
- -

Field descriptions:

-
    -
  • Setup Token: Your SimpleFIN one-time setup token from SimpleFIN Bridge (consumed on first use)
  • -
-
+ <% + sf_link = link_to("SimpleFIN Bridge", "https://beta-bridge.simplefin.org", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + %> + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.simplefin_panel.step_1_html", link: sf_link), + t("settings.providers.simplefin_panel.step_2"), + t("settings.providers.simplefin_panel.step_3") + ] %> <% if defined?(@error_message) && @error_message.present? %> -
-

<%= @error_message %>

-
+ <%= render DS::Alert.new(message: @error_message, variant: :error) %> <% end %> <%= styled_form_with model: SimplefinItem.new, @@ -26,23 +20,13 @@ data: { turbo: true }, class: "space-y-3" do |form| %> <%= form.text_field :setup_token, - label: "Setup Token", - placeholder: "Paste SimpleFIN setup token", + label: t("settings.providers.simplefin_panel.setup_token_label"), + placeholder: t("settings.providers.simplefin_panel.setup_token_placeholder"), type: :password %>
- <%= form.submit "Save Configuration", - class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %> + <%= form.submit t("settings.providers.simplefin_panel.save_and_connect") %>
<% end %> -
- <% if @simplefin_items&.any? %> -
-

Configured and ready to use. Visit the Accounts tab to manage and set up accounts.

- <% else %> -
-

Not configured

- <% end %> -
diff --git a/app/views/settings/providers/_snaptrade_panel.html.erb b/app/views/settings/providers/_snaptrade_panel.html.erb index 314213acc..1c814b234 100644 --- a/app/views/settings/providers/_snaptrade_panel.html.erb +++ b/app/views/settings/providers/_snaptrade_panel.html.erb @@ -1,23 +1,17 @@
-
-

<%= t("providers.snaptrade.description") %>

+ <%= render DS::Alert.new(message: t("providers.snaptrade.free_tier_warning"), variant: :warning) %> -

<%= t("providers.snaptrade.setup_title") %>

-
    -
  1. <%= t("providers.snaptrade.step_1_html") %>
  2. -
  3. <%= t("providers.snaptrade.step_2") %>
  4. -
  5. <%= t("providers.snaptrade.step_3") %>
  6. -
  7. <%= t("providers.snaptrade.step_4") %>
  8. -
- -

<%= icon("alert-triangle", class: "inline-block w-4 h-4 mr-1") %><%= t("providers.snaptrade.free_tier_warning") %>

-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("providers.snaptrade.step_1_html").html_safe, + t("providers.snaptrade.step_2"), + t("providers.snaptrade.step_3"), + t("providers.snaptrade.step_4") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -44,25 +38,37 @@ type: :password %>
- <%= form.submit is_new_record ? t("providers.snaptrade.save_button") : t("providers.snaptrade.update_button"), - class: "btn btn--primary" %> + <%= form.submit is_new_record ? t("providers.snaptrade.save_button") : t("providers.snaptrade.update_button") %>
<% end %> <% items = local_assigns[:snaptrade_items] || @snaptrade_items || Current.family.snaptrade_items.where.not(client_id: [nil, ""]) %> -
- <% if items&.any? %> - <% item = items.first %> - <% if item.user_registered? %> -
- + <% if items&.any? %> + <% item = items.first %> + <% unless item.user_registered? %> +
+ +

<%= t("providers.snaptrade.status_needs_registration") %>

+
+ <% end %> + <% end %> + + <% if items&.any? && items.first.user_registered? %> + <% item = items.first %> +
+ <%= render DS::Disclosure.new( + variant: :inline, + data: { + controller: "lazy-load", + action: "toggle->lazy-load#toggled", + lazy_load_url_value: connections_snaptrade_item_path(item), + lazy_load_auto_open_param_value: "manage" + } + ) do |disclosure| %> + <% disclosure.with_summary_content do %> +
-

<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> <% if item.unlinked_accounts_count > 0 %> @@ -72,35 +78,21 @@

<%= t("providers.snaptrade.manage_connections") %> - <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> + <%= icon "chevron-right", class: "w-3 h-3 group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> -
- -
-

- <%= t("providers.snaptrade.connection_limit_info") %> -

- -
- <%= icon "loader-2", class: "w-4 h-4 animate-spin" %> - <%= t("providers.snaptrade.loading_connections") %> -
- -
-
-
- <% else %> -
-
-

<%= t("providers.snaptrade.status_needs_registration") %>

+ <% end %> + +
+
+ <%= icon "loader-2", class: "w-4 h-4 animate-spin" %> + <%= t("providers.snaptrade.loading_connections") %> +
+ +
+
<% end %> - <% else %> -
-
-

<%= t("providers.snaptrade.status_not_configured") %>

-
- <% end %> -
+
+ <% end %>
diff --git a/app/views/settings/providers/_sophtron_panel.html.erb b/app/views/settings/providers/_sophtron_panel.html.erb new file mode 100644 index 000000000..4511d01dc --- /dev/null +++ b/app/views/settings/providers/_sophtron_panel.html.erb @@ -0,0 +1,47 @@ +
+ <%= render "settings/providers/setup_steps", + steps: [ + t("sophtron_items.sophtron_panel.setup_instructions.step_1_html", url: "https://www.sophtron.com").html_safe, + t("sophtron_items.sophtron_panel.setup_instructions.step_2"), + t("sophtron_items.sophtron_panel.setup_instructions.step_3") + ] %> + + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> + <%= render DS::Alert.new(message: error_msg, variant: :error) %> + <% end %> + + <% + # Variables passed from controller + sophtron_item = @sophtron_item || Current.family.sophtron_items.build + is_new_record = @is_new_record || sophtron_item.new_record? + sophtron_items = @sophtron_items + %> + + <%= styled_form_with model: sophtron_item, + url: sophtron_item.new_record? ? sophtron_items_path : sophtron_item_path(sophtron_item), + scope: :sophtron_item, + method: sophtron_item.new_record? ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :user_id, + label: t("sophtron_items.sophtron_panel.fields.user_id.label"), + placeholder: is_new_record ? t("sophtron_items.sophtron_panel.fields.user_id.placeholder_new") : t("sophtron_items.sophtron_panel.fields.user_id.placeholder_edit"), + type: :password %> + + <%= form.text_field :access_key, + label: t("sophtron_items.sophtron_panel.fields.access_key.label"), + placeholder: is_new_record ? t("sophtron_items.sophtron_panel.fields.access_key.placeholder_new") : t("sophtron_items.sophtron_panel.fields.access_key.placeholder_edit"), + type: :password %> + + <%= form.text_field :base_url, + label: t("sophtron_items.sophtron_panel.fields.base_url.label"), + placeholder: t("sophtron_items.sophtron_panel.fields.base_url.placeholder"), + value: sophtron_item.base_url %> + +
+ <%= form.submit is_new_record ? t("sophtron_items.sophtron_panel.save") : t("sophtron_items.sophtron_panel.update") %> +
+ <% end %> + +
diff --git a/app/views/settings/providers/_status_pill.html.erb b/app/views/settings/providers/_status_pill.html.erb new file mode 100644 index 000000000..efabfd989 --- /dev/null +++ b/app/views/settings/providers/_status_pill.html.erb @@ -0,0 +1,15 @@ +<%# locals: (status:) %> +<% + tone = case status.to_s.to_sym + when :ok then :success + when :warn then :warning + when :err then :error + else :neutral + end +%> +<%= render DS::Pill.new( + label: t("settings.providers.status.#{status}"), + tone: tone, + marker: false, + size: :sm +) %> diff --git a/app/views/settings/providers/_sync_button.html.erb b/app/views/settings/providers/_sync_button.html.erb new file mode 100644 index 000000000..6e3df16ae --- /dev/null +++ b/app/views/settings/providers/_sync_button.html.erb @@ -0,0 +1,15 @@ +<%# locals: (provider_key:, last_synced_at: nil) %> +<% recently_synced = last_synced_at.present? && last_synced_at > 60.seconds.ago %> +<% button_label = recently_synced ? t("settings.providers.recently_synced") : t("settings.providers.sync_provider") %> +<%= render DS::Button.new( + variant: "icon", + size: "sm", + icon: "refresh-cw", + href: sync_provider_settings_providers_path(provider_key: provider_key), + method: :post, + disabled: recently_synced, + title: button_label, + aria: { label: button_label }, + class: "disabled:opacity-40 disabled:cursor-not-allowed", + form: { onclick: "event.stopPropagation()", class: "inline-flex" } + ) %> diff --git a/app/views/settings/providers/connect_form.html.erb b/app/views/settings/providers/connect_form.html.erb new file mode 100644 index 000000000..f4c6e6d3c --- /dev/null +++ b/app/views/settings/providers/connect_form.html.erb @@ -0,0 +1,21 @@ +<%= render DS::Dialog.new(frame: "drawer", responsive: true, auto_open: true) do |dialog| %> + <% provider_key = @panel_key || @provider_configuration&.provider_key&.to_s %> + <% dialog.with_header(custom_header: true) do %> + <%= render "settings/providers/drawer_header", provider_key: provider_key, title: @panel_title %> + <% end %> + <% dialog.with_body do %> + <% if @panel_partial %> + + <%= render "settings/providers/#{@panel_partial}" %> + + <% else %> + + <%= render "settings/providers/provider_form", configuration: @provider_configuration %> + + <% end %> + +

+ <%= t("settings.providers.drawer_trust_statement") %> +

+ <% end %> +<% end %> diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 4a9d76bdc..9108eabcd 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -1,88 +1,96 @@ -<%= content_for :page_title, "Sync Providers" %> +<%= content_for :page_title, t("settings.providers.bank_sync.page_title") %>
<% if @encryption_error %> -
-
- <%= icon("triangle-alert", class: "w-5 h-5 text-destructive shrink-0 mt-0.5") %> -
-

<%= t("settings.providers.encryption_error.title") %>

-

<%= t("settings.providers.encryption_error.message") %>

+ <%= render DS::Alert.new( + variant: :error, + title: t("settings.providers.encryption_error.title"), + message: t("settings.providers.encryption_error.message") + ) %> + <% else %> +
+

<%= t("settings.providers.bank_sync.lede") %>

+ <% if @connected.any? || @needs_attention.any? %> + <% sync_all_disabled = Current.family.last_sync_all_attempted_at.present? && Current.family.last_sync_all_attempted_at > 30.seconds.ago %> + <%= render DS::Link.new( + text: t("settings.providers.sync_all"), + icon: "refresh-cw", + variant: "outline", + href: sync_all_settings_providers_path, + method: :post, + title: sync_all_disabled ? t("settings.providers.sync_all_recently") : nil, + aria: { disabled: sync_all_disabled.to_s }, + class: sync_all_disabled ? "opacity-50 pointer-events-none" : nil + ) %> + <% end %> +
+ + <% all_connections = @needs_attention + @connected %> + + <% if all_connections.any? %> + <% if @health %> + <%= render "settings/providers/health_strip", + connected: @health[:connected], + needs_attention: @health[:needs_attention], + accounts_syncing: @health[:accounts_syncing], + last_synced_at: @health[:last_synced_at] %> + <% end %> + + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.your_connections"), + count: all_connections.size %> + +
+ <% all_connections.each do |entry| %> + <% auto_open = all_connections.size == 1 %> + <%= render "settings/providers/connection_row", entry: entry, open: auto_open %> + <% end %> +
+ <% end %> + + <% if @available.any? %> +
+ <%= render "settings/providers/search_filters" %> + +
+

+ <%= t("settings.providers.groups.available") %> + + · <%= @available.size %> + +

+
+ + + +
+ <% @available.each do |entry| %> + <% meta = Provider::Metadata.for(entry[:provider_key]) %> + <%= render Settings::ProviderCard.new( + provider_key: entry[:provider_key], + name: entry[:title], + tagline: t("settings.providers.taglines.#{entry[:provider_key]}", default: nil), + region: meta[:region], + kind: meta[:kind], + tier: meta[:tier], + maturity: meta[:maturity] || :stable, + logo_bg: meta[:logo_bg], + logo_text: meta[:logo_text] + ) %> + <% end %>
-
- <% else %> -
-

- Configure credentials for third-party sync providers. Settings configured here will override environment variables. -

-
- <% end %> - - <% unless @encryption_error %> - <% @provider_configurations.each do |config| %> - <%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %> - <%= render "settings/providers/provider_form", configuration: config %> - <% end %> - <% end %> - - <%# Providers below are hardcoded because they manage Family-scoped connections %> - <%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %> - <%# They require custom UI for connection management, status display, and sync actions. %> - <%# The controller excludes them from @provider_configurations (see prepare_show_context). %> - - <%= settings_section title: "Lunch Flow", collapsible: true, open: false do %> - - <%= render "settings/providers/lunchflow_panel" %> - - <% end %> - - <%= settings_section title: "SimpleFIN", collapsible: true, open: false do %> - - <%= render "settings/providers/simplefin_panel" %> - - <% end %> - - <%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/enable_banking_panel" %> - - <% end %> - - <%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/coinstats_panel" %> - - <% end %> - - <%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/mercury_panel" %> - - <% end %> - - <%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/coinbase_panel" %> - - <% end %> - - <%= settings_section title: "Binance (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/binance_panel" %> - - <% end %> - - <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %> - - <%= render "settings/providers/snaptrade_panel" %> - - <% end %> - - <%= settings_section title: "Indexa Capital (alpha)", collapsible: true, open: false do %> - - <%= render "settings/providers/indexa_capital_panel" %> - + <% else %> + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.available"), + count: 0, + anchor: "available" %> +

<%= t("settings.providers.groups.empty_available") %>

<% end %> <% end %>
diff --git a/app/views/settings/securities/show.html.erb b/app/views/settings/securities/show.html.erb index b7866c225..c0482ac98 100644 --- a/app/views/settings/securities/show.html.erb +++ b/app/views/settings/securities/show.html.erb @@ -4,17 +4,17 @@
-
+
<%= icon "shield-check" %>
<% if Current.user.otp_required? %> -

Two-factor authentication is enabled

-

Your account is protected with an additional layer of security.

+

<%= t(".mfa_enabled_status_html") %>

+

<%= t(".mfa_enabled_description") %>

<% else %> -

Two-factor authentication is disabled

-

Enable 2FA to add an extra layer of security to your account.

+

<%= t(".mfa_disabled_status_html") %>

+

<%= t(".mfa_disabled_description") %>

<% end %>
@@ -45,6 +45,82 @@
<% end %> +<% if Current.user.otp_required? %> + <%= settings_section title: t(".webauthn_title"), subtitle: t(".webauthn_description") do %> +
+ <% if @webauthn_credentials.any? %> +
+ <% @webauthn_credentials.each do |credential| %> +
+
+
+ <%= icon "fingerprint", class: "w-5 h-5 text-secondary" %> +
+
+

<%= credential.nickname %>

+

+ <%= t(".webauthn_added", date: l(credential.created_at.to_date)) %> + <% if credential.last_used_at.present? %> + <%= t(".webauthn_last_used", time_ago: time_ago_in_words(credential.last_used_at)) %> + <% end %> +

+
+
+ + <%= render DS::Button.new( + text: t(".webauthn_remove"), + variant: "outline_destructive", + size: "sm", + href: settings_webauthn_credential_path(credential), + method: :delete, + confirm: CustomConfirm.new( + title: t(".webauthn_remove_confirm"), + body: t(".webauthn_remove_confirm_body"), + btn_text: t(".webauthn_remove"), + destructive: true + ) + ) %> +
+ <% end %> +
+ <% else %> +
+

<%= t(".webauthn_empty") %>

+
+ <% end %> + + <%= styled_form_with scope: :webauthn_credential, + url: settings_webauthn_credentials_path, + method: :post, + class: "space-y-3", + data: { + controller: "webauthn-registration", + action: "submit->webauthn-registration#register", + webauthn_registration_options_url_value: options_settings_webauthn_credentials_path, + webauthn_registration_create_url_value: settings_webauthn_credentials_path, + webauthn_registration_unsupported_message_value: t(".webauthn_unsupported"), + webauthn_registration_error_fallback_value: t("webauthn_credentials.failure") + } do |form| %> + <%= form.text_field :nickname, + placeholder: t(".webauthn_name_placeholder"), + label: t(".webauthn_name_label"), + data: { webauthn_registration_target: "nickname" } %> + + + +
+ <%= render DS::Button.new( + text: t(".webauthn_add"), + variant: "secondary", + icon: "fingerprint", + type: "submit" + ) %> +
+ <% end %> +
+ <% end %> +<% end %> + <% if @oidc_identities.any? || AuthConfig.sso_providers.any? %> <%= settings_section title: t(".sso_title"), subtitle: t(".sso_subtitle") do %> <% if @oidc_identities.any? %> diff --git a/app/views/shared/_badge.html.erb b/app/views/shared/_badge.html.erb index 56e099cb3..7d46618ec 100644 --- a/app/views/shared/_badge.html.erb +++ b/app/views/shared/_badge.html.erb @@ -1,22 +1,22 @@ <%# locals: (color: nil, pulse: false) %> - -<% - def badge_classes(c, p) - classes = case c - when "success" - "bg-green-500/5 text-green-500" - when "error" - "bg-red-500/5 text-red-500" - when "warning" - "bg-orange-500/5 text-orange-500" - else - "bg-gray-500/5 text-secondary" - end - - p ? "#{classes} animate-pulse" : classes - end +<%# + Thin shim over DS::Pill: maps the shared/_badge string color values + (`success` / `error` / `warning` / nil) to semantic tones and renders + the yielded block as the pill label. `pulse: true` wraps the pill in + an `animate-pulse` span (DS::Pill itself doesn't support pulse). %> - - - <%= yield %> - +<% + tone = case color + when "success" then :success + when "error" then :error + when "warning" then :warning + else :neutral + end + label = yield + pill = render DS::Pill.new(label: label, tone: tone, marker: false, show_dot: false) +%> +<% if pulse %> + <%= pill %> +<% else %> + <%= pill %> +<% end %> diff --git a/app/views/shared/_exchange_rate_tabs.html.erb b/app/views/shared/_exchange_rate_tabs.html.erb new file mode 100644 index 000000000..1ba15d706 --- /dev/null +++ b/app/views/shared/_exchange_rate_tabs.html.erb @@ -0,0 +1,39 @@ +<%# locals: (controller_id:, controller_key:, help_text:, convert_tab_label:, calculate_rate_tab_label:, destination_amount_label:, exchange_rate_label:, convert_input:, destination_input:) %> + + diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index 41776c28f..21c773da8 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -9,8 +9,8 @@
data-money-field-precision-value="<%= options[:precision] %>"<% end %> - <% if options[:step].present? %>data-money-field-step-value="<%= options[:step] %>"<% end %>> + <% if options[:precision].present? %> data-money-field-precision-value="<%= options[:precision] %>"<% end %> + <% if options[:step].present? %> data-money-field-step-value="<%= options[:step] %>"<% end %>> <% if options[:label_tooltip] %>
<%= form.label options[:label] || t(".label"), class: "form-field__label" do %> @@ -22,7 +22,7 @@
<%= icon "help-circle", size: "sm", color: "default", class: "cursor-help" %> - @@ -60,26 +60,30 @@ max: options[:max] || 99999999999999, step: options[:step] || currency.step, disabled: options[:disabled], - data: { + data: (options[:amount_data] || {}).merge({ "money-field-target": "amount", "auto-submit-form-target": ("auto" if options[:auto_submit]) - }.compact, + }.compact), required: options[:required] %>
<% unless options[:hide_currency] %>
+ <% currency_data = (options[:currency_data] || {}).merge({ + "money-field-target": "currency", + "auto-submit-form-target": ("auto" if options[:auto_submit]) + }.compact) + # Preserve any existing action and append money-field handler + existing_action = currency_data.delete("action") + currency_data["action"] = ["change->money-field#handleCurrencyChange", existing_action].compact.join(" ") + %> <%= form.select currency_method, - Money::Currency.as_options.map(&:iso_code), + currency_picker_options_for_family(extra: currency.iso_code), { inline: true, selected: currency.iso_code }, { class: "w-fit pr-5 disabled:text-subdued form-field__input", disabled: options[:disable_currency], - data: { - "money-field-target": "currency", - action: "change->money-field#handleCurrencyChange", - "auto-submit-form-target": ("auto" if options[:auto_submit]) - }.compact + data: currency_data } %>
<% end %> diff --git a/app/views/shared/_text_tooltip.erb b/app/views/shared/_text_tooltip.erb index a576b114c..b95fb4712 100644 --- a/app/views/shared/_text_tooltip.erb +++ b/app/views/shared/_text_tooltip.erb @@ -1,5 +1,5 @@ - <% if @error_message %> -
+
<%= icon "alert-triangle", size: "sm", class: "text-destructive mt-0.5 flex-shrink-0" %>

<%= @error_message %>

@@ -46,14 +46,14 @@
<%= render DS::Button.new( - text: "Update", + text: t(".update"), variant: "primary", icon: "refresh-cw", type: "submit", class: "flex-1" ) %> <%= render DS::Link.new( - text: "Cancel", + text: t(".cancel"), variant: "secondary", href: accounts_path ) %> diff --git a/app/views/simplefin_items/new.html.erb b/app/views/simplefin_items/new.html.erb index ade7c448c..d0b3ffd9b 100644 --- a/app/views/simplefin_items/new.html.erb +++ b/app/views/simplefin_items/new.html.erb @@ -9,15 +9,20 @@
<% end %> - <%= form_with model: @simplefin_item, url: simplefin_items_path, method: :post, data: { turbo: true, turbo_frame: "_top" } do |f| %> + <%= styled_form_with model: @simplefin_item, url: simplefin_items_path, method: :post, data: { turbo: true, turbo_frame: "_top" } do |f| %>
<%= f.label :setup_token, t(".setup_token"), class: "text-sm text-secondary block mb-1" %> <%= f.text_field :setup_token, class: "input", placeholder: t(".setup_token_placeholder") %>
- <%= link_to t(".cancel"), accounts_path, class: "btn", data: { turbo_frame: "_top", action: "DS--dialog#close" } %> - <%= f.submit t(".connect"), class: "btn btn--primary" %> + <%= render DS::Link.new( + text: t(".cancel"), + href: accounts_path, + variant: :secondary, + data: { turbo_frame: "_top", action: "DS--dialog#close" } + ) %> + <%= render DS::Button.new(text: t(".connect"), type: :submit) %>
<% end %> diff --git a/app/views/simplefin_items/select_existing_account.html.erb b/app/views/simplefin_items/select_existing_account.html.erb index b47727556..8e2b27f7c 100644 --- a/app/views/simplefin_items/select_existing_account.html.erb +++ b/app/views/simplefin_items/select_existing_account.html.erb @@ -17,7 +17,7 @@ <%= hidden_field_tag :account_id, @account.id %>
<% @available_simplefin_accounts.each do |sfa| %> -
@@ -58,29 +56,49 @@ <% @simplefin_accounts.each do |simplefin_account| %> <% inferred = @inferred_map[simplefin_account.id] || {} %> - <% selected_type = inferred[:confidence] == :high ? inferred[:type] : "skip" %> + <% activity = simplefin_account.activity_summary %> + <% near_zero_balance = (simplefin_account.current_balance || 0).to_d.abs <= 1 %> + <%# "Likely closed" requires evidence of prior activity that has since stopped. + An empty payload carries no signal (could be a brand-new card) so it renders + the neutral "no transactions imported yet" badge instead. %> + <% likely_closed = activity.last_transacted_at.present? && activity.dormant? && near_zero_balance %> + <%# Default likely-closed accounts (dormant AND near-zero balance) to "skip" so + users don't accidentally create empty Sure accounts for closed/replaced cards. %> + <% selected_type = if likely_closed + "skip" + else + inferred[:confidence] == :high ? inferred[:type] : "skip" + end %> <%# Check if this account needs user attention (type selected but subtype missing) %> <% types_with_subtypes = %w[Depository Investment Loan] %> <% needs_subtype_attention = selected_type != "skip" && types_with_subtypes.include?(selected_type) && inferred[:subtype].blank? %> -
"> + <% card_class = if needs_subtype_attention + "border-2 border-warning bg-warning/5" + elsif likely_closed + "border border-tertiary bg-surface-inset" + else + "border border-primary" + end %> +
-
-

+
+

"> <%= simplefin_account.name %> <% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %> • <%= simplefin_account.org_data["name"] %> <% end %>

- Balance: <%= number_to_currency(simplefin_account.current_balance || 0, unit: simplefin_account.currency) %> + <%= t(".account_card.balance") %>: <%= number_to_currency(simplefin_account.current_balance || 0, unit: simplefin_account.currency) %>

+ <%= render "activity_badge", activity: activity, likely_closed: likely_closed %>

- <%= label_tag "account_types[#{simplefin_account.id}]", "Account Type:", + <%= label_tag "account_types[#{simplefin_account.id}]", t(".account_type_label"), class: "block text-sm font-medium text-primary mb-2" %> <%= select_tag "account_types[#{simplefin_account.id}]", options_for_select(@account_type_options, selected_type), @@ -126,7 +144,7 @@
<%= render DS::Button.new( - text: "Create Accounts", + text: t(".create_accounts"), variant: "primary", icon: "plus", type: "submit", @@ -134,7 +152,7 @@ data: { loading_button_target: "button" } ) %> <%= render DS::Link.new( - text: "Cancel", + text: t(".cancel"), variant: "secondary", href: accounts_path ) %> diff --git a/app/views/snaptrade_items/_snaptrade_item.html.erb b/app/views/snaptrade_items/_snaptrade_item.html.erb index f36712ec2..4314200c8 100644 --- a/app/views/snaptrade_items/_snaptrade_item.html.erb +++ b/app/views/snaptrade_items/_snaptrade_item.html.erb @@ -1,114 +1,116 @@ <%# locals: (snaptrade_item:) %> <%= tag.div id: dom_id(snaptrade_item) do %> -
- -
- <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + <% unlinked_count = snaptrade_item.unlinked_accounts_count %> -
- <% if snaptrade_item.logo.attached? %> - <%= image_tag snaptrade_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> - <% else %> -
- <%= tag.p snaptrade_item.name.first.upcase, class: "text-success text-xs font-medium" %> -
- <% end %> -
+ <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+ <%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> - <% unlinked_count = snaptrade_item.unlinked_accounts_count %> - -
-
- <%= tag.p snaptrade_item.name, class: "font-medium text-primary" %> - <% if snaptrade_item.scheduled_for_deletion? %> -

<%= t(".deletion_in_progress") %>

+
+ <% if snaptrade_item.logo.attached? %> + <%= image_tag snaptrade_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> + <% else %> +
+ <%= tag.p snaptrade_item.name.first.upcase, class: "text-success text-xs font-medium" %> +
<% end %>
- <% if snaptrade_item.snaptrade_accounts.any? %> -

- <%= snaptrade_item.brokerage_summary %> -

- <% end %> - <% if snaptrade_item.syncing? %> -
- <%= icon "loader", size: "sm", class: "animate-spin" %> - <%= tag.span t(".syncing") %> -
- <% elsif snaptrade_item.requires_update? %> -
- <%= icon "alert-triangle", size: "sm", color: "warning" %> - <%= tag.span t(".requires_update") %> -
- <% elsif snaptrade_item.sync_error.present? %> -
- <%= render DS::Tooltip.new(text: snaptrade_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> - <%= tag.span t(".error"), class: "text-destructive" %> -
- <% else %> -

- <% if snaptrade_item.last_synced_at %> - <%= t(".status", timestamp: time_ago_in_words(snaptrade_item.last_synced_at), summary: snaptrade_item.sync_status_summary) %> - <% else %> - <%= t(".status_never") %> + +

+
+ <%= tag.p snaptrade_item.name, class: "font-medium text-primary" %> + <% if snaptrade_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

<% end %> -

- <% end %> +
+ <% if snaptrade_item.snaptrade_accounts.any? %> +

+ <%= snaptrade_item.brokerage_summary %> +

+ <% end %> + <% if snaptrade_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif snaptrade_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".requires_update") %> +
+ <% elsif snaptrade_item.sync_error.present? %> +
+ <%= render DS::Tooltip.new(text: snaptrade_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> + <%= tag.span t(".error"), class: "text-destructive" %> +
+ <% else %> +

+ <% if snaptrade_item.last_synced_at %> + <%= t(".status", timestamp: time_ago_in_words(snaptrade_item.last_synced_at), summary: snaptrade_item.sync_status_summary) %> + <% else %> + <%= t(".status_never") %> + <% end %> +

+ <% end %> +
-
- <% if Current.user&.admin? %> -
- <% if snaptrade_item.requires_update? || !snaptrade_item.user_registered? %> - <%= render DS::Link.new( - text: t(".reconnect"), - icon: "link", - variant: "secondary", - href: connect_snaptrade_item_path(snaptrade_item) - ) %> - <% else %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_snaptrade_item_path(snaptrade_item), - disabled: snaptrade_item.syncing? - ) %> - <% end %> - - <%= render DS::Menu.new do |menu| %> - <% menu.with_item( - variant: "link", - text: t(".connect_brokerage"), - icon: "plus", - href: connect_snaptrade_item_path(snaptrade_item) - ) %> - <% if unlinked_count > 0 %> - <% menu.with_item( - variant: "link", - text: t(".setup_accounts_menu"), - icon: "settings", - href: setup_accounts_snaptrade_item_path(snaptrade_item), - frame: :modal + <% if Current.user&.admin? %> +
+ <% if snaptrade_item.requires_update? || !snaptrade_item.user_registered? %> + <%= render DS::Link.new( + text: t(".reconnect"), + icon: "link", + variant: "secondary", + href: connect_snaptrade_item_path(snaptrade_item) + ) %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_snaptrade_item_path(snaptrade_item), + disabled: snaptrade_item.syncing? ) %> <% end %> - <% menu.with_item( - variant: "link", - text: t(".manage_connections"), - icon: "cable", - href: settings_providers_path(manage: "1") - ) %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - icon: "trash-2", - href: snaptrade_item_path(snaptrade_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(snaptrade_item.name, high_severity: true) - ) %> - <% end %> -
- <% end %> -
+ + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "link", + text: t(".connect_brokerage"), + icon: "plus", + href: connect_snaptrade_item_path(snaptrade_item) + ) %> + <% if unlinked_count > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_accounts_menu"), + icon: "settings", + href: setup_accounts_snaptrade_item_path(snaptrade_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "link", + text: t(".manage_connections"), + icon: "cable", + href: settings_providers_path(manage: "1") + ) %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: snaptrade_item_path(snaptrade_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(snaptrade_item.name, high_severity: true) + ) %> + <% end %> +
+ <% end %> +
+ <% end %> <% unless snaptrade_item.scheduled_for_deletion? %>
@@ -155,5 +157,5 @@ <% end %>
<% end %> - + <% end %> <% end %> diff --git a/app/views/snaptrade_items/select_existing_account.html.erb b/app/views/snaptrade_items/select_existing_account.html.erb index ab4d49ff2..bf817c98d 100644 --- a/app/views/snaptrade_items/select_existing_account.html.erb +++ b/app/views/snaptrade_items/select_existing_account.html.erb @@ -14,7 +14,13 @@ <%= icon "alert-circle", class: "text-warning mx-auto mb-4", size: "lg" %>

<%= t("snaptrade_items.select_existing_account.no_accounts", default: "No unlinked SnapTrade accounts available.") %>

<%= t("snaptrade_items.select_existing_account.connect_hint", default: "You may need to connect a brokerage first.") %>

- <%= link_to t("snaptrade_items.select_existing_account.settings_link", default: "Go to Provider Settings"), settings_providers_path, class: "btn btn--primary btn--sm mt-4" %> + <%= render DS::Link.new( + text: t("snaptrade_items.select_existing_account.settings_link", default: "Go to Provider Settings"), + href: settings_providers_path, + variant: :primary, + size: :sm, + class: "mt-4" + ) %>
<% else %>
diff --git a/app/views/sophtron_items/_api_error.html.erb b/app/views/sophtron_items/_api_error.html.erb new file mode 100644 index 000000000..e62f9628f --- /dev/null +++ b/app/views/sophtron_items/_api_error.html.erb @@ -0,0 +1,42 @@ +<%# locals: (error_message:, return_path: nil, heading: nil, issue_keys: nil, action_label: nil) %> +<% heading ||= t("sophtron_items.api_error.unable_to_connect") %> +<% issue_keys ||= %w[incorrect_user_id invalid_access_key expired_credentials network_issue service_down] %> +<% action_path = return_path.presence || settings_providers_path %> +<% action_label ||= t("sophtron_items.api_error.check_provider_settings") %> + +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("sophtron_items.api_error.title")) %> + <% dialog.with_body do %> +
+
+
+ <%= icon("alert-circle", color: "destructive", size: "sm", class: "mt-0.5") %> +
+

<%= heading %>

+

<%= h(error_message) %>

+
+
+
+ +
+

<%= t("sophtron_items.api_error.common_issues_title") %>

+
    + <% issue_keys.each do |issue_key| %> +
  • <%= t("sophtron_items.api_error.#{issue_key}") %>
  • + <% end %> +
+
+ +
+ <%= render DS::Link.new( + text: action_label, + href: action_path, + variant: :primary, + data: { turbo: false } + ) %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/_loading.html.erb b/app/views/sophtron_items/_loading.html.erb new file mode 100644 index 000000000..e0ea126c1 --- /dev/null +++ b/app/views/sophtron_items/_loading.html.erb @@ -0,0 +1,16 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".loading_title")) %> + + <% dialog.with_body do %> +
+
+ <%= icon("loader-circle", class: "h-8 w-8 animate-spin text-primary") %> +

+ <%= t(".loading_message") %> +

+
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/_mfa_context_fields.html.erb b/app/views/sophtron_items/_mfa_context_fields.html.erb new file mode 100644 index 000000000..84dab88f7 --- /dev/null +++ b/app/views/sophtron_items/_mfa_context_fields.html.erb @@ -0,0 +1,6 @@ +<%= hidden_field_tag :accountable_type, @accountable_type %> +<%= hidden_field_tag :account_id, @account_id %> +<%= hidden_field_tag :return_to, @return_to %> +<%= hidden_field_tag :manual_sync, @manual_sync_flow if @manual_sync_flow.present? %> +<%= hidden_field_tag :sync_id, @manual_sync_id if @manual_sync_id.present? %> +<%= hidden_field_tag :sophtron_account_id, @manual_sync_sophtron_account_id if @manual_sync_sophtron_account_id.present? %> diff --git a/app/views/sophtron_items/_setup_required.html.erb b/app/views/sophtron_items/_setup_required.html.erb new file mode 100644 index 000000000..d306640f4 --- /dev/null +++ b/app/views/sophtron_items/_setup_required.html.erb @@ -0,0 +1,37 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("sophtron_items.sophtron_setup_required.title")) %> + <% dialog.with_body do %> +
+
+
+ <%= icon("alert-circle", color: "warning", size: "sm", class: "mt-0.5") %> +
+

<%= t("sophtron_items.sophtron_setup_required.heading") %>

+

<%= t("sophtron_items.sophtron_setup_required.description") %>

+
+
+
+ +
+

<%= t("sophtron_items.sophtron_setup_required.setup_steps_title") %>

+
    +
  1. <%= t("sophtron_items.sophtron_setup_required.step_1_html") %>
  2. +
  3. <%= t("sophtron_items.sophtron_setup_required.step_2_html") %>
  4. +
  5. <%= t("sophtron_items.sophtron_setup_required.step_3_html") %>
  6. +
  7. <%= t("sophtron_items.sophtron_setup_required.step_4") %>
  8. +
+
+ +
+ <%= render DS::Link.new( + text: t("sophtron_items.sophtron_setup_required.go_to_provider_settings"), + href: settings_providers_path, + variant: :primary, + data: { turbo: false } + ) %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/_sophtron_item.html.erb b/app/views/sophtron_items/_sophtron_item.html.erb new file mode 100644 index 000000000..a000d23b8 --- /dev/null +++ b/app/views/sophtron_items/_sophtron_item.html.erb @@ -0,0 +1,166 @@ +<%# locals: (sophtron_item:) %> + +<%= tag.div id: dom_id(sophtron_item) do %> + <% provider_display_name = sophtron_item.provider_display_name %> + <% manual_sync_required = sophtron_item.manual_sync_required? %> + <% connected_institution_options = sophtron_item.connected_institution_options %> + <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+ <%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> + +
+ <% if sophtron_item.logo.attached? %> + <%= image_tag sophtron_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> + <% else %> +
+ <%= tag.p provider_display_name.first.upcase, class: "text-orange-600 text-xs font-medium" %> +
+ <% end %> +
+ +
+
+ <%= tag.p provider_display_name, class: "font-medium text-primary" %> + <% if manual_sync_required %> + <%= render DS::Pill.new( + label: t(".manual_sync"), + tone: :warning, + marker: false, + show_dot: false, + size: :sm + ) %> + <% end %> + <% if sophtron_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+ <% if sophtron_item.accounts.any? %> +

+ <%= sophtron_item.institution_summary %> +

+ <% end %> + <% if sophtron_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif sophtron_item.sync_error.present? %> +
+ <%= render DS::Tooltip.new(text: sophtron_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> + <%= tag.span t(".error"), class: "text-destructive" %> +
+ <% else %> +

+ <% if sophtron_item.last_synced_at %> + <% if sophtron_item.sync_status_summary %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(sophtron_item.last_synced_at), summary: sophtron_item.sync_status_summary) %> + <% else %> + <%= t(".status", timestamp: time_ago_in_words(sophtron_item.last_synced_at)) %> + <% end %> + <% else %> + <%= t(".status_never") %> + <% end %> +

+ <% end %> +
+
+ +
+ <% if manual_sync_required || Rails.env.development? %> + <%= render DS::Button.new( + variant: :icon, + icon: "refresh-cw", + href: sync_sophtron_item_path(sophtron_item), + frame: (manual_sync_required ? "modal" : nil), + title: t(".sync_now") + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% if connected_institution_options.many? %> + <% connected_institution_options.each do |institution| %> + <% institution_manual_sync_required = sophtron_item.manual_sync_required_for_institution?(institution[:institution_key]) %> + <% menu.with_item( + variant: "button", + text: t(institution_manual_sync_required ? ".automatic_sync_for" : ".manual_sync_action_for", institution: institution[:name]), + icon: institution_manual_sync_required ? "refresh-cw" : "pause-circle", + href: toggle_manual_sync_sophtron_item_path(sophtron_item, institution_key: institution[:institution_key]), + method: :post + ) %> + <% end %> + <% else %> + <% menu.with_item( + variant: "button", + text: t(manual_sync_required ? ".automatic_sync" : ".manual_sync_action"), + icon: manual_sync_required ? "refresh-cw" : "pause-circle", + href: toggle_manual_sync_sophtron_item_path(sophtron_item), + method: :post + ) %> + <% end %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: sophtron_item_path(sophtron_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(provider_display_name, high_severity: true) + ) %> + <% end %> +
+
+ <% end %> + + <% unless sophtron_item.scheduled_for_deletion? %> +
+ <% if sophtron_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: sophtron_item.accounts %> + <% end %> + + <%# Sync summary (collapsible) - using shared ProviderSyncSummary component %> + <% stats = if defined?(@sophtron_sync_stats_map) && @sophtron_sync_stats_map + @sophtron_sync_stats_map[sophtron_item.id] || {} + else + sophtron_item.syncs.ordered.first&.sync_stats || {} + end %> + <%= render ProviderSyncSummary.new( + stats: stats, + provider_item: sophtron_item, + institutions_count: sophtron_item.connected_institutions.size + ) %> + + <%# Use model methods for consistent counts %> + <% unlinked_count = sophtron_item.unlinked_accounts_count %> + <% linked_count = sophtron_item.linked_accounts_count %> + <% total_count = sophtron_item.total_accounts_count %> + + <% if unlinked_count > 0 %> +
+

<%= t(".setup_needed") %>

+

<%= t(".setup_description", linked: linked_count, total: total_count) %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_sophtron_item_path(sophtron_item), + frame: :modal + ) %> +
+ <% elsif sophtron_item.accounts.empty? && total_count == 0 %> +
+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_description") %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_sophtron_item_path(sophtron_item), + frame: :modal + ) %> +
+ <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/_subtype_select.html.erb b/app/views/sophtron_items/_subtype_select.html.erb new file mode 100644 index 000000000..6ca3bcb8d --- /dev/null +++ b/app/views/sophtron_items/_subtype_select.html.erb @@ -0,0 +1,23 @@ + diff --git a/app/views/sophtron_items/connect.html.erb b/app/views/sophtron_items/connect.html.erb new file mode 100644 index 000000000..210f63a5a --- /dev/null +++ b/app/views/sophtron_items/connect.html.erb @@ -0,0 +1,82 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+ <%= form_with url: select_accounts_sophtron_items_path, method: :get, class: "space-y-3" do %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :account_id, @account&.id %> + <%= hidden_field_tag :return_to, @return_to %> + <%= hidden_field_tag :connect_new_institution, true if @connect_new_institution %> + +
+
+ <%= label_tag :institution_name, t(".institution_search_label"), class: "form-field__label" %> + <%= text_field_tag :institution_name, @institution_search, placeholder: t(".institution_search_placeholder"), autocomplete: "off", class: "form-field__input" %> +
+
+ +
+ <%= render DS::Button.new(text: t(".search"), type: "submit") %> +
+ <% end %> + + <% if @institution_search.present? && @institution_search.length < 2 %> +

<%= t(".search_too_short") %>

+ <% elsif @institution_search.present? && @institutions.empty? %> +

<%= t(".no_institutions") %>

+ <% end %> + +
+ <% @institutions.each_with_index do |institution, index| %> + <% institution = institution.with_indifferent_access %> + <% institution_id = institution[:InstitutionID] || institution[:institution_id] %> + <% field_suffix = (institution_id.presence || index).to_s.parameterize %> + <% bank_username_id = "bank_username_#{field_suffix}" %> + <% bank_password_id = "bank_password_#{field_suffix}" %> + <%= form_with url: connect_institution_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3 rounded-lg border border-primary bg-container-inset p-3", data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :institution_id, institution_id %> + <%= hidden_field_tag :institution_name, institution[:InstitutionName] || institution[:institution_name] %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :account_id, @account&.id %> + <%= hidden_field_tag :return_to, @return_to %> + <%= hidden_field_tag :connect_new_institution, true if @connect_new_institution %> + +

<%= institution[:InstitutionName] || institution[:institution_name] %>

+ +
+
+
+ <%= label_tag bank_username_id, t(".username"), class: "form-field__label" %> + <%= text_field_tag :bank_username, nil, id: bank_username_id, autocomplete: "username", class: "form-field__input" %> +
+
+ +
+
+ <%= label_tag bank_password_id, t(".password"), class: "form-field__label" %> + <%= password_field_tag :bank_password, nil, id: bank_password_id, autocomplete: "current-password", class: "form-field__input" %> +
+
+
+ +
+ <%= render DS::Button.new(text: t(".connect"), type: "submit") %> +
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t(".cancel"), + href: @return_to || accounts_path, + variant: :secondary, + data: { turbo_frame: "_top", action: "click->DS--dialog#close" } + ) %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/connection_status.html.erb b/app/views/sophtron_items/connection_status.html.erb new file mode 100644 index 000000000..5a2e8b5b6 --- /dev/null +++ b/app/views/sophtron_items/connection_status.html.erb @@ -0,0 +1,51 @@ +<% polling_data = if @timed_out + {} + else + { + controller: "polling", + polling_frame_id_value: "modal", + polling_url_value: connection_status_sophtron_item_path(@sophtron_item, accountable_type: @accountable_type, account_id: @account_id, return_to: @return_to, poll_attempt: @next_poll_attempt, post_mfa: @post_mfa_polling, manual_sync: @manual_sync_flow, sync_id: @manual_sync_id, sophtron_account_id: @manual_sync_sophtron_account_id), + polling_interval_value: @poll_interval_ms + } + end %> +<% check_again_attempt = @timed_out ? 1 : (@next_poll_attempt || @poll_attempt.to_i + 1) %> + +<%= turbo_frame_tag "modal" do %> + <%= tag.div(data: polling_data) do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+
+
+ <% if @timed_out %> + <%= icon "alert-circle", size: "sm", class: "mt-0.5" %> + <% else %> + <%= icon "loader", size: "sm", class: "mt-0.5 animate-spin" %> + <% end %> + +
+

+ <%= @timed_out ? t(".timeout") : t(".waiting") %> +

+

+ <%= t(".attempt", attempt: (@poll_attempt || 1), max: @max_poll_attempts) %> +

+
+
+
+ +
+ <%= render DS::Link.new( + text: t(".check_again"), + href: connection_status_sophtron_item_path(@sophtron_item, accountable_type: @accountable_type, account_id: @account_id, return_to: @return_to, poll_attempt: check_again_attempt, post_mfa: @post_mfa_polling, manual_sync: @manual_sync_flow, sync_id: @manual_sync_id, sophtron_account_id: @manual_sync_sophtron_account_id), + variant: :primary, + data: { turbo_frame: "modal", turbo_prefetch: false } + ) %> +
+
+ <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/manual_sync_complete.html.erb b/app/views/sophtron_items/manual_sync_complete.html.erb new file mode 100644 index 000000000..70b0f3e57 --- /dev/null +++ b/app/views/sophtron_items/manual_sync_complete.html.erb @@ -0,0 +1,23 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+
+
+ <%= icon "check-circle", size: "sm", color: "success", class: "mt-0.5" %> +
+

<%= t(".message") %>

+

<%= t(".description") %>

+
+
+
+ +
+ <%= render DS::Link.new(text: t(".close"), href: accounts_path, variant: :primary, data: { turbo_frame: "_top" }) %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/mfa.html.erb b/app/views/sophtron_items/mfa.html.erb new file mode 100644 index 000000000..2247c7c29 --- /dev/null +++ b/app/views/sophtron_items/mfa.html.erb @@ -0,0 +1,87 @@ +<%= turbo_frame_tag "modal" do %> + <% security_questions = Array(@challenge[:security_questions]) %> + <% token_methods = Array(@challenge[:token_methods]) %> + <% safe_captcha_image = @challenge[:captcha_image].to_s.split(%r{[^A-Za-z0-9+/=\s]}, 2).first.to_s.gsub(/\s+/, "") %> + + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+ <% if security_questions.any? %> + <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3", data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :mfa_type, "security_answer" %> + <%= render "sophtron_items/mfa_context_fields" %> + + <% security_questions.each_with_index do |question, index| %> + <% answer_field_id = "security_answer_#{index}" %> +
+
+ <%= label_tag answer_field_id, question, class: "form-field__label" %> + <%= text_field_tag "security_answers[]", nil, id: answer_field_id, autocomplete: "off", class: "form-field__input" %> +
+
+ <% end %> + +
+ <%= render DS::Button.new(text: t(".submit"), type: "submit") %> +
+ <% end %> + <% elsif token_methods.any? %> +
+ <% token_methods.each do |method| %> + <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :mfa_type, "token_choice" %> + <%= hidden_field_tag :token_choice, method %> + <%= render "sophtron_items/mfa_context_fields" %> + <%= button_tag type: "submit", class: "w-full rounded-lg border border-primary bg-container-inset p-3 text-left text-sm text-primary transition-colors hover:bg-container-inset-hover" do %> + <%= method %> + <% end %> + <% end %> + <% end %> +
+ <% elsif @challenge[:token_sent] %> + <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3", data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :mfa_type, "token_input" %> + <%= render "sophtron_items/mfa_context_fields" %> +
+
+ <%= label_tag :token_input, t(".token"), class: "form-field__label" %> + <%= text_field_tag :token_input, nil, autocomplete: "one-time-code", class: "form-field__input" %> +
+
+
+ <%= render DS::Button.new(text: t(".submit"), type: "submit") %> +
+ <% end %> + <% elsif @challenge[:token_read].present? %> +
+

<%= @challenge[:token_read] %>

+ <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :mfa_type, "verify_phone" %> + <%= render "sophtron_items/mfa_context_fields" %> + <%= render DS::Button.new(text: t(".phone_confirmed"), type: "submit") %> + <% end %> +
+ <% elsif safe_captcha_image.present? %> + <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3", data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :mfa_type, "captcha" %> + <%= render "sophtron_items/mfa_context_fields" %> +
+ <%= image_tag "data:image/png;base64,#{safe_captcha_image}", alt: t(".captcha_alt"), class: "max-w-full rounded-md" %> +
+
+
+ <%= label_tag :captcha_input, t(".captcha"), class: "form-field__label" %> + <%= text_field_tag :captcha_input, nil, autocomplete: "off", class: "form-field__input" %> +
+
+
+ <%= render DS::Button.new(text: t(".submit"), type: "submit") %> +
+ <% end %> + <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/select_accounts.html.erb b/app/views/sophtron_items/select_accounts.html.erb new file mode 100644 index 000000000..760847e2e --- /dev/null +++ b/app/views/sophtron_items/select_accounts.html.erb @@ -0,0 +1,56 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+

+ <%= t(".description", product_name: product_name) %> +

+ +
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @available_accounts.each do |account| %> + <% Rails.logger.debug "Sophtron account data: #{account.inspect}" %> + <% has_blank_name = account[:account_name].blank? %> + + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t(".cancel"), + href: @return_to || new_account_path, + variant: :secondary, + data: { turbo_frame: "_top", action: "DS--dialog#close" } + ) %> + <%= render DS::Button.new(text: t(".link_accounts"), type: "submit") %> +
+
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/select_existing_account.html.erb b/app/views/sophtron_items/select_existing_account.html.erb new file mode 100644 index 000000000..a717a37ab --- /dev/null +++ b/app/views/sophtron_items/select_existing_account.html.erb @@ -0,0 +1,60 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title", account_name: @account.name)) %> + + <% dialog.with_body do %> +
+

+ <%= t(".description") %> +

+ + <%= form_with url: link_existing_account_sophtron_items_path, + method: :post, + class: "space-y-4", + data: { turbo_frame: "_top" } do %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @available_accounts.each do |account| %> + <% has_blank_name = account[:account_name].blank? %> + + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t(".cancel"), + href: @return_to || accounts_path, + variant: :secondary, + data: { turbo_frame: "_top", action: "click->DS--dialog#close" } + ) %> + <%= render DS::Button.new(text: t(".link_account"), type: "submit") %> +
+ <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/setup_accounts.html.erb b/app/views/sophtron_items/setup_accounts.html.erb new file mode 100644 index 000000000..006cc1594 --- /dev/null +++ b/app/views/sophtron_items/setup_accounts.html.erb @@ -0,0 +1,105 @@ +<% content_for :title, "Set Up Sophtron Accounts" %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "building-2", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: complete_account_setup_sophtron_item_path(@sophtron_item), + method: :post, + local: true, + data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t(".creating_accounts"), + turbo_frame: "_top" + }, + class: "space-y-6" do |form| %> + +
+ <% if @api_error.present? %> +
+ <%= icon "alert-circle", size: "lg", class: "text-destructive" %> +

<%= t(".fetch_failed") %>

+

<%= @api_error %>

+
+ <% elsif @sophtron_accounts.empty? %> +
+ <%= icon "check-circle", size: "lg", class: "text-success" %> +

<%= t(".no_accounts_to_setup") %>

+

<%= t(".all_accounts_linked") %>

+
+ <% else %> +
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

+ <%= t(".choose_account_type") %> +

+
    + <% @account_type_options.reject { |_, type| type == "skip" }.each do |label, type| %> +
  • <%= label %>
  • + <% end %> +
+
+
+
+ + <% @sophtron_accounts.each do |sophtron_account| %> +
+
+
+

+ <%= sophtron_account.name %> +

+
+
+ +
+
+ <%= label_tag "account_types[#{sophtron_account.id}]", t(".account_type_label"), + class: "block text-sm font-medium text-primary mb-2" %> + <%= select_tag "account_types[#{sophtron_account.id}]", + options_for_select(@account_type_options, "skip"), + { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full", + data: { + action: "change->account-type-selector#updateSubtype" + } } %> +
+ + +
+ <% @subtype_options.each do |account_type, subtype_config| %> + <%= render "sophtron_items/subtype_select", account_type: account_type, subtype_config: subtype_config, sophtron_account: sophtron_account %> + <% end %> +
+
+
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".create_accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + disabled: @api_error.present? || @sophtron_accounts.empty?, + data: { loading_button_target: "button" } + ) %> + <%= render DS::Link.new( + text: t(".cancel"), + variant: "secondary", + href: accounts_path + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/splits/_category_select.html.erb b/app/views/splits/_category_select.html.erb index 001919322..033a0915a 100644 --- a/app/views/splits/_category_select.html.erb +++ b/app/views/splits/_category_select.html.erb @@ -33,14 +33,15 @@ <% end %>
- <%= content_tag :p, format_money(-entry.amount_money) %> + <%= content_tag :p, format_money(-entry.amount_money), class: "privacy-sensitive" %>
diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index 071d2d3e3..8bccd8b4e 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -1,6 +1,7 @@ <%# locals: (entry:, balance_trend: nil, view_ctx: "global", in_split_group: false) %> <% transaction = entry.entryable %> +<% transaction_security_logo_url = transaction.activity_security&.display_logo_url %> <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(transaction) do %> @@ -12,24 +13,40 @@ class: "checkbox checkbox--light hidden lg:block", data: { id: entry.id, + entry_type: entry.entryable_type, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection", checkbox_toggle_target: "selectionEntry" } %>
- <%= render "transactions/transaction_category", transaction: transaction, variant: "mobile" %> - <% if transaction.merchant&.logo_url.present? %> + <%= render "transactions/transaction_category", transaction: transaction, variant: "mobile", in_split_group: in_split_group %> + <% if transaction_security_logo_url.present? %> + <%= image_tag Setting.transform_brand_fetch_url(transaction_security_logo_url), + class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none", + loading: "lazy" %> + <% elsif transaction.merchant&.logo_url.present? %> <%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url), class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none", loading: "lazy" %> <% end %>
-
+
"> <%= content_tag :div, class: ["flex items-center gap-3 lg:gap-4"] do %> -