diff --git a/.cursor/rules/general-rules.mdc b/.cursor/rules/general-rules.mdc index efe32cd30..f9f053759 100644 --- a/.cursor/rules/general-rules.mdc +++ b/.cursor/rules/general-rules.mdc @@ -11,7 +11,6 @@ alwaysApply: true - Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase - Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase - Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically -- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development. - ActiveRecord migrations must inherit from `ActiveRecord::Migration[7.2]`. Do **not** use version 8.0 yet. ## Prohibited actions diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 511013a97..b871287d4 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,5 @@ -ARG RUBY_VERSION=3.4.4 -FROM ruby:${RUBY_VERSION}-slim-bullseye +ARG RUBY_VERSION=3.4.7 +FROM ruby:${RUBY_VERSION}-slim-bookworm ENV DEBIAN_FRONTEND=noninteractive @@ -17,6 +17,7 @@ RUN apt-get update -qq \ openssh-client \ postgresql-client \ vim \ + procps \ && rm -rf /var/lib/apt/lists /var/cache/apt/archives RUN gem install bundler foreman diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8fd793ac5..f16e433cf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,13 +2,15 @@ "name": "Sure", "dockerComposeFile": "docker-compose.yml", "service": "app", + "runServices": [ + "db", + "redis" + ], "workspaceFolder": "/workspace", "containerEnv": { "GIT_EDITOR": "code --wait", "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}", - "GITHUB_USER": "${localEnv:GITHUB_USER}", - "PLAID_CLIENT_ID": "foo", - "PLAID_SECRET": "bar" + "GITHUB_USER": "${localEnv:GITHUB_USER}" }, "remoteEnv": { "PATH": "/workspace/bin:${containerEnv:PATH}" diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 442072675..cfaf22ece 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -50,9 +50,11 @@ services: restart: unless-stopped db: - image: postgres:latest + image: postgres:16 volumes: - postgres-data:/var/lib/postgresql/data + ports: + - "5432:5432" restart: unless-stopped environment: <<: *db_env diff --git a/.env.example b/.env.example index b6014d453..14db14ce1 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ # Enables self hosting features (should be set to true unless you know what you're doing) SELF_HOSTED=true +# Controls onboarding flow (valid: open, closed, invite_only) +ONBOARDING_STATE=open + # Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base) # Has to be a random string, generated eg. by running `openssl rand -hex 64` SECRET_KEY_BASE=secret-value @@ -32,10 +35,15 @@ LANGFUSE_SECRET_KEY= # Get it here: https://twelvedata.com/ TWELVE_DATA_API_KEY= -# Optional: Twelve Data provider is the default for exchange rates and securities. +# Optional: Provider selection for exchange rates and securities data +# Options: twelve_data (default), yahoo_finance EXCHANGE_RATE_PROVIDER=twelve_data SECURITIES_PROVIDER=twelve_data +# Alternative: Use Yahoo Finance as provider (free, no API key required) +# EXCHANGE_RATE_PROVIDER=yahoo_finance +# SECURITIES_PROVIDER=yahoo_finance + # Custom port config # For users who have other applications listening at 3000, this allows them to set a value puma will listen to. PORT=3000 @@ -58,6 +66,9 @@ DB_PORT=5432 POSTGRES_PASSWORD=postgres POSTGRES_USER=postgres +# Redis configuration +REDIS_URL=redis://localhost:6379/1 + # 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= @@ -72,6 +83,10 @@ OIDC_REDIRECT_URI= PRODUCT_NAME= BRAND_NAME= +# PostHog configuration +POSTHOG_KEY= +POSTHOG_HOST= + # Disable enforcing SSL connections # DISABLE_SSL=true @@ -107,3 +122,12 @@ BRAND_NAME= # CLOUDFLARE_SECRET_ACCESS_KEY= # CLOUDFLARE_BUCKET= # +# Generic S3 +# ========== +# ACTIVE_STORAGE_SERVICE=generic_s3 <- Enables Generic S3 storage +# GENERIC_S3_ACCESS_KEY_ID= +# GENERIC_S3_SECRET_ACCESS_KEY= +# GENERIC_S3_REGION= +# GENERIC_S3_BUCKET= +# GENERIC_S3_ENDPOINT= +# GENERIC_S3_FORCE_PATH_STYLE= <- defaults to false diff --git a/.env.local.example b/.env.local.example index 830a73906..7aacba4a2 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,6 +1,9 @@ # To enable / disable self-hosting features. SELF_HOSTED = true +# Controls onboarding flow (valid: open, closed, invite_only) +ONBOARDING_STATE = open + # Enable Twelve market data (careful, this will use your API credits) TWELVE_DATA_API_KEY = diff --git a/.env.test.example b/.env.test.example index 4c6c62cda..57d67d064 100644 --- a/.env.test.example +++ b/.env.test.example @@ -1,5 +1,8 @@ SELF_HOSTED=false +# Controls onboarding flow (valid: open, closed, invite_only) +ONBOARDING_STATE=open + # OpenID Connect for tests OIDC_ISSUER= OIDC_CLIENT_ID= @@ -21,4 +24,4 @@ OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback COVERAGE=false # Set to true to run test suite serially -DISABLE_PARALLELIZATION=false \ No newline at end of file +DISABLE_PARALLELIZATION=false diff --git a/.gitignore b/.gitignore index 24423a7dc..ff1f3f80d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ /tmp/storage/* !/tmp/storage/ !/tmp/storage/.keep +/db/development.sqlite3 /public/assets @@ -106,3 +107,4 @@ scripts/ .windsurfrules .cursor/rules/dev_workflow.mdc .cursor/rules/taskmaster.mdc + diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 000000000..88c9eb4e9 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,799 @@ +# Sure Project — Junie Guidelines (Persistent Context) + +This single file provides optional, persistent context for JetBrains Junie/RubyMine users. It is a direct, verbatim port of the project’s `.cursor/rules/*.mdc` guidelines into one document, with only path normalization for links and cross-references updated to point to sections below. It does not alter or interfere with Cursor/Codex workflows. + +Self-hosted emphasis: the Sure project primarily operates in self-hosted mode; if references to managed mode exist in the original text below, they are preserved as-is for accuracy. + +--- + + +## Original File: .cursor/rules/general-rules.mdc + +```markdown +--- +description: Miscellaneous rules to get the AI to behave +globs: * +alwaysApply: true +--- +# General rules for AI + +- Use `Current.user` for the current user. Do NOT use `current_user`. +- Use `Current.family` for the current family. Do NOT use `current_family`. +- Prior to generating any code, carefully read the project conventions and guidelines + - Read [project-design.mdc](#original-file-cursorrulesproject-designmdc) to understand the codebase + - Read [project-conventions.mdc](#original-file-cursorrulesproject-conventionsmdc) to understand _how_ to write code for the codebase + - Read [ui-ux-design-guidelines.mdc](#original-file-cursorrulesui-ux-design-guidelinesmdc) to understand how to implement frontend code specifically +- ActiveRecord migrations must inherit from `ActiveRecord::Migration[7.2]`. Do **not** use version 8.0 yet. + +## Prohibited actions + +- Do not run `rails server` in your responses. +- Do not run `touch tmp/restart.txt` +- Do not run `rails credentials` +- Do not automatically run migrations +``` + + +--- + +## Original File: .cursor/rules/project-design.mdc + +```markdown +--- +description: This rule explains the system architecture and data flow of the Rails app +globs: * +alwaysApply: true +--- + +This file outlines how the codebase is structured and how data flows through the app. + +This is a personal finance application built in Ruby on Rails. The primary domain entities for this app are outlined below. For an authoritative overview of the relationships, [schema.rb](db/schema.rb) is the source of truth. + +## App Modes + +The codebase runs in two distinct "modes", dictated by `Rails.application.config.app_mode`, which can be `managed` or `self_hosted`. + +- "Managed" - in managed mode, a team operates and manages servers for users +- "Self Hosted" - in self hosted mode, users host the codebase on their own infrastructure, typically through Docker Compose. We have an example [docker-compose.example.yml](docker-compose.example.yml) file that runs [Dockerfile](Dockerfile) for this mode. + +## Families and Users + +- `Family` - all Stripe subscriptions, financial accounts, and the majority of preferences are stored at the [family.rb](app/models/family.rb) level. +- `User` - all [session.rb](app/models/session.rb) happen at the [user.rb](app/models/user.rb) level. A user belongs to a `Family` and can either be an `admin` or a `member`. Typically, a `Family` has a single admin, or "head of household" that manages finances while there will be several `member` users who can see the family's finances from varying perspectives. + +## Currency Preference + +Each `Family` selects a currency preference. This becomes the "main" currency in which all records are "normalized" to via [exchange_rate.rb](app/models/exchange_rate.rb) records so that the app can calculate metrics, historical graphs, and other insights in a single family currency. + +## Accounts + +The center of the app's domain is the [account.rb](app/models/account.rb). This represents a single financial account that has a `balance` and `currency`. For example, an `Account` could be "Chase Checking", which is a single financial account at Chase Bank. A user could have multiple accounts at a single institution (i.e. "Chase Checking", "Chase Credit Card", "Chase Savings") or an account could be a standalone account, such as "My Home" (a primary residence). + +### Accountables + +In the app, [account.rb](app/models/account.rb) is a Rails "delegated type" with the following subtypes (separate DB tables). Each account has a `classification` or either `asset` or `liability`. While the types are a flat hierarchy, below, they have been organized by their classification: + +- Asset accountables + - [depository.rb](app/models/depository.rb) - a typical "bank account" such as a savings or checking account + - [investment.rb](app/models/investment.rb) - an account that has "holdings" such as a brokerage, 401k, etc. + - [crypto.rb](app/models/crypto.rb) - an account that tracks the value of one or more crypto holdings + - [property.rb](app/models/property.rb) - an account that tracks the value of a physical property such as a house or rental property + - [vehicle.rb](app/models/vehicle.rb) - an account that tracks the value of a vehicle + - [other_asset.rb](app/models/other_asset.rb) - an asset that cannot be classified by the other account types. For example, "jewelry". +- Liability accountables + - [credit_card.rb](app/models/credit_card.rb) - an account that tracks the debt owed on a credit card + - [loan.rb](app/models/loan.rb) - an account that tracks the debt owed on a loan (i.e. mortgage, student loan) + - [other_liability.rb](app/models/other_liability.rb) - a liability that cannot be classified by the other account types. For example, "IOU to a friend" + +### Account Balances + +An account [balance.rb](app/models/account/balance.rb) represents a single balance value for an account on a specific `date`. A series of balance records is generated daily for each account and is how we show a user's historical balance graph. + +- For simple accounts like a "Checking Account", the balance represents the amount of cash in the account for a date. +- For a more complex account like "Investment Brokerage", the `balance` represents the combination of the "cash balance" + "holdings value". Each accountable type has different components that make up the "balance", but in all cases, the "balance" represents "How much the account is worth" (when `classification` is `asset`) or "How much is owed on the account" (when `classification` is `liability`) + +All balances are calculated daily by [balance_calculator.rb](app/models/account/balance_calculator.rb). + +### Account Holdings + +An account [holding.rb](app/models/holding.rb) applies to [investment.rb](app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](app/models/security.rb) at a specific `price` on a specific `date`. + +For investment accounts with holdings, [base_calculator.rb](app/models/holding/base_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [base_calculator.rb](app/models/account/balance/base_calculator.rb). + +### Account Entries + +An account [entry.rb](app/models/entry.rb) is also a Rails "delegated type". `Entry` represents any record that _modifies_ an `Account` [balance.rb](app/models/account/balance.rb) and/or [holding.rb](app/models/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`. + +The `amount` of an [entry.rb](app/models/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example: + +- A negative amount for a credit card account represents a "payment" to that account, which _reduces_ its balance (since it is a `liability`) +- A negative amount for a checking account represents an "income" to that account, which _increases_ its balance (since it is an `asset`) +- A negative amount for an investment/brokerage trade represents a "sell" transaction, which _increases_ the cash balance of the account + +There are 3 entry types, defined as [entryable.rb](app/models/entryable.rb) records: + +- `Valuation` - an account [valuation.rb](app/models/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today. +- `Transaction` - an account [transaction.rb](app/models/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense". +- `Trade` - an account [trade.rb](app/models/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`. + +### Account Transfers + +A [transfer.rb](app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](app/models/transaction.rb) and an outflow [transaction.rb](app/models/transaction.rb). The codebase auto-matches transfers based on the following criteria: + +- Must be from different accounts +- Must be within 4 days of each other +- Must be the same currency +- Must be opposite values + +There are two primary forms of a transfer: + +- Regular transfer - a normal movement of money between two accounts. For example, "Transfer $500 from Checking account to Brokerage account". +- Debt payment - a special form of transfer where the _receiver_ of funds is a [loan.rb](app/models/loan.rb) type account. + +Regular transfers are typically _excluded_ from income and expense calculations while a debt payment is considered an "expense". + +## Plaid Items + +A [plaid_item.rb](app/models/plaid_item.rb) represents a "connection" maintained by our external data provider, Plaid in the "hosted" mode of the app. An "Item" has 1 or more [plaid_account.rb](app/models/plaid_account.rb) records, which are each associated 1:1 with an internal app [account.rb](app/models/account.rb). + +All relevant metadata about the item and its underlying accounts are stored on [plaid_item.rb](app/models/plaid_item.rb) and [plaid_account.rb](app/models/plaid_account.rb), while the "normalized" data is then stored on internal app domain models. + +## "Syncs" + +The codebase has the concept of a [syncable.rb](app/models/concerns/syncable.rb), which represents any model which can have its data "synced" in the background. "Syncables" include: + +- `Account` - an account "sync" will sync account holdings, balances, and enhance transaction metadata +- `PlaidItem` - a Plaid Item "sync" fetches data from Plaid APIs, normalizes that data, stores it on internal app models, and then finally performs an "Account sync" for each of the underlying accounts created from the Plaid Item. +- `Family` - a Family "sync" loops through the family's Plaid Items and individual Accounts and "syncs" each of them. A family is synced once per day, automatically through [auto_sync.rb](app/controllers/concerns/auto_sync.rb). + +Each "sync" creates a [sync.rb](app/models/sync.rb) record in the database, which keeps track of the status of the sync, any errors that it encounters, and acts as an "audit table" for synced data. + +Below are brief descriptions of each type of sync in more detail. + +### Account Syncs + +The most important type of sync is the account sync. It is orchestrated by the account's `sync_data` method, which performs a few important tasks: + +- Auto-matches transfer records for the account +- Calculates daily [balance.rb](app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](app/models/account/balance/base_calculator.rb) + - Balances are dependent on the calculation of [holding.rb](app/models/holding.rb), which uses [base_calculator.rb](app/models/account/holding/base_calculator.rb) +- Enriches transaction data if enabled by user + +An account sync happens every time an [entry.rb](app/models/entry.rb) is updated. + +### Plaid Item Syncs + +A Plaid Item sync is an ETL (extract, transform, load) operation: + +1. [plaid_item.rb](app/models/plaid_item.rb) fetches data from the external Plaid API +2. [plaid_item.rb](app/models/plaid_item.rb) creates and loads this data to [plaid_account.rb](app/models/plaid_account.rb) records +3. [plaid_item.rb](app/models/plaid_item.rb) and [plaid_account.rb](app/models/plaid_account.rb) transform and load data to [account.rb](app/models/account.rb) and [entry.rb](app/models/entry.rb), the internal codebase representations of the data. + +### Family Syncs + +A family sync happens once daily via [auto_sync.rb](app/controllers/concerns/auto_sync.rb). A family sync is an "orchestrator" of Account and Plaid Item syncs. + +## Data Providers + +The codebase utilizes several 3rd party data services to calculate historical account balances, enrich data, and more. Since the app can be run in both "hosted" and "self hosted" mode, this means that data providers are _optional_ for self hosted users and must be configured. + +Because of this optionality, data providers must be configured at _runtime_ through [registry.rb](app/models/provider/registry.rb) utilizing [setting.rb](app/models/setting.rb) for runtime parameters like API keys: + +There are two types of 3rd party data in the codebase: + +1. "Concept" data +2. One-off data + +### "Concept" data + +Since the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices. When data is generic enough where we can easily swap out different providers, we call it a data "concept". + +Each "concept" has an interface defined in the `app/models/provider/concepts` directory. + +```plain +app/models/ + exchange_rate/ + provided.rb # <- Responsible for selecting the concept provider from the registry + provider.rb # <- Base provider class + provider/ + registry.rb <- Defines available providers by concept + concepts/ + exchange_rate.rb <- defines the interface required for the exchange rate concept +``` + +### One-off data + +For data that does not fit neatly into a "concept", an interface is not required and the concrete provider may implement ad-hoc methods called directly in code. + +## "Provided" Concerns + +In general, domain models should not be calling [registry.rb](app/models/provider/registry.rb) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for: + +- Choosing the provider to use for this "concept" +- Providing convenience methods on the model for accessing data + +For example, [exchange_rate.rb](app/models/exchange_rate.rb) has a [provided.rb](app/models/exchange_rate/provided.rb) concern with the following convenience methods: + +```rb +module ExchangeRate::Provided + extend ActiveSupport::Concern + + class_methods do + def provider + registry = Provider::Registry.for_concept(:exchange_rates) + registry.get_provider(:synth) + end + + def find_or_fetch_rate(from:, to:, date: Date.current, cache: true) + # Implementation + end + + def sync_provider_rates(from:, to:, start_date:, end_date: Date.current) + # Implementation + end + end +end +``` + +This exposes a generic access pattern where the caller does not care _which_ provider has been chosen for the concept of exchange rates and can get a predictable response: + +```rb +def access_patterns_example + # Call exchange rate provider directly + ExchangeRate.provider.fetch_exchange_rate(from: "USD", to: "CAD", date: Date.current) + + # Call convenience method + ExchangeRate.sync_provider_rates(from: "USD", to: "CAD", start_date: 2.days.ago.to_date) +end +``` + +## Concrete provider implementations + +Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `with_provider_response`, which will return a `Provider::ProviderResponse` object: + +```rb +class ConcreteProvider < Provider + def fetch_some_data + with_provider_response do + ExampleData.new( + example: "data" + ) + end + end +end +``` + +The `with_provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible: + +```rb +class ConcreteProvider < Provider + def fetch_some_data + with_provider_response do + data = nil + + # Raise an error if data cannot be returned + raise ProviderError.new("Could not find the data you need") if data.nil? + + data + end + end +end +``` +``` + +--- + +## Original File: .cursor/rules/project-conventions.mdc + +```markdown +--- +description: +globs: +alwaysApply: true +--- +This rule serves as high-level documentation for how you should write code in this codebase. + +## Project Tech Stack + +- Web framework: Ruby on Rails + - Minitest + fixtures for testing + - Propshaft for asset pipeline + - Hotwire Turbo/Stimulus for SPA-like UI/UX + - TailwindCSS for styles + - Lucide Icons for icons + - OpenAI for AI chat +- Database: PostgreSQL +- Jobs: Sidekiq + Redis +- External + - Payments: Stripe + - User bank data syncing: Plaid + +## Project conventions + +These conventions should be used when writing code for the project. + +### Convention 1: Minimize dependencies, vanilla Rails is plenty + +Dependencies are a natural part of building software, but we aim to minimize them when possible to keep this open-source codebase easy to understand, maintain, and contribute to. + +- Push Rails to its limits before adding new dependencies +- When a new dependency is added, there must be a strong technical or business reason to add it +- When adding dependencies, you should favor old and reliable over new and flashy + +### Convention 2: Leverage POROs and concerns over "service objects" + +This codebase adopts a "skinny controller, fat models" convention. Furthermore, we put almost _everything_ directly in the `app/models/` folder and avoid separate folders for business logic such as `app/services/`. + +- Organize large pieces of business logic into Rails concerns and POROs (Plain ole' Ruby Objects) +- While a Rails concern _may_ offer shared functionality (i.e. "duck types"), it can also be a "one-off" concern that is only included in one place for better organization and readability. +- When concerns are used for code organization, they should be organized around the "traits" of a model; not for simply moving code to another spot in the codebase. +- When possible, models should answer questions about themselves—for example, we might have a method, `account.balance_series` that returns a time-series of the account's most recent balances. We prefer this over something more service-like such as `AccountSeries.new(account).call`. + +### Convention 3: Leverage Hotwire, write semantic HTML, CSS, and JS, prefer server-side solutions + +- Native HTML is always preferred over JS-based components + - Example 1: Use `` element for modals instead of creating a custom component + - Example 2: Use `
...
` for disclosures rather than custom components +- Leverage Turbo frames to break up the page over JS-driven client-side solutions + - Example 1: A good example of turbo frame usage is in [application.html.erb](app/views/layouts/application.html.erb) where we load [chats_controller.rb](app/controllers/chats_controller.rb) actions in a turbo frame in the global layout +- Leverage query params in the URL for state over local storage and sessions. If absolutely necessary, utilize the DB for persistent state. +- Use Turbo streams to enhance functionality, but do not solely depend on it +- Format currencies, numbers, dates, and other values server-side, then pass to Stimulus controllers for display only +- Keep client-side code for where it truly shines. For example, @bulk_select_controller.js is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this. +- Always use the `icon` helper in [application_helper.rb](app/helpers/application_helper.rb) for icons. NEVER use `lucide_icon` helper directly. + +The Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this. + +### Convention 4: Optimize for simplicitly and clarity + +All code should maximize readability and simplicity. + +- Prioritize good OOP domain design over performance +- Only focus on performance for critical and global areas of the codebase; otherwise, don't sweat the small stuff. + - Example 1: be mindful of loading large data payloads in global layouts + - Example 2: Avoid N+1 queries + +### Convention 5: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB + +- Enforce `null` checks, unique indexes, and other simple validations in the DB +- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible. +- Complex validations and business logic should remain in ActiveRecord +``` + +--- + +## Original File: .cursor/rules/testing.mdc + +```markdown +--- +description: +globs: test/** +alwaysApply: false +--- +Use this rule to learn how to write tests for the codebase. + +Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability. + +- **General testing rules** + - Always use Minitest and fixtures for testing, NEVER rspec or factories + - Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed. + - For tests that require a large number of fixture records to be created, use Rails helpers to help create the records needed for the test, then inline the creation. For example, [entries_test_helper.rb](test/support/entries_test_helper.rb) provides helpers to easily do this. + +- **Write minimal, effective tests** + - Use system tests sparingly as they increase the time to complete the test suite + - Only write tests for critical and important code paths + - Write tests as you go, when required + - Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_. + + Below are examples of necessary vs. unnecessary tests: + + ```rb + # GOOD!! + # Necessary test - in this case, we're testing critical domain business logic + test "syncs balances" do + Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once + + @account.expects(:start_date).returns(2.days.ago.to_date) + + Balance::ForwardCalculator.any_instance.expects(:calculate).returns( + [ + Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), + Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") + ] + ) + + assert_difference "@account.balances.count", 2 do + Balance::Syncer.new(@account, strategy: :forward).sync_balances + end + end + + # BAD!! + # Unnecessary test - in this case, this is simply testing ActiveRecord's functionality + test "saves balance" do + balance_record = Balance.new(balance: 100, currency: "USD") + + assert balance_record.save + end + ``` + +- **Test boundaries correctly** + - Distinguish between commands and query methods. Test output of query methods; test that commands were called with the correct params. See an example below: + + ```rb + class ExampleClass + def do_something + result = 2 + 2 + + CustomEventProcessor.process_result(result) + + result + end + end + + class ExampleClass < ActiveSupport::TestCase + test "boundaries are tested correctly" do + result = ExampleClass.new.do_something + + # GOOD - we're only testing that the command was received, not internal implementation details + # The actual tests for CustomEventProcessor belong in a different test suite! + CustomEventProcessor.expects(:process_result).with(4).once + + # GOOD - we're testing the implementation of ExampleClass inside its own test suite + assert_equal 4, result + end + end + ``` + + - Never test the implementation details of one class in another classes test suite + +- **Stubs and mocks** + - Use `mocha` gem + - Always prefer `OpenStruct` when creating mock instances, or in complex cases, a mock class + - Only mock what's necessary. If you're not testing return values, don't mock a return value. +``` + +--- + +## Original File: .cursor/rules/ui-ux-design-guidelines.mdc + +```markdown +--- +description: This file describes Sure's design system and how views should be styled +globs: app/views/**,app/helpers/**,app/javascript/controllers/** +alwaysApply: true +--- +Use the rules below when: + +- You are writing HTML +- You are writing CSS +- You are writing styles in a JavaScript Stimulus controller + +## 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) + +- 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. + - 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 +- Always generate semantic HTML +``` + +--- + +## Original File: .cursor/rules/stimulus_conventions.mdc + +```markdown +--- +description: +globs: +alwaysApply: false +--- +This rule describes how to write Stimulus controllers. + +- **Use declarative actions, not imperative event listeners** + - Instead of assigning a Stimulus target and binding it to an event listener in the initializer, always write Controllers + ERB views declaratively by using Stimulus actions in ERB to call methods in the Stimulus JS controller. Below are good vs. bad code. + + BAD code: + + ```js + // BAD!!!! DO NOT DO THIS!! + // Imperative - controller does all the work + export default class extends Controller { + static targets = ["button", "content"] + + connect() { + this.buttonTarget.addEventListener("click", this.toggle.bind(this)) + } + + toggle() { + this.contentTarget.classList.toggle("hidden") + this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide" + } + } + ``` + + GOOD code: + + ```erb + + +
+ + +
+ ``` + + ```js + // Declarative - controller just responds + export default class extends Controller { + static targets = ["button", "content"] + + toggle() { + this.contentTarget.classList.toggle("hidden") + this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide" + } + } + ``` + +- **Keep Stimulus controllers lightweight and simple** + - Always aim for less than 7 controller targets. Any more is a sign of too much complexity. + - Use private methods and expose a clear public API + +- **Keep Stimulus controllers focused on what they do best** + - Domain logic does NOT belong in a Stimulus controller + - Stimulus controllers should aim for a single responsibility, or a group of highly related responsibilities + - Make good use of Stimulus's callbacks, actions, targets, values, and classes + +- **Component controllers should not be used outside the component** + - If a Stimulus controller is in the app/components directory, it should only be used in its component view. It should not be used anywhere in app/views. +``` + +--- + +## Original File: .cursor/rules/view_conventions.mdc + +``` +--- +description: +globs: app/views/**,app/javascript/**,app/components/**/*.js +alwaysApply: false +--- +Use this rule to learn how to write ERB views, partials, and Stimulus controllers should be incorporated into them. + +- **Component vs. Partial Decision Making** + - **Use ViewComponents when:** + - Element has complex logic or styling patterns + - Element will be reused across multiple views/contexts + - Element needs structured styling with variants/sizes (like buttons, badges) + - Element requires interactive behavior or Stimulus controllers + - Element has configurable slots or complex APIs + - Element needs accessibility features or ARIA support + + - **Use Partials when:** + - Element is primarily static HTML with minimal logic + - Element is used in only one or few specific contexts + - Element is simple template content (like CTAs, static sections) + - Element doesn't need variants, sizes, or complex configuration + - Element is more about content organization than reusable functionality + +- **Prefer components over partials** + - If there is a component available for the use case in app/components, use it + - If there is no component, look for a partial + - If there is no partial, decide between component or partial based on the criteria above + +- **Examples of Component vs. Partial Usage** + ```erb + <%# Component: Complex, reusable with variants and interactivity %> + <%= render DialogComponent.new(variant: :drawer) do |dialog| %> + <% dialog.with_header(title: "Account Settings") %> + <% dialog.with_body { "Dialog content here" } %> + <% end %> + + <%# Component: Interactive with complex styling options %> + <%= render ButtonComponent.new(text: "Save Changes", variant: "primary", confirm: "Are you sure?") %> + + <%# Component: Reusable with variants %> + <%= render FilledIconComponent.new(icon: "credit-card", variant: :surface) %> + + <%# Partial: Static template content %> + <%= render "shared/logo" %> + + <%# Partial: Simple, context-specific content with basic styling %> + <%= render "shared/trend_change", trend: @account.trend, comparison_label: "vs last month" %> + + <%# Partial: Simple divider/utility %> + <%= render "shared/ruler", classes: "my-4" %> + + <%# Partial: Simple form utility %> + <%= render "shared/form_errors", model: @account %> + ``` + +- **Keep domain logic out of the views** + ```erb + <%# BAD!!! %> + + <%# This belongs in the component file, not the template file! %> + <% button_classes = { class: "bg-blue-500 hover:bg-blue-600" } %> + + <%= tag.button class: button_classes do %> + Save Account + <% end %> + + <%# GOOD! %> + + <%= tag.button class: computed_button_classes do %> + Save Account + <% end %> + ``` + +- **Stimulus Integration in Views** + - Always use the **declarative approach** when integrating Stimulus controllers + - The ERB template should declare what happens, the Stimulus controller should respond + - Refer to [stimulus_conventions.mdc](#original-file-cursorrulesstimulus_conventionsmdc) to learn how to incorporate them into + + GOOD Stimulus controller integration into views: + + ```erb + + +
+ + +
+ ``` + +- **Stimulus Controller Placement Guidelines** + - **Component controllers** (in `app/components/`) should only be used within their component templates + - **Global controllers** (in `app/javascript/controllers/`) can be used across any view + - Pass data from Rails to Stimulus using `data-*-value` attributes, not inline JavaScript + - Use Stimulus targets to reference DOM elements, not manual `getElementById` calls + +- **Naming Conventions** + - **Components**: Use `ComponentName` suffix (e.g., `ButtonComponent`, `DialogComponent`, `FilledIconComponent`) + - **Partials**: Use underscore prefix (e.g., `_trend_change.html.erb`, `_form_errors.html.erb`, `_sync_indicator.html.erb`) + - **Shared partials**: Place in `app/views/shared/` directory for reusable content + - **Context-specific partials**: Place in relevant controller view directory (e.g., `accounts/_account_sidebar_tabs.html.erb`) +``` + +--- + +## Original File: .cursor/rules/cursor_rules.mdc + +``` +--- +description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness. +globs: .cursor/rules/*.mdc +alwaysApply: true +--- + +- **Required Rule Structure:** + ```markdown + --- + description: Clear, one-line description of what the rule enforces + globs: path/to/files/*.ext, other/path/**/* + alwaysApply: boolean + --- + + - **Main Points in Bold** + - Sub-points with details + - Examples and explanations + ``` + +- **File References:** + - Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files + - Example: [prisma.mdc](.cursor/rules/prisma.mdc) for rule references + - Example: [schema.prisma](prisma/schema.prisma) for code references + +- **Code Examples:** + - Use language-specific code blocks + ```typescript + // ✅ DO: Show good examples + const goodExample = true; + + // ❌ DON'T: Show anti-patterns + const badExample = false; + ``` + +- **Rule Content Guidelines:** + - Start with high-level overview + - Include specific, actionable requirements + - Show examples of correct implementation + - Reference existing code when possible + - Keep rules DRY by referencing other rules + +- **Rule Maintenance:** + - Update rules when new patterns emerge + - Add examples from actual codebase + - Remove outdated patterns + - Cross-reference related rules + +- **Best Practices:** + - Use bullet points for clarity + - Keep descriptions concise + - Include both DO and DON'T examples + - Reference actual code over theoretical examples + - Use consistent formatting across rules +``` + +--- + +## Original File: .cursor/rules/self_improve.mdc + +``` +--- +description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices. +globs: **/* +alwaysApply: true +--- + +- **Rule Improvement Triggers:** + - New code patterns not covered by existing rules + - Repeated similar implementations across files + - Common error patterns that could be prevented + - New libraries or tools being used consistently + - Emerging best practices in the codebase + +- **Analysis Process:** + - Compare new code with existing rules + - Identify patterns that should be standardized + - Look for references to external documentation + - Check for consistent error handling patterns + - Monitor test patterns and coverage + +- **Rule Updates:** + - **Add New Rules When:** + - A new technology/pattern is used in 3+ files + - Common bugs could be prevented by a rule + - Code reviews repeatedly mention the same feedback + - New security or performance patterns emerge + + - **Modify Existing Rules When:** + - Better examples exist in the codebase + - Additional edge cases are discovered + - Related rules have been updated + - Implementation details have changed + +- **Example Pattern Recognition:** + ```typescript + // If you see repeated patterns like: + const data = await prisma.user.findMany({ + select: { id: true, email: true }, + where: { status: 'ACTIVE' } + }); + + // Consider adding to [prisma.mdc](.cursor/rules/prisma.mdc): + // - Standard select fields + // - Common where conditions + // - Performance optimization patterns + ``` + +- **Rule Quality Checks:** + - Rules should be actionable and specific + - Examples should come from actual code + - References should be up to date + - Patterns should be consistently enforced + +- **Continuous Improvement:** + - Monitor code review comments + - Track common development questions + - Update rules after major refactors + - Add links to relevant documentation + - Cross-reference related rules + +- **Rule Deprecation:** + - Mark outdated patterns as deprecated + - Remove rules that no longer apply + - Update references to deprecated rules + - Document migration paths for old patterns + +- **Documentation Updates:** + - Keep examples synchronized with code + - Update references to external docs + - Maintain links between related rules + - Document breaking changes + +Follow [cursor_rules.mdc](#original-file-cursorrulescursor_rulesmdc) for proper rule formatting and structure. +``` diff --git a/.ruby-version b/.ruby-version index f9892605c..2aa513199 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.4 +3.4.7 diff --git a/CLAUDE.md b/CLAUDE.md index e5d961409..a46d3b359 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,8 +56,7 @@ Only proceed with pull request creation if ALL checks pass. - Use `Current.family` for the current family. Do NOT use `current_family`. ### Development Guidelines -- Prior to generating any code, carefully read the project conventions and guidelines -- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development +- Carefully read project conventions and guidelines before generating any code. - Do not run `rails server` in your responses - Do not run `touch tmp/restart.txt` - Do not run `rails credentials` @@ -119,6 +118,15 @@ Sidekiq handles asynchronous tasks: - 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 +- **i18n**: All user-facing strings must use localization (i18n). Update locale files for each new or changed element. + +### Internationalization (i18n) Guidelines +- **Key Organization**: Use hierarchical keys by feature: `accounts.index.title`, `transactions.form.amount_label` +- **Translation Helper**: Always use `t()` helper for user-facing strings +- **Interpolation**: Use for dynamic content: `t("users.greeting", name: user.name)` +- **Pluralization**: Use Rails pluralization: `t("transactions.count", count: @transactions.count)` +- **Locale Files**: Update `config/locales/en.yml` for new strings +- **Missing Translations**: Configure to raise errors in development for missing keys ### Multi-Currency Support - All monetary values stored in base currency (user's primary currency) @@ -227,11 +235,36 @@ Sidekiq handles asynchronous tasks: ```erb
- - + +
``` +**Example locale file structure (config/locales/en.yml):** +```yaml +en: + components: + transaction_details: + show_details: "Show Details" + hide_details: "Hide Details" + amount_label: "Amount" + date_label: "Date" + category_label: "Category" +``` + +**i18n Best Practices:** +- Organize keys by feature/component: `components.transaction_details.show_details` +- Use descriptive key names that indicate purpose: `show_details` not `button` +- Group related translations together in the same namespace +- Use interpolation for dynamic content: `t("users.welcome", name: user.name)` +- Always update locale files when adding new user-facing strings + **Controller Best Practices:** - Keep controllers lightweight and simple (< 7 targets) - Use private methods and expose clear public API diff --git a/Dockerfile b/Dockerfile index 2248d8278..2cb2c83e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax = docker/dockerfile:1 # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.4.4 +ARG RUBY_VERSION=3.4.7 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here @@ -9,7 +9,7 @@ WORKDIR /rails # Install base packages RUN apt-get update -qq \ - && apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2 \ + && apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2 procps \ && rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment diff --git a/Gemfile b/Gemfile index 9d4ac1fe9..787bb57ee 100644 --- a/Gemfile +++ b/Gemfile @@ -33,6 +33,7 @@ gem "hotwire_combobox" # Background Jobs gem "sidekiq" gem "sidekiq-cron" +gem "sidekiq-unique-jobs" # Monitoring gem "vernier" @@ -40,6 +41,7 @@ gem "rack-mini-profiler" gem "sentry-ruby" gem "sentry-rails" gem "sentry-sidekiq" +gem "posthog-ruby" gem "logtail-rails" gem "skylight", groups: [ :production ] diff --git a/Gemfile.lock b/Gemfile.lock index d85c4caac..b470127de 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -125,7 +125,7 @@ GEM bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.1.0) + brakeman (7.1.1) racc builder (3.3.0) capybara (3.40.0) @@ -425,6 +425,8 @@ GEM platform_agent (1.0.1) activesupport (>= 5.2.0) useragent (~> 0.16.3) + posthog-ruby (3.3.3) + concurrent-ruby (~> 1) pp (0.6.2) prettyprint prettyprint (0.2.0) @@ -601,6 +603,10 @@ GEM fugit (~> 1.8, >= 1.11.1) globalid (>= 1.0.1) sidekiq (>= 6.5.0) + sidekiq-unique-jobs (8.0.11) + concurrent-ruby (~> 1.0, >= 1.0.5) + sidekiq (>= 7.0.0, < 9.0.0) + thor (>= 1.0, < 3.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -736,6 +742,7 @@ DEPENDENCIES pagy pg (~> 1.5) plaid + posthog-ruby propshaft puma (>= 5.0) rack-attack (~> 6.6) @@ -756,6 +763,7 @@ DEPENDENCIES sentry-sidekiq sidekiq sidekiq-cron + sidekiq-unique-jobs simplecov skylight stackprof @@ -771,7 +779,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.4p34 + ruby 3.4.7p58 BUNDLED WITH 2.6.7 diff --git a/README.md b/README.md index fb2bd0e64..48654668d 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ # Sure: The personal finance app for everyone Get -involved: [Discord](https://discord.gg/36ZGBsxYEK) • [(archived) Website](https://web.archive.org/web/20250715182050/https://maybefinance.com/) • [Issues](https://github.com/we-promise/sure/issues) +involved: [Discord](https://discord.gg/36ZGBsxYEK) • [Website](https://sure.am) • [Issues](https://github.com/we-promise/sure/issues) > [!IMPORTANT] > This repository is a community fork of the now-abandoned Maybe Finance project.
@@ -60,6 +60,7 @@ The instructions below are for developers to get started with contributing to th - See `.ruby-version` file for required Ruby version - PostgreSQL >9.3 (latest stable version recommended) +- Redis > 5.4 (latest stable version recommended) ### Getting Started ```sh @@ -86,7 +87,9 @@ 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 deploys +### One-click + +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=maybe) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/sure?referralCode=CW_fPQ) diff --git a/app/assets/images/logomark.svg b/app/assets/images/logomark.svg index a8f2dc7d2..80e043546 100644 --- a/app/assets/images/logomark.svg +++ b/app/assets/images/logomark.svg @@ -1,6 +1,6 @@ - - - \ No newline at end of file + + + diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index e2df294c3..2d4298a36 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -11,6 +11,7 @@ @import "./simonweb_pickr.css"; @import "./google-sign-in.css"; +@import "./date-picker-dark-mode.css"; @layer components { .pcr-app{ @@ -186,4 +187,4 @@ opacity: 0; pointer-events: none; position: absolute; -} \ No newline at end of file +} diff --git a/app/assets/tailwind/date-picker-dark-mode.css b/app/assets/tailwind/date-picker-dark-mode.css new file mode 100644 index 000000000..2cf082882 --- /dev/null +++ b/app/assets/tailwind/date-picker-dark-mode.css @@ -0,0 +1,12 @@ +/* + Fix for date input calendar picker icon visibility in dark mode. + + The native browser calendar picker icon is typically dark, making it + invisible on dark backgrounds. This rule inverts the icon color in + dark mode to ensure proper visibility. +*/ + +[data-theme=dark] input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(1); + cursor: pointer; +} diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index de87765fa..3d094216c 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -367,6 +367,11 @@ } } } + + textarea.form-field__input { + @apply whitespace-normal overflow-auto; + text-overflow: clip; + } select.form-field__input { @apply pr-10 appearance-none; diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb index b83557b17..7eeb5ee66 100644 --- a/app/components/DS/buttonish.rb +++ b/app/components/DS/buttonish.rb @@ -5,7 +5,7 @@ class DS::Buttonish < DesignSystemComponent icon_classes: "fg-inverse" }, secondary: { - container_classes: "text-primary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600", + 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" }, destructive: { diff --git a/app/components/DS/menu_controller.js b/app/components/DS/menu_controller.js index d5cfec2bc..1dc01db3d 100644 --- a/app/components/DS/menu_controller.js +++ b/app/components/DS/menu_controller.js @@ -1,7 +1,6 @@ import { autoUpdate, computePosition, - flip, offset, shift, } from "@floating-ui/dom"; @@ -103,15 +102,30 @@ export default class extends Controller { } update() { + if (!this.buttonTarget || !this.contentTarget) return; + + const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches; + computePosition(this.buttonTarget, this.contentTarget, { - placement: this.placementValue, - middleware: [offset(this.offsetValue), flip(), shift({ padding: 5 })], + placement: isSmallScreen ? "bottom" : this.placementValue, + middleware: [offset(this.offsetValue), shift({ padding: 5 })], + strategy: "fixed", }).then(({ x, y }) => { - Object.assign(this.contentTarget.style, { - position: "fixed", - left: `${x}px`, - top: `${y}px`, - }); + if (isSmallScreen) { + Object.assign(this.contentTarget.style, { + position: "fixed", + left: "0px", + width: "100vw", + top: `${y}px`, + }); + } else { + Object.assign(this.contentTarget.style, { + position: "fixed", + left: `${x}px`, + top: `${y}px`, + width: "", + }); + } }); } } diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb index 14753b825..dd9528430 100644 --- a/app/components/UI/account/activity_feed.html.erb +++ b/app/components/UI/account/activity_feed.html.erb @@ -1,15 +1,15 @@ <%= turbo_frame_tag dom_id(account, "entries") do %>
- <%= tag.h2 "Activity", class: "font-medium text-lg" %> + <%= tag.h2 t("accounts.show.activity.title"), class: "font-medium text-lg" %> <% if account.manual? %> <%= render DS::Menu.new(variant: "button") do |menu| %> - <% menu.with_button(text: "New", variant: "secondary", icon: "plus") %> + <% menu.with_button(text: t("accounts.show.activity.new"), variant: "secondary", icon: "plus") %> <% menu.with_item( variant: "link", - text: "New balance", + text: t("accounts.show.activity.new_balance"), icon: "circle-dollar-sign", href: new_valuation_path(account_id: account.id), data: { turbo_frame: :modal }) %> @@ -17,7 +17,7 @@ <% unless account.crypto? %> <% menu.with_item( variant: "link", - text: "New transaction", + text: t("accounts.show.activity.new_transaction"), icon: "credit-card", href: account.investment? ? new_trade_path(account_id: account.id) : new_transaction_path(account_id: account.id), data: { turbo_frame: :modal }) %> @@ -40,7 +40,7 @@ <%= hidden_field_tag :account_id, account.id %> <%= form.search_field :search, - placeholder: "Search entries by name", + placeholder: t("accounts.show.activity.search.placeholder"), value: search, class: "form-field__input placeholder:text-sm placeholder:text-secondary", "data-auto-submit-form-target": "auto" %> @@ -51,7 +51,7 @@
<% if activity_dates.empty? %> -

No entries yet

+ <%= tag.p t("accounts.show.activity.no_entries"), class: "text-secondary text-sm p-4" %> <% else %> <%= tag.div id: dom_id(account, "entries_bulk_select"), data: { @@ -68,10 +68,10 @@ <%= check_box_tag "selection_entry", class: "checkbox checkbox--light", data: { action: "bulk-select#togglePageSelection" } %> -

Date

+ <%= tag.p t("accounts.show.activity.date") %>
- <%= tag.p "Amount", class: "col-span-4 justify-self-end" %> + <%= tag.p t("accounts.show.activity.amount"), class: "col-span-4 justify-self-end" %>
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index e394d6ce9..0a31eb593 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -1,18 +1,70 @@ class AccountsController < ApplicationController - before_action :set_account, only: %i[sync sparkline toggle_active show destroy] + before_action :set_account, only: %i[sync sparkline toggle_active show destroy unlink confirm_unlink select_provider] include Periodable def index - @manual_accounts = family.accounts.manual.alphabetically + @manual_accounts = family.accounts + .listable_manual + .order(:name) @plaid_items = family.plaid_items.ordered - @simplefin_items = family.simplefin_items.ordered + @simplefin_items = family.simplefin_items.ordered.includes(:syncs) + @lunchflow_items = family.lunchflow_items.ordered + # Precompute per-item maps to avoid queries in the view + @simplefin_sync_stats_map = {} + @simplefin_has_unlinked_map = {} + + @simplefin_items.each do |item| + latest_sync = item.syncs.ordered.first + @simplefin_sync_stats_map[item.id] = (latest_sync&.sync_stats || {}) + @simplefin_has_unlinked_map[item.id] = item.family.accounts + .listable_manual + .exists? + end + + # Count of SimpleFin accounts that are not linked (no legacy account and no AccountProvider) + @simplefin_unlinked_count_map = {} + @simplefin_items.each do |item| + count = item.simplefin_accounts + .left_joins(:account, :account_provider) + .where(accounts: { id: nil }, account_providers: { id: nil }) + .count + @simplefin_unlinked_count_map[item.id] = count + end + + # Compute CTA visibility map used by the simplefin_item partial + @simplefin_show_relink_map = {} + @simplefin_items.each do |item| + begin + unlinked_count = @simplefin_unlinked_count_map[item.id] || 0 + manuals_exist = @simplefin_has_unlinked_map[item.id] + sfa_any = if item.simplefin_accounts.loaded? + item.simplefin_accounts.any? + else + item.simplefin_accounts.exists? + end + @simplefin_show_relink_map[item.id] = (unlinked_count.to_i == 0 && manuals_exist && sfa_any) + rescue => e + Rails.logger.warn("SimpleFin card: CTA computation failed for item #{item.id}: #{e.class} - #{e.message}") + @simplefin_show_relink_map[item.id] = false + end + end + + # Prevent Turbo Drive from caching this page to ensure fresh account lists + expires_now render layout: "settings" end + def new + # Get all registered providers with any credentials configured + @provider_configs = Provider::Factory.registered_adapters.flat_map do |adapter_class| + adapter_class.connection_configs(family: family) + end + end + def sync_all family.sync_later - redirect_to accounts_path, notice: "Syncing accounts..." + redirect_to accounts_path, notice: t("accounts.sync_all.syncing") end def show @@ -28,7 +80,17 @@ class AccountsController < ApplicationController def sync unless @account.syncing? - @account.sync_later + if @account.linked? + # Sync all provider items for this account + # Each provider item will trigger an account sync when complete + @account.account_providers.each do |account_provider| + item = account_provider.adapter&.item + item&.sync_later if item && !item.syncing? + end + else + # Manual accounts just need balance materialization + @account.sync_later + end end redirect_to account_path(@account) @@ -56,10 +118,69 @@ class AccountsController < ApplicationController def destroy if @account.linked? - redirect_to account_path(@account), alert: "Cannot delete a linked account" + redirect_to account_path(@account), alert: t("accounts.destroy.cannot_delete_linked") else @account.destroy_later - redirect_to accounts_path, notice: "Account scheduled for deletion" + redirect_to accounts_path, notice: t("accounts.destroy.success", type: @account.accountable_type) + end + end + + def confirm_unlink + unless @account.linked? + redirect_to account_path(@account), alert: t("accounts.unlink.not_linked") + end + end + + def unlink + unless @account.linked? + redirect_to account_path(@account), alert: t("accounts.unlink.not_linked") + return + end + + begin + Account.transaction do + # Remove new system links (account_providers join table) + @account.account_providers.destroy_all + + # Remove legacy system links (foreign keys) + @account.update!(plaid_account_id: nil, simplefin_account_id: nil) + end + + redirect_to accounts_path, notice: t("accounts.unlink.success") + rescue ActiveRecord::RecordInvalid => e + redirect_to account_path(@account), alert: t("accounts.unlink.error", error: e.message) + rescue StandardError => e + Rails.logger.error "Failed to unlink account #{@account.id}: #{e.message}" + redirect_to account_path(@account), alert: t("accounts.unlink.error", error: t("accounts.unlink.generic_error")) + end + end + + def select_provider + if @account.linked? + redirect_to account_path(@account), alert: t("accounts.select_provider.already_linked") + return + end + + account_type_name = @account.accountable_type + + # Get all available provider configs dynamically for this account type + provider_configs = Provider::Factory.connection_configs_for_account_type( + account_type: account_type_name, + family: family + ) + + # Build available providers list with paths resolved for this specific account + @available_providers = provider_configs.map do |config| + { + name: config[:name], + key: config[:key], + description: config[:description], + path: config[:existing_account_path].call(@account.id) + } + end + + if @available_providers.empty? + redirect_to account_path(@account), alert: t("accounts.select_provider.no_providers") end end diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 3b06ff163..bc1d636f4 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -66,8 +66,13 @@ module AccountableResource private def set_link_options - @show_us_link = Current.family.can_connect_plaid_us? - @show_eu_link = Current.family.can_connect_plaid_eu? + account_type_name = accountable_type.name + + # Get all available provider configs dynamically for this account type + @provider_configs = Provider::Factory.connection_configs_for_account_type( + account_type: account_type_name, + family: Current.family + ) end def accountable_type diff --git a/app/controllers/concerns/invitable.rb b/app/controllers/concerns/invitable.rb index 5e12d2df9..a295e859f 100644 --- a/app/controllers/concerns/invitable.rb +++ b/app/controllers/concerns/invitable.rb @@ -8,7 +8,11 @@ module Invitable private def invite_code_required? return false if @invitation.present? - self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true" + if self_hosted? + Setting.onboarding_state == "invite_only" + else + ENV["REQUIRE_INVITE_CODE"] == "true" + end end def self_hosted? diff --git a/app/controllers/concerns/simplefin_items/maps_helper.rb b/app/controllers/concerns/simplefin_items/maps_helper.rb new file mode 100644 index 000000000..8067cb504 --- /dev/null +++ b/app/controllers/concerns/simplefin_items/maps_helper.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module SimplefinItems + module MapsHelper + extend ActiveSupport::Concern + + # Build per-item maps consumed by the simplefin_item partial. + # Accepts a single SimplefinItem or a collection. + def build_simplefin_maps_for(items) + items = Array(items).compact + return if items.empty? + + @simplefin_sync_stats_map ||= {} + @simplefin_has_unlinked_map ||= {} + @simplefin_unlinked_count_map ||= {} + @simplefin_duplicate_only_map ||= {} + @simplefin_show_relink_map ||= {} + + # Batch-check if ANY family has manual accounts (same result for all items from same family) + family_ids = items.map { |i| i.family_id }.uniq + families_with_manuals = Account + .visible_manual + .where(family_id: family_ids) + .distinct + .pluck(:family_id) + .to_set + + # Batch-fetch unlinked counts for all items in one query + unlinked_counts = SimplefinAccount + .where(simplefin_item_id: items.map(&:id)) + .left_joins(:account, :account_provider) + .where(accounts: { id: nil }, account_providers: { id: nil }) + .group(:simplefin_item_id) + .count + + items.each do |item| + # Latest sync stats (avoid N+1; rely on includes(:syncs) where appropriate) + latest_sync = if item.syncs.loaded? + item.syncs.max_by(&:created_at) + else + item.syncs.ordered.first + end + stats = (latest_sync&.sync_stats || {}) + @simplefin_sync_stats_map[item.id] = stats + + # Whether the family has any manual accounts available to link (from batch query) + @simplefin_has_unlinked_map[item.id] = families_with_manuals.include?(item.family_id) + + # Count from batch query (defaults to 0 if not found) + @simplefin_unlinked_count_map[item.id] = unlinked_counts[item.id] || 0 + + # Whether all reported errors for this item are duplicate-account warnings + @simplefin_duplicate_only_map[item.id] = compute_duplicate_only_flag(stats) + + # Compute CTA visibility: show relink only when there are zero unlinked SFAs, + # there exist manual accounts to link, and the item has at least one SFA + begin + unlinked_count = @simplefin_unlinked_count_map[item.id] || 0 + manuals_exist = @simplefin_has_unlinked_map[item.id] + sfa_any = if item.simplefin_accounts.loaded? + item.simplefin_accounts.any? + else + item.simplefin_accounts.exists? + end + @simplefin_show_relink_map[item.id] = (unlinked_count.to_i == 0 && manuals_exist && sfa_any) + rescue StandardError => e + Rails.logger.warn("SimpleFin card: CTA computation failed for item #{item.id}: #{e.class} - #{e.message}") + @simplefin_show_relink_map[item.id] = false + end + end + + # Ensure maps are hashes even when items empty + @simplefin_sync_stats_map ||= {} + @simplefin_has_unlinked_map ||= {} + @simplefin_unlinked_count_map ||= {} + @simplefin_duplicate_only_map ||= {} + @simplefin_show_relink_map ||= {} + end + + private + def compute_duplicate_only_flag(stats) + errs = Array(stats && stats["errors"]).map do |e| + if e.is_a?(Hash) + e["message"] || e[:message] + else + e.to_s + end + end + errs.present? && errs.all? { |m| m.to_s.downcase.include?("duplicate upstream account detected") } + rescue + false + end + end +end diff --git a/app/controllers/family_merchants_controller.rb b/app/controllers/family_merchants_controller.rb index 1798056fd..b2b0e317e 100644 --- a/app/controllers/family_merchants_controller.rb +++ b/app/controllers/family_merchants_controller.rb @@ -4,6 +4,7 @@ class FamilyMerchantsController < ApplicationController def index @breadcrumbs = [ [ "Home", root_path ], [ "Merchants", nil ] ] + # Show all merchants for this family @family_merchants = Current.family.merchants.alphabetically render layout: "settings" diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb index db9d59b44..96ce2cd49 100644 --- a/app/controllers/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -9,11 +9,11 @@ class HoldingsController < ApplicationController end def destroy - if @holding.account.plaid_account_id.present? - flash[:alert] = "You cannot delete this holding" - else + if @holding.account.can_delete_holdings? @holding.destroy_holding_and_entries! flash[:notice] = t(".success") + else + flash[:alert] = "You cannot delete this holding" end respond_to do |format| diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index f723c63ef..989e44409 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -20,7 +20,7 @@ class Import::ConfigurationsController < ApplicationController end def import_params - params.require(:import).permit( + params.fetch(:import, {}).permit( :date_col_label, :amount_col_label, :name_col_label, diff --git a/app/controllers/import/rows_controller.rb b/app/controllers/import/rows_controller.rb index a3905b148..ecab770bc 100644 --- a/app/controllers/import/rows_controller.rb +++ b/app/controllers/import/rows_controller.rb @@ -12,7 +12,7 @@ class Import::RowsController < ApplicationController private def row_params - params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes) + params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes, :category_color, :category_classification, :category_parent, :category_icon) end def set_import_row diff --git a/app/controllers/lunchflow_items_controller.rb b/app/controllers/lunchflow_items_controller.rb new file mode 100644 index 000000000..c16cd20ef --- /dev/null +++ b/app/controllers/lunchflow_items_controller.rb @@ -0,0 +1,764 @@ +class LunchflowItemsController < ApplicationController + before_action :set_lunchflow_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + + def index + @lunchflow_items = Current.family.lunchflow_items.active.ordered + render layout: "settings" + end + + def show + end + + # Preload Lunchflow accounts in background (async, non-blocking) + def preload_accounts + begin + # Check if family has credentials + unless Current.family.has_lunchflow_credentials? + render json: { success: false, error: "no_credentials", has_accounts: false } + return + end + + cache_key = "lunchflow_accounts_#{Current.family.id}" + + # Check if already cached + cached_accounts = Rails.cache.read(cache_key) + + if cached_accounts.present? + render json: { success: true, has_accounts: cached_accounts.any?, cached: true } + return + end + + # Fetch from API + lunchflow_provider = Provider::LunchflowAdapter.build_provider(family: Current.family) + + unless lunchflow_provider.present? + render json: { success: false, error: "no_api_key", has_accounts: false } + return + end + + accounts_data = lunchflow_provider.get_accounts + available_accounts = accounts_data[:accounts] || [] + + # Cache the accounts for 5 minutes + Rails.cache.write(cache_key, available_accounts, expires_in: 5.minutes) + + render json: { success: true, has_accounts: available_accounts.any?, cached: false } + rescue Provider::Lunchflow::LunchflowError => e + Rails.logger.error("Lunchflow preload error: #{e.message}") + # API error (bad key, network issue, etc) - keep button visible, show error when clicked + render json: { success: false, error: "api_error", error_message: e.message, has_accounts: nil } + rescue StandardError => e + Rails.logger.error("Unexpected error preloading Lunchflow accounts: #{e.class}: #{e.message}") + # Unexpected error - keep button visible, show error when clicked + render json: { success: false, error: "unexpected_error", error_message: e.message, has_accounts: nil } + end + end + + # Fetch available accounts from Lunchflow API and show selection UI + def select_accounts + begin + # Check if family has Lunchflow credentials configured + unless Current.family.has_lunchflow_credentials? + if turbo_frame_request? + # Render setup modal for turbo frame requests + render partial: "lunchflow_items/setup_required", layout: false + else + # Redirect for regular requests + redirect_to settings_providers_path, + alert: t(".no_credentials_configured", + default: "Please configure your Lunch Flow API key first in Provider Settings.") + end + return + end + + cache_key = "lunchflow_accounts_#{Current.family.id}" + + # Try to get cached accounts first + @available_accounts = Rails.cache.read(cache_key) + + # If not cached, fetch from API + if @available_accounts.nil? + lunchflow_provider = Provider::LunchflowAdapter.build_provider(family: Current.family) + + unless lunchflow_provider.present? + redirect_to settings_providers_path, alert: t(".no_api_key", + default: "Lunch Flow API key not found. Please configure it in Provider Settings.") + return + end + + accounts_data = lunchflow_provider.get_accounts + + @available_accounts = accounts_data[:accounts] || [] + + # Cache the accounts for 5 minutes + Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes) + end + + # Filter out already linked accounts + lunchflow_item = Current.family.lunchflow_items.first + if lunchflow_item + linked_account_ids = lunchflow_item.lunchflow_accounts.joins(:account_provider).pluck(:account_id) + @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } + end + + @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::Lunchflow::LunchflowError => e + Rails.logger.error("Lunch flow API error in select_accounts: #{e.message}") + @error_message = e.message + @return_path = safe_return_to_path + render partial: "lunchflow_items/api_error", + locals: { error_message: @error_message, return_path: @return_path }, + layout: false + rescue StandardError => e + Rails.logger.error("Unexpected error in select_accounts: #{e.class}: #{e.message}") + @error_message = "An unexpected error occurred. Please try again later." + @return_path = safe_return_to_path + render partial: "lunchflow_items/api_error", + locals: { error_message: @error_message, return_path: @return_path }, + layout: false + end + end + + # Create accounts from selected Lunchflow accounts + 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 + + # Create or find lunchflow_item for this family + lunchflow_item = Current.family.lunchflow_items.first_or_create!( + name: "Lunch Flow Connection" + ) + + # Fetch account details from API + lunchflow_provider = Provider::LunchflowAdapter.build_provider(family: Current.family) + unless lunchflow_provider.present? + redirect_to new_account_path, alert: t(".no_api_key") + return + end + + accounts_data = lunchflow_provider.get_accounts + + created_accounts = [] + already_linked_accounts = [] + invalid_accounts = [] + + selected_account_ids.each do |account_id| + # Find the account data from API response + account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s } + next unless account_data + + # Validate account name is not blank (required by Account model) + if account_data[:name].blank? + invalid_accounts << account_id + Rails.logger.warn "LunchflowItemsController - Skipping account #{account_id} with blank name" + next + end + + # Create or find lunchflow_account + lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by( + account_id: account_id.to_s + ) + lunchflow_account.upsert_lunchflow_snapshot!(account_data) + lunchflow_account.save! + + # Check if this lunchflow_account is already linked + if lunchflow_account.account_provider.present? + already_linked_accounts << account_data[:name] + next + end + + # Create the internal Account with proper balance initialization + account = Account.create_and_sync( + family: Current.family, + name: account_data[:name], + balance: 0, # Initial balance will be set during sync + currency: account_data[:currency] || "USD", + accountable_type: accountable_type, + accountable_attributes: {} + ) + + # Link account to lunchflow_account via account_providers join table + AccountProvider.create!( + account: account, + provider: lunchflow_account + ) + + created_accounts << account + end + + # Trigger sync to fetch transactions if any accounts were created + lunchflow_item.sync_later if created_accounts.any? + + # Build appropriate flash message + if invalid_accounts.any? && created_accounts.empty? && already_linked_accounts.empty? + # All selected accounts were invalid (blank names) + 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?) + # Some accounts were created/already linked, but some had invalid names + 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 + rescue Provider::Lunchflow::LunchflowError => e + redirect_to new_account_path, alert: t(".api_error", message: e.message) + end + + # Fetch available Lunchflow accounts to link with an existing account + def select_existing_account + account_id = params[:account_id] + + unless account_id.present? + redirect_to accounts_path, alert: t(".no_account_specified") + return + end + + @account = Current.family.accounts.find(account_id) + + # Check if account is already linked + if @account.account_providers.exists? + redirect_to accounts_path, alert: t(".account_already_linked") + return + end + + # Check if family has Lunchflow credentials configured + unless Current.family.has_lunchflow_credentials? + if turbo_frame_request? + # Render setup modal for turbo frame requests + render partial: "lunchflow_items/setup_required", layout: false + else + # Redirect for regular requests + redirect_to settings_providers_path, + alert: t(".no_credentials_configured", + default: "Please configure your Lunch Flow API key first in Provider Settings.") + end + return + end + + begin + cache_key = "lunchflow_accounts_#{Current.family.id}" + + # Try to get cached accounts first + @available_accounts = Rails.cache.read(cache_key) + + # If not cached, fetch from API + if @available_accounts.nil? + lunchflow_provider = Provider::LunchflowAdapter.build_provider(family: Current.family) + + unless lunchflow_provider.present? + redirect_to settings_providers_path, alert: t(".no_api_key", + default: "Lunch Flow API key not found. Please configure it in Provider Settings.") + return + end + + accounts_data = lunchflow_provider.get_accounts + + @available_accounts = accounts_data[:accounts] || [] + + # Cache the accounts for 5 minutes + Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes) + end + + if @available_accounts.empty? + redirect_to accounts_path, alert: t(".no_accounts_found") + return + end + + # Filter out already linked accounts + lunchflow_item = Current.family.lunchflow_items.first + if lunchflow_item + linked_account_ids = lunchflow_item.lunchflow_accounts.joins(:account_provider).pluck(:account_id) + @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } + end + + if @available_accounts.empty? + redirect_to accounts_path, alert: t(".all_accounts_already_linked") + return + end + + @return_to = safe_return_to_path + + render layout: false + rescue Provider::Lunchflow::LunchflowError => e + Rails.logger.error("Lunch flow API error in select_existing_account: #{e.message}") + @error_message = e.message + render partial: "lunchflow_items/api_error", + locals: { error_message: @error_message, return_path: accounts_path }, + layout: false + rescue StandardError => e + Rails.logger.error("Unexpected error in select_existing_account: #{e.class}: #{e.message}") + @error_message = "An unexpected error occurred. Please try again later." + render partial: "lunchflow_items/api_error", + locals: { error_message: @error_message, return_path: accounts_path }, + layout: false + end + end + + # Link a selected Lunchflow account to an existing account + def link_existing_account + account_id = params[:account_id] + lunchflow_account_id = params[:lunchflow_account_id] + return_to = safe_return_to_path + + unless account_id.present? && lunchflow_account_id.present? + redirect_to accounts_path, alert: t(".missing_parameters") + return + end + + @account = Current.family.accounts.find(account_id) + + # Check if account is already linked + if @account.account_providers.exists? + redirect_to accounts_path, alert: t(".account_already_linked") + return + end + + # Create or find lunchflow_item for this family + lunchflow_item = Current.family.lunchflow_items.first_or_create!( + name: "Lunch Flow Connection" + ) + + # Fetch account details from API + lunchflow_provider = Provider::LunchflowAdapter.build_provider(family: Current.family) + unless lunchflow_provider.present? + redirect_to accounts_path, alert: t(".no_api_key") + return + end + + accounts_data = lunchflow_provider.get_accounts + + # Find the selected Lunchflow account data + account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == lunchflow_account_id.to_s } + unless account_data + redirect_to accounts_path, alert: t(".lunchflow_account_not_found") + return + end + + # Validate account name is not blank (required by Account model) + if account_data[:name].blank? + redirect_to accounts_path, alert: t(".invalid_account_name") + return + end + + # Create or find lunchflow_account + lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by( + account_id: lunchflow_account_id.to_s + ) + lunchflow_account.upsert_lunchflow_snapshot!(account_data) + lunchflow_account.save! + + # Check if this lunchflow_account is already linked to another account + if lunchflow_account.account_provider.present? + redirect_to accounts_path, alert: t(".lunchflow_account_already_linked") + return + end + + # Link account to lunchflow_account via account_providers join table + AccountProvider.create!( + account: @account, + provider: lunchflow_account + ) + + # Trigger sync to fetch transactions + lunchflow_item.sync_later + + redirect_to return_to || accounts_path, + notice: t(".success", account_name: @account.name) + rescue Provider::Lunchflow::LunchflowError => e + redirect_to accounts_path, alert: t(".api_error", message: e.message) + end + + def new + @lunchflow_item = Current.family.lunchflow_items.build + end + + def create + @lunchflow_item = Current.family.lunchflow_items.build(lunchflow_params) + @lunchflow_item.name ||= "Lunch Flow Connection" + + if @lunchflow_item.save + # Trigger initial sync to fetch accounts + @lunchflow_item.sync_later + + if turbo_frame_request? + flash.now[:notice] = t(".success") + @lunchflow_items = Current.family.lunchflow_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "lunchflow-providers-panel", + partial: "settings/providers/lunchflow_panel", + locals: { lunchflow_items: @lunchflow_items } + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end + else + @error_message = @lunchflow_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "lunchflow-providers-panel", + partial: "settings/providers/lunchflow_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + render :new, status: :unprocessable_entity + end + end + end + + def edit + end + + def update + if @lunchflow_item.update(lunchflow_params) + if turbo_frame_request? + flash.now[:notice] = t(".success") + @lunchflow_items = Current.family.lunchflow_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "lunchflow-providers-panel", + partial: "settings/providers/lunchflow_panel", + locals: { lunchflow_items: @lunchflow_items } + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end + else + @error_message = @lunchflow_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "lunchflow-providers-panel", + partial: "settings/providers/lunchflow_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + render :edit, status: :unprocessable_entity + end + end + end + + def destroy + # Ensure we detach provider links before scheduling deletion + begin + @lunchflow_item.unlink_all!(dry_run: false) + rescue => e + Rails.logger.warn("LunchFlow unlink during destroy failed: #{e.class} - #{e.message}") + end + @lunchflow_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + unless @lunchflow_item.syncing? + @lunchflow_item.sync_later + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + # Show unlinked Lunchflow accounts for setup (similar to SimpleFIN setup_accounts) + def setup_accounts + # First, ensure we have the latest accounts from the API + @api_error = fetch_lunchflow_accounts_from_api + + # Get Lunchflow accounts that are not linked (no AccountProvider) + @lunchflow_accounts = @lunchflow_item.lunchflow_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + + # Get supported account types from the adapter + supported_types = Provider::LunchflowAdapter.supported_account_types + + # Map of account type keys to their internal values + account_type_keys = { + "depository" => "Depository", + "credit_card" => "CreditCard", + "investment" => "Investment", + "loan" => "Loan", + "other_asset" => "OtherAsset" + } + + # Build account type options using i18n, filtering to supported types + all_account_type_options = account_type_keys.filter_map do |key, type| + next unless supported_types.include?(type) + [ t(".account_types.#{key}"), type ] + end + + # Add "Skip" option at the beginning + @account_type_options = [ [ t(".account_types.skip"), "skip" ] ] + all_account_type_options + + # Helper to translate subtype options + translate_subtypes = ->(type_key, subtypes_hash) { + subtypes_hash.keys.map { |k| [ t(".subtypes.#{type_key}.#{k}"), k ] } + } + + # Subtype options for each account type (only include supported types) + all_subtype_options = { + "Depository" => { + label: t(".subtype_labels.depository"), + options: translate_subtypes.call("depository", Depository::SUBTYPES) + }, + "CreditCard" => { + label: t(".subtype_labels.credit_card"), + options: [], + message: t(".subtype_messages.credit_card") + }, + "Investment" => { + label: t(".subtype_labels.investment"), + options: translate_subtypes.call("investment", Investment::SUBTYPES) + }, + "Loan" => { + label: t(".subtype_labels.loan"), + options: translate_subtypes.call("loan", Loan::SUBTYPES) + }, + "OtherAsset" => { + label: t(".subtype_labels.other_asset").presence, + options: [], + message: t(".subtype_messages.other_asset") + } + } + + @subtype_options = all_subtype_options.slice(*supported_types) + end + + def complete_account_setup + account_types = params[:account_types] || {} + account_subtypes = params[:account_subtypes] || {} + + # Valid account types for this provider + valid_types = Provider::LunchflowAdapter.supported_account_types + + created_accounts = [] + skipped_count = 0 + + begin + ActiveRecord::Base.transaction do + account_types.each do |lunchflow_account_id, selected_type| + # Skip accounts marked as "skip" + if selected_type == "skip" || selected_type.blank? + skipped_count += 1 + next + end + + # Validate account type is supported + unless valid_types.include?(selected_type) + Rails.logger.warn("Invalid account type '#{selected_type}' submitted for LunchFlow account #{lunchflow_account_id}") + next + end + + # Find account - scoped to this item to prevent cross-item manipulation + lunchflow_account = @lunchflow_item.lunchflow_accounts.find_by(id: lunchflow_account_id) + unless lunchflow_account + Rails.logger.warn("LunchFlow account #{lunchflow_account_id} not found for item #{@lunchflow_item.id}") + next + end + + # Skip if already linked (race condition protection) + if lunchflow_account.account_provider.present? + Rails.logger.info("LunchFlow account #{lunchflow_account_id} already linked, skipping") + next + end + + selected_subtype = account_subtypes[lunchflow_account_id] + + # Default subtype for CreditCard since it only has one option + selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank? + + # Create account with user-selected type and subtype (raises on failure) + account = Account.create_and_sync( + family: Current.family, + name: lunchflow_account.name, + balance: lunchflow_account.current_balance || 0, + currency: lunchflow_account.currency || "USD", + accountable_type: selected_type, + accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {} + ) + + # Link account to lunchflow_account via account_providers join table (raises on failure) + AccountProvider.create!( + account: account, + provider: lunchflow_account + ) + + created_accounts << account + end + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error("LunchFlow account setup failed: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + flash[:alert] = t(".creation_failed", error: e.message) + redirect_to accounts_path, status: :see_other + return + rescue StandardError => e + Rails.logger.error("LunchFlow account setup failed unexpectedly: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + flash[:alert] = t(".creation_failed", error: "An unexpected error occurred") + redirect_to accounts_path, status: :see_other + return + end + + # Trigger a sync to process transactions + @lunchflow_item.sync_later if created_accounts.any? + + # Set appropriate flash message + if created_accounts.any? + flash[:notice] = t(".success", count: created_accounts.count) + elsif skipped_count > 0 + flash[:notice] = t(".all_skipped") + else + flash[:notice] = t(".no_accounts") + end + + if turbo_frame_request? + # Recompute data needed by Accounts#index partials + @manual_accounts = Account.uncached { + Current.family.accounts + .visible_manual + .order(:name) + .to_a + } + @lunchflow_items = Current.family.lunchflow_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(@lunchflow_item), + partial: "lunchflow_items/lunchflow_item", + locals: { lunchflow_item: @lunchflow_item } + ) + ] + Array(flash_notification_stream_items) + else + redirect_to accounts_path, status: :see_other + end + end + + private + + # Fetch Lunchflow accounts from the API and store them locally + # Returns nil on success, or an error message string on failure + def fetch_lunchflow_accounts_from_api + # Skip if we already have accounts cached + return nil unless @lunchflow_item.lunchflow_accounts.empty? + + # Validate API key is configured + unless @lunchflow_item.credentials_configured? + return t("lunchflow_items.setup_accounts.no_api_key") + end + + # Use the specific lunchflow_item's provider (scoped to this family's item) + lunchflow_provider = @lunchflow_item.lunchflow_provider + unless lunchflow_provider.present? + return t("lunchflow_items.setup_accounts.no_api_key") + end + + begin + accounts_data = lunchflow_provider.get_accounts + available_accounts = accounts_data[:accounts] || [] + + if available_accounts.empty? + Rails.logger.info("LunchFlow API returned no accounts for item #{@lunchflow_item.id}") + return nil + end + + available_accounts.each do |account_data| + next if account_data[:name].blank? + + lunchflow_account = @lunchflow_item.lunchflow_accounts.find_or_initialize_by( + account_id: account_data[:id].to_s + ) + lunchflow_account.upsert_lunchflow_snapshot!(account_data) + lunchflow_account.save! + end + + nil # Success + rescue Provider::Lunchflow::LunchflowError => e + Rails.logger.error("LunchFlow API error: #{e.message}") + t("lunchflow_items.setup_accounts.api_error", message: e.message) + rescue StandardError => e + Rails.logger.error("Unexpected error fetching LunchFlow accounts: #{e.class}: #{e.message}") + t("lunchflow_items.setup_accounts.api_error", message: e.message) + end + end + def set_lunchflow_item + @lunchflow_item = Current.family.lunchflow_items.find(params[:id]) + end + + def lunchflow_params + params.require(:lunchflow_item).permit(:name, :sync_start_date, :api_key, :base_url) + end + + # Sanitize return_to parameter to prevent XSS attacks + # Only allow internal paths, reject external URLs and javascript: URIs + def safe_return_to_path + return nil if params[:return_to].blank? + + return_to = params[:return_to].to_s + + # Parse the URL to check if it's external + begin + uri = URI.parse(return_to) + + # Reject absolute URLs with schemes (http:, https:, javascript:, etc.) + # Only allow relative paths + return nil if uri.scheme.present? + + # Ensure the path starts with / (is a relative path) + return nil unless return_to.start_with?("/") + + return_to + rescue URI::InvalidURIError + # If the URI is invalid, reject it + nil + end + end +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 07a7d10f0..210dff919 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -7,44 +7,28 @@ class PagesController < ApplicationController @balance_sheet = Current.family.balance_sheet @accounts = Current.family.accounts.visible.with_attached_logo - # Handle cashflow period - cashflow_period_param = params[:cashflow_period] - @cashflow_period = if cashflow_period_param.present? - begin - Period.from_key(cashflow_period_param) - rescue Period::InvalidKeyError - Period.last_30_days - end - else - Period.last_30_days - end - - # Handle outflows period - outflows_period_param = params[:outflows_period] - @outflows_period = if outflows_period_param.present? - begin - Period.from_key(outflows_period_param) - rescue Period::InvalidKeyError - Period.last_30_days - end - else - Period.last_30_days - end - family_currency = Current.family.currency - # Get data for cashflow section - income_totals = Current.family.income_statement.income_totals(period: @cashflow_period) - cashflow_expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period) - @cashflow_sankey_data = build_cashflow_sankey_data(income_totals, cashflow_expense_totals, family_currency) + # Use the same period for all widgets (set by Periodable concern) + income_totals = Current.family.income_statement.income_totals(period: @period) + expense_totals = Current.family.income_statement.expense_totals(period: @period) - # Get data for outflows section (using its own period) - outflows_expense_totals = Current.family.income_statement.expense_totals(period: @outflows_period) - @outflows_data = build_outflows_donut_data(outflows_expense_totals) + @cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency) + @outflows_data = build_outflows_donut_data(expense_totals) + + @dashboard_sections = build_dashboard_sections @breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ] end + def update_preferences + if Current.user.update_dashboard_preferences(preferences_params) + head :ok + else + head :unprocessable_entity + end + end + def changelog @release_notes = github_provider.fetch_latest_release_notes @@ -71,6 +55,64 @@ class PagesController < ApplicationController end private + def preferences_params + prefs = params.require(:preferences) + {}.tap do |permitted| + permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections] + permitted["section_order"] = prefs[:section_order] if prefs[:section_order] + end + end + + def build_dashboard_sections + all_sections = [ + { + key: "cashflow_sankey", + title: "pages.dashboard.cashflow_sankey.title", + partial: "pages/dashboard/cashflow_sankey", + locals: { sankey_data: @cashflow_sankey_data, period: @period }, + visible: Current.family.accounts.any?, + collapsible: true + }, + { + key: "outflows_donut", + title: "pages.dashboard.outflows_donut.title", + partial: "pages/dashboard/outflows_donut", + locals: { outflows_data: @outflows_data, period: @period }, + visible: Current.family.accounts.any? && @outflows_data[:categories].present?, + collapsible: true + }, + { + key: "net_worth_chart", + title: "pages.dashboard.net_worth_chart.title", + partial: "pages/dashboard/net_worth_chart", + locals: { balance_sheet: @balance_sheet, period: @period }, + visible: Current.family.accounts.any?, + collapsible: true + }, + { + key: "balance_sheet", + title: "pages.dashboard.balance_sheet.title", + partial: "pages/dashboard/balance_sheet", + locals: { balance_sheet: @balance_sheet }, + visible: Current.family.accounts.any?, + collapsible: true + } + ] + + # Order sections according to user preference + section_order = Current.user.dashboard_section_order + ordered_sections = section_order.map do |key| + all_sections.find { |s| s[:key] == key } + end.compact + + # Add any new sections that aren't in the saved order (future-proofing) + all_sections.each do |section| + ordered_sections << section unless ordered_sections.include?(section) + end + + ordered_sections + end + def github_provider Provider::Registry.get_provider(:github) end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 8f32084f8..f2846d04e 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -48,6 +48,48 @@ class PlaidItemsController < ApplicationController end end + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + @region = params[:region] || "us" + + # Get all Plaid accounts from this family's Plaid items for the specified region + # that are not yet linked to any account + @available_plaid_accounts = Current.family.plaid_items + .where(plaid_region: @region) + .includes(:plaid_accounts) + .flat_map(&:plaid_accounts) + .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." + end + end + + def link_existing_account + @account = Current.family.accounts.find(params[:account_id]) + plaid_account = PlaidAccount.find(params[:plaid_account_id]) + + # 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" + 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" + return + end + + # Create the link via AccountProvider + AccountProvider.create!( + account: @account, + provider: plaid_account + ) + + redirect_to accounts_path, notice: "Account successfully linked to Plaid" + end + private def set_plaid_item @plaid_item = Current.family.plaid_items.find(params[:id]) diff --git a/app/controllers/recurring_transactions_controller.rb b/app/controllers/recurring_transactions_controller.rb new file mode 100644 index 000000000..d6b6691d1 --- /dev/null +++ b/app/controllers/recurring_transactions_controller.rb @@ -0,0 +1,58 @@ +class RecurringTransactionsController < ApplicationController + layout "settings" + + def index + @recurring_transactions = Current.family.recurring_transactions + .includes(:merchant) + .order(status: :asc, next_expected_date: :asc) + end + + def identify + count = RecurringTransaction.identify_patterns_for(Current.family) + + respond_to do |format| + format.html do + flash[:notice] = t("recurring_transactions.identified", count: count) + redirect_to recurring_transactions_path + end + end + end + + def cleanup + count = RecurringTransaction.cleanup_stale_for(Current.family) + + respond_to do |format| + format.html do + flash[:notice] = t("recurring_transactions.cleaned_up", count: count) + redirect_to recurring_transactions_path + end + end + end + + def toggle_status + @recurring_transaction = Current.family.recurring_transactions.find(params[:id]) + + if @recurring_transaction.active? + @recurring_transaction.mark_inactive! + message = t("recurring_transactions.marked_inactive") + else + @recurring_transaction.mark_active! + message = t("recurring_transactions.marked_active") + end + + respond_to do |format| + format.html do + flash[:notice] = message + redirect_to recurring_transactions_path + end + end + end + + def destroy + @recurring_transaction = Current.family.recurring_transactions.find(params[:id]) + @recurring_transaction.destroy! + + flash[:notice] = t("recurring_transactions.deleted") + redirect_to recurring_transactions_path + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index b57b508d9..83287e708 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -3,6 +3,7 @@ class RegistrationsController < ApplicationController layout "auth" + 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? @@ -79,4 +80,10 @@ class RegistrationsController < ApplicationController render :new, status: :unprocessable_entity end end + + def ensure_signup_open + return unless Setting.onboarding_state == "closed" + + redirect_to new_session_path, alert: t("registrations.closed") + end end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb new file mode 100644 index 000000000..4addfb06e --- /dev/null +++ b/app/controllers/reports_controller.rb @@ -0,0 +1,866 @@ +class ReportsController < ApplicationController + include Periodable + + # Allow API key authentication for exports (for Google Sheets integration) + # Note: We run authentication_for_export which handles both session and API key auth + skip_authentication only: :export_transactions + before_action :authenticate_for_export, only: :export_transactions + + def index + @period_type = params[:period_type]&.to_sym || :monthly + @start_date = parse_date_param(:start_date) || default_start_date + @end_date = parse_date_param(:end_date) || default_end_date + + # Validate and fix date range if end_date is before start_date + validate_and_fix_date_range(show_flash: true) + + # Build the period + @period = Period.custom(start_date: @start_date, end_date: @end_date) + @previous_period = build_previous_period + + # Get aggregated data + @current_income_totals = Current.family.income_statement.income_totals(period: @period) + @current_expense_totals = Current.family.income_statement.expense_totals(period: @period) + + @previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period) + @previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period) + + # Calculate summary metrics + @summary_metrics = build_summary_metrics + + # Build trend data (last 6 months) + @trends_data = build_trends_data + + # Spending patterns (weekday vs weekend) + @spending_patterns = build_spending_patterns + + # Transactions breakdown + @transactions = build_transactions_breakdown + + # Build reports sections for collapsible/reorderable UI + @reports_sections = build_reports_sections + + @breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ] + end + + def update_preferences + if Current.user.update_reports_preferences(preferences_params) + head :ok + else + head :unprocessable_entity + end + end + + def export_transactions + @period_type = params[:period_type]&.to_sym || :monthly + @start_date = parse_date_param(:start_date) || default_start_date + @end_date = parse_date_param(:end_date) || default_end_date + + # Validate and fix date range if end_date is before start_date + # Don't show flash message since we're returning CSV data + validate_and_fix_date_range(show_flash: false) + + @period = Period.custom(start_date: @start_date, end_date: @end_date) + + # Build monthly breakdown data for export + @export_data = build_monthly_breakdown_for_export + + respond_to do |format| + format.csv do + csv_data = generate_transactions_csv + send_data csv_data, + filename: "transactions_breakdown_#{@start_date.strftime('%Y%m%d')}_to_#{@end_date.strftime('%Y%m%d')}.csv", + type: "text/csv" + end + + # Excel and PDF exports require additional gems (caxlsx and prawn) + # Uncomment and install gems if needed: + # + # format.xlsx do + # xlsx_data = generate_transactions_xlsx + # send_data xlsx_data, + # filename: "transactions_breakdown_#{@start_date.strftime('%Y%m%d')}_to_#{@end_date.strftime('%Y%m%d')}.xlsx", + # type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + # end + # + # format.pdf do + # pdf_data = generate_transactions_pdf + # send_data pdf_data, + # filename: "transactions_breakdown_#{@start_date.strftime('%Y%m%d')}_to_#{@end_date.strftime('%Y%m%d')}.pdf", + # type: "application/pdf" + # end + end + end + + def google_sheets_instructions + # Re-build the params needed for the export URL + base_params = { + period_type: params[:period_type], + start_date: params[:start_date], + end_date: params[:end_date], + sort_by: params[:sort_by], + sort_direction: params[:sort_direction] + }.compact + + # Build the full URL with the API key, if present + @csv_url = export_transactions_reports_url(base_params.merge(format: :csv)) + @api_key_present = @csv_url.include?("api_key=") + + # This action will render `app/views/reports/google_sheets_instructions.html.erb` + # It will render *inside* the modal frame. + end + + private + def preferences_params + prefs = params.require(:preferences) + {}.tap do |permitted| + permitted["reports_collapsed_sections"] = prefs[:reports_collapsed_sections].to_unsafe_h if prefs[:reports_collapsed_sections] + permitted["reports_section_order"] = prefs[:reports_section_order] if prefs[:reports_section_order] + end + end + + def build_reports_sections + all_sections = [ + { + key: "trends_insights", + title: "reports.trends.title", + partial: "reports/trends_insights", + locals: { trends_data: @trends_data, spending_patterns: @spending_patterns }, + visible: Current.family.transactions.any?, + collapsible: true + }, + { + key: "transactions_breakdown", + title: "reports.transactions_breakdown.title", + partial: "reports/transactions_breakdown", + locals: { + transactions: @transactions, + period_type: @period_type, + start_date: @start_date, + end_date: @end_date + }, + visible: Current.family.transactions.any?, + collapsible: true + } + ] + + # Order sections according to user preference + section_order = Current.user.reports_section_order + ordered_sections = section_order.map do |key| + all_sections.find { |s| s[:key] == key } + end.compact + + # Add any new sections that aren't in the saved order (future-proofing) + all_sections.each do |section| + ordered_sections << section unless ordered_sections.include?(section) + end + + ordered_sections + end + + def validate_and_fix_date_range(show_flash: false) + return unless @start_date > @end_date + + # Swap the dates to maintain user's intended date range + @start_date, @end_date = @end_date, @start_date + flash.now[:alert] = t("reports.invalid_date_range") if show_flash + end + + def ensure_money(value) + return value if value.is_a?(Money) + # Value is numeric (BigDecimal or Integer) in dollars - pass directly to Money.new + Money.new(value, Current.family.currency) + end + + def parse_date_param(param_name) + date_string = params[param_name] + return nil if date_string.blank? + + Date.parse(date_string) + rescue Date::Error + nil + end + + def default_start_date + case @period_type + when :monthly + Date.current.beginning_of_month.to_date + when :quarterly + Date.current.beginning_of_quarter.to_date + when :ytd + Date.current.beginning_of_year.to_date + when :last_6_months + 6.months.ago.beginning_of_month.to_date + when :custom + 1.month.ago.to_date + else + Date.current.beginning_of_month.to_date + end + end + + def default_end_date + case @period_type + when :monthly, :last_6_months + Date.current.end_of_month.to_date + when :quarterly + Date.current.end_of_quarter.to_date + when :ytd + Date.current + when :custom + Date.current + else + Date.current.end_of_month.to_date + end + end + + def build_previous_period + duration = (@end_date - @start_date).to_i + previous_end = @start_date - 1.day + previous_start = previous_end - duration.days + + Period.custom(start_date: previous_start, end_date: previous_end) + end + + def build_summary_metrics + # Ensure we always have Money objects + current_income = ensure_money(@current_income_totals.total) + current_expenses = ensure_money(@current_expense_totals.total) + net_savings = current_income - current_expenses + + previous_income = ensure_money(@previous_income_totals.total) + previous_expenses = ensure_money(@previous_expense_totals.total) + + # Calculate percentage changes + income_change = calculate_percentage_change(previous_income, current_income) + expense_change = calculate_percentage_change(previous_expenses, current_expenses) + + # Get budget performance for current period + budget_percent = calculate_budget_performance + + { + current_income: current_income, + income_change: income_change, + current_expenses: current_expenses, + expense_change: expense_change, + net_savings: net_savings, + budget_percent: budget_percent + } + end + + def calculate_percentage_change(previous_value, current_value) + return 0 if previous_value.zero? + + ((current_value - previous_value) / previous_value * 100).round(1) + end + + def calculate_budget_performance + # Only calculate if we're looking at current month + return nil unless @period_type == :monthly && @start_date.beginning_of_month.to_date == Date.current.beginning_of_month.to_date + + budget = Budget.find_or_bootstrap(Current.family, start_date: @start_date.beginning_of_month.to_date) + return 0 if budget.nil? || budget.allocated_spending.zero? + + (budget.actual_spending / budget.allocated_spending * 100).round(1) + rescue StandardError + nil + end + + def build_trends_data + # Generate month-by-month data based on the current period filter + trends = [] + + # Generate list of months within the period + current_month = @start_date.beginning_of_month + end_of_period = @end_date.end_of_month + + while current_month <= end_of_period + month_start = current_month + month_end = current_month.end_of_month + + # Ensure we don't go beyond the end date + month_end = @end_date if month_end > @end_date + + period = Period.custom(start_date: month_start, end_date: month_end) + + income = Current.family.income_statement.income_totals(period: period).total + expenses = Current.family.income_statement.expense_totals(period: period).total + + trends << { + month: month_start.strftime("%b %Y"), + income: income, + expenses: expenses, + net: income - expenses + } + + current_month = current_month.next_month + end + + trends + end + + def build_spending_patterns + # Analyze weekday vs weekend spending + weekday_total = 0 + weekend_total = 0 + weekday_count = 0 + weekend_count = 0 + + # Build query matching income_statement logic: + # Expenses are transactions with positive amounts, regardless of category + expense_transactions = Transaction + .joins(:entry) + .joins(entry: :account) + .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] }) + .where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range }) + .where(kind: [ "standard", "loan_payment" ]) + .where("entries.amount > 0") # Positive amount = expense (matching income_statement logic) + + # Sum up amounts by weekday vs weekend + expense_transactions.each do |transaction| + entry = transaction.entry + amount = entry.amount.abs + + if entry.date.wday.in?([ 0, 6 ]) # Sunday or Saturday + weekend_total += amount + weekend_count += 1 + else + weekday_total += amount + weekday_count += 1 + end + end + + weekday_avg = weekday_count.positive? ? (weekday_total / weekday_count) : 0 + weekend_avg = weekend_count.positive? ? (weekend_total / weekend_count) : 0 + + { + weekday_total: weekday_total, + weekend_total: weekend_total, + weekday_avg: weekday_avg, + weekend_avg: weekend_avg, + weekday_count: weekday_count, + weekend_count: weekend_count + } + end + + def default_spending_patterns + { + weekday_total: 0, + weekend_total: 0, + weekday_avg: 0, + weekend_avg: 0, + weekday_count: 0, + weekend_count: 0 + } + end + + def build_transactions_breakdown + # Base query: all transactions in the period + # Exclude transfers, one-time, and CC payments (matching income_statement logic) + transactions = Transaction + .joins(:entry) + .joins(entry: :account) + .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] }) + .where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range }) + .where.not(kind: [ "funds_movement", "one_time", "cc_payment" ]) + .includes(entry: :account, category: []) + + # Apply filters + transactions = apply_transaction_filters(transactions) + + # Get sort parameters + sort_by = params[:sort_by] || "amount" + sort_direction = params[:sort_direction] || "desc" + + # Group by category and type + all_transactions = transactions.to_a + grouped_data = {} + + all_transactions.each do |transaction| + entry = transaction.entry + is_expense = entry.amount > 0 + type = is_expense ? "expense" : "income" + category_name = transaction.category&.name || "Uncategorized" + category_color = transaction.category&.color || "#9CA3AF" + + key = [ category_name, type, category_color ] + grouped_data[key] ||= { total: 0, count: 0 } + grouped_data[key][:count] += 1 + grouped_data[key][:total] += entry.amount.abs + end + + # Convert to array + result = grouped_data.map do |key, data| + { + category_name: key[0], + type: key[1], + category_color: key[2], + total: data[:total], + count: data[:count] + } + end + + # Sort by amount (total) with the specified direction + if sort_direction == "asc" + result.sort_by { |g| g[:total] } + else + result.sort_by { |g| -g[:total] } + end + end + + def apply_transaction_filters(transactions) + # Filter by category + if params[:filter_category_id].present? + transactions = transactions.where(category_id: params[:filter_category_id]) + end + + # Filter by account + if params[:filter_account_id].present? + transactions = transactions.where(entries: { account_id: params[:filter_account_id] }) + end + + # Filter by tag + if params[:filter_tag_id].present? + transactions = transactions.joins(:taggings).where(taggings: { tag_id: params[:filter_tag_id] }) + end + + # Filter by amount range + if params[:filter_amount_min].present? + transactions = transactions.where("ABS(entries.amount) >= ?", params[:filter_amount_min].to_f) + end + + if params[:filter_amount_max].present? + transactions = transactions.where("ABS(entries.amount) <= ?", params[:filter_amount_max].to_f) + end + + # Filter by date range (within the period) + if params[:filter_date_start].present? + filter_start = Date.parse(params[:filter_date_start]) + transactions = transactions.where("entries.date >= ?", filter_start) if filter_start >= @start_date + end + + if params[:filter_date_end].present? + filter_end = Date.parse(params[:filter_date_end]) + transactions = transactions.where("entries.date <= ?", filter_end) if filter_end <= @end_date + end + + transactions + rescue Date::Error + transactions + end + + def build_transactions_breakdown_for_export + # Get flat transactions list (not grouped) for export + # Exclude transfers, one-time, and CC payments (matching income_statement logic) + transactions = Transaction + .joins(:entry) + .joins(entry: :account) + .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] }) + .where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range }) + .where.not(kind: [ "funds_movement", "one_time", "cc_payment" ]) + .includes(entry: :account, category: []) + + transactions = apply_transaction_filters(transactions) + + sort_by = params[:sort_by] || "date" + # Whitelist sort_direction to prevent SQL injection + sort_direction = %w[asc desc].include?(params[:sort_direction]&.downcase) ? params[:sort_direction].upcase : "DESC" + + case sort_by + when "date" + transactions.order("entries.date #{sort_direction}") + when "amount" + transactions.order("entries.amount #{sort_direction}") + else + transactions.order("entries.date DESC") + end + end + + def build_monthly_breakdown_for_export + # Generate list of months in the period + months = [] + current_month = @start_date.beginning_of_month + end_of_period = @end_date.end_of_month + + while current_month <= end_of_period + months << current_month + current_month = current_month.next_month + end + + # Get all transactions in the period + # Exclude transfers, one-time, and CC payments (matching income_statement logic) + transactions = Transaction + .joins(:entry) + .joins(entry: :account) + .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] }) + .where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range }) + .where.not(kind: [ "funds_movement", "one_time", "cc_payment" ]) + .includes(entry: :account, category: []) + + transactions = apply_transaction_filters(transactions) + + # Group transactions by category, type, and month + breakdown = {} + + transactions.each do |transaction| + entry = transaction.entry + is_expense = entry.amount > 0 + type = is_expense ? "expense" : "income" + category_name = transaction.category&.name || "Uncategorized" + month_key = entry.date.beginning_of_month + + key = [ category_name, type ] + breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 } + breakdown[key][:months][month_key] ||= 0 + breakdown[key][:months][month_key] += entry.amount.abs + breakdown[key][:total] += entry.amount.abs + end + + # Convert to array and sort by type and total (descending) + result = breakdown.map do |key, data| + { + category: data[:category], + type: data[:type], + months: data[:months], + total: data[:total] + } + end + + # Separate and sort income and expenses + income_data = result.select { |r| r[:type] == "income" }.sort_by { |r| -r[:total] } + expense_data = result.select { |r| r[:type] == "expense" }.sort_by { |r| -r[:total] } + + { + months: months, + income: income_data, + expenses: expense_data + } + end + + def generate_transactions_csv + require "csv" + + CSV.generate do |csv| + # Build header row: Category + Month columns + Total + month_headers = @export_data[:months].map { |m| m.strftime("%b %Y") } + header_row = [ "Category" ] + month_headers + [ "Total" ] + csv << header_row + + # Income section + if @export_data[:income].any? + csv << [ "INCOME" ] + Array.new(month_headers.length + 1, "") + + @export_data[:income].each do |category_data| + row = [ category_data[:category] ] + + # Add amounts for each month + @export_data[:months].each do |month| + amount = category_data[:months][month] || 0 + row << Money.new(amount, Current.family.currency).format + end + + # Add row total + row << Money.new(category_data[:total], Current.family.currency).format + csv << row + end + + # Income totals row + totals_row = [ "TOTAL INCOME" ] + @export_data[:months].each do |month| + month_total = @export_data[:income].sum { |c| c[:months][month] || 0 } + totals_row << Money.new(month_total, Current.family.currency).format + end + grand_income_total = @export_data[:income].sum { |c| c[:total] } + totals_row << Money.new(grand_income_total, Current.family.currency).format + csv << totals_row + + # Blank row + csv << [] + end + + # Expenses section + if @export_data[:expenses].any? + csv << [ "EXPENSES" ] + Array.new(month_headers.length + 1, "") + + @export_data[:expenses].each do |category_data| + row = [ category_data[:category] ] + + # Add amounts for each month + @export_data[:months].each do |month| + amount = category_data[:months][month] || 0 + row << Money.new(amount, Current.family.currency).format + end + + # Add row total + row << Money.new(category_data[:total], Current.family.currency).format + csv << row + end + + # Expenses totals row + totals_row = [ "TOTAL EXPENSES" ] + @export_data[:months].each do |month| + month_total = @export_data[:expenses].sum { |c| c[:months][month] || 0 } + totals_row << Money.new(month_total, Current.family.currency).format + end + grand_expenses_total = @export_data[:expenses].sum { |c| c[:total] } + totals_row << Money.new(grand_expenses_total, Current.family.currency).format + csv << totals_row + end + end + end + + def generate_transactions_xlsx + require "caxlsx" + + package = Axlsx::Package.new + workbook = package.workbook + bold_style = workbook.styles.add_style(b: true) + + workbook.add_worksheet(name: "Breakdown") do |sheet| + # Build header row: Category + Month columns + Total + month_headers = @export_data[:months].map { |m| m.strftime("%b %Y") } + header_row = [ "Category" ] + month_headers + [ "Total" ] + sheet.add_row header_row, style: bold_style + + # Income section + if @export_data[:income].any? + sheet.add_row [ "INCOME" ] + Array.new(month_headers.length + 1, ""), style: bold_style + + @export_data[:income].each do |category_data| + row = [ category_data[:category] ] + + # Add amounts for each month + @export_data[:months].each do |month| + amount = category_data[:months][month] || 0 + row << Money.new(amount, Current.family.currency).format + end + + # Add row total + row << Money.new(category_data[:total], Current.family.currency).format + sheet.add_row row + end + + # Income totals row + totals_row = [ "TOTAL INCOME" ] + @export_data[:months].each do |month| + month_total = @export_data[:income].sum { |c| c[:months][month] || 0 } + totals_row << Money.new(month_total, Current.family.currency).format + end + grand_income_total = @export_data[:income].sum { |c| c[:total] } + totals_row << Money.new(grand_income_total, Current.family.currency).format + sheet.add_row totals_row, style: bold_style + + # Blank row + sheet.add_row [] + end + + # Expenses section + if @export_data[:expenses].any? + sheet.add_row [ "EXPENSES" ] + Array.new(month_headers.length + 1, ""), style: bold_style + + @export_data[:expenses].each do |category_data| + row = [ category_data[:category] ] + + # Add amounts for each month + @export_data[:months].each do |month| + amount = category_data[:months][month] || 0 + row << Money.new(amount, Current.family.currency).format + end + + # Add row total + row << Money.new(category_data[:total], Current.family.currency).format + sheet.add_row row + end + + # Expenses totals row + totals_row = [ "TOTAL EXPENSES" ] + @export_data[:months].each do |month| + month_total = @export_data[:expenses].sum { |c| c[:months][month] || 0 } + totals_row << Money.new(month_total, Current.family.currency).format + end + grand_expenses_total = @export_data[:expenses].sum { |c| c[:total] } + totals_row << Money.new(grand_expenses_total, Current.family.currency).format + sheet.add_row totals_row, style: bold_style + end + end + + package.to_stream.read + end + + def generate_transactions_pdf + require "prawn" + + Prawn::Document.new(page_layout: :landscape) do |pdf| + pdf.text "Transaction Breakdown Report", size: 20, style: :bold + pdf.text "Period: #{@start_date.strftime('%b %-d, %Y')} to #{@end_date.strftime('%b %-d, %Y')}", size: 12 + pdf.move_down 20 + + if @export_data[:income].any? || @export_data[:expenses].any? + # Build header row + month_headers = @export_data[:months].map { |m| m.strftime("%b %Y") } + header_row = [ "Category" ] + month_headers + [ "Total" ] + + # Income section + if @export_data[:income].any? + pdf.text "INCOME", size: 14, style: :bold + pdf.move_down 10 + + income_table_data = [ header_row ] + + @export_data[:income].each do |category_data| + row = [ category_data[:category] ] + + @export_data[:months].each do |month| + amount = category_data[:months][month] || 0 + row << Money.new(amount, Current.family.currency).format + end + + row << Money.new(category_data[:total], Current.family.currency).format + income_table_data << row + end + + # Income totals row + totals_row = [ "TOTAL INCOME" ] + @export_data[:months].each do |month| + month_total = @export_data[:income].sum { |c| c[:months][month] || 0 } + totals_row << Money.new(month_total, Current.family.currency).format + end + grand_income_total = @export_data[:income].sum { |c| c[:total] } + totals_row << Money.new(grand_income_total, Current.family.currency).format + income_table_data << totals_row + + pdf.table(income_table_data, header: true, width: pdf.bounds.width, cell_style: { size: 8 }) do + row(0).font_style = :bold + row(0).background_color = "CCFFCC" + row(-1).font_style = :bold + row(-1).background_color = "99FF99" + columns(0).align = :left + columns(1..-1).align = :right + self.row_colors = [ "FFFFFF", "F9F9F9" ] + end + + pdf.move_down 20 + end + + # Expenses section + if @export_data[:expenses].any? + pdf.text "EXPENSES", size: 14, style: :bold + pdf.move_down 10 + + expenses_table_data = [ header_row ] + + @export_data[:expenses].each do |category_data| + row = [ category_data[:category] ] + + @export_data[:months].each do |month| + amount = category_data[:months][month] || 0 + row << Money.new(amount, Current.family.currency).format + end + + row << Money.new(category_data[:total], Current.family.currency).format + expenses_table_data << row + end + + # Expenses totals row + totals_row = [ "TOTAL EXPENSES" ] + @export_data[:months].each do |month| + month_total = @export_data[:expenses].sum { |c| c[:months][month] || 0 } + totals_row << Money.new(month_total, Current.family.currency).format + end + grand_expenses_total = @export_data[:expenses].sum { |c| c[:total] } + totals_row << Money.new(grand_expenses_total, Current.family.currency).format + expenses_table_data << totals_row + + pdf.table(expenses_table_data, header: true, width: pdf.bounds.width, cell_style: { size: 8 }) do + row(0).font_style = :bold + row(0).background_color = "FFCCCC" + row(-1).font_style = :bold + row(-1).background_color = "FF9999" + columns(0).align = :left + columns(1..-1).align = :right + self.row_colors = [ "FFFFFF", "F9F9F9" ] + end + end + else + pdf.text "No transactions found for this period.", size: 12 + end + end.render + end + + # Export Authentication - handles both session and API key auth + def authenticate_for_export + if api_key_present? + # Use API key authentication + authenticate_with_api_key + else + # Use normal session authentication + authenticate_user! + end + end + + # API Key Authentication Methods + def api_key_present? + params[:api_key].present? || request.headers["X-Api-Key"].present? + end + + def authenticate_with_api_key + api_key_value = params[:api_key] || request.headers["X-Api-Key"] + + unless api_key_value + render plain: "API key is required", status: :unauthorized + return false + end + + @api_key = ApiKey.find_by_value(api_key_value) + + unless @api_key && @api_key.active? + render plain: "Invalid or expired API key", status: :unauthorized + return false + end + + # Check if API key has read permissions + unless @api_key.scopes&.include?("read") || @api_key.scopes&.include?("read_write") + render plain: "API key does not have read permission", status: :forbidden + return false + end + + # Set up the current user and session context + @current_user = @api_key.user + @api_key.update_last_used! + + # Set up Current context for API requests (similar to Api::V1::BaseController) + # Return false if setup fails to halt the filter chain + return false unless setup_current_context_for_api_key + + true + end + + def setup_current_context_for_api_key + unless @current_user + render plain: "User not found for API key", status: :internal_server_error + return false + end + + # Find or create a session for this API request + # We need to find or create a persisted session so that Current.user delegation works properly + session = @current_user.sessions.first_or_create!( + user_agent: request.user_agent, + ip_address: request.ip + ) + + Current.session = session + + # Verify the delegation chain works + unless Current.user + render plain: "Failed to establish user context", status: :internal_server_error + return false + end + + # Ensure we have a valid family context + unless Current.family + render plain: "User does not have an associated family", status: :internal_server_error + return false + end + + true + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 5083adcb8..fd4f45dc7 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -5,6 +5,22 @@ class SessionsController < ApplicationController layout "auth" def new + begin + demo = Rails.application.config_for(:demo) + @prefill_demo_credentials = demo_host_match?(demo) + if @prefill_demo_credentials + @email = params[:email].presence || demo["email"] + @password = params[:password].presence || demo["password"] + else + @email = params[:email] + @password = params[:password] + end + rescue RuntimeError, Errno::ENOENT, Psych::SyntaxError + # Demo config file missing or malformed - disable demo credential prefilling + @prefill_demo_credentials = false + @email = params[:email] + @password = params[:password] + end end def create @@ -76,4 +92,10 @@ class SessionsController < ApplicationController def set_session @session = Current.user.sessions.find(params[:id]) end + + def demo_host_match?(demo) + return false unless demo.present? && demo["hosts"].present? + + demo["hosts"].include?(request.host) + end end diff --git a/app/controllers/settings/bank_sync_controller.rb b/app/controllers/settings/bank_sync_controller.rb index 21f3cda31..fd0f6e2d8 100644 --- a/app/controllers/settings/bank_sync_controller.rb +++ b/app/controllers/settings/bank_sync_controller.rb @@ -18,9 +18,11 @@ class Settings::BankSyncController < ApplicationController rel: "noopener noreferrer" }, { - name: "SimpleFin", - description: "US & Canada connections via SimpleFin protocol.", - path: simplefin_items_path + name: "SimpleFIN", + description: "US & Canada connections via SimpleFIN protocol.", + path: "https://beta-bridge.simplefin.org", + target: "_blank", + rel: "noopener noreferrer" } ] end diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index c86a6dac0..2fb0c9df7 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -10,13 +10,32 @@ class Settings::HostingsController < ApplicationController [ "Home", root_path ], [ "Self-Hosting", nil ] ] - twelve_data_provider = Provider::Registry.get_provider(:twelve_data) - @twelve_data_usage = twelve_data_provider&.usage + + # 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 + + # 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" + + # Only fetch provider data if we're showing the section + if @show_twelve_data_settings + twelve_data_provider = Provider::Registry.get_provider(:twelve_data) + @twelve_data_usage = twelve_data_provider&.usage + end + + if @show_yahoo_finance_settings + @yahoo_finance_provider = Provider::Registry.get_provider(:yahoo_finance) + end end def update - if hosting_params.key?(:require_invite_for_signup) - Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup] + if hosting_params.key?(:onboarding_state) + onboarding_state = hosting_params[:onboarding_state].to_s + Setting.onboarding_state = onboarding_state end if hosting_params.key?(:require_email_confirmation) @@ -31,6 +50,14 @@ class Settings::HostingsController < ApplicationController Setting.twelve_data_api_key = hosting_params[:twelve_data_api_key] end + if hosting_params.key?(:exchange_rate_provider) + Setting.exchange_rate_provider = hosting_params[:exchange_rate_provider] + end + + if hosting_params.key?(:securities_provider) + Setting.securities_provider = hosting_params[:securities_provider] + end + if hosting_params.key?(:openai_access_token) token_param = hosting_params[:openai_access_token].to_s.strip # Ignore blanks and redaction placeholders to prevent accidental overwrite @@ -68,7 +95,7 @@ class Settings::HostingsController < ApplicationController private def hosting_params - params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :exchange_rate_provider, :securities_provider) end def ensure_admin diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb new file mode 100644 index 000000000..0ba123eef --- /dev/null +++ b/app/controllers/settings/providers_controller.rb @@ -0,0 +1,133 @@ +class Settings::ProvidersController < ApplicationController + layout "settings" + + guard_feature unless: -> { self_hosted? } + + before_action :ensure_admin, only: [ :show, :update ] + + def show + @breadcrumbs = [ + [ "Home", root_path ], + [ "Bank Sync Providers", nil ] + ] + + prepare_show_context + end + + def update + # Build index of valid configurable fields with their metadata + Provider::Factory.ensure_adapters_loaded + valid_fields = {} + Provider::ConfigurationRegistry.all.each do |config| + config.fields.each do |field| + valid_fields[field.setting_key.to_s] = field + end + end + + updated_fields = [] + + # Perform all updates within a transaction for consistency + Setting.transaction do + provider_params.each do |param_key, param_value| + # Only process keys that exist in the configuration registry + field = valid_fields[param_key.to_s] + next unless field + + # Clean the value and convert blank/empty strings to nil + value = param_value.to_s.strip + value = nil if value.empty? + + # For secret fields only, skip placeholder values to prevent accidental overwrite + if field.secret && value == "********" + next + end + + key_str = field.setting_key.to_s + + # Check if the setting is a declared field in setting.rb + # Use method_defined? to check if the setter actually exists on the singleton class, + # not just respond_to? which returns true for dynamic fields due to respond_to_missing? + if Setting.singleton_class.method_defined?("#{key_str}=") + # If it's a declared field (e.g., openai_model), set it directly. + # This is safe and uses the proper setter. + Setting.public_send("#{key_str}=", value) + else + # If it's a dynamic field, set it as an individual entry + # Each field is stored independently, preventing race conditions + Setting[key_str] = value + end + + updated_fields << param_key + end + end + + if updated_fields.any? + # Reload provider configurations if needed + reload_provider_configs(updated_fields) + + redirect_to settings_providers_path, notice: "Provider settings updated successfully" + else + redirect_to settings_providers_path, notice: "No changes were made" + end + rescue => error + Rails.logger.error("Failed to update provider settings: #{error.message}") + flash.now[:alert] = "Failed to update provider settings: #{error.message}" + prepare_show_context + render :show, status: :unprocessable_entity + end + + private + def provider_params + # Dynamically permit all provider configuration fields + Provider::Factory.ensure_adapters_loaded + permitted_fields = [] + + Provider::ConfigurationRegistry.all.each do |config| + config.fields.each do |field| + permitted_fields << field.setting_key + end + end + + params.require(:setting).permit(*permitted_fields) + end + + def ensure_admin + redirect_to settings_providers_path, alert: "Not authorized" unless Current.user.admin? + end + + # Reload provider configurations after settings update + def reload_provider_configs(updated_fields) + # Build a set of provider keys that had fields updated + updated_provider_keys = Set.new + + # Look up the provider key directly from the configuration registry + updated_fields.each do |field_key| + Provider::ConfigurationRegistry.all.each do |config| + field = config.fields.find { |f| f.setting_key.to_s == field_key.to_s } + if field + updated_provider_keys.add(field.provider_key) + break + end + end + end + + # Reload configuration for each updated provider + updated_provider_keys.each do |provider_key| + adapter_class = Provider::ConfigurationRegistry.get_adapter_class(provider_key) + adapter_class&.reload_configuration + 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) + 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? + 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) + end +end diff --git a/app/controllers/simplefin_items_controller.rb b/app/controllers/simplefin_items_controller.rb index 3b2696ed3..dd5c5eea4 100644 --- a/app/controllers/simplefin_items_controller.rb +++ b/app/controllers/simplefin_items_controller.rb @@ -1,5 +1,6 @@ class SimplefinItemsController < ApplicationController - before_action :set_simplefin_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + include SimplefinItems::MapsHelper + before_action :set_simplefin_item, only: [ :show, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup ] def index @simplefin_items = Current.family.simplefin_items.active.ordered @@ -50,7 +51,16 @@ class SimplefinItemsController < ApplicationController # Clear any requires_update status on new item updated_item.update!(status: :good) - redirect_to accounts_path, notice: t(".success") + if turbo_frame_request? + @simplefin_items = Current.family.simplefin_items.ordered + render turbo_stream: turbo_stream.replace( + "simplefin-providers-panel", + partial: "settings/providers/simplefin_panel", + locals: { simplefin_items: @simplefin_items } + ) + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end rescue ArgumentError, URI::InvalidURIError render_error(t(".errors.invalid_token"), setup_token, context: :edit) rescue Provider::Simplefin::SimplefinError => e @@ -79,10 +89,23 @@ class SimplefinItemsController < ApplicationController begin @simplefin_item = Current.family.create_simplefin_item!( setup_token: setup_token, - item_name: "SimpleFin Connection" + item_name: "SimpleFIN Connection" ) - redirect_to accounts_path, notice: t(".success") + if turbo_frame_request? + flash.now[:notice] = t(".success") + @simplefin_items = Current.family.simplefin_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "simplefin-providers-panel", + partial: "settings/providers/simplefin_panel", + locals: { simplefin_items: @simplefin_items } + ), + *flash_notification_stream_items + ] + else + redirect_to accounts_path, notice: t(".success"), status: :see_other + end rescue ArgumentError, URI::InvalidURIError render_error(t(".errors.invalid_token"), setup_token) rescue Provider::Simplefin::SimplefinError => e @@ -100,8 +123,14 @@ class SimplefinItemsController < ApplicationController end def destroy + # Ensure we detach provider links and legacy associations before scheduling deletion + begin + @simplefin_item.unlink_all!(dry_run: false) + rescue => e + Rails.logger.warn("SimpleFin unlink during destroy failed: #{e.class} - #{e.message}") + end @simplefin_item.destroy_later - redirect_to accounts_path, notice: t(".success") + redirect_to accounts_path, notice: t(".success"), status: :see_other end def sync @@ -115,9 +144,21 @@ class SimplefinItemsController < ApplicationController end end + # Starts a balances-only sync for this SimpleFin item + def balances + sync = @simplefin_item.syncs.create!(status: :pending, sync_stats: { "balances_only" => true }) + SimplefinItem::Syncer.new(@simplefin_item).perform_sync(sync) + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { render json: { ok: true, sync_id: sync.id } } + end + end + def setup_accounts @simplefin_accounts = @simplefin_item.simplefin_accounts.includes(:account).where(accounts: { id: nil }) @account_type_options = [ + [ "Skip this account", "skip" ], [ "Checking or Savings Account", "Depository" ], [ "Credit Card", "CreditCard" ], [ "Investment Account", "Investment" ], @@ -125,6 +166,28 @@ class SimplefinItemsController < ApplicationController [ "Other Asset", "OtherAsset" ] ] + # Compute UI-only suggestions (preselect only when high confidence) + @inferred_map = {} + @simplefin_accounts.each do |sfa| + holdings = sfa.raw_holdings_payload.presence || sfa.raw_payload.to_h["holdings"] + institution_name = nil + begin + od = sfa.org_data + institution_name = od["name"] if od.is_a?(Hash) + rescue + institution_name = nil + end + inf = Simplefin::AccountTypeMapper.infer( + name: sfa.name, + holdings: holdings, + extra: sfa.extra, + balance: sfa.current_balance, + available_balance: sfa.available_balance, + institution: institution_name + ) + @inferred_map[sfa.id] = { type: inf.accountable_type, subtype: inf.subtype, confidence: inf.confidence } + end + # Subtype options for each account type @subtype_options = { "Depository" => { @@ -161,8 +224,38 @@ class SimplefinItemsController < ApplicationController @simplefin_item.update!(sync_start_date: params[:sync_start_date]) end + # Valid account types for this provider (plus OtherAsset which SimpleFIN UI allows) + valid_types = Provider::SimplefinAdapter.supported_account_types + [ "OtherAsset" ] + + created_accounts = [] + skipped_count = 0 + account_types.each do |simplefin_account_id, selected_type| - simplefin_account = @simplefin_item.simplefin_accounts.find(simplefin_account_id) + # Skip accounts marked as "skip" + if selected_type == "skip" || selected_type.blank? + skipped_count += 1 + next + end + + # Validate account type is supported + unless valid_types.include?(selected_type) + Rails.logger.warn("Invalid account type '#{selected_type}' submitted for SimpleFIN account #{simplefin_account_id}") + next + end + + # Find account - scoped to this item to prevent cross-item manipulation + simplefin_account = @simplefin_item.simplefin_accounts.find_by(id: simplefin_account_id) + unless simplefin_account + Rails.logger.warn("SimpleFIN account #{simplefin_account_id} not found for item #{@simplefin_item.id}") + next + end + + # Skip if already linked (race condition protection) + if simplefin_account.account.present? + Rails.logger.info("SimpleFIN account #{simplefin_account_id} already linked, skipping") + next + end + selected_subtype = account_subtypes[simplefin_account_id] # Default subtype for CreditCard since it only has one option @@ -175,19 +268,273 @@ class SimplefinItemsController < ApplicationController selected_subtype ) simplefin_account.update!(account: account) + created_accounts << account end # Clear pending status and mark as complete @simplefin_item.update!(pending_account_setup: false) # Trigger a sync to process the imported SimpleFin data (transactions and holdings) - @simplefin_item.sync_later + @simplefin_item.sync_later if created_accounts.any? - redirect_to accounts_path, notice: t(".success") + # Set appropriate flash message + if created_accounts.any? + flash[:notice] = t(".success", count: created_accounts.count) + elsif skipped_count > 0 + flash[:notice] = t(".all_skipped") + else + flash[:notice] = t(".no_accounts") + end + if turbo_frame_request? + # Recompute data needed by Accounts#index partials + @manual_accounts = Account.uncached { + Current.family.accounts + .visible_manual + .order(:name) + .to_a + } + @simplefin_items = Current.family.simplefin_items.ordered.includes(:syncs) + build_simplefin_maps_for(@simplefin_items) + + 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(@simplefin_item), + partial: "simplefin_items/simplefin_item", + locals: { simplefin_item: @simplefin_item } + ) + ] + Array(flash_notification_stream_items) + else + redirect_to accounts_path, status: :see_other + end end + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + + # Filter out SimpleFIN accounts that are already linked to any account + # (either via account_provider or legacy account association) + @available_simplefin_accounts = Current.family.simplefin_items + .includes(:simplefin_accounts) + .flat_map(&:simplefin_accounts) + .reject { |sfa| sfa.account_provider.present? || sfa.account.present? } + .sort_by { |sfa| sfa.updated_at || sfa.created_at } + .reverse + + # Always render a modal: either choices or a helpful empty-state + render :select_existing_account, layout: false + end + + def link_existing_account + @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] = "Only manual accounts can be linked" + if turbo_frame_request? + return render turbo_stream: Array(flash_notification_stream_items) + else + return redirect_to account_path(@account), alert: flash[:alert] + end + end + + # Verify the SimpleFIN account belongs to this family's SimpleFIN items + unless Current.family.simplefin_items.include?(simplefin_account.simplefin_item) + flash[:alert] = "Invalid SimpleFIN account selected" + if turbo_frame_request? + render turbo_stream: Array(flash_notification_stream_items) + else + redirect_to account_path(@account), alert: flash[:alert] + end + return + end + + # Relink behavior: detach any legacy link and point provider link at the chosen account + Account.transaction do + simplefin_account.lock! + # Clear legacy association if present + if simplefin_account.account_id.present? + simplefin_account.update!(account_id: nil) + end + + # Upsert the AccountProvider mapping deterministically + ap = AccountProvider.find_or_initialize_by(provider: simplefin_account) + previous_account = ap.account + ap.account_id = @account.id + ap.save! + + # If the provider was previously linked to a different account in this family, + # and that account is now orphaned, quietly disable it so it disappears from the + # visible manual list. This mirrors the unified flow expectation that the provider + # follows the chosen account. + if previous_account && previous_account.id != @account.id && previous_account.family_id == @account.family_id + previous_account.disable! rescue nil + end + end + + if turbo_frame_request? + # Reload the item to ensure associations are fresh + simplefin_account.reload + item = simplefin_account.simplefin_item + item.reload + + # Recompute data needed by Accounts#index partials + @manual_accounts = Account.uncached { + Current.family.accounts + .visible_manual + .order(:name) + .to_a + } + @simplefin_items = Current.family.simplefin_items.ordered.includes(:syncs) + build_simplefin_maps_for(@simplefin_items) + + flash[:notice] = "Account successfully linked to SimpleFIN" + @account.reload + 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: [ + # Optimistic removal of the specific account row if it exists in the DOM + turbo_stream.remove(ActionView::RecordIdentifier.dom_id(@account)), + manual_accounts_stream, + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(item), + partial: "simplefin_items/simplefin_item", + locals: { simplefin_item: item } + ), + turbo_stream.replace("modal", view_context.turbo_frame_tag("modal")) + ] + Array(flash_notification_stream_items) + else + redirect_to accounts_path(cache_bust: SecureRandom.hex(6)), notice: "Account successfully linked to SimpleFIN", status: :see_other + end + end + + private + NAME_NORM_RE = /\s+/.freeze + + + def normalize_name(str) + s = str.to_s.downcase.strip + return s if s.empty? + s.gsub(NAME_NORM_RE, " ") + end + + def compute_relink_candidates + # Best-effort dedup before building candidates + @simplefin_item.dedup_simplefin_accounts! rescue nil + + family = @simplefin_item.family + manuals = Account.visible_manual.where(family_id: family.id).to_a + + # Evaluate only one SimpleFin account per upstream account_id (prefer linked, else newest) + grouped = @simplefin_item.simplefin_accounts.group_by(&:account_id) + sfas = grouped.values.map { |list| list.find { |s| s.current_account.present? } || list.max_by(&:updated_at) } + + Rails.logger.info("SimpleFin compute_relink_candidates: manuals=#{manuals.size} sfas=#{sfas.size} (item_id=#{@simplefin_item.id})") + + used_manual_ids = Set.new + pairs = [] + + sfas.each do |sfa| + next if sfa.name.blank? + # Heuristics (with ambiguity guards): last4 > balance ±0.01 > name + raw = (sfa.raw_payload || {}).with_indifferent_access + sfa_last4 = raw[:mask] || raw[:last4] || raw[:"last-4"] || raw[:"account_number_last4"] + sfa_last4 = sfa_last4.to_s.strip.presence + sfa_balance = (sfa.current_balance || sfa.available_balance).to_d rescue 0.to_d + + chosen = nil + reason = nil + + # 1) last4 match: compute all candidates not yet used + if sfa_last4.present? + last4_matches = manuals.reject { |a| used_manual_ids.include?(a.id) }.select do |a| + a_last4 = nil + %i[mask last4 number_last4 account_number_last4].each do |k| + if a.respond_to?(k) + val = a.public_send(k) + a_last4 = val.to_s.strip.presence if val.present? + break if a_last4 + end + end + a_last4.present? && a_last4 == sfa_last4 + end + # Ambiguity guard: skip if multiple matches + if last4_matches.size == 1 + cand = last4_matches.first + # Conflict guard: if both have balances and differ wildly, skip + begin + ab = (cand.balance || cand.cash_balance || 0).to_d + if sfa_balance.nonzero? && ab.nonzero? && (ab - sfa_balance).abs > BigDecimal("1.00") + cand = nil + end + rescue + # ignore balance parsing errors + end + if cand + chosen = cand + reason = "last4" + end + end + end + + # 2) balance proximity + if chosen.nil? && sfa_balance.nonzero? + balance_matches = manuals.reject { |a| used_manual_ids.include?(a.id) }.select do |a| + begin + ab = (a.balance || a.cash_balance || 0).to_d + (ab - sfa_balance).abs <= BigDecimal("0.01") + rescue + false + end + end + if balance_matches.size == 1 + chosen = balance_matches.first + reason = "balance" + end + end + + # 3) exact normalized name + if chosen.nil? + name_matches = manuals.reject { |a| used_manual_ids.include?(a.id) }.select { |a| normalize_name(a.name) == normalize_name(sfa.name) } + if name_matches.size == 1 + chosen = name_matches.first + reason = "name" + end + end + + if chosen + used_manual_ids << chosen.id + pairs << { sfa_id: sfa.id, sfa_name: sfa.name, manual_id: chosen.id, manual_name: chosen.name, reason: reason } + end + end + + Rails.logger.info("SimpleFin compute_relink_candidates: built #{pairs.size} pairs (item_id=#{@simplefin_item.id})") + + # Return without the reason field to the view + pairs.map { |p| p.slice(:sfa_id, :sfa_name, :manual_id, :manual_name) } + end + def set_simplefin_item @simplefin_item = Current.family.simplefin_items.find(params[:id]) end @@ -204,6 +551,17 @@ class SimplefinItemsController < ApplicationController @simplefin_item = Current.family.simplefin_items.build(setup_token: setup_token) end @error_message = message - render context, status: :unprocessable_entity + + if turbo_frame_request? + # Re-render the SimpleFIN providers panel in place to avoid "Content missing" + @simplefin_items = Current.family.simplefin_items.ordered + render turbo_stream: turbo_stream.replace( + "simplefin-providers-panel", + partial: "settings/providers/simplefin_panel", + locals: { simplefin_items: @simplefin_items } + ), status: :unprocessable_entity + else + render context, status: :unprocessable_entity + end end end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 707fa5d4f..aec15157b 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -22,6 +22,14 @@ class TransactionsController < ApplicationController ) @pagy, @transactions = pagy(base_scope, limit: per_page) + + # Load projected recurring transactions for next month + @projected_recurring = Current.family.recurring_transactions + .active + .where("next_expected_date <= ? AND next_expected_date >= ?", + 1.month.from_now.to_date, + Date.current) + .includes(:merchant) end def clear_filter @@ -108,6 +116,49 @@ class TransactionsController < ApplicationController end end + def mark_as_recurring + transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + + # Check if a recurring transaction already exists for this pattern + existing = Current.family.recurring_transactions.find_by( + merchant_id: transaction.merchant_id, + name: transaction.merchant_id.present? ? nil : transaction.entry.name, + currency: transaction.entry.currency, + manual: true + ) + + if existing + flash[:alert] = t("recurring_transactions.already_exists") + redirect_back_or_to transactions_path + return + end + + begin + recurring_transaction = RecurringTransaction.create_from_transaction(transaction) + + respond_to do |format| + format.html do + flash[:notice] = t("recurring_transactions.marked_as_recurring") + redirect_back_or_to transactions_path + end + end + rescue ActiveRecord::RecordInvalid => e + respond_to do |format| + format.html do + flash[:alert] = t("recurring_transactions.creation_failed") + redirect_back_or_to transactions_path + end + end + rescue StandardError => e + respond_to do |format| + format.html do + flash[:alert] = t("recurring_transactions.unexpected_error") + redirect_back_or_to transactions_path + end + end + end + end + private def per_page params[:per_page].to_i.positive? ? params[:per_page].to_i : 20 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 87beba71c..067abbd25 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -2,6 +2,14 @@ class UsersController < ApplicationController before_action :set_user before_action :ensure_admin, only: %i[reset reset_with_sample_data] + def resend_confirmation_email + if @user.resend_confirmation_email + redirect_to settings_profile_path, notice: t(".success") + else + redirect_to settings_profile_path, alert: t("no_pending_change") + end + end + def update @user = Current.user diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index de809abe4..99ef891ce 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -5,12 +5,7 @@ module AccountsHelper end def sync_path_for(account) - if account.plaid_account_id.present? - sync_plaid_item_path(account.plaid_account.plaid_item) - elsif account.simplefin_account_id.present? - sync_simplefin_item_path(account.simplefin_account.simplefin_item) - else - sync_account_path(account) - end + # Always use the account sync path, which handles syncing all providers + sync_account_path(account) end end diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index cabc31acb..1ad7587b9 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -22,7 +22,11 @@ module ImportsHelper ticker: "Ticker", exchange: "Exchange", price: "Price", - entity_type: "Type" + entity_type: "Type", + category_parent: "Parent category", + category_color: "Color", + category_classification: "Classification", + category_icon: "Lucide icon" }[key] end @@ -62,7 +66,7 @@ module ImportsHelper private def permitted_import_types - %w[transaction_import trade_import account_import mint_import] + %w[transaction_import trade_import account_import mint_import category_import] end DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true) diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index c26a692e1..6bd65c563 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -153,6 +153,17 @@ module LanguagesHelper "en-IND" ].freeze + # Locales with complete/extensive translations + SUPPORTED_LOCALES = [ + "en", # English - 61 translation files + "de", # German - 62 translation files + "es", # Spanish - 60 translation files + "tr", # Turkish - 57 translation files + "nb", # Norwegian Bokmål - 56 translation files + "ca", # Catalan - 56 translation files + "ro" # Romanian - 61 translation files + ].freeze + COUNTRY_MAPPING = { AF: "🇦🇫 Afghanistan", AL: "🇦🇱 Albania", @@ -356,7 +367,7 @@ module LanguagesHelper def language_options I18n.available_locales - .reject { |locale| EXCLUDED_LOCALES.include?(locale.to_s) } + .select { |locale| SUPPORTED_LOCALES.include?(locale.to_s) } .map do |locale| label = LANGUAGE_MAPPING[locale.to_sym] || locale.to_s.humanize [ "#{label} (#{locale})", locale ] diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 5b6f51186..cdce24be4 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -12,12 +12,15 @@ module SettingsHelper { name: "Tags", path: :tags_path }, { name: "Rules", path: :rules_path }, { name: "Merchants", path: :family_merchants_path }, + { name: "Recurring", path: :recurring_transactions_path }, # Advanced section - { name: "AI Prompts", path: :settings_ai_prompts_path }, - { name: "API Key", path: :settings_api_key_path }, - { name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted? }, - { name: "Imports", path: :imports_path }, - { name: "SimpleFin", path: :simplefin_items_path }, + { 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: "SimpleFin", path: :simplefin_items_path, condition: :admin_user? }, # More section { name: "Guides", path: :settings_guides_path }, { name: "What's new", path: :changelog_path }, @@ -41,9 +44,9 @@ module SettingsHelper } end - def settings_section(title:, subtitle: nil, &block) + def settings_section(title:, subtitle: nil, collapsible: false, open: true, &block) content = capture(&block) - render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content } + render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open } end def settings_nav_footer @@ -70,4 +73,13 @@ module SettingsHelper def not_self_hosted? !self_hosted? end + + # Helper used by SETTINGS_ORDER conditions + def admin_user? + Current.user&.admin? + end + + def self_hosted_and_admin? + self_hosted? && admin_user? + end end diff --git a/app/helpers/simplefin_items_helper.rb b/app/helpers/simplefin_items_helper.rb new file mode 100644 index 000000000..d141bdf17 --- /dev/null +++ b/app/helpers/simplefin_items_helper.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# View helpers for SimpleFin UI rendering +module SimplefinItemsHelper + # Builds a compact tooltip text summarizing sync errors from a stats hash. + # The stats structure comes from SimplefinItem::Importer and Sync records. + # Returns nil when there is nothing meaningful to display. + # + # Example structure: + # { + # "total_errors" => 3, + # "errors" => [ { "name" => "Chase", "message" => "Timeout" }, ... ], + # "error_buckets" => { "auth" => 1, "api" => 2 } + # } + def simplefin_error_tooltip(stats) + return nil unless stats.is_a?(Hash) + + total_errors = stats["total_errors"].to_i + return nil if total_errors.zero? + + # Build a small, de-duplicated sample of messages with counts + grouped = Array(stats["errors"]).map { |e| + name = (e[:name] || e["name"]).to_s + msg = (e[:message] || e["message"]).to_s + text = name.present? ? "#{name}: #{msg}" : msg + text.strip + }.reject(&:blank?).tally + + sample = grouped.first(2).map { |text, count| count > 1 ? "#{text} (×#{count})" : text }.join(" • ") + + buckets = stats["error_buckets"] || {} + bucket_text = if buckets.present? + buckets.map { |k, v| "#{k}: #{v}" }.join(", ") + end + + parts = [ "Errors: ", total_errors.to_s ] + parts << " (#{bucket_text})" if bucket_text.present? + parts << " — #{sample}" if sample.present? + parts.join + end +end diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb index 173306b91..024d742be 100644 --- a/app/helpers/transactions_helper.rb +++ b/app/helpers/transactions_helper.rb @@ -18,4 +18,61 @@ module TransactionsHelper def get_default_transaction_search_filter transaction_search_filters[0] 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). + # Structure: + # { + # kind: :simplefin | :raw, + # simplefin: { payee:, description:, memo: }, + # provider_extras: [ { key:, value:, title: } ], + # raw: String (pretty JSON or string) + # } + def build_transaction_extra_details(obj) + tx = obj.respond_to?(:transaction) ? obj.transaction : obj + return nil unless tx.respond_to?(:extra) && tx.extra.present? + + extra = tx.extra + + if extra.is_a?(Hash) && extra["simplefin"].present? + sf = extra["simplefin"] + simple = { + payee: sf.is_a?(Hash) ? sf["payee"].presence : nil, + description: sf.is_a?(Hash) ? sf["description"].presence : nil, + memo: sf.is_a?(Hash) ? sf["memo"].presence : nil + }.compact + + extras = [] + if sf.is_a?(Hash) && sf["extra"].is_a?(Hash) && sf["extra"].present? + sf["extra"].each do |k, v| + display = (v.is_a?(Hash) || v.is_a?(Array)) ? v.to_json : v + extras << { + key: k.to_s.humanize, + value: display, + title: (v.is_a?(String) ? v : display.to_s) + } + end + end + + { + kind: :simplefin, + simplefin: simple, + provider_extras: extras, + raw: nil + } + else + pretty = begin + JSON.pretty_generate(extra) + rescue StandardError + extra.to_s + end + { + kind: :raw, + simplefin: {}, + provider_extras: [], + raw: pretty + } + end + end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 12751637b..44dcb4a84 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -5,3 +5,16 @@ import "controllers"; Turbo.StreamActions.redirect = function () { Turbo.visit(this.target); }; + +// Register service worker for PWA offline support +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/service-worker') + .then(registration => { + console.log('Service Worker registered with scope:', registration.scope); + }) + .catch(error => { + console.log('Service Worker registration failed:', error); + }); + }); +} diff --git a/app/javascript/controllers/dashboard_section_controller.js b/app/javascript/controllers/dashboard_section_controller.js new file mode 100644 index 000000000..6c7067ebe --- /dev/null +++ b/app/javascript/controllers/dashboard_section_controller.js @@ -0,0 +1,97 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["content", "chevron", "container", "button"]; + static values = { + sectionKey: String, + collapsed: Boolean, + }; + + connect() { + if (this.collapsedValue) { + this.collapse(false); + } + } + + toggle(event) { + event.preventDefault(); + if (this.collapsedValue) { + this.expand(); + } else { + this.collapse(); + } + } + + handleToggleKeydown(event) { + // Handle Enter and Space keys for keyboard accessibility + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); // Prevent section's keyboard handler from firing + this.toggle(event); + } + } + + collapse(persist = true) { + this.contentTarget.classList.add("hidden"); + this.chevronTarget.classList.add("rotate-180"); + this.collapsedValue = true; + if (this.hasButtonTarget) { + this.buttonTarget.setAttribute("aria-expanded", "false"); + } + if (persist) { + this.savePreference(true); + } + } + + expand() { + this.contentTarget.classList.remove("hidden"); + this.chevronTarget.classList.remove("rotate-180"); + this.collapsedValue = false; + if (this.hasButtonTarget) { + this.buttonTarget.setAttribute("aria-expanded", "true"); + } + this.savePreference(false); + } + + async savePreference(collapsed) { + const preferences = { + collapsed_sections: { + [this.sectionKeyValue]: collapsed, + }, + }; + + // Safely obtain CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error( + "[Dashboard Section] CSRF token not found. Cannot save preferences.", + ); + return; + } + + try { + const response = await fetch("/dashboard/preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ preferences }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error( + "[Dashboard Section] Failed to save preferences:", + response.status, + errorData, + ); + } + } catch (error) { + console.error( + "[Dashboard Section] Network error saving preferences:", + error, + ); + } + } +} diff --git a/app/javascript/controllers/dashboard_sortable_controller.js b/app/javascript/controllers/dashboard_sortable_controller.js new file mode 100644 index 000000000..d44d47c92 --- /dev/null +++ b/app/javascript/controllers/dashboard_sortable_controller.js @@ -0,0 +1,267 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["section"]; + + connect() { + this.draggedElement = null; + this.placeholder = null; + this.touchStartY = 0; + this.currentTouchY = 0; + this.isTouching = false; + this.keyboardGrabbedElement = null; + } + + // ===== Mouse Drag Events ===== + dragStart(event) { + this.draggedElement = event.currentTarget; + this.draggedElement.classList.add("opacity-50"); + this.draggedElement.setAttribute("aria-grabbed", "true"); + event.dataTransfer.effectAllowed = "move"; + } + + dragEnd(event) { + event.currentTarget.classList.remove("opacity-50"); + event.currentTarget.setAttribute("aria-grabbed", "false"); + this.clearPlaceholders(); + } + + dragOver(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + + const afterElement = this.getDragAfterElement(event.clientY); + const container = this.element; + + this.clearPlaceholders(); + + if (afterElement == null) { + this.showPlaceholder(container.lastElementChild, "after"); + } else { + this.showPlaceholder(afterElement, "before"); + } + } + + drop(event) { + event.preventDefault(); + event.stopPropagation(); + + const afterElement = this.getDragAfterElement(event.clientY); + const container = this.element; + + if (afterElement == null) { + container.appendChild(this.draggedElement); + } else { + container.insertBefore(this.draggedElement, afterElement); + } + + this.clearPlaceholders(); + this.saveOrder(); + } + + // ===== Touch Events ===== + touchStart(event) { + this.draggedElement = event.currentTarget; + this.touchStartY = event.touches[0].clientY; + this.isTouching = true; + this.draggedElement.classList.add("opacity-50", "scale-105"); + this.draggedElement.setAttribute("aria-grabbed", "true"); + } + + touchMove(event) { + if (!this.isTouching || !this.draggedElement) return; + + event.preventDefault(); + this.currentTouchY = event.touches[0].clientY; + + const afterElement = this.getDragAfterElement(this.currentTouchY); + this.clearPlaceholders(); + + if (afterElement == null) { + this.showPlaceholder(this.element.lastElementChild, "after"); + } else { + this.showPlaceholder(afterElement, "before"); + } + } + + touchEnd(event) { + if (!this.isTouching || !this.draggedElement) return; + + const afterElement = this.getDragAfterElement(this.currentTouchY); + const container = this.element; + + if (afterElement == null) { + container.appendChild(this.draggedElement); + } else { + container.insertBefore(this.draggedElement, afterElement); + } + + this.draggedElement.classList.remove("opacity-50", "scale-105"); + this.draggedElement.setAttribute("aria-grabbed", "false"); + this.clearPlaceholders(); + this.saveOrder(); + + this.isTouching = false; + this.draggedElement = null; + } + + // ===== Keyboard Navigation ===== + handleKeyDown(event) { + const currentSection = event.currentTarget; + + switch (event.key) { + case "ArrowUp": + event.preventDefault(); + if (this.keyboardGrabbedElement === currentSection) { + this.moveUp(currentSection); + } + break; + case "ArrowDown": + event.preventDefault(); + if (this.keyboardGrabbedElement === currentSection) { + this.moveDown(currentSection); + } + break; + case "Enter": + case " ": + event.preventDefault(); + this.toggleGrabMode(currentSection); + break; + case "Escape": + if (this.keyboardGrabbedElement) { + event.preventDefault(); + this.releaseKeyboardGrab(); + } + break; + } + } + + toggleGrabMode(section) { + if (this.keyboardGrabbedElement === section) { + this.releaseKeyboardGrab(); + } else { + this.grabWithKeyboard(section); + } + } + + grabWithKeyboard(section) { + // Release any previously grabbed element + if (this.keyboardGrabbedElement) { + this.releaseKeyboardGrab(); + } + + this.keyboardGrabbedElement = section; + section.setAttribute("aria-grabbed", "true"); + section.classList.add("ring-2", "ring-primary", "ring-offset-2"); + } + + releaseKeyboardGrab() { + if (this.keyboardGrabbedElement) { + this.keyboardGrabbedElement.setAttribute("aria-grabbed", "false"); + this.keyboardGrabbedElement.classList.remove( + "ring-2", + "ring-primary", + "ring-offset-2", + ); + this.keyboardGrabbedElement = null; + this.saveOrder(); + } + } + + moveUp(section) { + const previousSibling = section.previousElementSibling; + if (previousSibling?.hasAttribute("data-section-key")) { + this.element.insertBefore(section, previousSibling); + section.focus(); + } + } + + moveDown(section) { + const nextSibling = section.nextElementSibling; + if (nextSibling?.hasAttribute("data-section-key")) { + this.element.insertBefore(nextSibling, section); + section.focus(); + } + } + + getDragAfterElement(y) { + const draggableElements = [ + ...this.sectionTargets.filter((section) => section !== this.draggedElement), + ]; + + return draggableElements.reduce( + (closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + + if (offset < 0 && offset > closest.offset) { + return { offset: offset, element: child }; + } + return closest; + }, + { offset: Number.NEGATIVE_INFINITY }, + ).element; + } + + showPlaceholder(element, position) { + if (!element) return; + + if (position === "before") { + element.classList.add("border-t-4", "border-primary"); + } else { + element.classList.add("border-b-4", "border-primary"); + } + } + + clearPlaceholders() { + this.sectionTargets.forEach((section) => { + section.classList.remove( + "border-t-4", + "border-b-4", + "border-primary", + "border-t-2", + "border-b-2", + ); + }); + } + + async saveOrder() { + const order = this.sectionTargets.map( + (section) => section.dataset.sectionKey, + ); + + // Safely obtain CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error( + "[Dashboard Sortable] CSRF token not found. Cannot save section order.", + ); + return; + } + + try { + const response = await fetch("/dashboard/preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ preferences: { section_order: order } }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error( + "[Dashboard Sortable] Failed to save section order:", + response.status, + errorData, + ); + } + } catch (error) { + console.error( + "[Dashboard Sortable] Network error saving section order:", + error, + ); + } + } +} diff --git a/app/javascript/controllers/lunchflow_preload_controller.js b/app/javascript/controllers/lunchflow_preload_controller.js new file mode 100644 index 000000000..38d05cd33 --- /dev/null +++ b/app/javascript/controllers/lunchflow_preload_controller.js @@ -0,0 +1,99 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="lunchflow-preload" +export default class extends Controller { + static targets = ["link", "spinner"]; + static values = { + accountableType: String, + returnTo: String, + }; + + connect() { + this.preloadAccounts(); + } + + async preloadAccounts() { + try { + // Show loading state if we have a link target (on method selector page) + if (this.hasLinkTarget) { + this.showLoading(); + } + + // Fetch accounts in background to populate cache + const url = new URL( + "/lunchflow_items/preload_accounts", + window.location.origin + ); + if (this.hasAccountableTypeValue) { + url.searchParams.append("accountable_type", this.accountableTypeValue); + } + if (this.hasReturnToValue) { + url.searchParams.append("return_to", this.returnToValue); + } + + const csrfToken = document.querySelector('[name="csrf-token"]'); + const headers = { + Accept: "application/json", + }; + if (csrfToken) { + headers["X-CSRF-Token"] = csrfToken.content; + } + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + if (data.success && data.has_accounts) { + // Accounts loaded successfully, enable the link + if (this.hasLinkTarget) { + this.hideLoading(); + } + } else if (data.error === "no_credentials") { + // No credentials configured - keep link visible so user can see setup message + if (this.hasLinkTarget) { + this.hideLoading(); + } + } else if (data.has_accounts === false) { + // Credentials configured and API works, but no accounts available - hide the link + if (this.hasLinkTarget) { + this.linkTarget.style.display = "none"; + } + } else if (data.has_accounts === null || data.error === "api_error" || data.error === "unexpected_error") { + // API error (bad credentials, network issue, etc) - keep link visible, user will see error when clicked + if (this.hasLinkTarget) { + this.hideLoading(); + } + } else { + // Other error - keep link visible + if (this.hasLinkTarget) { + this.hideLoading(); + } + console.error("Failed to preload Lunchflow accounts:", data.error); + } + } catch (error) { + // On error, still enable the link so user can try + if (this.hasLinkTarget) { + this.hideLoading(); + } + console.error("Error preloading Lunchflow accounts:", error); + } + } + + showLoading() { + this.linkTarget.classList.add("pointer-events-none", "opacity-50"); + if (this.hasSpinnerTarget) { + this.spinnerTarget.classList.remove("hidden"); + } + } + + hideLoading() { + this.linkTarget.classList.remove("pointer-events-none", "opacity-50"); + if (this.hasSpinnerTarget) { + this.spinnerTarget.classList.add("hidden"); + } + } +} diff --git a/app/javascript/controllers/reports_section_controller.js b/app/javascript/controllers/reports_section_controller.js new file mode 100644 index 000000000..6ed08a8b7 --- /dev/null +++ b/app/javascript/controllers/reports_section_controller.js @@ -0,0 +1,97 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["content", "chevron", "button"]; + static values = { + sectionKey: String, + collapsed: Boolean, + }; + + connect() { + if (this.collapsedValue) { + this.collapse(false); + } + } + + toggle(event) { + event.preventDefault(); + if (this.collapsedValue) { + this.expand(); + } else { + this.collapse(); + } + } + + handleToggleKeydown(event) { + // Handle Enter and Space keys for keyboard accessibility + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); // Prevent section's keyboard handler from firing + this.toggle(event); + } + } + + collapse(persist = true) { + this.contentTarget.classList.add("hidden"); + this.chevronTarget.classList.add("rotate-180"); + this.collapsedValue = true; + if (this.hasButtonTarget) { + this.buttonTarget.setAttribute("aria-expanded", "false"); + } + if (persist) { + this.savePreference(true); + } + } + + expand() { + this.contentTarget.classList.remove("hidden"); + this.chevronTarget.classList.remove("rotate-180"); + this.collapsedValue = false; + if (this.hasButtonTarget) { + this.buttonTarget.setAttribute("aria-expanded", "true"); + } + this.savePreference(false); + } + + async savePreference(collapsed) { + const preferences = { + reports_collapsed_sections: { + [this.sectionKeyValue]: collapsed, + }, + }; + + // Safely obtain CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error( + "[Reports Section] CSRF token not found. Cannot save preferences.", + ); + return; + } + + try { + const response = await fetch("/reports/update_preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ preferences }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error( + "[Reports Section] Failed to save preferences:", + response.status, + errorData, + ); + } + } catch (error) { + console.error( + "[Reports Section] Network error saving preferences:", + error, + ); + } + } +} diff --git a/app/javascript/controllers/reports_sortable_controller.js b/app/javascript/controllers/reports_sortable_controller.js new file mode 100644 index 000000000..82ddcc2f1 --- /dev/null +++ b/app/javascript/controllers/reports_sortable_controller.js @@ -0,0 +1,267 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["section"]; + + connect() { + this.draggedElement = null; + this.placeholder = null; + this.touchStartY = 0; + this.currentTouchY = 0; + this.isTouching = false; + this.keyboardGrabbedElement = null; + } + + // ===== Mouse Drag Events ===== + dragStart(event) { + this.draggedElement = event.currentTarget; + this.draggedElement.classList.add("opacity-50"); + this.draggedElement.setAttribute("aria-grabbed", "true"); + event.dataTransfer.effectAllowed = "move"; + } + + dragEnd(event) { + event.currentTarget.classList.remove("opacity-50"); + event.currentTarget.setAttribute("aria-grabbed", "false"); + this.clearPlaceholders(); + } + + dragOver(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + + const afterElement = this.getDragAfterElement(event.clientY); + const container = this.element; + + this.clearPlaceholders(); + + if (afterElement == null) { + this.showPlaceholder(container.lastElementChild, "after"); + } else { + this.showPlaceholder(afterElement, "before"); + } + } + + drop(event) { + event.preventDefault(); + event.stopPropagation(); + + const afterElement = this.getDragAfterElement(event.clientY); + const container = this.element; + + if (afterElement == null) { + container.appendChild(this.draggedElement); + } else { + container.insertBefore(this.draggedElement, afterElement); + } + + this.clearPlaceholders(); + this.saveOrder(); + } + + // ===== Touch Events ===== + touchStart(event) { + this.draggedElement = event.currentTarget; + this.touchStartY = event.touches[0].clientY; + this.isTouching = true; + this.draggedElement.classList.add("opacity-50", "scale-105"); + this.draggedElement.setAttribute("aria-grabbed", "true"); + } + + touchMove(event) { + if (!this.isTouching || !this.draggedElement) return; + + event.preventDefault(); + this.currentTouchY = event.touches[0].clientY; + + const afterElement = this.getDragAfterElement(this.currentTouchY); + this.clearPlaceholders(); + + if (afterElement == null) { + this.showPlaceholder(this.element.lastElementChild, "after"); + } else { + this.showPlaceholder(afterElement, "before"); + } + } + + touchEnd(event) { + if (!this.isTouching || !this.draggedElement) return; + + const afterElement = this.getDragAfterElement(this.currentTouchY); + const container = this.element; + + if (afterElement == null) { + container.appendChild(this.draggedElement); + } else { + container.insertBefore(this.draggedElement, afterElement); + } + + this.draggedElement.classList.remove("opacity-50", "scale-105"); + this.draggedElement.setAttribute("aria-grabbed", "false"); + this.clearPlaceholders(); + this.saveOrder(); + + this.isTouching = false; + this.draggedElement = null; + } + + // ===== Keyboard Navigation ===== + handleKeyDown(event) { + const currentSection = event.currentTarget; + + switch (event.key) { + case "ArrowUp": + event.preventDefault(); + if (this.keyboardGrabbedElement === currentSection) { + this.moveUp(currentSection); + } + break; + case "ArrowDown": + event.preventDefault(); + if (this.keyboardGrabbedElement === currentSection) { + this.moveDown(currentSection); + } + break; + case "Enter": + case " ": + event.preventDefault(); + this.toggleGrabMode(currentSection); + break; + case "Escape": + if (this.keyboardGrabbedElement) { + event.preventDefault(); + this.releaseKeyboardGrab(); + } + break; + } + } + + toggleGrabMode(section) { + if (this.keyboardGrabbedElement === section) { + this.releaseKeyboardGrab(); + } else { + this.grabWithKeyboard(section); + } + } + + grabWithKeyboard(section) { + // Release any previously grabbed element + if (this.keyboardGrabbedElement) { + this.releaseKeyboardGrab(); + } + + this.keyboardGrabbedElement = section; + section.setAttribute("aria-grabbed", "true"); + section.classList.add("ring-2", "ring-primary", "ring-offset-2"); + } + + releaseKeyboardGrab() { + if (this.keyboardGrabbedElement) { + this.keyboardGrabbedElement.setAttribute("aria-grabbed", "false"); + this.keyboardGrabbedElement.classList.remove( + "ring-2", + "ring-primary", + "ring-offset-2", + ); + this.keyboardGrabbedElement = null; + this.saveOrder(); + } + } + + moveUp(section) { + const previousSibling = section.previousElementSibling; + if (previousSibling?.hasAttribute("data-section-key")) { + this.element.insertBefore(section, previousSibling); + section.focus(); + } + } + + moveDown(section) { + const nextSibling = section.nextElementSibling; + if (nextSibling?.hasAttribute("data-section-key")) { + this.element.insertBefore(nextSibling, section); + section.focus(); + } + } + + getDragAfterElement(y) { + const draggableElements = [ + ...this.sectionTargets.filter((section) => section !== this.draggedElement), + ]; + + return draggableElements.reduce( + (closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + + if (offset < 0 && offset > closest.offset) { + return { offset: offset, element: child }; + } + return closest; + }, + { offset: Number.NEGATIVE_INFINITY }, + ).element; + } + + showPlaceholder(element, position) { + if (!element) return; + + if (position === "before") { + element.classList.add("border-t-4", "border-primary"); + } else { + element.classList.add("border-b-4", "border-primary"); + } + } + + clearPlaceholders() { + this.sectionTargets.forEach((section) => { + section.classList.remove( + "border-t-4", + "border-b-4", + "border-primary", + "border-t-2", + "border-b-2", + ); + }); + } + + async saveOrder() { + const order = this.sectionTargets.map( + (section) => section.dataset.sectionKey, + ); + + // Safely obtain CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error( + "[Reports Sortable] CSRF token not found. Cannot save section order.", + ); + return; + } + + try { + const response = await fetch("/reports/update_preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ preferences: { reports_section_order: order } }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error( + "[Reports Sortable] Failed to save section order:", + response.status, + errorData, + ); + } + } catch (error) { + console.error( + "[Reports Sortable] Network error saving section order:", + error, + ); + } + } +} diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 7c1a1a064..981e227b9 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -66,6 +66,18 @@ export default class extends Controller { } _draw() { + // Guard against invalid dimensions (e.g., when container is collapsed or not yet rendered) + const minWidth = 50; + const minHeight = 50; + + if ( + this._d3ContainerWidth < minWidth || + this._d3ContainerHeight < minHeight + ) { + // Skip rendering if dimensions are invalid + return; + } + if (this._normalDataPoints.length < 2) { this._drawEmpty(); } else { diff --git a/app/jobs/simplefin_holdings_apply_job.rb b/app/jobs/simplefin_holdings_apply_job.rb new file mode 100644 index 000000000..88022b822 --- /dev/null +++ b/app/jobs/simplefin_holdings_apply_job.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class SimplefinHoldingsApplyJob < ApplicationJob + queue_as :default + + # Idempotently materializes holdings for a SimplefinAccount by reading + # `raw_holdings_payload` and upserting Holding rows by (external_id) or + # (security,date,currency) via the ProviderImportAdapter used by the + # SimplefinAccount::Investments::HoldingsProcessor. + # + # Safe no-op when: + # - the SimplefinAccount is missing + # - there is no current linked Account + # - the linked Account is not an Investment/Crypto + # - there is no raw holdings payload + def perform(simplefin_account_id) + sfa = SimplefinAccount.find_by(id: simplefin_account_id) + return unless sfa + + account = sfa.current_account + return unless account + return unless [ "Investment", "Crypto" ].include?(account.accountable_type) + + holdings = Array(sfa.raw_holdings_payload) + return if holdings.empty? + + begin + SimplefinAccount::Investments::HoldingsProcessor.new(sfa).process + rescue => e + Rails.logger.warn("SimpleFin HoldingsApplyJob failed for SFA=#{sfa.id}: #{e.class} - #{e.message}") + end + end +end diff --git a/app/jobs/simplefin_item/balances_only_job.rb b/app/jobs/simplefin_item/balances_only_job.rb new file mode 100644 index 000000000..ec9f2a92f --- /dev/null +++ b/app/jobs/simplefin_item/balances_only_job.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class SimplefinItem::BalancesOnlyJob < ApplicationJob + queue_as :default + + # Performs a lightweight, balances-only discovery: + # - import_balances_only + # - update last_synced_at (when column exists) + # Any exceptions are logged and safely swallowed to avoid breaking user flow. + def perform(simplefin_item_id) + item = SimplefinItem.find_by(id: simplefin_item_id) + return unless item + + begin + SimplefinItem::Importer + .new(item, simplefin_provider: item.simplefin_provider) + .import_balances_only + rescue Provider::Simplefin::SimplefinError, ArgumentError, StandardError => e + Rails.logger.warn("SimpleFin BalancesOnlyJob import failed: #{e.class} - #{e.message}") + end + + # Best-effort freshness update + begin + item.update!(last_synced_at: Time.current) if item.has_attribute?(:last_synced_at) + rescue => e + Rails.logger.warn("SimpleFin BalancesOnlyJob last_synced_at update failed: #{e.class} - #{e.message}") + end + + # Refresh the SimpleFin card on Providers/Accounts pages so badges and statuses update without a full reload + begin + card_html = ApplicationController.render( + partial: "simplefin_items/simplefin_item", + formats: [ :html ], + locals: { simplefin_item: item } + ) + target_id = ActionView::RecordIdentifier.dom_id(item) + Turbo::StreamsChannel.broadcast_replace_to(item.family, target: target_id, html: card_html) + + # Also refresh Manual Accounts so the CTA state and duplicates clear without refresh + begin + manual_accounts = item.family.accounts + .visible_manual + .order(:name) + if manual_accounts.any? + manual_html = ApplicationController.render( + partial: "accounts/index/manual_accounts", + formats: [ :html ], + locals: { accounts: manual_accounts } + ) + Turbo::StreamsChannel.broadcast_update_to(item.family, target: "manual-accounts", html: manual_html) + else + manual_html = ApplicationController.render(inline: '
') + Turbo::StreamsChannel.broadcast_replace_to(item.family, target: "manual-accounts", html: manual_html) + end + rescue => inner + Rails.logger.warn("SimpleFin BalancesOnlyJob manual-accounts broadcast failed: #{inner.class} - #{inner.message}") + end + rescue => e + Rails.logger.warn("SimpleFin BalancesOnlyJob broadcast failed: #{e.class} - #{e.message}") + end + end +end diff --git a/app/jobs/sync_all_job.rb b/app/jobs/sync_all_job.rb new file mode 100644 index 000000000..bfd359d59 --- /dev/null +++ b/app/jobs/sync_all_job.rb @@ -0,0 +1,14 @@ +class SyncAllJob < ApplicationJob + queue_as :scheduled + sidekiq_options lock: :until_executed, on_conflict: :log + + def perform + Rails.logger.info("Starting sync for all families") + Family.find_each do |family| + family.sync_later + rescue => e + Rails.logger.error("Failed to sync family #{family.id}: #{e.message}") + end + Rails.logger.info("Completed sync for all families") + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 4c2e7eb06..1b9b5e23a 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -5,7 +5,6 @@ class Account < ApplicationRecord belongs_to :family belongs_to :import, optional: true - belongs_to :simplefin_account, optional: true has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" has_many :entries, dependent: :destroy @@ -23,7 +22,19 @@ class Account < ApplicationRecord scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } - scope :manual, -> { where(plaid_account_id: nil, simplefin_account_id: nil) } + scope :manual, -> { + left_joins(:account_providers) + .where(account_providers: { id: nil }) + .where(plaid_account_id: nil, simplefin_account_id: nil) + } + + scope :visible_manual, -> { + visible.manual + } + + scope :listable_manual, -> { + manual.where.not(status: :pending_deletion) + } has_one_attached :logo @@ -76,6 +87,12 @@ class Account < ApplicationRecord def create_from_simplefin_account(simplefin_account, account_type, subtype = nil) + # Respect user choice when provided; otherwise infer a sensible default + # Require an explicit account_type; do not infer on the backend + if account_type.blank? || account_type.to_s == "unknown" + raise ArgumentError, "account_type is required when creating an account from SimpleFIN" + end + # Get the balance from SimpleFin balance = simplefin_account.current_balance || simplefin_account.available_balance || 0 @@ -141,18 +158,7 @@ class Account < ApplicationRecord end def institution_domain - url_string = plaid_account&.plaid_item&.institution_url - return nil unless url_string.present? - - begin - uri = URI.parse(url_string) - # Use safe navigation on .host before calling gsub - uri.host&.gsub(/^www\./, "") - rescue URI::InvalidURIError - # Log a warning if the URL is invalid and return nil - Rails.logger.warn("Invalid institution URL encountered for account #{id}: #{url_string}") - nil - end + provider&.institution_domain end def destroy_later @@ -171,14 +177,15 @@ 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) + 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 def start_date diff --git a/app/models/account/current_balance_manager.rb b/app/models/account/current_balance_manager.rb index 2b1ba6309..f1c5928a5 100644 --- a/app/models/account/current_balance_manager.rb +++ b/app/models/account/current_balance_manager.rb @@ -107,13 +107,17 @@ class Account::CurrentBalanceManager end def create_current_anchor(balance) - account.entries.create!( + entry = account.entries.create!( date: Date.current, name: Valuation.build_current_anchor_name(account.accountable_type), amount: balance, currency: account.currency, entryable: Valuation.new(kind: "current_anchor") ) + + # Reload associations and clear memoized value so it gets the new anchor + account.valuations.reload + @current_anchor_valuation = nil end def update_current_anchor(balance) diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index 2a57e71c2..d3830ddda 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -2,13 +2,17 @@ module Account::Linkable extend ActiveSupport::Concern included do + # New generic provider association + has_many :account_providers, dependent: :destroy + + # Legacy provider associations - kept for backward compatibility during migration belongs_to :plaid_account, optional: true belongs_to :simplefin_account, optional: true end # A "linked" account gets transaction and balance data from a third party like Plaid or SimpleFin def linked? - plaid_account_id.present? || simplefin_account_id.present? + account_providers.any? || plaid_account.present? || simplefin_account.present? end # An "offline" or "unlinked" account is one where the user tracks values and @@ -17,4 +21,49 @@ module Account::Linkable !linked? end alias_method :manual?, :unlinked? + + # Returns the primary provider adapter for this account + # If multiple providers exist, returns the first one + def provider + return nil unless linked? + + @provider ||= account_providers.first&.adapter + end + + # Returns all provider adapters for this account + def providers + @providers ||= account_providers.map(&:adapter).compact + end + + # Returns the provider adapter for a specific provider type + def provider_for(provider_type) + account_provider = account_providers.find_by(provider_type: provider_type) + account_provider&.adapter + end + + # Convenience method to get the provider name + def provider_name + # Try new system first + return provider&.provider_name if provider.present? + + # Fall back to legacy system + return "plaid" if plaid_account.present? + return "simplefin" if simplefin_account.present? + + nil + end + + # Check if account is linked to a specific provider + def linked_to?(provider_type) + account_providers.exists?(provider_type: provider_type) + end + + # Check if holdings can be deleted + # If account has multiple providers, returns true only if ALL providers allow deletion + # This prevents deleting holdings that would be recreated on next sync + def can_delete_holdings? + return true if unlinked? + + providers.all?(&:can_delete_holdings?) + end end diff --git a/app/models/account/market_data_importer.rb b/app/models/account/market_data_importer.rb index af5383140..d00c22347 100644 --- a/app/models/account/market_data_importer.rb +++ b/app/models/account/market_data_importer.rb @@ -24,12 +24,18 @@ 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| diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb new file mode 100644 index 000000000..83f483fac --- /dev/null +++ b/app/models/account/provider_import_adapter.rb @@ -0,0 +1,418 @@ +class Account::ProviderImportAdapter + attr_reader :account + + def initialize(account) + @account = account + end + + # Imports a transaction from a provider + # + # @param external_id [String] Unique identifier from the provider (e.g., "plaid_12345", "simplefin_abc") + # @param amount [BigDecimal, Numeric] Transaction amount + # @param currency [String] Currency code (e.g., "USD") + # @param date [Date, String] Transaction date + # @param name [String] Transaction name/description + # @param source [String] Provider name (e.g., "plaid", "simplefin") + # @param category_id [Integer, nil] Optional category ID + # @param merchant [Merchant, nil] Optional merchant object + # @param notes [String, nil] Optional transaction notes/memo + # @param extra [Hash, nil] Optional provider-specific metadata to merge into transaction.extra + # @return [Entry] The created or updated entry + def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, extra: nil) + raise ArgumentError, "external_id is required" if external_id.blank? + raise ArgumentError, "source is required" if source.blank? + + Account.transaction do + # Find or initialize by both external_id AND source + # This allows multiple providers to sync same account with separate entries + entry = account.entries.find_or_initialize_by(external_id: external_id, source: source) do |e| + e.entryable = Transaction.new + end + + # If this is a new entry, check for potential duplicates from manual/CSV imports + # This handles the case where a user manually created or CSV imported a transaction + # before linking their account to a provider + # Note: We don't pass name here to allow matching even when provider formats names differently + if entry.new_record? + duplicate = find_duplicate_transaction(date: date, amount: amount, currency: currency) + if duplicate + # "Claim" the duplicate by updating its external_id and source + # This prevents future duplicate checks from matching it again + entry = duplicate + entry.assign_attributes(external_id: external_id, source: source) + end + end + + # Validate entryable type matches to prevent external_id collisions + if entry.persisted? && !entry.entryable.is_a?(Transaction) + raise ArgumentError, "Entry with external_id '#{external_id}' already exists with different entryable type: #{entry.entryable_type}" + end + + entry.assign_attributes( + amount: amount, + currency: currency, + date: date + ) + + # Use enrichment pattern to respect user overrides + entry.enrich_attribute(:name, name, source: source) + + # Enrich transaction-specific attributes + if category_id + entry.transaction.enrich_attribute(:category_id, category_id, source: source) + end + + if merchant + entry.transaction.enrich_attribute(:merchant_id, merchant.id, source: source) + end + + if notes.present? && entry.respond_to?(:enrich_attribute) + entry.enrich_attribute(:notes, notes, source: source) + end + + # Persist extra provider metadata on the transaction (non-enriched; always merged) + if extra.present? && entry.entryable.is_a?(Transaction) + existing = entry.transaction.extra || {} + incoming = extra.is_a?(Hash) ? extra.deep_stringify_keys : {} + entry.transaction.extra = existing.deep_merge(incoming) + end + entry.save! + entry + end + end + + # Finds or creates a merchant from provider data + # + # @param provider_merchant_id [String] Provider's merchant ID + # @param name [String] Merchant name + # @param source [String] Provider name (e.g., "plaid", "simplefin") + # @param website_url [String, nil] Optional merchant website + # @param logo_url [String, nil] Optional merchant logo URL + # @return [ProviderMerchant, nil] The merchant object or nil if data is insufficient + def find_or_create_merchant(provider_merchant_id:, name:, source:, website_url: nil, logo_url: nil) + return nil unless provider_merchant_id.present? && name.present? + + ProviderMerchant.find_or_create_by!( + provider_merchant_id: provider_merchant_id, + source: source + ) do |m| + m.name = name + m.website_url = website_url + m.logo_url = logo_url + end + end + + # Updates account balance from provider data + # + # @param balance [BigDecimal, Numeric] Total balance + # @param cash_balance [BigDecimal, Numeric] Cash balance (for investment accounts) + # @param source [String] Provider name (for logging/debugging) + def update_balance(balance:, cash_balance: nil, source: nil) + account.update!( + balance: balance, + cash_balance: cash_balance || balance + ) + end + + # Imports or updates a holding (investment position) from a provider + # + # @param security [Security] The security object + # @param quantity [BigDecimal, Numeric] Number of shares/units + # @param amount [BigDecimal, Numeric] Total value in account currency + # @param currency [String] Currency code + # @param date [Date, String] Holding date + # @param price [BigDecimal, Numeric, nil] Price per share (optional) + # @param cost_basis [BigDecimal, Numeric, nil] Cost basis (optional) + # @param external_id [String, nil] Provider's unique ID (optional, for deduplication) + # @param source [String] Provider name + # @param account_provider_id [String, nil] The AccountProvider ID that owns this holding (optional) + # @param delete_future_holdings [Boolean] Whether to delete holdings after this date (default: false) + # @return [Holding] The created or updated holding + def import_holding(security:, quantity:, amount:, currency:, date:, price: nil, cost_basis: nil, external_id: nil, source:, account_provider_id: nil, delete_future_holdings: false) + raise ArgumentError, "security is required" if security.nil? + raise ArgumentError, "source is required" if source.blank? + + Account.transaction do + # Two strategies for finding/creating holdings: + # 1. By external_id (SimpleFin approach) - tracks each holding uniquely + # 2. By security+date+currency (Plaid approach) - overwrites holdings for same security/date + holding = nil + + if external_id.present? + # Preferred path: match by provider's external_id + holding = account.holdings.find_by(external_id: external_id) + + unless holding + # Fallback path: match by (security, date, currency) — and when provided, + # also scope by account_provider_id to avoid cross‑provider claiming. + # This keeps behavior symmetric with deletion logic below which filters + # by account_provider_id when present. + find_by_attrs = { + security: security, + date: date, + currency: currency + } + if account_provider_id.present? + find_by_attrs[:account_provider_id] = account_provider_id + end + + holding = account.holdings.find_by(find_by_attrs) + end + + holding ||= account.holdings.new( + security: security, + date: date, + currency: currency, + account_provider_id: account_provider_id + ) + else + holding = account.holdings.find_or_initialize_by( + security: security, + date: date, + currency: currency + ) + end + + # Early cross-provider composite-key conflict guard: avoid attempting a write + # that would violate a unique index on (account_id, security_id, date, currency). + if external_id.present? + existing_composite = account.holdings.find_by( + security: security, + date: date, + currency: currency + ) + + if existing_composite && + account_provider_id.present? && + existing_composite.account_provider_id.present? && + existing_composite.account_provider_id != account_provider_id + Rails.logger.warn( + "ProviderImportAdapter: cross-provider holding collision for account=#{account.id} security=#{security.id} date=#{date} currency=#{currency}; returning existing id=#{existing_composite.id}" + ) + return existing_composite + end + end + + holding.assign_attributes( + security: security, + date: date, + currency: currency, + qty: quantity, + price: price, + amount: amount, + cost_basis: cost_basis, + account_provider_id: account_provider_id, + external_id: external_id + ) + + begin + Holding.transaction(requires_new: true) do + holding.save! + end + rescue ActiveRecord::RecordNotUnique => e + # Handle unique index collisions on (account_id, security_id, date, currency) + # that can occur when another provider (or concurrent import) already + # created a row for this composite key. Use the existing row and keep + # the outer transaction valid by isolating the error in a savepoint. + existing = account.holdings.find_by( + security: security, + date: date, + currency: currency + ) + + if existing + # If an existing row belongs to a different provider, do NOT claim it. + # Keep cross-provider isolation symmetrical with deletion logic. + if account_provider_id.present? && existing.account_provider_id.present? && existing.account_provider_id != account_provider_id + Rails.logger.warn( + "ProviderImportAdapter: cross-provider holding collision for account=#{account.id} security=#{security.id} date=#{date} currency=#{currency}; returning existing id=#{existing.id}" + ) + holding = existing + else + # Same provider (or unowned). Apply latest snapshot and attach external_id for idempotency. + updates = { + qty: quantity, + price: price, + amount: amount, + cost_basis: cost_basis + } + + # Adopt the row to this provider if it’s currently unowned + if account_provider_id.present? && existing.account_provider_id.nil? + updates[:account_provider_id] = account_provider_id + end + + # Attach external_id if provided and missing + if external_id.present? && existing.external_id.blank? + updates[:external_id] = external_id + end + + begin + # Use update_columns to avoid validations and keep this collision handler best-effort. + existing.update_columns(updates.compact) + rescue => _ + # Best-effort only; avoid raising in collision handler + end + + holding = existing + end + else + # Could not find an existing row; re-raise original error + raise e + end + end + + # Optionally delete future holdings for this security (Plaid behavior) + # Only delete if ALL providers allow deletion (cross-provider check) + if delete_future_holdings + unless account.can_delete_holdings? + Rails.logger.warn( + "Skipping future holdings deletion for account #{account.id} " \ + "because not all providers allow deletion" + ) + return holding + end + + # Build base query for future holdings + future_holdings_query = account.holdings + .where(security: security) + .where("date > ?", date) + + # If account_provider_id is provided, only delete holdings from this provider + # This prevents deleting positions imported by other providers + if account_provider_id.present? + future_holdings_query = future_holdings_query.where(account_provider_id: account_provider_id) + end + + future_holdings_query.destroy_all + end + + holding + end + end + + # Imports a trade (investment transaction) from a provider + # + # @param security [Security] The security object + # @param quantity [BigDecimal, Numeric] Number of shares (negative for sells, positive for buys) + # @param price [BigDecimal, Numeric] Price per share + # @param amount [BigDecimal, Numeric] Total trade value + # @param currency [String] Currency code + # @param date [Date, String] Trade date + # @param name [String, nil] Optional custom name for the trade + # @param external_id [String, nil] Provider's unique ID (optional, for deduplication) + # @param source [String] Provider name + # @return [Entry] The created entry with trade + def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:) + raise ArgumentError, "security is required" if security.nil? + raise ArgumentError, "source is required" if source.blank? + + Account.transaction do + # Generate name if not provided + trade_name = if name.present? + name + else + trade_type = quantity.negative? ? "sell" : "buy" + Trade.build_name(trade_type, quantity, security.ticker) + end + + # Use find_or_initialize_by with external_id if provided, otherwise create new + entry = if external_id.present? + # Find or initialize by both external_id AND source + # This allows multiple providers to sync same account with separate entries + account.entries.find_or_initialize_by(external_id: external_id, source: source) do |e| + e.entryable = Trade.new + end + else + account.entries.new( + entryable: Trade.new, + source: source + ) + end + + # Validate entryable type matches to prevent external_id collisions + if entry.persisted? && !entry.entryable.is_a?(Trade) + raise ArgumentError, "Entry with external_id '#{external_id}' already exists with different entryable type: #{entry.entryable_type}" + end + + # Always update Trade attributes (works for both new and existing records) + entry.entryable.assign_attributes( + security: security, + qty: quantity, + price: price, + currency: currency + ) + + entry.assign_attributes( + date: date, + amount: amount, + currency: currency, + name: trade_name + ) + + entry.save! + entry + end + end + + # Updates accountable-specific attributes (e.g., credit card details, loan details) + # + # @param attributes [Hash] Hash of attributes to update on the accountable + # @param source [String] Provider name (for logging/debugging) + # @return [Boolean] Whether the update was successful + def update_accountable_attributes(attributes:, source:) + return false unless account.accountable.present? + return false if attributes.blank? + + # Filter out nil values and only update attributes that exist on the accountable + valid_attributes = attributes.compact.select do |key, _| + account.accountable.respond_to?("#{key}=") + end + + return false if valid_attributes.empty? + + account.accountable.update!(valid_attributes) + true + rescue => e + Rails.logger.error("Failed to update #{account.accountable_type} attributes from #{source}: #{e.message}") + false + end + + # Finds a potential duplicate transaction from manual entry or CSV import + # Matches on date, amount, currency, and optionally name + # Only matches transactions without external_id (manual/CSV imported) + # + # @param date [Date, String] Transaction date + # @param amount [BigDecimal, Numeric] Transaction amount + # @param currency [String] Currency code + # @param name [String, nil] Optional transaction name for more accurate matching + # @param exclude_entry_ids [Set, Array, nil] Entry IDs to exclude from the search (e.g., already claimed entries) + # @return [Entry, nil] The duplicate entry or nil if not found + def find_duplicate_transaction(date:, amount:, currency:, name: nil, exclude_entry_ids: nil) + # Convert date to Date object if it's a string + date = Date.parse(date.to_s) unless date.is_a?(Date) + + # Look for entries on the same account with: + # 1. Same date + # 2. Same amount (exact match) + # 3. Same currency + # 4. No external_id (manual/CSV imported transactions) + # 5. Entry type is Transaction (not Trade or Valuation) + # 6. Optionally same name (if name parameter is provided) + # 7. Not in the excluded IDs list (if provided) + query = account.entries + .where(entryable_type: "Transaction") + .where(date: date) + .where(amount: amount) + .where(currency: currency) + .where(external_id: nil) + + # Add name filter if provided + query = query.where(name: name) if name.present? + + # Exclude already claimed entries if provided + query = query.where.not(id: exclude_entry_ids) if exclude_entry_ids.present? + + query.order(created_at: :asc).first + end +end diff --git a/app/models/account_provider.rb b/app/models/account_provider.rb new file mode 100644 index 000000000..bfb39f508 --- /dev/null +++ b/app/models/account_provider.rb @@ -0,0 +1,18 @@ +class AccountProvider < ApplicationRecord + belongs_to :account + belongs_to :provider, polymorphic: true + + validates :account_id, uniqueness: { scope: :provider_type } + validates :provider_id, uniqueness: { scope: :provider_type } + + # Returns the provider adapter for this connection + def adapter + Provider::Factory.create_adapter(provider, account: account) + end + + # Convenience method to get provider name + # Delegates to the adapter for consistency, falls back to underscored provider_type + def provider_name + adapter&.provider_name || provider_type.underscore + end +end diff --git a/app/models/assistant/function/get_accounts.rb b/app/models/assistant/function/get_accounts.rb index f8c6a6db4..777b81493 100644 --- a/app/models/assistant/function/get_accounts.rb +++ b/app/models/assistant/function/get_accounts.rb @@ -12,7 +12,7 @@ class Assistant::Function::GetAccounts < Assistant::Function def call(params = {}) { as_of_date: Date.current, - accounts: family.accounts.includes(:balances).map do |account| + accounts: family.accounts.includes(:balances, :account_providers).map do |account| { name: account.name, balance: account.balance, @@ -21,7 +21,8 @@ class Assistant::Function::GetAccounts < Assistant::Function classification: account.classification, type: account.accountable_type, start_date: account.start_date, - is_plaid_linked: account.plaid_account_id.present?, + is_linked: account.linked?, + provider: account.provider_name, status: account.status, historical_balances: historical_balances(account) } diff --git a/app/models/balance_sheet/classification_group.rb b/app/models/balance_sheet/classification_group.rb index a6d82bb3c..32e64214c 100644 --- a/app/models/balance_sheet/classification_group.rb +++ b/app/models/balance_sheet/classification_group.rb @@ -34,7 +34,7 @@ class BalanceSheet::ClassificationGroup .transform_keys { |at| Accountable.from_type(at) } .map do |accountable, account_rows| BalanceSheet::AccountGroup.new( - name: accountable.display_name, + name: I18n.t("accounts.types.#{accountable.name.underscore}", default: accountable.display_name), color: accountable.color, accountable_type: accountable, accounts: account_rows, diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb index a2a848503..dcecbb252 100644 --- a/app/models/budget_category.rb +++ b/app/models/budget_category.rb @@ -75,6 +75,34 @@ class BudgetCategory < ApplicationRecord (actual_spending / budgeted_spending) * 100 end + def bar_width_percent + [ percent_of_budget_spent, 100 ].min + end + + def over_budget? + available_to_spend.negative? + end + + def near_limit? + !over_budget? && percent_of_budget_spent >= 90 + end + + # Returns hash with suggested daily spending info or nil if not applicable + def suggested_daily_spending + return nil unless available_to_spend > 0 + + budget_date = budget.start_date + return nil unless budget_date.month == Date.current.month && budget_date.year == Date.current.year + + days_remaining = (budget_date.end_of_month - Date.current).to_i + 1 + return nil unless days_remaining > 0 + + { + amount: Money.new((available_to_spend / days_remaining), budget.family.currency), + days_remaining: days_remaining + } + end + def to_donut_segments_json unused_segment_id = "unused" overage_segment_id = "overage" @@ -112,8 +140,4 @@ class BudgetCategory < ApplicationRecord .joins(:category) .where(categories: { parent_id: category.id }) end - - def subcategory? - category.parent_id.present? - end end diff --git a/app/models/category_import.rb b/app/models/category_import.rb new file mode 100644 index 000000000..fadab4520 --- /dev/null +++ b/app/models/category_import.rb @@ -0,0 +1,86 @@ +class CategoryImport < Import + def import! + transaction do + rows.each do |row| + category_name = row.name.to_s.strip + category = family.categories.find_or_initialize_by(name: category_name) + category.color = row.category_color.presence || category.color || Category::UNCATEGORIZED_COLOR + category.classification = row.category_classification.presence || category.classification || "expense" + category.lucide_icon = row.category_icon.presence || category.lucide_icon || "shapes" + category.parent = nil + category.save! + + ensure_placeholder_category(row.category_parent) + end + + rows.each do |row| + category = family.categories.find_by!(name: row.name.to_s.strip) + parent = ensure_placeholder_category(row.category_parent) + + if parent && parent == category + errors.add(:base, "Category '#{category.name}' cannot be its own parent") + raise ActiveRecord::RecordInvalid.new(self) + end + + next if category.parent == parent + + category.update!(parent: parent) + end + end + end + + def column_keys + %i[name category_color category_parent category_classification category_icon] + end + + def required_column_keys + %i[name] + end + + def mapping_steps + [] + end + + def dry_run + { categories: rows.count } + end + + def csv_template + template = <<-CSV + name*,color,parent_category,classification,lucide-icon + Food & Drink,#f97316,,expense,carrot + Groceries,#407706,Food & Drink,expense,shopping-basket + Salary,#22c55e,,income,briefcase + CSV + + CSV.parse(template, headers: true) + end + + def generate_rows_from_csv + rows.destroy_all + + csv_rows.each do |row| + rows.create!( + name: row["name"].to_s.strip, + category_color: row["color"].to_s.strip, + category_parent: row["parent_category"].to_s.strip, + category_classification: row["classification"].to_s.strip, + category_icon: (row["lucide-icon"].presence || row["icon"]).to_s.strip, + currency: default_currency + ) + end + end + + private + + def ensure_placeholder_category(name) + trimmed_name = name.to_s.strip + return if trimmed_name.blank? + + family.categories.find_or_create_by!(name: trimmed_name) do |placeholder| + placeholder.color = Category::UNCATEGORIZED_COLOR + placeholder.classification = "expense" + placeholder.lucide_icon = "shapes" + end + end +end diff --git a/app/models/concerns/currency_normalizable.rb b/app/models/concerns/currency_normalizable.rb new file mode 100644 index 000000000..3d1f62331 --- /dev/null +++ b/app/models/concerns/currency_normalizable.rb @@ -0,0 +1,50 @@ +# Provides currency normalization and validation for provider data imports +# +# This concern provides a shared method to parse and normalize currency codes +# from external providers (Plaid, SimpleFIN, LunchFlow), ensuring: +# - Consistent uppercase formatting (e.g., "eur" -> "EUR") +# - Validation of 3-letter ISO currency codes +# - Proper handling of nil, empty, and invalid values +# +# Usage: +# include CurrencyNormalizable +# currency = parse_currency(api_data[:currency]) +module CurrencyNormalizable + extend ActiveSupport::Concern + + private + + # Parse and normalize a currency code from provider data + # + # @param currency_value [String, nil] Raw currency value from provider API + # @return [String, nil] Normalized uppercase 3-letter currency code, or nil if invalid + # + # @example + # parse_currency("usd") # => "USD" + # parse_currency("EUR") # => "EUR" + # parse_currency(" gbp ") # => "GBP" + # parse_currency("invalid") # => nil (logs warning) + # parse_currency(nil) # => nil + # parse_currency("") # => nil + def parse_currency(currency_value) + # Handle nil, empty string, or whitespace-only strings + return nil if currency_value.blank? + + # Normalize to uppercase 3-letter code + normalized = currency_value.to_s.strip.upcase + + # Validate it's a reasonable currency code (3 letters) + if normalized.match?(/\A[A-Z]{3}\z/) + normalized + else + log_invalid_currency(currency_value) + nil + end + end + + # Log warning for invalid currency codes + # Override this method in including classes to provide context-specific logging + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}', defaulting to fallback") + end +end diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index ead34908f..8324ccd91 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -1,5 +1,5 @@ class DataEnrichment < ApplicationRecord belongs_to :enrichable, polymorphic: true - enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", synth: "synth", ai: "ai" } + enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai" } end diff --git a/app/models/entry.rb b/app/models/entry.rb index 332cbee91..3a6672b7d 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -13,6 +13,7 @@ class Entry < ApplicationRecord validates :date, :name, :amount, :currency, presence: true validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? } validates :date, comparison: { greater_than: -> { min_supported_date } } + validates :external_id, uniqueness: { scope: [ :account_id, :source ] }, if: -> { external_id.present? && source.present? } scope :visible, -> { joins(:account).where(accounts: { status: [ "draft", "active" ] }) @@ -57,7 +58,7 @@ class Entry < ApplicationRecord end def linked? - plaid_id.present? + external_id.present? end class << self diff --git a/app/models/exchange_rate/importer.rb b/app/models/exchange_rate/importer.rb index 0975f2edf..a077401b3 100644 --- a/app/models/exchange_rate/importer.rb +++ b/app/models/exchange_rate/importer.rb @@ -43,7 +43,8 @@ class ExchangeRate::Importer end # Gapfill with LOCF strategy (last observation carried forward) - if chosen_rate.nil? + # 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 end diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index 4235bba83..c4e894d26 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -3,7 +3,7 @@ module ExchangeRate::Provided class_methods do def provider - provider = ENV["EXCHANGE_RATE_PROVIDER"] || "twelve_data" + provider = ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider registry = Provider::Registry.for_concept(:exchange_rates) registry.get_provider(provider.to_sym) end @@ -19,12 +19,23 @@ module ExchangeRate::Provided return nil unless response.success? # Provider error rate = response.data - ExchangeRate.find_or_create_by!( - from_currency: rate.from, - to_currency: rate.to, - date: rate.date, - rate: rate.rate - ) if cache + begin + ExchangeRate.find_or_create_by!( + from_currency: rate.from, + to_currency: rate.to, + date: rate.date + ) do |exchange_rate| + exchange_rate.rate = rate.rate + end if cache + rescue ActiveRecord::RecordNotUnique + # Race condition: another process inserted between our SELECT and INSERT + # Retry by finding the existing record + ExchangeRate.find_by!( + from_currency: rate.from, + to_currency: rate.to, + date: rate.date + ) if cache + end rate end diff --git a/app/models/family.rb b/app/models/family.rb index 0cc1e0848..b618f3785 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,5 +1,5 @@ class Family < ApplicationRecord - include PlaidConnectable, SimplefinConnectable, Syncable, AutoTransferMatchable, Subscribeable + include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, Syncable, AutoTransferMatchable, Subscribeable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], @@ -35,6 +35,7 @@ class Family < ApplicationRecord has_many :budget_categories, through: :budgets has_many :llm_usages, dependent: :destroy + has_many :recurring_transactions, dependent: :destroy validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) } diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index 7e74817ce..2448f16e1 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -16,9 +16,16 @@ class Family::AutoCategorizer Rails.logger.info("Auto-categorizing #{scope.count} transactions for family #{family.id}") end + categories_input = user_categories_input + + if categories_input.empty? + Rails.logger.error("Cannot auto-categorize transactions for family #{family.id}: no categories available") + return + end + result = llm_provider.auto_categorize( transactions: transactions_input, - user_categories: user_categories_input, + user_categories: categories_input, family: family ) @@ -30,7 +37,7 @@ class Family::AutoCategorizer scope.each do |transaction| auto_categorization = result.data.find { |c| c.transaction_id == transaction.id } - category_id = user_categories_input.find { |c| c[:name] == auto_categorization&.category_name }&.dig(:id) + category_id = categories_input.find { |c| c[:name] == auto_categorization&.category_name }&.dig(:id) if category_id.present? transaction.enrich_attribute( @@ -38,9 +45,8 @@ class Family::AutoCategorizer category_id, source: "ai" ) + transaction.lock_attr!(:category_id) end - - transaction.lock_attr!(:category_id) end end diff --git a/app/models/family/auto_merchant_detector.rb b/app/models/family/auto_merchant_detector.rb index 96bfa4144..783121269 100644 --- a/app/models/family/auto_merchant_detector.rb +++ b/app/models/family/auto_merchant_detector.rb @@ -50,11 +50,9 @@ class Family::AutoMerchantDetector merchant_id, source: "ai" ) - + # We lock the attribute so that this Rule doesn't try to run again + transaction.lock_attr!(:merchant_id) end - - # We lock the attribute so that this Rule doesn't try to run again - transaction.lock_attr!(:merchant_id) end end diff --git a/app/models/family/auto_transfer_matchable.rb b/app/models/family/auto_transfer_matchable.rb index 28d06f43a..8566cbdcf 100644 --- a/app/models/family/auto_transfer_matchable.rb +++ b/app/models/family/auto_transfer_matchable.rb @@ -60,10 +60,14 @@ module Family::AutoTransferMatchable next if used_transaction_ids.include?(match.inflow_transaction_id) || used_transaction_ids.include?(match.outflow_transaction_id) - Transfer.create!( - inflow_transaction_id: match.inflow_transaction_id, - outflow_transaction_id: match.outflow_transaction_id, - ) + begin + Transfer.find_or_create_by!( + inflow_transaction_id: match.inflow_transaction_id, + outflow_transaction_id: match.outflow_transaction_id, + ) + rescue ActiveRecord::RecordNotUnique + # Another concurrent job created the transfer; safe to ignore + end Transaction.find(match.inflow_transaction_id).update!(kind: "funds_movement") Transaction.find(match.outflow_transaction_id).update!(kind: Transfer.kind_for_account(Transaction.find(match.outflow_transaction_id).entry.account)) diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 93546cd6d..1247097aa 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -101,7 +101,7 @@ class Family::DataExporter def generate_categories_csv CSV.generate do |csv| - csv << [ "name", "color", "parent_category", "classification" ] + csv << [ "name", "color", "parent_category", "classification", "lucide_icon" ] # Only export categories belonging to this family @family.categories.includes(:parent).find_each do |category| @@ -109,7 +109,8 @@ class Family::DataExporter category.name, category.color, category.parent&.name, - category.classification + category.classification, + category.lucide_icon ] end end diff --git a/app/models/family/lunchflow_connectable.rb b/app/models/family/lunchflow_connectable.rb new file mode 100644 index 000000000..972c61e35 --- /dev/null +++ b/app/models/family/lunchflow_connectable.rb @@ -0,0 +1,28 @@ +module Family::LunchflowConnectable + extend ActiveSupport::Concern + + included do + has_many :lunchflow_items, dependent: :destroy + end + + def can_connect_lunchflow? + # Families can now configure their own Lunchflow credentials + true + end + + def create_lunchflow_item!(api_key:, base_url: nil, item_name: nil) + lunchflow_item = lunchflow_items.create!( + name: item_name || "Lunch Flow Connection", + api_key: api_key, + base_url: base_url + ) + + lunchflow_item.sync_later + + lunchflow_item + end + + def has_lunchflow_credentials? + lunchflow_items.where.not(api_key: nil).exists? + end +end diff --git a/app/models/family/sync_complete_event.rb b/app/models/family/sync_complete_event.rb index 628841d0e..c00226072 100644 --- a/app/models/family/sync_complete_event.rb +++ b/app/models/family/sync_complete_event.rb @@ -6,16 +6,34 @@ class Family::SyncCompleteEvent end def broadcast - family.broadcast_replace( - target: "balance-sheet", - partial: "pages/dashboard/balance_sheet", - locals: { balance_sheet: family.balance_sheet } - ) + # Dashboard partials can occasionally raise when rendered from background jobs + # (e.g., if intermediate series values are nil during a sync). Make broadcasts + # resilient so a post-sync UI refresh never causes the overall sync to report an error. + begin + family.broadcast_replace( + target: "balance-sheet", + partial: "pages/dashboard/balance_sheet", + locals: { balance_sheet: family.balance_sheet } + ) + rescue => e + Rails.logger.error("Family::SyncCompleteEvent balance_sheet broadcast failed: #{e.message}\n#{e.backtrace&.join("\n")}") + end - family.broadcast_replace( - target: "net-worth-chart", - partial: "pages/dashboard/net_worth_chart", - locals: { balance_sheet: family.balance_sheet, period: Period.last_30_days } - ) + begin + family.broadcast_replace( + target: "net-worth-chart", + partial: "pages/dashboard/net_worth_chart", + locals: { balance_sheet: family.balance_sheet, period: Period.last_30_days } + ) + rescue => e + Rails.logger.error("Family::SyncCompleteEvent net_worth_chart broadcast failed: #{e.message}\n#{e.backtrace&.join("\n")}") + end + + # Identify recurring transaction patterns after sync + begin + RecurringTransaction.identify_patterns_for(family) + rescue => e + Rails.logger.error("Family::SyncCompleteEvent recurring transaction identification failed: #{e.message}\n#{e.backtrace&.join("\n")}") + end end end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 30ce2ad5e..a91bbdbcd 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -26,6 +26,6 @@ class Family::Syncer private def child_syncables - family.plaid_items + family.accounts.manual + family.plaid_items + family.simplefin_items.active + family.lunchflow_items.active + family.accounts.manual end end diff --git a/app/models/holding.rb b/app/models/holding.rb index 90bf4f49c..d3c117b4e 100644 --- a/app/models/holding.rb +++ b/app/models/holding.rb @@ -5,6 +5,7 @@ class Holding < ApplicationRecord belongs_to :account belongs_to :security + belongs_to :account_provider, optional: true validates :qty, :currency, :date, :price, :amount, presence: true validates :qty, :price, :amount, numericality: { greater_than_or_equal_to: 0 } @@ -28,7 +29,7 @@ class Holding < ApplicationRecord # Basic approximation of cost-basis def avg_cost - avg_cost = account.trades + trades = account.trades .with_entry .joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON ( @@ -39,15 +40,43 @@ class Holding < ApplicationRecord ])) .where(security_id: security.id) .where("trades.qty > 0 AND entries.date <= ?", date) - .average("trades.price * COALESCE(exchange_rates.rate, 1)") - Money.new(avg_cost || price, currency) + total_cost, total_qty = trades.pick( + Arel.sql("SUM(trades.price * trades.qty * COALESCE(exchange_rates.rate, 1))"), + Arel.sql("SUM(trades.qty)") + ) + + weighted_avg = + if total_qty && total_qty > 0 + total_cost / total_qty + else + price + end + + Money.new(weighted_avg || price, currency) end def trend @trend ||= calculate_trend end + # Day change based on previous holding snapshot (same account/security/currency) + # Returns a Trend struct similar to other trend usages or nil if no prior snapshot. + def day_change + # Memoize even when nil to avoid repeated queries during a request lifecycle + return @day_change if instance_variable_defined?(:@day_change) + + return (@day_change = nil) unless amount_money + + prev = account.holdings + .where(security_id: security_id, currency: currency) + .where("date < ?", date) + .order(date: :desc) + .first + + @day_change = prev&.amount_money ? Trend.new(current: amount_money, previous: prev.amount_money) : nil + end + def trades account.entries.where(entryable: account.trades.where(security: security)).reverse_chronological end diff --git a/app/models/import.rb b/app/models/import.rb index 5d6bea647..f18c03091 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -1,7 +1,8 @@ class Import < ApplicationRecord MaxRowCountExceededError = Class.new(StandardError) + MappingError = Class.new(StandardError) - TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze + TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index cfa066fe7..b30b352f0 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -1,3 +1,5 @@ +require "digest/md5" + class IncomeStatement include Monetizable diff --git a/app/models/investment.rb b/app/models/investment.rb index 4e4c25c86..c7a4898e5 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -7,6 +7,8 @@ class Investment < ApplicationRecord "retirement" => { short: "Retirement", long: "Retirement" }, "401k" => { short: "401(k)", long: "401(k)" }, "roth_401k" => { short: "Roth 401(k)", long: "Roth 401(k)" }, + "403b" => { short: "403(b)", long: "403(b)" }, + "tsp" => { short: "TSP", long: "Thrift Savings Plan" }, "529_plan" => { short: "529 Plan", long: "529 Plan" }, "hsa" => { short: "HSA", long: "Health Savings Account" }, "mutual_fund" => { short: "Mutual Fund", long: "Mutual Fund" }, diff --git a/app/models/llm_usage.rb b/app/models/llm_usage.rb index fdab6a6e0..789a86ca9 100644 --- a/app/models/llm_usage.rb +++ b/app/models/llm_usage.rb @@ -128,6 +128,21 @@ class LlmUsage < ApplicationRecord estimated_cost.nil? ? "N/A" : "$#{estimated_cost.round(4)}" end + # Check if this usage record represents a failed API call + def failed? + metadata.present? && metadata["error"].present? + end + + # Get the HTTP status code from metadata + def http_status_code + metadata&.dig("http_status_code") + end + + # Get the error message from metadata + def error_message + metadata&.dig("error") + end + # Estimate cost for auto-categorizing a batch of transactions # Based on typical token usage patterns: # - ~100 tokens per transaction in the prompt diff --git a/app/models/lunchflow_account.rb b/app/models/lunchflow_account.rb new file mode 100644 index 000000000..01087450d --- /dev/null +++ b/app/models/lunchflow_account.rb @@ -0,0 +1,52 @@ +class LunchflowAccount < ApplicationRecord + include CurrencyNormalizable + + belongs_to :lunchflow_item + + # New association through account_providers + 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 + + # Helper to get account using account_providers system + def current_account + account + end + + def upsert_lunchflow_snapshot!(account_snapshot) + # Convert to symbol keys or handle both string and symbol keys + snapshot = account_snapshot.with_indifferent_access + + # Map Lunchflow field names to our field names + # Lunchflow API returns: { id, name, institution_name, institution_logo, provider, currency, status } + update!( + current_balance: nil, # Balance not provided by accounts endpoint + currency: parse_currency(snapshot[:currency]) || "USD", + name: snapshot[:name], + account_id: snapshot[:id].to_s, + account_status: snapshot[:status], + provider: snapshot[:provider], + institution_metadata: { + name: snapshot[:institution_name], + logo: snapshot[:institution_logo] + }.compact, + raw_payload: account_snapshot + ) + end + + def upsert_lunchflow_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 LunchFlow account #{id}, defaulting to USD") + end +end diff --git a/app/models/lunchflow_account/processor.rb b/app/models/lunchflow_account/processor.rb new file mode 100644 index 000000000..4431080b7 --- /dev/null +++ b/app/models/lunchflow_account/processor.rb @@ -0,0 +1,78 @@ +class LunchflowAccount::Processor + include CurrencyNormalizable + + attr_reader :lunchflow_account + + def initialize(lunchflow_account) + @lunchflow_account = lunchflow_account + end + + def process + unless lunchflow_account.current_account.present? + Rails.logger.info "LunchflowAccount::Processor - No linked account for lunchflow_account #{lunchflow_account.id}, skipping processing" + return + end + + Rails.logger.info "LunchflowAccount::Processor - Processing lunchflow_account #{lunchflow_account.id} (account #{lunchflow_account.account_id})" + + begin + process_account! + rescue StandardError => e + Rails.logger.error "LunchflowAccount::Processor - Failed to process account #{lunchflow_account.id}: #{e.message}" + Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}" + report_exception(e, "account") + raise + end + + process_transactions + end + + private + + def process_account! + if lunchflow_account.current_account.blank? + Rails.logger.error("Lunchflow account #{lunchflow_account.id} has no associated Account") + return + end + + # Update account balance from latest Lunchflow data + account = lunchflow_account.current_account + balance = lunchflow_account.current_balance || 0 + + # LunchFlow 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 lunchflow currency -> existing account currency -> USD + currency = parse_currency(lunchflow_account.currency) || account.currency || "USD" + + # Update account balance + account.update!( + balance: balance, + cash_balance: balance, + currency: currency + ) + end + + def process_transactions + LunchflowAccount::Transactions::Processor.new(lunchflow_account).process + rescue => e + report_exception(e, "transactions") + end + + def report_exception(error, context) + Sentry.capture_exception(error) do |scope| + scope.set_tags( + lunchflow_account_id: lunchflow_account.id, + context: context + ) + end + end +end diff --git a/app/models/lunchflow_account/transactions/processor.rb b/app/models/lunchflow_account/transactions/processor.rb new file mode 100644 index 000000000..ca8210412 --- /dev/null +++ b/app/models/lunchflow_account/transactions/processor.rb @@ -0,0 +1,71 @@ +class LunchflowAccount::Transactions::Processor + attr_reader :lunchflow_account + + def initialize(lunchflow_account) + @lunchflow_account = lunchflow_account + end + + def process + unless lunchflow_account.raw_transactions_payload.present? + Rails.logger.info "LunchflowAccount::Transactions::Processor - No transactions in raw_transactions_payload for lunchflow_account #{lunchflow_account.id}" + return { success: true, total: 0, imported: 0, failed: 0, errors: [] } + end + + total_count = lunchflow_account.raw_transactions_payload.count + Rails.logger.info "LunchflowAccount::Transactions::Processor - Processing #{total_count} transactions for lunchflow_account #{lunchflow_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. + lunchflow_account.raw_transactions_payload.each_with_index do |transaction_data, index| + begin + result = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: lunchflow_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 "LunchflowAccount::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 "LunchflowAccount::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 "LunchflowAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions" + else + Rails.logger.info "LunchflowAccount::Transactions::Processor - Successfully processed #{imported_count} transactions" + end + + result + end +end diff --git a/app/models/lunchflow_entry/processor.rb b/app/models/lunchflow_entry/processor.rb new file mode 100644 index 000000000..11a7801dd --- /dev/null +++ b/app/models/lunchflow_entry/processor.rb @@ -0,0 +1,152 @@ +require "digest/md5" + +class LunchflowEntry::Processor + include CurrencyNormalizable + # lunchflow_transaction is the raw hash fetched from Lunchflow API and converted to JSONB + # Transaction structure: { id, accountId, amount, currency, date, merchant, description } + def initialize(lunchflow_transaction, lunchflow_account:) + @lunchflow_transaction = lunchflow_transaction + @lunchflow_account = lunchflow_account + end + + def process + # Validate that we have a linked account before processing + unless account.present? + Rails.logger.warn "LunchflowEntry::Processor - No linked account for lunchflow_account #{lunchflow_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: "lunchflow", + merchant: merchant + ) + rescue ArgumentError => e + # Re-raise validation errors (missing required fields, invalid data) + Rails.logger.error "LunchflowEntry::Processor - Validation error for transaction #{external_id}: #{e.message}" + raise + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + # Handle database save errors + Rails.logger.error "LunchflowEntry::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 "LunchflowEntry::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 :lunchflow_transaction, :lunchflow_account + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= lunchflow_account.current_account + end + + def data + @data ||= lunchflow_transaction.with_indifferent_access + end + + def external_id + id = data[:id].presence + raise ArgumentError, "Lunchflow transaction missing required field 'id'" unless id + "lunchflow_#{id}" + end + + def name + # Use Lunchflow's merchant and description to create informative transaction names + merchant_name = data[:merchant] + description = data[:description] + + # Combine merchant + description when both are present and different + if merchant_name.present? && description.present? && merchant_name != description + "#{merchant_name} - #{description}" + elsif merchant_name.present? + merchant_name + elsif description.present? + description + else + "Unknown transaction" + end + end + + 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: "lunchflow_merchant_#{merchant_id}", + name: merchant_name, + source: "lunchflow" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "LunchflowEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + nil + end + end + + def amount + parsed_amount = case data[:amount] + when String + BigDecimal(data[:amount]) + when Numeric + BigDecimal(data[:amount].to_s) + else + BigDecimal("0") + end + + # Lunchflow 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 Lunchflow to Maybe format + -parsed_amount + rescue ArgumentError => e + Rails.logger.error "Failed to parse Lunchflow transaction amount: #{data[:amount].inspect} - #{e.message}" + raise + end + + def currency + parse_currency(data[:currency]) || account&.currency || "USD" + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' in LunchFlow transaction #{external_id}, falling back to account currency") + end + + 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("Lunchflow 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 Lunchflow transaction date '#{data[:date]}': #{e.message}") + raise ArgumentError, "Unable to parse transaction date: #{data[:date].inspect}" + end +end diff --git a/app/models/lunchflow_item.rb b/app/models/lunchflow_item.rb new file mode 100644 index 000000000..a2af0fa25 --- /dev/null +++ b/app/models/lunchflow_item.rb @@ -0,0 +1,165 @@ +class LunchflowItem < ApplicationRecord + include Syncable, Provided, Unlinking + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + # Helper to detect if ActiveRecord Encryption is configured for this app + 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 :api_key, deterministic: true + end + + validates :name, presence: true + validates :api_key, presence: true, on: :create + + belongs_to :family + has_one_attached :logo + + has_many :lunchflow_accounts, dependent: :destroy + has_many :accounts, through: :lunchflow_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + 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 import_latest_lunchflow_data + provider = lunchflow_provider + unless provider + Rails.logger.error "LunchflowItem #{id} - Cannot import: Lunchflow provider is not configured (missing API key)" + raise StandardError.new("Lunchflow provider is not configured") + end + + LunchflowItem::Importer.new(self, lunchflow_provider: provider).import + rescue => e + Rails.logger.error "LunchflowItem #{id} - Failed to import data: #{e.message}" + raise + end + + def process_accounts + return [] if lunchflow_accounts.empty? + + results = [] + # Only process accounts that are linked and have active status + lunchflow_accounts.joins(:account).merge(Account.visible).each do |lunchflow_account| + begin + result = LunchflowAccount::Processor.new(lunchflow_account).process + results << { lunchflow_account_id: lunchflow_account.id, success: true, result: result } + rescue => e + Rails.logger.error "LunchflowItem #{id} - Failed to process account #{lunchflow_account.id}: #{e.message}" + results << { lunchflow_account_id: lunchflow_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) + return [] if accounts.empty? + + results = [] + # Only schedule syncs for active accounts + 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 "LunchflowItem #{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 upsert_lunchflow_snapshot!(accounts_snapshot) + assign_attributes( + raw_payload: accounts_snapshot + ) + + save! + 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 + lunchflow_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + lunchflow_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + lunchflow_accounts.count + end + + def institution_display_name + # Try to get institution name from stored metadata + institution_name.presence || institution_domain.presence || name + end + + def connected_institutions + # Get unique institutions from all accounts + lunchflow_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? + api_key.present? + end + + def effective_base_url + base_url.presence || "https://lunchflow.app/api/v1" + end +end diff --git a/app/models/lunchflow_item/importer.rb b/app/models/lunchflow_item/importer.rb new file mode 100644 index 000000000..54310e66e --- /dev/null +++ b/app/models/lunchflow_item/importer.rb @@ -0,0 +1,329 @@ +class LunchflowItem::Importer + attr_reader :lunchflow_item, :lunchflow_provider + + def initialize(lunchflow_item, lunchflow_provider:) + @lunchflow_item = lunchflow_item + @lunchflow_provider = lunchflow_provider + end + + def import + Rails.logger.info "LunchflowItem::Importer - Starting import for item #{lunchflow_item.id}" + + # Step 1: Fetch all accounts from Lunchflow + accounts_data = fetch_accounts_data + unless accounts_data + Rails.logger.error "LunchflowItem::Importer - Failed to fetch accounts data for item #{lunchflow_item.id}" + return { success: false, error: "Failed to fetch accounts data", accounts_imported: 0, transactions_imported: 0 } + end + + # Store raw payload + begin + lunchflow_item.upsert_lunchflow_snapshot!(accounts_data) + rescue => e + Rails.logger.error "LunchflowItem::Importer - Failed to store accounts snapshot: #{e.message}" + # Continue with import even if snapshot storage fails + end + + # Step 2: Update only previously selected accounts (don't create new ones) + accounts_updated = 0 + accounts_failed = 0 + + if accounts_data[:accounts].present? + # Get only linked lunchflow account IDs (ones actually imported/used by the user) + # This prevents updating orphaned accounts from old behavior that saved everything + existing_account_ids = lunchflow_item.lunchflow_accounts + .joins(:account_provider) + .pluck(:account_id) + .map(&:to_s) + + accounts_data[:accounts].each do |account_data| + account_id = account_data[:id]&.to_s + next unless account_id.present? + + # Only update if this account was previously selected (exists in our DB) + next unless existing_account_ids.include?(account_id) + + begin + import_account(account_data) + accounts_updated += 1 + rescue => e + accounts_failed += 1 + Rails.logger.error "LunchflowItem::Importer - Failed to update account #{account_id}: #{e.message}" + # Continue updating other accounts even if one fails + end + end + end + + Rails.logger.info "LunchflowItem::Importer - Updated #{accounts_updated} accounts (#{accounts_failed} failed)" + + # Step 3: Fetch transactions only for linked accounts with active status + transactions_imported = 0 + transactions_failed = 0 + + lunchflow_item.lunchflow_accounts.joins(:account).merge(Account.visible).each do |lunchflow_account| + begin + result = fetch_and_store_transactions(lunchflow_account) + if result[:success] + transactions_imported += result[:transactions_count] + else + transactions_failed += 1 + end + rescue => e + transactions_failed += 1 + Rails.logger.error "LunchflowItem::Importer - Failed to fetch/store transactions for account #{lunchflow_account.account_id}: #{e.message}" + # Continue with other accounts even if one fails + end + end + + Rails.logger.info "LunchflowItem::Importer - Completed import for item #{lunchflow_item.id}: #{accounts_updated} accounts updated, #{transactions_imported} transactions" + + { + success: accounts_failed == 0 && transactions_failed == 0, + accounts_updated: accounts_updated, + accounts_failed: accounts_failed, + transactions_imported: transactions_imported, + transactions_failed: transactions_failed + } + end + + private + + def fetch_accounts_data + begin + accounts_data = lunchflow_provider.get_accounts + rescue Provider::Lunchflow::LunchflowError => e + # Handle authentication errors by marking item as requiring update + if e.error_type == :unauthorized || e.error_type == :access_forbidden + begin + lunchflow_item.update!(status: :requires_update) + rescue => update_error + Rails.logger.error "LunchflowItem::Importer - Failed to update item status: #{update_error.message}" + end + end + Rails.logger.error "LunchflowItem::Importer - Lunch flow API error: #{e.message}" + return nil + rescue JSON::ParserError => e + Rails.logger.error "LunchflowItem::Importer - Failed to parse Lunch flow API response: #{e.message}" + return nil + rescue => e + Rails.logger.error "LunchflowItem::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 "LunchflowItem::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 + + def import_account(account_data) + # Validate account data structure + unless account_data.is_a?(Hash) + Rails.logger.error "LunchflowItem::Importer - Invalid account_data format: expected Hash, got #{account_data.class}" + raise ArgumentError, "Invalid account data format" + end + + account_id = account_data[:id] + + # Validate required account_id + if account_id.blank? + Rails.logger.warn "LunchflowItem::Importer - Skipping account with missing ID" + raise ArgumentError, "Account ID is required" + end + + # Only find existing accounts, don't create new ones during sync + lunchflow_account = lunchflow_item.lunchflow_accounts.find_by( + account_id: account_id.to_s + ) + + # Skip if account wasn't previously selected + unless lunchflow_account + Rails.logger.debug "LunchflowItem::Importer - Skipping unselected account #{account_id}" + return + end + + begin + lunchflow_account.upsert_lunchflow_snapshot!(account_data) + lunchflow_account.save! + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "LunchflowItem::Importer - Failed to save lunchflow_account: #{e.message}" + raise StandardError.new("Failed to save account: #{e.message}") + end + end + + def fetch_and_store_transactions(lunchflow_account) + start_date = determine_sync_start_date(lunchflow_account) + Rails.logger.info "LunchflowItem::Importer - Fetching transactions for account #{lunchflow_account.account_id} from #{start_date}" + + begin + # Fetch transactions + transactions_data = lunchflow_provider.get_account_transactions( + lunchflow_account.account_id, + start_date: start_date + ) + + # Validate response structure + unless transactions_data.is_a?(Hash) + Rails.logger.error "LunchflowItem::Importer - Invalid transactions_data format for account #{lunchflow_account.account_id}" + return { success: false, transactions_count: 0, error: "Invalid response format" } + end + + transactions_count = transactions_data[:transactions]&.count || 0 + Rails.logger.info "LunchflowItem::Importer - Fetched #{transactions_count} transactions for account #{lunchflow_account.account_id}" + + # Store transactions in the account + if transactions_data[:transactions].present? + begin + existing_transactions = lunchflow_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_data[: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 "LunchflowItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions_data[:transactions].count - new_transactions.count} duplicates skipped) for account #{lunchflow_account.account_id}" + lunchflow_account.upsert_lunchflow_transactions_snapshot!(existing_transactions + new_transactions) + else + Rails.logger.info "LunchflowItem::Importer - No new transactions to store (all #{transactions_data[:transactions].count} were duplicates) for account #{lunchflow_account.account_id}" + end + rescue => e + Rails.logger.error "LunchflowItem::Importer - Failed to store transactions for account #{lunchflow_account.account_id}: #{e.message}" + return { success: false, transactions_count: 0, error: "Failed to store transactions: #{e.message}" } + end + else + Rails.logger.info "LunchflowItem::Importer - No transactions to store for account #{lunchflow_account.account_id}" + end + + # Fetch and update balance + begin + fetch_and_update_balance(lunchflow_account) + rescue => e + # Log but don't fail transaction import if balance fetch fails + Rails.logger.warn "LunchflowItem::Importer - Failed to update balance for account #{lunchflow_account.account_id}: #{e.message}" + end + + { success: true, transactions_count: transactions_count } + rescue Provider::Lunchflow::LunchflowError => e + Rails.logger.error "LunchflowItem::Importer - Lunchflow API error for account #{lunchflow_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: e.message } + rescue JSON::ParserError => e + Rails.logger.error "LunchflowItem::Importer - Failed to parse transaction response for account #{lunchflow_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: "Failed to parse response" } + rescue => e + Rails.logger.error "LunchflowItem::Importer - Unexpected error fetching transactions for account #{lunchflow_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 fetch_and_update_balance(lunchflow_account) + begin + balance_data = lunchflow_provider.get_account_balance(lunchflow_account.account_id) + + # Validate response structure + unless balance_data.is_a?(Hash) + Rails.logger.error "LunchflowItem::Importer - Invalid balance_data format for account #{lunchflow_account.account_id}" + return + end + + if balance_data[:balance].present? + balance_info = balance_data[:balance] + + # Validate balance info structure + unless balance_info.is_a?(Hash) + Rails.logger.error "LunchflowItem::Importer - Invalid balance info format for account #{lunchflow_account.account_id}" + return + end + + # Only update if we have a valid amount + if balance_info[:amount].present? + lunchflow_account.update!( + current_balance: balance_info[:amount], + currency: balance_info[:currency].presence || lunchflow_account.currency + ) + else + Rails.logger.warn "LunchflowItem::Importer - No amount in balance data for account #{lunchflow_account.account_id}" + end + else + Rails.logger.warn "LunchflowItem::Importer - No balance data returned for account #{lunchflow_account.account_id}" + end + rescue Provider::Lunchflow::LunchflowError => e + Rails.logger.error "LunchflowItem::Importer - Lunchflow API error fetching balance for account #{lunchflow_account.id}: #{e.message}" + # Don't fail if balance fetch fails + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "LunchflowItem::Importer - Failed to save balance for account #{lunchflow_account.id}: #{e.message}" + # Don't fail if balance save fails + rescue => e + Rails.logger.error "LunchflowItem::Importer - Unexpected error updating balance for account #{lunchflow_account.id}: #{e.class} - #{e.message}" + # Don't fail if balance update fails + end + end + + def determine_sync_start_date(lunchflow_account) + # Check if this account has any stored transactions + # If not, treat it as a first sync for this account even if the item has been synced before + has_stored_transactions = lunchflow_account.raw_transactions_payload.to_a.any? + + if has_stored_transactions + # Account has been synced before, use item-level logic with buffer + # For subsequent syncs, fetch from last sync date with a buffer + if lunchflow_item.last_synced_at + lunchflow_item.last_synced_at - 7.days + else + # Fallback if item hasn't been synced but account has transactions + 90.days.ago + end + else + # Account has no stored transactions - this is a first sync for this account + # Use account creation date or a generous historical window + account_baseline = lunchflow_account.created_at || Time.current + first_sync_window = [ account_baseline - 7.days, 90.days.ago ].max + + # Use the more recent of: (account created - 7 days) or (90 days ago) + # This caps old accounts at 90 days while respecting recent account creation dates + first_sync_window + end + end + + 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?("api key") + + if needs_update + begin + lunchflow_item.update!(status: :requires_update) + rescue => e + Rails.logger.error "LunchflowItem::Importer - Failed to update item status: #{e.message}" + end + end + + Rails.logger.error "LunchflowItem::Importer - API error: #{error_message}" + raise Provider::Lunchflow::LunchflowError.new( + "Lunchflow API error: #{error_message}", + :api_error + ) + end +end diff --git a/app/models/lunchflow_item/provided.rb b/app/models/lunchflow_item/provided.rb new file mode 100644 index 000000000..82d7c140d --- /dev/null +++ b/app/models/lunchflow_item/provided.rb @@ -0,0 +1,9 @@ +module LunchflowItem::Provided + extend ActiveSupport::Concern + + def lunchflow_provider + return nil unless credentials_configured? + + Provider::Lunchflow.new(api_key, base_url: effective_base_url) + end +end diff --git a/app/models/lunchflow_item/sync_complete_event.rb b/app/models/lunchflow_item/sync_complete_event.rb new file mode 100644 index 000000000..0a33c2714 --- /dev/null +++ b/app/models/lunchflow_item/sync_complete_event.rb @@ -0,0 +1,25 @@ +class LunchflowItem::SyncCompleteEvent + attr_reader :lunchflow_item + + def initialize(lunchflow_item) + @lunchflow_item = lunchflow_item + end + + def broadcast + # Update UI with latest account data + lunchflow_item.accounts.each do |account| + account.broadcast_sync_complete + end + + # Update the Lunchflow item view + lunchflow_item.broadcast_replace_to( + lunchflow_item.family, + target: "lunchflow_item_#{lunchflow_item.id}", + partial: "lunchflow_items/lunchflow_item", + locals: { lunchflow_item: lunchflow_item } + ) + + # Let family handle sync notifications + lunchflow_item.family.broadcast_sync_complete + end +end diff --git a/app/models/lunchflow_item/syncer.rb b/app/models/lunchflow_item/syncer.rb new file mode 100644 index 000000000..4d10b2578 --- /dev/null +++ b/app/models/lunchflow_item/syncer.rb @@ -0,0 +1,61 @@ +class LunchflowItem::Syncer + attr_reader :lunchflow_item + + def initialize(lunchflow_item) + @lunchflow_item = lunchflow_item + end + + def perform_sync(sync) + # Phase 1: Import data from Lunchflow API + sync.update!(status_text: "Importing accounts from Lunchflow...") if sync.respond_to?(:status_text) + lunchflow_item.import_latest_lunchflow_data + + # Phase 2: Check account setup status and collect sync statistics + sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + total_accounts = lunchflow_item.lunchflow_accounts.count + linked_accounts = lunchflow_item.lunchflow_accounts.joins(:account).merge(Account.visible) + unlinked_accounts = lunchflow_item.lunchflow_accounts.includes(:account).where(accounts: { id: nil }) + + # Store sync statistics for display + sync_stats = { + total_accounts: total_accounts, + linked_accounts: linked_accounts.count, + unlinked_accounts: unlinked_accounts.count + } + + # Set pending_account_setup if there are unlinked accounts + if unlinked_accounts.any? + lunchflow_item.update!(pending_account_setup: true) + sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text) + else + lunchflow_item.update!(pending_account_setup: false) + end + + # Phase 3: Process transactions for linked accounts only + if linked_accounts.any? + sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text) + Rails.logger.info "LunchflowItem::Syncer - Processing #{linked_accounts.count} linked accounts" + lunchflow_item.process_accounts + Rails.logger.info "LunchflowItem::Syncer - Finished processing accounts" + + # Phase 4: Schedule balance calculations for linked accounts + sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) + lunchflow_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + else + Rails.logger.info "LunchflowItem::Syncer - No linked accounts to process" + end + + # Store sync statistics in the sync record for status display + if sync.respond_to?(:sync_stats) + sync.update!(sync_stats: sync_stats) + end + end + + def perform_post_sync + # no-op + end +end diff --git a/app/models/lunchflow_item/unlinking.rb b/app/models/lunchflow_item/unlinking.rb new file mode 100644 index 000000000..60deae774 --- /dev/null +++ b/app/models/lunchflow_item/unlinking.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module LunchflowItem::Unlinking + # Concern that encapsulates unlinking logic for a Lunchflow item. + # Mirrors the SimplefinItem::Unlinking behavior. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this Lunchflow item and local accounts. + # - Detaches any AccountProvider links for each LunchflowAccount + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-account result payload for observability + def unlink_all!(dry_run: false) + results = [] + + lunchflow_accounts.find_each do |lfa| + links = AccountProvider.where(provider_type: "LunchflowAccount", provider_id: lfa.id).to_a + link_ids = links.map(&:id) + result = { + lfa_id: lfa.id, + name: lfa.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( + "LunchflowItem Unlinker: failed to fully unlink LFA ##{lfa.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/market_data_importer.rb b/app/models/market_data_importer.rb index 86c3c2351..d1ac7d7d7 100644 --- a/app/models/market_data_importer.rb +++ b/app/models/market_data_importer.rb @@ -76,6 +76,9 @@ 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 @@ -91,6 +94,9 @@ 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/merchant.rb b/app/models/merchant.rb index 4a4257bc2..f3a7bc95c 100644 --- a/app/models/merchant.rb +++ b/app/models/merchant.rb @@ -2,6 +2,7 @@ class Merchant < ApplicationRecord TYPES = %w[FamilyMerchant ProviderMerchant].freeze has_many :transactions, dependent: :nullify + has_many :recurring_transactions, dependent: :destroy validates :name, presence: true validates :type, inclusion: { in: TYPES } diff --git a/app/models/period.rb b/app/models/period.rb index 183c6ef8c..3e369f410 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -34,11 +34,17 @@ class Period label: "Current Month", comparison_label: "vs. start of month" }, + "last_month" => { + date_range: -> { [ 1.month.ago.beginning_of_month.to_date, 1.month.ago.end_of_month.to_date ] }, + label_short: "LM", + label: "Last Month", + comparison_label: "vs. last month" + }, "last_30_days" => { date_range: -> { [ 30.days.ago.to_date, Date.current ] }, label_short: "30D", label: "Last 30 Days", - comparison_label: "vs. last month" + comparison_label: "vs. last 30 days" }, "last_90_days" => { date_range: -> { [ 90.days.ago.to_date, Date.current ] }, @@ -69,6 +75,22 @@ class Period label_short: "10Y", label: "Last 10 Years", comparison_label: "vs. 10 years ago" + }, + "all_time" => { + date_range: -> { + oldest_date = Current.family&.oldest_entry_date + # If no family or no entries exist, use a reasonable historical fallback + # to ensure "All Time" represents a meaningful range, not just today + start_date = if oldest_date && oldest_date < Date.current + oldest_date + else + 5.years.ago.to_date + end + [ start_date, Date.current ] + }, + label_short: "All", + label: "All Time", + comparison_label: "vs. beginning" } } diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 949167ce5..4217a7926 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -1,11 +1,20 @@ class PlaidAccount < ApplicationRecord belongs_to :plaid_item - has_one :account, dependent: :destroy + # Legacy association via foreign key (will be removed after migration) + has_one :account, dependent: :nullify, foreign_key: :plaid_account_id + # New association through account_providers + has_one :account_provider, as: :provider, dependent: :destroy + has_one :linked_account, through: :account_provider, source: :account validates :name, :plaid_type, :currency, presence: true validate :has_balance + # Helper to get account using new system first, falling back to legacy + def current_account + linked_account || account + end + def upsert_plaid_snapshot!(account_snapshot) assign_attributes( current_balance: account_snapshot.balances.current, diff --git a/app/models/plaid_account/investments/holdings_processor.rb b/app/models/plaid_account/investments/holdings_processor.rb index 8dac6baee..493b8a9d1 100644 --- a/app/models/plaid_account/investments/holdings_processor.rb +++ b/app/models/plaid_account/investments/holdings_processor.rb @@ -11,40 +11,82 @@ class PlaidAccount::Investments::HoldingsProcessor next unless resolved_security_result.security.present? security = resolved_security_result.security - holding_date = plaid_holding["institution_price_as_of"] || Date.current - holding = account.holdings.find_or_initialize_by( + # Parse quantity and price into BigDecimal for proper arithmetic + quantity_bd = parse_decimal(plaid_holding["quantity"]) + price_bd = parse_decimal(plaid_holding["institution_price"]) + + # Skip if essential values are missing + next if quantity_bd.nil? || price_bd.nil? + + # Compute amount using BigDecimal arithmetic to avoid floating point drift + amount_bd = quantity_bd * price_bd + + # Normalize date - handle string, Date, or nil + holding_date = parse_date(plaid_holding["institution_price_as_of"]) || Date.current + + import_adapter.import_holding( security: security, + quantity: quantity_bd, + amount: amount_bd, + currency: plaid_holding["iso_currency_code"] || account.currency, date: holding_date, - currency: plaid_holding["iso_currency_code"] + price: price_bd, + account_provider_id: plaid_account.account_provider&.id, + source: "plaid", + delete_future_holdings: false # Plaid doesn't allow holdings deletion ) - - holding.assign_attributes( - qty: plaid_holding["quantity"], - price: plaid_holding["institution_price"], - amount: plaid_holding["quantity"] * plaid_holding["institution_price"] - ) - - ActiveRecord::Base.transaction do - holding.save! - - # Delete all holdings for this security after the institution price date - account.holdings - .where(security: security) - .where("date > ?", holding_date) - .destroy_all - end end end private attr_reader :plaid_account, :security_resolver + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + def account - plaid_account.account + plaid_account.current_account end def holdings - plaid_account.raw_investments_payload["holdings"] || [] + plaid_account.raw_investments_payload&.[]("holdings") || [] + end + + def parse_decimal(value) + return nil if value.nil? + + case value + when BigDecimal + value + when String + BigDecimal(value) + when Numeric + BigDecimal(value.to_s) + else + nil + end + rescue ArgumentError => e + Rails.logger.error("Failed to parse Plaid holding decimal value: #{value.inspect} - #{e.message}") + nil + end + + def parse_date(date_value) + return nil if date_value.nil? + + case date_value + when Date + date_value + when String + Date.parse(date_value) + when Time, DateTime + date_value.to_date + else + nil + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Plaid holding date: #{date_value.inspect} - #{e.message}") + nil end end diff --git a/app/models/plaid_account/investments/transactions_processor.rb b/app/models/plaid_account/investments/transactions_processor.rb index df4945047..922a3f2a6 100644 --- a/app/models/plaid_account/investments/transactions_processor.rb +++ b/app/models/plaid_account/investments/transactions_processor.rb @@ -19,8 +19,12 @@ class PlaidAccount::Investments::TransactionsProcessor private attr_reader :plaid_account, :security_resolver + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + def account - plaid_account.account + plaid_account.current_account end def cash_transaction?(transaction) @@ -38,50 +42,34 @@ class PlaidAccount::Investments::TransactionsProcessor return # We can't process a non-cash transaction without a security end - entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e| - e.entryable = Trade.new - end + external_id = transaction["investment_transaction_id"] + return if external_id.blank? - entry.assign_attributes( + import_adapter.import_trade( + external_id: external_id, + security: resolved_security_result.security, + quantity: derived_qty(transaction), + price: transaction["price"], amount: derived_qty(transaction) * transaction["price"], currency: transaction["iso_currency_code"], - date: transaction["date"] - ) - - entry.trade.assign_attributes( - security: resolved_security_result.security, - qty: derived_qty(transaction), - price: transaction["price"], - currency: transaction["iso_currency_code"] - ) - - entry.enrich_attribute( - :name, - transaction["name"], + date: transaction["date"], + name: transaction["name"], source: "plaid" ) - - entry.save! end def find_or_create_cash_entry(transaction) - entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e| - e.entryable = Transaction.new - end + external_id = transaction["investment_transaction_id"] + return if external_id.blank? - entry.assign_attributes( + import_adapter.import_transaction( + external_id: external_id, amount: transaction["amount"], currency: transaction["iso_currency_code"], - date: transaction["date"] - ) - - entry.enrich_attribute( - :name, - transaction["name"], + date: transaction["date"], + name: transaction["name"], source: "plaid" ) - - entry.save! end def transactions diff --git a/app/models/plaid_account/liabilities/credit_processor.rb b/app/models/plaid_account/liabilities/credit_processor.rb index cc4872951..7a5b216db 100644 --- a/app/models/plaid_account/liabilities/credit_processor.rb +++ b/app/models/plaid_account/liabilities/credit_processor.rb @@ -6,17 +6,24 @@ class PlaidAccount::Liabilities::CreditProcessor def process return unless credit_data.present? - account.credit_card.update!( - minimum_payment: credit_data.dig("minimum_payment_amount"), - apr: credit_data.dig("aprs", 0, "apr_percentage") + import_adapter.update_accountable_attributes( + attributes: { + minimum_payment: credit_data.dig("minimum_payment_amount"), + apr: credit_data.dig("aprs", 0, "apr_percentage") + }, + source: "plaid" ) end private attr_reader :plaid_account + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + def account - plaid_account.account + plaid_account.current_account end def credit_data diff --git a/app/models/plaid_account/liabilities/mortgage_processor.rb b/app/models/plaid_account/liabilities/mortgage_processor.rb index d4610362d..d5744c9a7 100644 --- a/app/models/plaid_account/liabilities/mortgage_processor.rb +++ b/app/models/plaid_account/liabilities/mortgage_processor.rb @@ -16,7 +16,7 @@ class PlaidAccount::Liabilities::MortgageProcessor attr_reader :plaid_account def account - plaid_account.account + plaid_account.current_account end def mortgage_data diff --git a/app/models/plaid_account/liabilities/student_loan_processor.rb b/app/models/plaid_account/liabilities/student_loan_processor.rb index c3c3b4f2e..61d6f484c 100644 --- a/app/models/plaid_account/liabilities/student_loan_processor.rb +++ b/app/models/plaid_account/liabilities/student_loan_processor.rb @@ -18,7 +18,7 @@ class PlaidAccount::Liabilities::StudentLoanProcessor attr_reader :plaid_account def account - plaid_account.account + plaid_account.current_account end def term_months diff --git a/app/models/plaid_account/processor.rb b/app/models/plaid_account/processor.rb index fa898b3b3..4faead9d9 100644 --- a/app/models/plaid_account/processor.rb +++ b/app/models/plaid_account/processor.rb @@ -30,9 +30,20 @@ class PlaidAccount::Processor def process_account! PlaidAccount.transaction do - account = family.accounts.find_or_initialize_by( - plaid_account_id: plaid_account.id - ) + # Find existing account through account_provider or legacy plaid_account_id + account_provider = AccountProvider.find_by(provider: plaid_account) + account = if account_provider + account_provider.account + else + # Legacy fallback: find by plaid_account_id if it still exists + family.accounts.find_by(plaid_account_id: plaid_account.id) + end + + # Initialize new account if not found + if account.nil? + account = family.accounts.new + account.accountable = map_accountable(plaid_account.plaid_type) + end # Create or assign the accountable if needed if account.accountable.nil? @@ -65,6 +76,15 @@ class PlaidAccount::Processor account.save! + # Create account provider link if it doesn't exist + unless account_provider + AccountProvider.find_or_create_by!( + account: account, + provider: plaid_account, + provider_type: "PlaidAccount" + ) + end + # Create or update the current balance anchor valuation for event-sourced ledger # Note: This is a partial implementation. In the future, we'll introduce HoldingValuation # to properly track the holdings vs. cash breakdown, but for now we're only tracking diff --git a/app/models/plaid_account/transactions/processor.rb b/app/models/plaid_account/transactions/processor.rb index 8aa07162a..90e55d2d9 100644 --- a/app/models/plaid_account/transactions/processor.rb +++ b/app/models/plaid_account/transactions/processor.rb @@ -39,7 +39,7 @@ class PlaidAccount::Transactions::Processor end def account - plaid_account.account + plaid_account.current_account end def remove_plaid_transaction(raw_transaction) diff --git a/app/models/plaid_entry/processor.rb b/app/models/plaid_entry/processor.rb index 182e382d8..13d35a93e 100644 --- a/app/models/plaid_entry/processor.rb +++ b/app/models/plaid_entry/processor.rb @@ -7,53 +7,30 @@ class PlaidEntry::Processor end def process - PlaidAccount.transaction do - entry = account.entries.find_or_initialize_by(plaid_id: plaid_id) do |e| - e.entryable = Transaction.new - end - - entry.assign_attributes( - amount: amount, - currency: currency, - date: date - ) - - entry.enrich_attribute( - :name, - name, - source: "plaid" - ) - - if detailed_category - matched_category = category_matcher.match(detailed_category) - - if matched_category - entry.transaction.enrich_attribute( - :category_id, - matched_category.id, - source: "plaid" - ) - end - end - - if merchant - entry.transaction.enrich_attribute( - :merchant_id, - merchant.id, - source: "plaid" - ) - end - end + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "plaid", + category_id: matched_category&.id, + merchant: merchant + ) end private attr_reader :plaid_transaction, :plaid_account, :category_matcher - def account - plaid_account.account + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) end - def plaid_id + def account + plaid_account.current_account + end + + def external_id plaid_transaction["transaction_id"] end @@ -77,19 +54,18 @@ class PlaidEntry::Processor plaid_transaction.dig("personal_finance_category", "detailed") end + def matched_category + return nil unless detailed_category + @matched_category ||= category_matcher.match(detailed_category) + end + def merchant - merchant_id = plaid_transaction["merchant_entity_id"] - merchant_name = plaid_transaction["merchant_name"] - - return nil unless merchant_id.present? && merchant_name.present? - - ProviderMerchant.find_or_create_by!( + @merchant ||= import_adapter.find_or_create_merchant( + provider_merchant_id: plaid_transaction["merchant_entity_id"], + name: plaid_transaction["merchant_name"], source: "plaid", - name: merchant_name, - ) do |m| - m.provider_merchant_id = merchant_id - m.website_url = plaid_transaction["website"] - m.logo_url = plaid_transaction["logo_url"] - end + website_url: plaid_transaction["website"], + logo_url: plaid_transaction["logo_url"] + ) end end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index abd11beb3..c60c8421c 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -4,11 +4,22 @@ class PlaidItem < ApplicationRecord enum :plaid_region, { us: "us", eu: "eu" } enum :status, { good: "good", requires_update: "requires_update" }, default: :good - if Rails.application.credentials.active_record_encryption.present? + # Helper to detect if ActiveRecord Encryption is configured for this app + 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 :access_token, deterministic: true end - validates :name, :access_token, presence: true + validates :name, presence: true + validates :access_token, presence: true, on: :create before_destroy :remove_plaid_item @@ -16,12 +27,22 @@ class PlaidItem < ApplicationRecord has_one_attached :logo has_many :plaid_accounts, dependent: :destroy - has_many :accounts, through: :plaid_accounts + has_many :legacy_accounts, through: :plaid_accounts, source: :account scope :active, -> { where(scheduled_for_deletion: false) } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } + # Get accounts from both new and legacy systems + def accounts + # Preload associations to avoid N+1 queries + plaid_accounts + .includes(:account, account_provider: :account) + .map(&:current_account) + .compact + .uniq + end + def get_update_link_token(webhooks_url:, redirect_url:) family.get_link_token( webhooks_url: webhooks_url, @@ -104,12 +125,19 @@ class PlaidItem < ApplicationRecord plaid_provider.remove_item(access_token) rescue Plaid::ApiError => e json_response = JSON.parse(e.response_body) + error_code = json_response["error_code"] - # If the item is not found, that means it was already deleted by the user on their - # Plaid portal OR by Plaid support. Either way, we're not being billed, so continue - # with the deletion of our internal record. - unless json_response["error_code"] == "ITEM_NOT_FOUND" - raise e + # Continue with deletion if: + # - ITEM_NOT_FOUND: Item was already deleted by the user on their Plaid portal OR by Plaid support + # - INVALID_API_KEYS: API credentials are invalid/missing, so we can't communicate with Plaid anyway + # - Other credential errors: We're deleting our record, so no need to fail if we can't reach Plaid + ignorable_errors = %w[ITEM_NOT_FOUND INVALID_API_KEYS INVALID_CLIENT_ID INVALID_SECRET] + + unless ignorable_errors.include?(error_code) + # Log the error but don't prevent deletion - we're removing the item from our database + # If we can't tell Plaid, we'll at least stop using it on our end + Rails.logger.warn("Failed to remove Plaid item: #{error_code} - #{json_response['error_message']}") + Sentry.capture_exception(e) if defined?(Sentry) end end diff --git a/app/models/provider/base.rb b/app/models/provider/base.rb new file mode 100644 index 000000000..37f63577a --- /dev/null +++ b/app/models/provider/base.rb @@ -0,0 +1,90 @@ +# Base class for all provider adapters +# Provides common interface for working with different third-party data providers +# +# To create a new provider adapter: +# 1. Inherit from Provider::Base +# 2. Implement #provider_name +# 3. Include optional modules (Provider::Syncable, Provider::InstitutionMetadata) +# 4. Register with Provider::Factory in the class body +# +# Example: +# class Provider::AcmeAdapter < Provider::Base +# Provider::Factory.register("AcmeAccount", self) +# include Provider::Syncable +# include Provider::InstitutionMetadata +# +# def provider_name +# "acme" +# end +# end +class Provider::Base + attr_reader :provider_account, :account + + def initialize(provider_account, account: nil) + @provider_account = provider_account + @account = account || provider_account.account + end + + # Provider identification - must be implemented by subclasses + # @return [String] The provider name (e.g., "plaid", "simplefin") + def provider_name + raise NotImplementedError, "#{self.class} must implement #provider_name" + end + + # Defines which account types this provider supports + # Override in subclasses to specify supported account types + # @return [Array] Array of account type class names (e.g., ["Depository", "CreditCard"]) + def self.supported_account_types + [] + end + + # Returns provider connection configurations + # Override in subclasses to provide connection metadata for UI + # @param family [Family] The family to check connection availability for + # @return [Array] Array of connection configurations with keys: + # - key: Unique identifier (e.g., "lunchflow", "plaid_us") + # - name: Display name (e.g., "Lunch Flow", "Plaid") + # - description: User-facing description + # - can_connect: Boolean, whether family can connect to this provider + # - new_account_path: Proc that generates path for new account flow + # - existing_account_path: Proc that generates path for linking existing account + def self.connection_configs(family:) + [] + end + + # Returns the provider type (class name) + # @return [String] The provider account class name + def provider_type + provider_account.class.name + end + + # Whether this provider allows deletion of holdings + # Override in subclass if provider supports holdings deletion + # @return [Boolean] True if holdings can be deleted, false otherwise + def can_delete_holdings? + false + end + + # Provider-specific raw data payload + # @return [Hash, nil] The raw payload from the provider + def raw_payload + provider_account.raw_payload + end + + # Returns metadata about this provider and account + # Automatically includes institution metadata if the adapter includes Provider::InstitutionMetadata + # @return [Hash] Metadata hash + def metadata + base_metadata = { + provider_name: provider_name, + provider_type: provider_type + } + + # Include institution metadata if the module is included + if respond_to?(:institution_metadata) + base_metadata.merge!(institution: institution_metadata) + end + + base_metadata + end +end diff --git a/app/models/provider/configurable.rb b/app/models/provider/configurable.rb new file mode 100644 index 000000000..eaf0fb2a2 --- /dev/null +++ b/app/models/provider/configurable.rb @@ -0,0 +1,307 @@ +# Module for providers to declare their configuration requirements +# +# Providers can declare their own configuration fields without needing to modify +# the Setting model. Settings are stored dynamically as individual entries using +# RailsSettings::Base's bracket-style access (Setting[:key] = value). +# +# Configuration fields are automatically registered and displayed in the UI at +# /settings/providers. The system checks Setting storage first, then ENV variables, +# then falls back to defaults. +# +# Example usage in an adapter: +# class Provider::PlaidAdapter < Provider::Base +# include Provider::Configurable +# +# configure do +# description <<~DESC +# Setup instructions: +# 1. Visit [Plaid Dashboard](https://dashboard.plaid.com) to get your API credentials +# 2. Configure your Client ID and Secret Key below +# DESC +# +# field :client_id, +# label: "Client ID", +# required: true, +# env_key: "PLAID_CLIENT_ID", +# description: "Your Plaid Client ID from the dashboard" +# +# field :secret, +# label: "Secret Key", +# required: true, +# secret: true, +# env_key: "PLAID_SECRET", +# description: "Your Plaid Secret key" +# +# field :environment, +# label: "Environment", +# required: false, +# env_key: "PLAID_ENV", +# default: "sandbox", +# description: "Plaid environment: sandbox, development, or production" +# end +# end +# +# The provider_key is automatically derived from the class name: +# Provider::PlaidAdapter -> "plaid" +# Provider::SimplefinAdapter -> "simplefin" +# +# Fields are stored with keys like "plaid_client_id", "plaid_secret", etc. +# Access values via: configuration.get_value(:client_id) or field.value +module Provider::Configurable + extend ActiveSupport::Concern + + class_methods do + # Define configuration for this provider + def configure(&block) + @configuration = Configuration.new(provider_key) + @configuration.instance_eval(&block) + Provider::ConfigurationRegistry.register(provider_key, @configuration, self) + end + + # Get the configuration for this provider + def configuration + @configuration || Provider::ConfigurationRegistry.get(provider_key) + end + + # Get the provider key (derived from class name) + # Example: Provider::PlaidAdapter -> "plaid" + def provider_key + name.demodulize.gsub(/Adapter$/, "").underscore + end + + # Get a configuration value + def config_value(field_name) + configuration&.get_value(field_name) + end + + # Check if provider is configured (all required fields present) + def configured? + configuration&.configured? || false + end + + # Reload provider-specific configuration (override in subclasses if needed) + # This is called after settings are updated in the UI + # Example: reload Rails.application.config values, reinitialize API clients, etc. + def reload_configuration + # Default implementation does nothing + # Override in provider adapters that need to reload configuration + end + end + + # Instance methods + def provider_key + self.class.provider_key + end + + def configuration + self.class.configuration + end + + def config_value(field_name) + self.class.config_value(field_name) + end + + def configured? + self.class.configured? + end + + # Configuration DSL + class Configuration + attr_reader :provider_key, :fields, :provider_description + + def initialize(provider_key) + @provider_key = provider_key + @fields = [] + @provider_description = nil + @configured_check = nil + end + + # Set the provider-level description (markdown supported) + # @param text [String] The description text for this provider + def description(text) + @provider_description = text + end + + # Define a custom check for whether this provider is configured + # @param block [Proc] A block that returns true if the provider is configured + # Example: + # configured_check { get_value(:client_id).present? && get_value(:secret).present? } + def configured_check(&block) + @configured_check = block + end + + # Define a configuration field + # @param name [Symbol] The field name + # @param label [String] Human-readable label + # @param required [Boolean] Whether this field is required + # @param secret [Boolean] Whether this field contains sensitive data (will be masked in UI) + # @param env_key [String] The ENV variable key for this field + # @param default [String] Default value if none provided + # @param description [String] Optional help text + def field(name, label:, required: false, secret: false, env_key: nil, default: nil, description: nil) + @fields << ConfigField.new( + name: name, + label: label, + required: required, + secret: secret, + env_key: env_key, + default: default, + description: description, + provider_key: @provider_key + ) + end + + # Get value for a field (checks Setting, then ENV, then default) + def get_value(field_name) + field = fields.find { |f| f.name == field_name } + return nil unless field + + field.value + end + + # Check if provider is properly configured + # Uses custom configured_check if defined, otherwise checks required fields + def configured? + if @configured_check + instance_eval(&@configured_check) + else + required_fields = fields.select(&:required) + if required_fields.any? + required_fields.all? { |f| f.value.present? } + else + # If no required fields, provider is not considered configured + # unless it defines a custom configured_check + false + end + end + end + + # Get all field values as a hash + def to_h + fields.each_with_object({}) do |field, hash| + hash[field.name] = field.value + end + end + end + + # Represents a single configuration field + class ConfigField + attr_reader :name, :label, :required, :secret, :env_key, :default, :description, :provider_key + + def initialize(name:, label:, required:, secret:, env_key:, default:, description:, provider_key:) + @name = name + @label = label + @required = required + @secret = secret + @env_key = env_key + @default = default + @description = description + @provider_key = provider_key + end + + # Get the setting key for this field + # Example: plaid_client_id + def setting_key + "#{provider_key}_#{name}".to_sym + end + + # Get the value for this field (Setting -> ENV -> default) + def value + # First try Setting using dynamic bracket-style access + # Each field is stored as an individual entry without explicit field declarations + setting_value = Setting[setting_key] + return normalize_value(setting_value) if setting_value.present? + + # Then try ENV if env_key is specified + if env_key.present? + env_value = ENV[env_key] + return normalize_value(env_value) if env_value.present? + end + + # Finally return default + normalize_value(default) + end + + # Check if this field has a value + def present? + value.present? + end + + # Validate the current value + # Returns true if valid, false otherwise + def valid? + validate.empty? + end + + # Get validation errors for the current value + # Returns an array of error messages + def validate + errors = [] + current_value = value + + # Required validation + if required && current_value.blank? + errors << "#{label} is required" + end + + # Additional validations can be added here in the future: + # - Format validation (regex) + # - Length validation + # - Enum validation + # - Custom validation blocks + + errors + end + + # Validate and raise an error if invalid + def validate! + errors = validate + raise ArgumentError, "Invalid configuration for #{setting_key}: #{errors.join(", ")}" if errors.any? + true + end + + private + # Normalize value by stripping whitespace and converting empty strings to nil + def normalize_value(val) + return nil if val.nil? + normalized = val.to_s.strip + normalized.empty? ? nil : normalized + end + end +end + +# Registry to store all provider configurations +module Provider::ConfigurationRegistry + class << self + def register(provider_key, configuration, adapter_class = nil) + registry[provider_key] = configuration + adapter_registry[provider_key] = adapter_class if adapter_class + end + + def get(provider_key) + registry[provider_key] + end + + def all + registry.values + end + + def providers + registry.keys + end + + # Get the adapter class for a provider key + def get_adapter_class(provider_key) + adapter_registry[provider_key] + end + + private + def registry + @registry ||= {} + end + + def adapter_registry + @adapter_registry ||= {} + end + end +end diff --git a/app/models/provider/factory.rb b/app/models/provider/factory.rb new file mode 100644 index 000000000..f8d5ac688 --- /dev/null +++ b/app/models/provider/factory.rb @@ -0,0 +1,134 @@ +class Provider::Factory + class AdapterNotFoundError < StandardError; end + + class << self + # Register a provider adapter + # @param provider_type [String] The provider account class name (e.g., "PlaidAccount") + # @param adapter_class [Class] The adapter class (e.g., Provider::PlaidAdapter) + def register(provider_type, adapter_class) + registry[provider_type] = adapter_class + end + + # Creates an adapter for a given provider account + # @param provider_account [PlaidAccount, SimplefinAccount] The provider-specific account + # @param account [Account] Optional account reference + # @return [Provider::Base] An adapter instance + def create_adapter(provider_account, account: nil) + return nil if provider_account.nil? + + provider_type = provider_account.class.name + adapter_class = find_adapter_class(provider_type) + + raise AdapterNotFoundError, "No adapter registered for provider type: #{provider_type}" unless adapter_class + + adapter_class.new(provider_account, account: account) + end + + # Creates an adapter from an AccountProvider record + # @param account_provider [AccountProvider] The account provider record + # @return [Provider::Base] An adapter instance + def from_account_provider(account_provider) + return nil if account_provider.nil? + + create_adapter(account_provider.provider, account: account_provider.account) + end + + # Get list of registered provider types + # @return [Array] List of registered provider type names + def registered_provider_types + ensure_adapters_loaded + registry.keys.sort + end + + # Ensures all provider adapters are loaded and registered + # Uses Rails autoloading to discover adapters dynamically + def ensure_adapters_loaded + # Eager load all adapter files to trigger their registration + adapter_files.each do |adapter_name| + adapter_class_name = "Provider::#{adapter_name}" + + # Use Rails autoloading (constantize) instead of require + begin + adapter_class_name.constantize + rescue NameError => e + Rails.logger.warn("Failed to load adapter: #{adapter_class_name} - #{e.message}") + end + end + end + + # Check if a provider type has a registered adapter + # @param provider_type [String] The provider account class name + # @return [Boolean] + def registered?(provider_type) + find_adapter_class(provider_type).present? + end + + # Get all registered adapter classes + # @return [Array] List of registered adapter classes + def registered_adapters + ensure_adapters_loaded + registry.values.uniq + end + + # Get adapters that support a specific account type + # @param account_type [String] The account type class name (e.g., "Depository", "CreditCard") + # @return [Array] List of adapter classes that support this account type + def adapters_for_account_type(account_type) + registered_adapters.select do |adapter_class| + adapter_class.supported_account_types.include?(account_type) + end + end + + # Check if any provider supports a given account type + # @param account_type [String] The account type class name + # @return [Boolean] + def supports_account_type?(account_type) + adapters_for_account_type(account_type).any? + end + + # Get all available provider connection configs for a given account type + # @param account_type [String] The account type class name (e.g., "Depository") + # @param family [Family] The family to check connection availability for + # @return [Array] Array of connection configurations from all providers + def connection_configs_for_account_type(account_type:, family:) + adapters_for_account_type(account_type).flat_map do |adapter_class| + adapter_class.connection_configs(family: family) + end + end + + # Clear all registered adapters (useful for testing) + def clear_registry! + @registry = {} + end + + private + + def registry + @registry ||= {} + end + + # Find adapter class, attempting to load all adapters if not registered + def find_adapter_class(provider_type) + # Return if already registered + return registry[provider_type] if registry[provider_type] + + # Load all adapters to ensure they're registered + # This triggers their self-registration calls + ensure_adapters_loaded + + # Check registry again after loading + registry[provider_type] + end + + # Discover all adapter files in the provider directory + # Returns adapter class names (e.g., ["PlaidAdapter", "SimplefinAdapter"]) + def adapter_files + return [] unless defined?(Rails) + + pattern = Rails.root.join("app/models/provider/*_adapter.rb") + Dir[pattern].map do |file| + File.basename(file, ".rb").camelize + end + end + end +end diff --git a/app/models/provider/institution_metadata.rb b/app/models/provider/institution_metadata.rb new file mode 100644 index 000000000..2998d3aa4 --- /dev/null +++ b/app/models/provider/institution_metadata.rb @@ -0,0 +1,40 @@ +# Module for providers that provide institution/bank metadata +# Include this module in your adapter if the provider returns institution information +module Provider::InstitutionMetadata + extend ActiveSupport::Concern + + # Returns the institution's domain (e.g., "chase.com") + # @return [String, nil] The institution domain or nil if not available + def institution_domain + nil + end + + # Returns the institution's display name (e.g., "Chase Bank") + # @return [String, nil] The institution name or nil if not available + def institution_name + nil + end + + # Returns the institution's website URL + # @return [String, nil] The institution URL or nil if not available + def institution_url + nil + end + + # Returns the institution's brand color (for UI purposes) + # @return [String, nil] The hex color code or nil if not available + def institution_color + nil + end + + # Returns a hash of all institution metadata + # @return [Hash] Hash containing institution metadata + def institution_metadata + { + domain: institution_domain, + name: institution_name, + url: institution_url, + color: institution_color + }.compact + end +end diff --git a/app/models/provider/lunchflow.rb b/app/models/provider/lunchflow.rb new file mode 100644 index 000000000..dfd5f5109 --- /dev/null +++ b/app/models/provider/lunchflow.rb @@ -0,0 +1,120 @@ +class Provider::Lunchflow + include HTTParty + + headers "User-Agent" => "Sure Finance Lunch Flow Client" + default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + + attr_reader :api_key, :base_url + + def initialize(api_key, base_url: "https://lunchflow.app/api/v1") + @api_key = api_key + @base_url = base_url + end + + # Get all accounts + # Returns: { accounts: [...], total: N } + def get_accounts + response = self.class.get( + "#{@base_url}/accounts", + headers: auth_headers + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Lunch Flow API: GET /accounts failed: #{e.class}: #{e.message}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + rescue => e + Rails.logger.error "Lunch Flow API: Unexpected error during GET /accounts: #{e.class}: #{e.message}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + end + + # Get transactions for a specific account + # Returns: { transactions: [...], total: N } + # Transaction structure: { id, accountId, amount, currency, date, merchant, description } + def get_account_transactions(account_id, start_date: nil, end_date: nil) + query_params = {} + + if start_date + query_params[:start_date] = start_date.to_date.to_s + end + + if end_date + query_params[:end_date] = end_date.to_date.to_s + end + + path = "/accounts/#{ERB::Util.url_encode(account_id.to_s)}/transactions" + path += "?#{URI.encode_www_form(query_params)}" unless query_params.empty? + + response = self.class.get( + "#{@base_url}#{path}", + headers: auth_headers + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Lunch Flow API: GET #{path} failed: #{e.class}: #{e.message}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + rescue => e + Rails.logger.error "Lunch Flow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + end + + # Get balance for a specific account + # Returns: { balance: { amount: N, currency: "USD" } } + def get_account_balance(account_id) + path = "/accounts/#{ERB::Util.url_encode(account_id.to_s)}/balance" + + response = self.class.get( + "#{@base_url}#{path}", + headers: auth_headers + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Lunch Flow API: GET #{path} failed: #{e.class}: #{e.message}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + rescue => e + Rails.logger.error "Lunch Flow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + end + + private + + def auth_headers + { + "x-api-key" => api_key, + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def handle_response(response) + case response.code + when 200 + JSON.parse(response.body, symbolize_names: true) + when 400 + Rails.logger.error "Lunch Flow API: Bad request - #{response.body}" + raise LunchflowError.new("Bad request to Lunch Flow API: #{response.body}", :bad_request) + when 401 + raise LunchflowError.new("Invalid API key", :unauthorized) + when 403 + raise LunchflowError.new("Access forbidden - check your API key permissions", :access_forbidden) + when 404 + raise LunchflowError.new("Resource not found", :not_found) + when 429 + raise LunchflowError.new("Rate limit exceeded. Please try again later.", :rate_limited) + else + Rails.logger.error "Lunch Flow API: Unexpected response - Code: #{response.code}, Body: #{response.body}" + raise LunchflowError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed) + end + end + + class LunchflowError < StandardError + attr_reader :error_type + + def initialize(message, error_type = :unknown) + super(message) + @error_type = error_type + end + end +end diff --git a/app/models/provider/lunchflow_adapter.rb b/app/models/provider/lunchflow_adapter.rb new file mode 100644 index 000000000..ec5b3ef92 --- /dev/null +++ b/app/models/provider/lunchflow_adapter.rb @@ -0,0 +1,106 @@ +class Provider::LunchflowAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("LunchflowAccount", self) + + # Define which account types this provider supports + def self.supported_account_types + %w[Depository CreditCard Loan] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_lunchflow? + + [ { + key: "lunchflow", + name: "Lunch Flow", + description: "Connect to your bank via Lunch Flow", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_lunchflow_items_path( + accountable_type: accountable_type, + return_to: return_to + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_lunchflow_items_path( + account_id: account_id + ) + } + } ] + end + + def provider_name + "lunchflow" + end + + # Build a Lunch Flow provider instance with family-specific credentials + # Lunchflow is now fully per-family - no global credentials supported + # @param family [Family] The family to get credentials for (required) + # @return [Provider::Lunchflow, nil] Returns nil if API key is not configured + def self.build_provider(family: nil) + return nil unless family.present? + + # Get family-specific credentials + lunchflow_item = family.lunchflow_items.where.not(api_key: nil).first + return nil unless lunchflow_item&.credentials_configured? + + Provider::Lunchflow.new( + lunchflow_item.api_key, + base_url: lunchflow_item.effective_base_url + ) + end + + def sync_path + Rails.application.routes.url_helpers.sync_lunchflow_item_path(item) + end + + def item + provider_account.lunchflow_item + end + + def can_delete_holdings? + false + end + + def institution_domain + # Lunch Flow 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 Lunch Flow account #{provider_account.id}: #{url}") + end + end + + domain + end + + def institution_name + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["name"] || item&.institution_name + 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/openai.rb b/app/models/provider/openai.rb index e25361e36..a732a1cd6 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -54,6 +54,11 @@ class Provider::Openai < Provider def auto_categorize(transactions: [], user_categories: [], model: "", family: 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") + raise Error, "No categories available for auto-categorization" + end effective_model = model.presence || @default_model @@ -236,6 +241,7 @@ class Provider::Openai < Provider session_id: session_id, user_identifier: user_identifier ) + record_llm_usage(family: family, model: model, operation: "chat", error: e) raise end end @@ -309,6 +315,7 @@ class Provider::Openai < Provider session_id: session_id, user_identifier: user_identifier ) + record_llm_usage(family: family, model: model, operation: "chat", error: e) raise end end @@ -441,8 +448,36 @@ class Provider::Openai < Provider Rails.logger.warn("Langfuse logging failed: #{e.message}") end - def record_llm_usage(family:, model:, operation:, usage:) - return unless family && usage + def record_llm_usage(family:, model:, operation:, usage: nil, error: nil) + return unless family + + # For error cases, record with zero tokens + if error.present? + Rails.logger.info("Recording failed LLM usage - Error: #{error.message}") + + # Extract HTTP status code if available from the error + http_status_code = extract_http_status_code(error) + + inferred_provider = LlmUsage.infer_provider(model) + family.llm_usages.create!( + provider: inferred_provider, + model: model, + operation: operation, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + estimated_cost: nil, + metadata: { + error: error.message, + http_status_code: http_status_code + } + ) + + Rails.logger.info("Failed LLM usage recorded successfully - Status: #{http_status_code}") + return + end + + return unless usage Rails.logger.info("Recording LLM usage - Raw usage data: #{usage.inspect}") @@ -482,4 +517,23 @@ class Provider::Openai < Provider rescue => e Rails.logger.error("Failed to record LLM usage: #{e.message}") end + + def extract_http_status_code(error) + # Try to extract HTTP status code from various error types + # OpenAI gem errors may have status codes in different formats + if error.respond_to?(:code) + error.code + elsif error.respond_to?(:http_status) + error.http_status + elsif error.respond_to?(:status_code) + error.status_code + elsif error.respond_to?(:response) && error.response.respond_to?(:code) + error.response.code.to_i + elsif error.message =~ /(\d{3})/ + # Extract 3-digit HTTP status code from error message + $1.to_i + else + nil + end + end end diff --git a/app/models/provider/openai/concerns/usage_recorder.rb b/app/models/provider/openai/concerns/usage_recorder.rb index 647b17f8b..55f94f052 100644 --- a/app/models/provider/openai/concerns/usage_recorder.rb +++ b/app/models/provider/openai/concerns/usage_recorder.rb @@ -44,4 +44,53 @@ module Provider::Openai::Concerns::UsageRecorder rescue => e Rails.logger.error("Failed to record LLM usage: #{e.message}") end + + # Records failed LLM usage for a family with error details + def record_usage_error(model_name, operation:, error:, metadata: {}) + return unless family + + Rails.logger.info("Recording failed LLM usage - Operation: #{operation}, Error: #{error.message}") + + # Extract HTTP status code if available from the error + http_status_code = extract_http_status_code(error) + + error_metadata = metadata.merge( + error: error.message, + http_status_code: http_status_code + ) + + inferred_provider = LlmUsage.infer_provider(model_name) + family.llm_usages.create!( + provider: inferred_provider, + model: model_name, + operation: operation, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + estimated_cost: nil, + metadata: error_metadata + ) + + Rails.logger.info("Failed LLM usage recorded - Operation: #{operation}, Status: #{http_status_code}") + rescue => e + Rails.logger.error("Failed to record LLM usage error: #{e.message}") + end + + def extract_http_status_code(error) + # Try to extract HTTP status code from various error types + if error.respond_to?(:code) + error.code + elsif error.respond_to?(:http_status) + error.http_status + elsif error.respond_to?(:status_code) + error.status_code + elsif error.respond_to?(:response) && error.response.respond_to?(:code) + error.response.code.to_i + elsif error.message =~ /(\d{3})/ + # Extract 3-digit HTTP status code from error message + $1.to_i + else + nil + end + end end diff --git a/app/models/provider/plaid_adapter.rb b/app/models/provider/plaid_adapter.rb new file mode 100644 index 000000000..ac4fd8187 --- /dev/null +++ b/app/models/provider/plaid_adapter.rb @@ -0,0 +1,185 @@ +# PlaidAdapter serves dual purposes: +# +# 1. Configuration Manager (class-level): +# - Manages Rails.application.config.plaid (US region) +# - Exposes 3 configurable fields in "Plaid" section of settings UI +# - PlaidEuAdapter separately manages EU region in "Plaid Eu" section +# +# 2. Instance Adapter (instance-level): +# - Wraps ALL PlaidAccount instances regardless of region (US or EU) +# - The PlaidAccount's plaid_item.plaid_region determines which config to use +# - Delegates to Provider::Registry.plaid_provider_for_region(region) +class Provider::PlaidAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + include Provider::Configurable + + # Register this adapter with the factory for ALL PlaidAccount instances + Provider::Factory.register("PlaidAccount", self) + + # Define which account types this provider supports (US region) + def self.supported_account_types + %w[Depository CreditCard Loan Investment] + end + + # Returns connection configurations for this provider + # Plaid can return multiple configs (US and EU) depending on family setup + def self.connection_configs(family:) + configs = [] + + # US configuration + if family.can_connect_plaid_us? + configs << { + key: "plaid_us", + name: "Plaid", + description: "Connect to your US bank via Plaid", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.new_plaid_item_path( + region: "us", + accountable_type: accountable_type + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_plaid_items_path( + account_id: account_id, + region: "us" + ) + } + } + end + + # EU configuration + if family.can_connect_plaid_eu? + configs << { + key: "plaid_eu", + name: "Plaid (EU)", + description: "Connect to your EU bank via Plaid", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.new_plaid_item_path( + region: "eu", + accountable_type: accountable_type + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_plaid_items_path( + account_id: account_id, + region: "eu" + ) + } + } + end + + configs + end + + # Mutex for thread-safe configuration loading + # Initialized at class load time to avoid race conditions on mutex creation + @config_mutex = Mutex.new + + # 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, + env_key: "PLAID_CLIENT_ID", + description: "Your Plaid Client ID from the Plaid Dashboard" + + field :secret, + label: "Secret Key", + required: false, + secret: true, + env_key: "PLAID_SECRET", + description: "Your Plaid Secret from the Plaid Dashboard" + + field :environment, + label: "Environment", + required: false, + env_key: "PLAID_ENV", + default: "sandbox", + description: "Plaid environment: sandbox, development, or production" + + # Plaid requires both client_id and secret to be configured + configured_check { get_value(:client_id).present? && get_value(:secret).present? } + end + + def provider_name + "plaid" + end + + # Thread-safe lazy loading of Plaid US configuration + # Ensures configuration is loaded exactly once even under concurrent access + def self.ensure_configuration_loaded + # Fast path: return immediately if already loaded (no lock needed) + return if Rails.application.config.plaid.present? + + # Slow path: acquire lock and reload if still needed + @config_mutex.synchronize do + # Double-check after acquiring lock (another thread may have loaded it) + return if Rails.application.config.plaid.present? + + reload_configuration + end + end + + # Reload Plaid US configuration when settings are updated + def self.reload_configuration + client_id = config_value(:client_id).presence || ENV["PLAID_CLIENT_ID"] + secret = config_value(:secret).presence || ENV["PLAID_SECRET"] + environment = config_value(:environment).presence || ENV["PLAID_ENV"] || "sandbox" + + if client_id.present? && secret.present? + Rails.application.config.plaid = Plaid::Configuration.new + Rails.application.config.plaid.server_index = Plaid::Configuration::Environment[environment] + Rails.application.config.plaid.api_key["PLAID-CLIENT-ID"] = client_id + Rails.application.config.plaid.api_key["PLAID-SECRET"] = secret + else + Rails.application.config.plaid = nil + end + end + + def sync_path + Rails.application.routes.url_helpers.sync_plaid_item_path(item) + end + + def item + provider_account.plaid_item + end + + def can_delete_holdings? + false + end + + def institution_domain + url_string = item&.institution_url + return nil unless url_string.present? + + begin + uri = URI.parse(url_string) + uri.host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for Plaid account #{provider_account.id}: #{url_string}") + nil + end + end + + def institution_name + item&.name + end + + def institution_url + item&.institution_url + end + + def institution_color + item&.institution_color + end +end diff --git a/app/models/provider/plaid_eu_adapter.rb b/app/models/provider/plaid_eu_adapter.rb new file mode 100644 index 000000000..497bb13c4 --- /dev/null +++ b/app/models/provider/plaid_eu_adapter.rb @@ -0,0 +1,83 @@ +# PlaidEuAdapter is a configuration-only manager for Plaid EU credentials. +# +# It does NOT register as a provider type because: +# - There's no separate "PlaidEuAccount" model +# - All PlaidAccounts (regardless of region) use PlaidAdapter as their instance adapter +# +# This class only manages Rails.application.config.plaid_eu, which +# Provider::Registry.plaid_provider_for_region(:eu) uses to create Provider::Plaid instances. +# +# This separation into a distinct adapter class provides: +# - Clear UI separation: "Plaid" vs "Plaid Eu" sections in settings +# - Better UX: Users only configure the region they need +class Provider::PlaidEuAdapter + include Provider::Configurable + + # Mutex for thread-safe configuration loading + # Initialized at class load time to avoid race conditions on mutex creation + @config_mutex = Mutex.new + + # 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, + env_key: "PLAID_EU_CLIENT_ID", + description: "Your Plaid Client ID from the Plaid Dashboard for EU region" + + field :secret, + label: "Secret Key", + required: false, + secret: true, + env_key: "PLAID_EU_SECRET", + description: "Your Plaid Secret from the Plaid Dashboard for EU region" + + field :environment, + label: "Environment", + required: false, + env_key: "PLAID_EU_ENV", + default: "sandbox", + description: "Plaid environment: sandbox, development, or production" + + # Plaid EU requires both client_id and secret to be configured + configured_check { get_value(:client_id).present? && get_value(:secret).present? } + end + + # Thread-safe lazy loading of Plaid EU configuration + # Ensures configuration is loaded exactly once even under concurrent access + def self.ensure_configuration_loaded + # Fast path: return immediately if already loaded (no lock needed) + return if Rails.application.config.plaid_eu.present? + + # Slow path: acquire lock and reload if still needed + @config_mutex.synchronize do + # Double-check after acquiring lock (another thread may have loaded it) + return if Rails.application.config.plaid_eu.present? + + reload_configuration + end + end + + # Reload Plaid EU configuration when settings are updated + def self.reload_configuration + client_id = config_value(:client_id).presence || ENV["PLAID_EU_CLIENT_ID"] + secret = config_value(:secret).presence || ENV["PLAID_EU_SECRET"] + environment = config_value(:environment).presence || ENV["PLAID_EU_ENV"] || "sandbox" + + if client_id.present? && secret.present? + Rails.application.config.plaid_eu = Plaid::Configuration.new + Rails.application.config.plaid_eu.server_index = Plaid::Configuration::Environment[environment] + Rails.application.config.plaid_eu.api_key["PLAID-CLIENT-ID"] = client_id + Rails.application.config.plaid_eu.api_key["PLAID-SECRET"] = secret + else + Rails.application.config.plaid_eu = nil + end + end +end diff --git a/app/models/provider/plaid_sandbox.rb b/app/models/provider/plaid_sandbox.rb index e4d7a3476..23c2a4551 100644 --- a/app/models/provider/plaid_sandbox.rb +++ b/app/models/provider/plaid_sandbox.rb @@ -40,6 +40,8 @@ class Provider::PlaidSandbox < Provider::Plaid def create_client raise "Plaid sandbox is not supported in production" if Rails.env.production? + Provider::PlaidAdapter.ensure_configuration_loaded + api_client = Plaid::ApiClient.new( Rails.application.config.plaid ) diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index 3d5af2f62..aa7a443c5 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -41,6 +41,7 @@ class Provider::Registry end def plaid_us + Provider::PlaidAdapter.ensure_configuration_loaded config = Rails.application.config.plaid return nil unless config.present? @@ -49,6 +50,7 @@ class Provider::Registry end def plaid_eu + Provider::PlaidEuAdapter.ensure_configuration_loaded config = Rails.application.config.plaid_eu return nil unless config.present? @@ -75,6 +77,10 @@ class Provider::Registry Provider::Openai.new(access_token, uri_base: uri_base, model: model) end + + def yahoo_finance + Provider::YahooFinance.new + end end def initialize(concept) @@ -100,9 +106,9 @@ class Provider::Registry def available_providers case concept when :exchange_rates - %i[twelve_data] + %i[twelve_data yahoo_finance] when :securities - %i[twelve_data] + %i[twelve_data yahoo_finance] when :llm %i[openai] else diff --git a/app/models/provider/simplefin_adapter.rb b/app/models/provider/simplefin_adapter.rb new file mode 100644 index 000000000..0d76acad3 --- /dev/null +++ b/app/models/provider/simplefin_adapter.rb @@ -0,0 +1,87 @@ +class Provider::SimplefinAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("SimplefinAccount", 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_simplefin? + + [ { + key: "simplefin", + name: "SimpleFIN", + description: "Connect to your bank via SimpleFIN", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.new_simplefin_item_path( + accountable_type: accountable_type + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_simplefin_items_path( + account_id: account_id + ) + } + } ] + end + + def provider_name + "simplefin" + end + + def sync_path + Rails.application.routes.url_helpers.sync_simplefin_item_path(item) + end + + def item + provider_account.simplefin_item + end + + def can_delete_holdings? + false + end + + def institution_domain + org_data = provider_account.org_data + return nil unless org_data.present? + + domain = org_data["domain"] + url = org_data["url"] || org_data["sfin-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 SimpleFin account #{provider_account.id}: #{url}") + end + end + + domain + end + + def institution_name + org_data = provider_account.org_data + return nil unless org_data.present? + + org_data["name"] || item&.institution_name + end + + def institution_url + org_data = provider_account.org_data + return nil unless org_data.present? + + org_data["url"] || org_data["sfin-url"] || item&.institution_url + end + + def institution_color + item&.institution_color + end +end diff --git a/app/models/provider/syncable.rb b/app/models/provider/syncable.rb new file mode 100644 index 000000000..918325448 --- /dev/null +++ b/app/models/provider/syncable.rb @@ -0,0 +1,35 @@ +# Module for providers that support syncing with external services +# Include this module in your adapter if the provider supports sync operations +module Provider::Syncable + extend ActiveSupport::Concern + + # Returns the path to sync this provider's item + # @return [String] The sync path + def sync_path + raise NotImplementedError, "#{self.class} must implement #sync_path" + end + + # Returns the provider's item/connection object + # @return [Object] The item object (e.g., PlaidItem, SimplefinItem) + def item + raise NotImplementedError, "#{self.class} must implement #item" + end + + # Check if the item is currently syncing + # @return [Boolean] True if syncing, false otherwise + def syncing? + item&.syncing? || false + end + + # Returns the current sync status + # @return [String, nil] The status string or nil + def status + item&.status + end + + # Check if the item requires an update (e.g., re-authentication) + # @return [Boolean] True if update required, false otherwise + def requires_update? + status == "requires_update" + end +end diff --git a/app/models/provider/twelve_data.rb b/app/models/provider/twelve_data.rb index 5a7738bb4..8f9d81a42 100644 --- a/app/models/provider/twelve_data.rb +++ b/app/models/provider/twelve_data.rb @@ -55,6 +55,7 @@ class Provider::TwelveData < Provider 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 response = client.get("#{base_url}/time_series") do |req| req.params["symbol"] = "#{from}/#{to}" req.params["start_date"] = start_date.to_s @@ -65,6 +66,21 @@ class Provider::TwelveData < Provider parsed = JSON.parse(response.body) 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") + response = client.get("#{base_url}/time_series/cross") do |req| + req.params["base"] = from + req.params["quote"] = to + req.params["start_date"] = start_date.to_s + req.params["end_date"] = end_date.to_s + req.params["interval"] = "1day" + end + + parsed = JSON.parse(response.body) + data = parsed.dig("values") + end + if data.nil? error_message = parsed.dig("message") || "No data returned" error_code = parsed.dig("code") || "unknown" @@ -74,7 +90,7 @@ class Provider::TwelveData < Provider data.map do |resp| rate = resp.dig("close") date = resp.dig("datetime") - if rate.nil? + if rate.nil? || rate.to_f <= 0 Rails.logger.warn("#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}. Rate data: #{rate.inspect}") next end @@ -178,7 +194,7 @@ class Provider::TwelveData < Provider values.map do |resp| price = resp.dig("close") date = resp.dig("datetime") - if price.nil? + 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 @@ -187,7 +203,7 @@ class Provider::TwelveData < Provider symbol: symbol, date: date.to_date, price: price, - currency: parsed.dig("currency"), + currency: parsed.dig("meta", "currency") || parsed.dig("currency"), exchange_operating_mic: exchange_operating_mic ) end.compact diff --git a/app/models/provider/yahoo_finance.rb b/app/models/provider/yahoo_finance.rb new file mode 100644 index 000000000..bb008b5f6 --- /dev/null +++ b/app/models/provider/yahoo_finance.rb @@ -0,0 +1,629 @@ +class Provider::YahooFinance < Provider + include ExchangeRateConcept, SecurityConcept + + # Subclass so errors caught in this provider are raised as Provider::YahooFinance::Error + Error = Class.new(Provider::Error) + InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) + InvalidSymbolError = Class.new(Error) + MarketClosedError = Class.new(Error) + + # Cache duration for repeated requests (5 minutes) + CACHE_DURATION = 5.minutes + + # Maximum lookback window for historical data (configurable) + MAX_LOOKBACK_WINDOW = 10.years + + def initialize + # Yahoo Finance doesn't require an API key but we may want to add proxy support later + @cache_prefix = "yahoo_finance" + end + + def healthy? + begin + # Test with a known stable ticker (Apple) + response = client.get("#{base_url}/v8/finance/chart/AAPL") do |req| + req.params["interval"] = "1d" + req.params["range"] = "1d" + end + + data = JSON.parse(response.body) + result = data.dig("chart", "result") + health_status = result.present? && result.any? + + health_status + rescue => e + false + end + end + + def usage + # Yahoo Finance doesn't expose usage data, so we return a mock structure + with_provider_response do + usage_data = UsageData.new( + used: 0, + limit: 2000, # Estimated daily limit based on community knowledge + utilization: 0, + plan: "Free" + ) + + usage_data + end + end + + # ================================ + # Exchange Rates + # ================================ + + def fetch_exchange_rate(from:, to:, date:) + with_provider_response do + # Return 1.0 if same currency + if from == to + Rate.new(date: date, from: from, to: to, rate: 1.0) + else + cache_key = "exchange_rate_#{from}_#{to}_#{date}" + if cached_result = get_cached_result(cache_key) + cached_result + else + # For a single date, we'll fetch a range and find the closest match + end_date = date + start_date = date - 10.days # Extended range for better coverage + + rates_response = fetch_exchange_rates( + from: from, + to: to, + start_date: start_date, + end_date: end_date + ) + + raise Error, "Failed to fetch exchange rates: #{rates_response.error.message}" unless rates_response.success? + + rates = rates_response.data + if rates.length == 1 + rates.first + else + # Find the exact date or the closest previous date + target_rate = rates.find { |r| r.date == date } || + rates.select { |r| r.date <= date }.max_by(&:date) + + raise Error, "No exchange rate found for #{from}/#{to} on or before #{date}" unless target_rate + + cache_result(cache_key, target_rate) + target_rate + end + end + end + end + end + + def fetch_exchange_rates(from:, to:, start_date:, end_date:) + with_provider_response do + validate_date_range!(start_date, end_date) + # Return 1.0 rates if same currency + if from == to + generate_same_currency_rates(from, to, start_date, end_date) + else + cache_key = "exchange_rates_#{from}_#{to}_#{start_date}_#{end_date}" + if cached_result = get_cached_result(cache_key) + cached_result + else + # Try both direct and inverse currency pairs + rates = fetch_currency_pair_data(from, to, start_date, end_date) || + fetch_inverse_currency_pair_data(from, to, start_date, end_date) + + raise Error, "No chart data found for currency pair #{from}/#{to}" unless rates&.any? + + cache_result(cache_key, rates) + rates + end + end + rescue JSON::ParserError => e + raise Error, "Invalid response format: #{e.message}" + end + end + + # ================================ + # Securities + # ================================ + + def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) + with_provider_response do + cache_key = "search_#{symbol}_#{country_code}_#{exchange_operating_mic}" + if cached_result = get_cached_result(cache_key) + return cached_result + end + + response = client.get("#{base_url}/v1/finance/search") do |req| + req.params["q"] = symbol.strip.upcase + req.params["quotesCount"] = 25 + end + + data = JSON.parse(response.body) + quotes = data.dig("quotes") || [] + + securities = quotes.filter_map do |quote| + Security.new( + symbol: quote["symbol"], + name: quote["longname"] || quote["shortname"] || quote["symbol"], + logo_url: nil, # Yahoo search doesn't provide logos + exchange_operating_mic: map_exchange_mic(quote["exchange"]), + country_code: map_country_code(quote["exchDisp"]) + ) + end + + cache_result(cache_key, securities) + securities + rescue JSON::ParserError => e + raise Error, "Invalid search response format: #{e.message}" + end + end + + def fetch_security_info(symbol:, exchange_operating_mic:) + with_provider_response do + # Use quoteSummary endpoint which is more reliable + response = client.get("#{base_url}/v10/finance/quoteSummary/#{symbol}") do |req| + req.params["modules"] = "assetProfile,price,quoteType" + end + + data = JSON.parse(response.body) + result = data.dig("quoteSummary", "result", 0) + + raise Error, "No security info found for #{symbol}" unless result + + asset_profile = result["assetProfile"] || {} + price_info = result["price"] || {} + quote_type = result["quoteType"] || {} + + security_info = SecurityInfo.new( + symbol: symbol, + name: price_info["longName"] || price_info["shortName"] || quote_type["longName"] || quote_type["shortName"], + links: asset_profile["website"], + logo_url: nil, # Yahoo doesn't provide reliable logo URLs + description: asset_profile["longBusinessSummary"], + kind: map_security_type(quote_type["quoteType"]), + exchange_operating_mic: exchange_operating_mic + ) + + security_info + rescue JSON::ParserError => e + raise Error, "Invalid response format: #{e.message}" + end + end + + def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) + with_provider_response do + cache_key = "security_price_#{symbol}_#{exchange_operating_mic}_#{date}" + if cached_result = get_cached_result(cache_key) + return cached_result + end + + # For a single date, we'll fetch a range and find the closest match + end_date = date + start_date = date - 10.days # Extended range for better coverage + + prices_response = fetch_security_prices( + symbol: symbol, + exchange_operating_mic: exchange_operating_mic, + start_date: start_date, + end_date: end_date + ) + + raise Error, "Failed to fetch security prices: #{prices_response.error.message}" unless prices_response.success? + + prices = prices_response.data + return prices.first if prices.length == 1 + + # Find the exact date or the closest previous date + target_price = prices.find { |p| p.date == date } || + prices.select { |p| p.date <= date }.max_by(&:date) + + raise Error, "No price found for #{symbol} on or before #{date}" unless target_price + + cache_result(cache_key, target_price) + target_price + end + end + + def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) + with_provider_response do + 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 + period2 = end_date.end_of_day.to_time.utc.to_i + + response = client.get("#{base_url}/v8/finance/chart/#{symbol}") do |req| + req.params["period1"] = period1 + req.params["period2"] = period2 + req.params["interval"] = "1d" + req.params["includeAdjustedClose"] = true + end + + data = JSON.parse(response.body) + chart_data = data.dig("chart", "result", 0) + + raise Error, "No chart data found for #{symbol}" unless chart_data + + timestamps = chart_data.dig("timestamp") || [] + quotes = chart_data.dig("indicators", "quote", 0) || {} + closes = quotes["close"] || [] + + # Get currency from metadata + raw_currency = chart_data.dig("meta", "currency") || "USD" + + prices = [] + timestamps.each_with_index do |timestamp, index| + close_price = closes[index] + next if close_price.nil? # Skip days with no data (weekends, holidays) + + # Normalize currency and price to handle minor units + normalized_currency, normalized_price = normalize_currency_and_price(raw_currency, close_price.to_f) + + prices << Price.new( + symbol: symbol, + date: Time.at(timestamp).to_date, + price: normalized_price, + currency: normalized_currency, + exchange_operating_mic: exchange_operating_mic + ) + end + + sorted_prices = prices.sort_by(&:date) + sorted_prices + rescue JSON::ParserError => e + raise Error, "Invalid response format: #{e.message}" + end + end + + private + + def base_url + ENV["YAHOO_FINANCE_URL"] || "https://query1.finance.yahoo.com" + end + + # ================================ + # Currency Normalization + # ================================ + + # 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 + MINOR_CURRENCY_CONVERSIONS = { + "GBp" => { currency: "GBP", multiplier: 0.01 }, # British pence to pounds (eg. https://finance.yahoo.com/quote/IITU.L/) + "ZAc" => { currency: "ZAR", multiplier: 0.01 } # South African cents to rand (eg. https://finance.yahoo.com/quote/JSE.JO) + }.freeze + + # Normalizes Yahoo Finance currency codes and prices + # Returns [currency_code, price] with currency converted to standard ISO code + # and price converted from minor units to major units if applicable + def normalize_currency_and_price(currency, price) + if conversion = MINOR_CURRENCY_CONVERSIONS[currency] + [ conversion[:currency], price * conversion[:multiplier] ] + else + [ currency, price ] + end + end + + # ================================ + # Validation + # ================================ + + + def validate_date_range!(start_date, end_date) + raise Error, "Start date cannot be after end date" if start_date > end_date + raise Error, "Date range too large (max 5 years)" if end_date > start_date + 5.years + end + + def validate_date_params!(start_date, end_date) + # Validate presence and coerce to dates + validated_start_date = validate_and_coerce_date!(start_date, "start_date") + validated_end_date = validate_and_coerce_date!(end_date, "end_date") + + # Ensure start_date <= end_date + if validated_start_date > validated_end_date + error_msg = "Start date (#{validated_start_date}) cannot be after end date (#{validated_end_date})" + raise ArgumentError, error_msg + end + + # Ensure end_date is not in the future + today = Date.current + if validated_end_date > today + error_msg = "End date (#{validated_end_date}) cannot be in the future" + raise ArgumentError, error_msg + end + + # Optional: Enforce max lookback window (configurable via constant) + max_lookback = MAX_LOOKBACK_WINDOW.ago.to_date + if validated_start_date < max_lookback + error_msg = "Start date (#{validated_start_date}) exceeds maximum lookback window (#{max_lookback})" + raise ArgumentError, error_msg + end + end + + def validate_and_coerce_date!(date_param, param_name) + # Check presence + if date_param.blank? + error_msg = "#{param_name} cannot be blank" + raise ArgumentError, error_msg + end + + # Try to coerce to date + begin + if date_param.respond_to?(:to_date) + date_param.to_date + else + Date.parse(date_param.to_s) + end + rescue ArgumentError => e + error_msg = "Invalid #{param_name}: #{date_param} (#{e.message})" + raise ArgumentError, error_msg + end + end + + # ================================ + # Caching + # ================================ + + def get_cached_result(key) + full_key = "#{@cache_prefix}_#{key}" + data = Rails.cache.read(full_key) + data + end + + def cache_result(key, data) + full_key = "#{@cache_prefix}_#{key}" + Rails.cache.write(full_key, data, expires_in: CACHE_DURATION) + end + + + + # ================================ + # Helper Methods + # ================================ + + def generate_same_currency_rates(from, to, start_date, end_date) + (start_date..end_date).map do |date| + Rate.new(date: date, from: from, to: to, rate: 1.0) + end + end + + def fetch_currency_pair_data(from, to, start_date, end_date) + symbol = "#{from}#{to}=X" + fetch_chart_data(symbol, start_date, end_date) do |timestamp, close_rate| + Rate.new( + date: Time.at(timestamp).to_date, + from: from, + to: to, + rate: close_rate.to_f + ) + end + end + + def fetch_inverse_currency_pair_data(from, to, start_date, end_date) + symbol = "#{to}#{from}=X" + rates = fetch_chart_data(symbol, start_date, end_date) do |timestamp, close_rate| + Rate.new( + date: Time.at(timestamp).to_date, + from: from, + to: to, + rate: (1.0 / close_rate.to_f).round(8) + ) + end + + rates + end + + def fetch_chart_data(symbol, start_date, end_date, &block) + period1 = start_date.to_time.utc.to_i + period2 = end_date.end_of_day.to_time.utc.to_i + + + begin + response = client.get("#{base_url}/v8/finance/chart/#{symbol}") do |req| + req.params["period1"] = period1 + req.params["period2"] = period2 + req.params["interval"] = "1d" + req.params["includeAdjustedClose"] = true + end + + data = JSON.parse(response.body) + + # Check for Yahoo Finance errors + if data.dig("chart", "error") + error_msg = data.dig("chart", "error", "description") || "Unknown Yahoo Finance error" + return nil + end + + chart_data = data.dig("chart", "result", 0) + return nil unless chart_data + + timestamps = chart_data.dig("timestamp") || [] + quotes = chart_data.dig("indicators", "quote", 0) || {} + closes = quotes["close"] || [] + + results = [] + timestamps.each_with_index do |timestamp, index| + close_value = closes[index] + next if close_value.nil? || close_value <= 0 + + results << block.call(timestamp, close_value) + end + + results.sort_by(&:date) + rescue Faraday::Error => e + nil + end + end + + def client + @client ||= Faraday.new(url: base_url) do |faraday| + faraday.request(:retry, { + max: 3, + interval: 0.1, + interval_randomness: 0.5, + backoff_factor: 2, + exceptions: [ Faraday::ConnectionFailed, Faraday::TimeoutError ] + }) + + faraday.request :json + faraday.response :raise_error + + # Yahoo Finance requires common browser headers to avoid blocking + faraday.headers["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + faraday.headers["Accept"] = "application/json" + faraday.headers["Accept-Language"] = "en-US,en;q=0.9" + faraday.headers["Cache-Control"] = "no-cache" + faraday.headers["Pragma"] = "no-cache" + + # Set reasonable timeouts + faraday.options.timeout = 10 + faraday.options.open_timeout = 5 + end + end + + def map_country_code(exchange_name) + return nil if exchange_name.blank? + + # Map common exchange names to country codes + case exchange_name.upcase.strip + when /NASDAQ|NYSE|AMEX|BATS|IEX/ + "US" + when /TSX|TSXV|CSE/ + "CA" + when /LSE|LONDON|AIM/ + "GB" + when /TOKYO|TSE|NIKKEI|JASDAQ/ + "JP" + when /ASX|AUSTRALIA/ + "AU" + when /EURONEXT|PARIS|AMSTERDAM|BRUSSELS|LISBON/ + case exchange_name.upcase + when /PARIS/ then "FR" + when /AMSTERDAM/ then "NL" + when /BRUSSELS/ then "BE" + when /LISBON/ then "PT" + else "FR" # Default to France for Euronext + end + when /FRANKFURT|XETRA|GETTEX/ + "DE" + when /SIX|ZURICH/ + "CH" + when /BME|MADRID/ + "ES" + when /BORSA|MILAN/ + "IT" + when /OSLO|OSE/ + "NO" + when /STOCKHOLM|OMX/ + "SE" + when /COPENHAGEN/ + "DK" + when /HELSINKI/ + "FI" + when /VIENNA/ + "AT" + when /WARSAW|GPW/ + "PL" + when /PRAGUE/ + "CZ" + when /BUDAPEST/ + "HU" + when /SHANGHAI|SHENZHEN/ + "CN" + when /HONG\s*KONG|HKG/ + "HK" + when /KOREA|KRX/ + "KR" + when /SINGAPORE|SGX/ + "SG" + when /MUMBAI|NSE|BSE/ + "IN" + when /SAO\s*PAULO|BOVESPA/ + "BR" + when /MEXICO|BMV/ + "MX" + when /JSE|JOHANNESBURG/ + "ZA" + else + nil + end + end + + def map_exchange_mic(exchange_code) + return nil if exchange_code.blank? + + # Map Yahoo exchange codes to MIC codes + case exchange_code.upcase.strip + when "NMS" + "XNAS" # NASDAQ Global Select + when "NGM" + "XNAS" # NASDAQ Global Market + when "NCM" + "XNAS" # NASDAQ Capital Market + when "NYQ" + "XNYS" # NYSE + when "PCX", "PSX" + "ARCX" # NYSE Arca + when "ASE", "AMX" + "XASE" # NYSE American + when "YHD" + "XNAS" # Yahoo default, assume NASDAQ + when "TSE", "TOR" + "XTSE" # Toronto Stock Exchange + when "CVE" + "XTSX" # TSX Venture Exchange + when "LSE", "LON" + "XLON" # London Stock Exchange + when "FRA" + "XFRA" # Frankfurt Stock Exchange + when "PAR" + "XPAR" # Euronext Paris + when "AMS" + "XAMS" # Euronext Amsterdam + when "BRU" + "XBRU" # Euronext Brussels + when "SWX" + "XSWX" # SIX Swiss Exchange + when "HKG" + "XHKG" # Hong Kong Stock Exchange + when "TYO" + "XJPX" # Japan Exchange Group + when "ASX" + "XASX" # Australian Securities Exchange + else + exchange_code.upcase + end + end + + def map_security_type(quote_type) + case quote_type&.downcase + when "equity" + "common stock" + when "etf" + "etf" + when "mutualfund" + "mutual fund" + when "index" + "index" + else + quote_type&.downcase + end + end + + # Override default error transformer to handle Yahoo Finance specific errors + def default_error_transformer(error) + case error + when Faraday::TooManyRequestsError + RateLimitError.new("Yahoo Finance rate limit exceeded", details: error.response&.dig(:body)) + when Faraday::Error + Error.new( + error.message, + details: error.response&.dig(:body) + ) + when Error + # Already a Yahoo Finance error, return as is + error + else + Error.new(error.message) + end + end +end diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 2ec1c88ba..add803979 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", synth: "synth", ai: "ai" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb new file mode 100644 index 000000000..069440725 --- /dev/null +++ b/app/models/recurring_transaction.rb @@ -0,0 +1,306 @@ +class RecurringTransaction < ApplicationRecord + include Monetizable + + belongs_to :family + belongs_to :merchant, optional: true + + monetize :amount + monetize :expected_amount_min, allow_nil: true + monetize :expected_amount_max, allow_nil: true + monetize :expected_amount_avg, allow_nil: true + + enum :status, { active: "active", inactive: "inactive" } + + 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 } + validate :merchant_or_name_present + validate :amount_variance_consistency + + def merchant_or_name_present + if merchant_id.blank? && name.blank? + errors.add(:base, "Either merchant or name must be present") + end + end + + def amount_variance_consistency + return unless manual? + + if expected_amount_min.present? && expected_amount_max.present? + if expected_amount_min > expected_amount_max + errors.add(:expected_amount_min, "cannot be greater than expected_amount_max") + end + end + end + + scope :for_family, ->(family) { where(family: family) } + scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) } + + # Class methods for identification and cleanup + def self.identify_patterns_for(family) + Identifier.new(family).identify_recurring_patterns + end + + def self.cleanup_stale_for(family) + Cleaner.new(family).cleanup_stale_transactions + 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) + entry = transaction.entry + family = entry.account.family + expected_day = entry.date.day + + # Find matching transactions from the past 6 months + matching_amounts = find_matching_transaction_amounts( + family: family, + merchant_id: transaction.merchant_id, + name: transaction.merchant_id.present? ? nil : entry.name, + currency: entry.currency, + expected_day: expected_day, + lookback_months: 6 + ) + + # Calculate amount variance from historical data + expected_min = expected_max = expected_avg = nil + if matching_amounts.size > 1 + # Multiple transactions found - calculate variance + expected_min = matching_amounts.min + expected_max = matching_amounts.max + expected_avg = matching_amounts.sum / matching_amounts.size + elsif matching_amounts.size == 1 + # Single transaction - no variance yet + amount = matching_amounts.first + expected_min = amount + expected_max = amount + expected_avg = amount + end + + # Calculate next expected date relative to today, not the transaction date + next_expected = calculate_next_expected_date_from_today(expected_day) + + create!( + family: family, + merchant_id: transaction.merchant_id, + name: transaction.merchant_id.present? ? nil : entry.name, + amount: entry.amount, + currency: entry.currency, + expected_day_of_month: expected_day, + last_occurrence_date: entry.date, + next_expected_date: next_expected, + status: "active", + occurrence_count: matching_amounts.size, + manual: true, + expected_amount_min: expected_min, + expected_amount_max: expected_max, + expected_amount_avg: expected_avg + ) + end + + # Find matching transaction entries for variance calculation + def self.find_matching_transaction_entries(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6) + lookback_date = lookback_months.months.ago.to_date + + entries = family.entries + .where(entryable_type: "Transaction") + .where(currency: currency) + .where("entries.date >= ?", lookback_date) + .where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?", + [ expected_day - 2, 1 ].max, + [ expected_day + 2, 31 ].min) + .order(date: :desc) + + # Filter by merchant or name + if merchant_id.present? + # Join with transactions table to filter by merchant_id in SQL (avoids N+1) + entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id") + .where(transactions: { merchant_id: merchant_id }) + .to_a + else + entries.where(name: name).to_a + end + end + + # Find matching transaction amounts for variance calculation + def self.find_matching_transaction_amounts(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6) + matching_entries = find_matching_transaction_entries( + family: family, + merchant_id: merchant_id, + name: name, + currency: currency, + expected_day: expected_day, + lookback_months: lookback_months + ) + + matching_entries.map(&:amount) + end + + # Calculate next expected date from today + def self.calculate_next_expected_date_from_today(expected_day) + today = Date.current + + # Try this month first + begin + this_month_date = Date.new(today.year, today.month, expected_day) + return this_month_date if this_month_date > today + rescue ArgumentError + # Day doesn't exist in this month (e.g., 31st in February) + end + + # Otherwise use next month + calculate_next_expected_date_for(today, expected_day) + end + + def self.calculate_next_expected_date_for(from_date, expected_day) + next_month = from_date.next_month + begin + Date.new(next_month.year, next_month.month, expected_day) + rescue ArgumentError + next_month.end_of_month + end + end + + # Find matching transactions for this recurring pattern + def matching_transactions + # For manual recurring with amount variance, match within range + # For automatic recurring, match exact amount + entries = if manual? && has_amount_variance? + family.entries + .where(entryable_type: "Transaction") + .where(currency: currency) + .where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max) + .where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?", + [ expected_day_of_month - 2, 1 ].max, + [ expected_day_of_month + 2, 31 ].min) + .order(date: :desc) + else + family.entries + .where(entryable_type: "Transaction") + .where(currency: currency) + .where("entries.amount = ?", amount) + .where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?", + [ expected_day_of_month - 2, 1 ].max, + [ expected_day_of_month + 2, 31 ].min) + .order(date: :desc) + end + + # Filter by merchant or name + if merchant_id.present? + # Match by merchant through the entryable (Transaction) + entries.select do |entry| + entry.entryable.is_a?(Transaction) && entry.entryable.merchant_id == merchant_id + end + else + # Match by entry name + entries.where(name: name) + end + end + + # Check if this recurring transaction has amount variance configured + def has_amount_variance? + expected_amount_min.present? && expected_amount_max.present? + end + + # Check if this recurring transaction should be marked inactive + def should_be_inactive? + return false if last_occurrence_date.nil? + # Manual recurring transactions have a longer threshold + threshold = manual? ? 6.months.ago : 2.months.ago + last_occurrence_date < threshold + end + + # Mark as inactive + def mark_inactive! + update!(status: "inactive") + end + + # Mark as active + def mark_active! + update!(status: "active") + end + + # Update based on a new transaction occurrence + def record_occurrence!(transaction_date, transaction_amount = nil) + self.last_occurrence_date = transaction_date + self.next_expected_date = calculate_next_expected_date(transaction_date) + + # Update amount variance for manual recurring transactions BEFORE incrementing count + if manual? && transaction_amount.present? + update_amount_variance(transaction_amount) + end + + self.occurrence_count += 1 + self.status = "active" + save! + end + + # Update amount variance tracking based on a new transaction + def update_amount_variance(transaction_amount) + # First sample - initialize everything + if expected_amount_avg.nil? + self.expected_amount_min = transaction_amount + self.expected_amount_max = transaction_amount + self.expected_amount_avg = transaction_amount + return + end + + # Update min/max + self.expected_amount_min = [ expected_amount_min, transaction_amount ].min if expected_amount_min.present? + self.expected_amount_max = [ expected_amount_max, transaction_amount ].max if expected_amount_max.present? + + # Calculate new average using incremental formula + # For n samples with average A_n, adding sample x_{n+1} gives: + # A_{n+1} = A_n + (x_{n+1} - A_n)/(n+1) + # occurrence_count includes the initial occurrence, so subtract 1 to get variance samples recorded + n = occurrence_count - 1 # Number of variance samples recorded so far + self.expected_amount_avg = expected_amount_avg + ((transaction_amount - expected_amount_avg) / (n + 1)) + end + + # Calculate the next expected date based on the last occurrence + def calculate_next_expected_date(from_date = last_occurrence_date) + # Start with next month + next_month = from_date.next_month + + # Try to use the expected day of month + begin + Date.new(next_month.year, next_month.month, expected_day_of_month) + rescue ArgumentError + # If day doesn't exist in month (e.g., 31st in February), use last day of month + next_month.end_of_month + end + end + + # Get the projected transaction for display + def projected_entry + return nil unless active? + return nil unless next_expected_date.future? + + # Use average amount for manual recurring with variance, otherwise use fixed amount + display_amount = if manual? && expected_amount_avg.present? + expected_amount_avg + else + amount + end + + OpenStruct.new( + date: next_expected_date, + amount: display_amount, + currency: currency, + merchant: merchant, + name: merchant.present? ? merchant.name : name, + recurring: true, + projected: true, + amount_min: expected_amount_min, + amount_max: expected_amount_max, + amount_avg: expected_amount_avg, + has_variance: has_amount_variance? + ) + end + + private + def monetizable_currency + currency + end +end diff --git a/app/models/recurring_transaction/cleaner.rb b/app/models/recurring_transaction/cleaner.rb new file mode 100644 index 000000000..4aecbb343 --- /dev/null +++ b/app/models/recurring_transaction/cleaner.rb @@ -0,0 +1,44 @@ +class RecurringTransaction + class Cleaner + attr_reader :family + + def initialize(family) + @family = family + end + + # Mark recurring transactions as inactive if they haven't occurred recently + # Uses 2 months for automatic recurring, 6 months for manual recurring + def cleanup_stale_transactions + stale_count = 0 + + family.recurring_transactions.active.find_each do |recurring_transaction| + next unless recurring_transaction.should_be_inactive? + + # Determine threshold based on manual flag + threshold = recurring_transaction.manual? ? 6.months.ago.to_date : 2.months.ago.to_date + + # Double-check if there are any recent matching transactions + recent_matches = recurring_transaction.matching_transactions.select { |entry| entry.date >= threshold } + + if recent_matches.empty? + recurring_transaction.mark_inactive! + stale_count += 1 + end + end + + stale_count + end + + # Remove inactive recurring transactions that have been inactive for 6+ months + # Manual recurring transactions are never automatically deleted + def remove_old_inactive_transactions + six_months_ago = 6.months.ago + + family.recurring_transactions + .inactive + .where(manual: false) + .where("updated_at < ?", six_months_ago) + .destroy_all + end + end +end diff --git a/app/models/recurring_transaction/identifier.rb b/app/models/recurring_transaction/identifier.rb new file mode 100644 index 000000000..86cc6a558 --- /dev/null +++ b/app/models/recurring_transaction/identifier.rb @@ -0,0 +1,266 @@ +class RecurringTransaction + class Identifier + attr_reader :family + + def initialize(family) + @family = family + end + + # Identify and create/update recurring transactions for the family + def identify_recurring_patterns + three_months_ago = 3.months.ago.to_date + + # Get all transactions from the last 3 months + entries_with_transactions = family.entries + .where(entryable_type: "Transaction") + .where("entries.date >= ?", three_months_ago) + .includes(:entryable) + .to_a + + # 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| + transaction = entry.entryable + # Use merchant_id if present, otherwise use entry name + identifier = transaction.merchant_id.present? ? [ :merchant, transaction.merchant_id ] : [ :name, entry.name ] + [ identifier, entry.amount.round(2), entry.currency ] + end + + recurring_patterns = [] + + grouped_transactions.each do |(identifier, amount, currency), entries| + next if entries.size < 3 # Must have at least 3 occurrences + + # Check if the last occurrence was within the last 45 days + last_occurrence = entries.max_by(&:date) + next if last_occurrence.date < 45.days.ago.to_date + + # Check if transactions occur on similar days (within 5 days of each other) + days_of_month = entries.map { |e| e.date.day }.sort + + # Calculate if days cluster together (standard deviation check) + if days_cluster_together?(days_of_month) + expected_day = calculate_expected_day(days_of_month) + + # Unpack identifier - either [:merchant, id] or [:name, name_string] + identifier_type, identifier_value = identifier + + pattern = { + amount: amount, + currency: currency, + expected_day_of_month: expected_day, + last_occurrence_date: last_occurrence.date, + occurrence_count: entries.size, + entries: entries + } + + if identifier_type == :merchant + pattern[:merchant_id] = identifier_value + else + pattern[:name] = identifier_value + end + + recurring_patterns << pattern + end + end + + # Create or update RecurringTransaction records + recurring_patterns.each do |pattern| + # Build find conditions based on whether it's merchant-based or name-based + find_conditions = { + amount: pattern[:amount], + currency: pattern[:currency] + } + + if pattern[:merchant_id].present? + find_conditions[:merchant_id] = pattern[:merchant_id] + find_conditions[:name] = nil + else + find_conditions[:name] = pattern[:name] + find_conditions[:merchant_id] = nil + end + + recurring_transaction = family.recurring_transactions.find_or_initialize_by(find_conditions) + + # Handle manual recurring transactions specially + if recurring_transaction.persisted? && recurring_transaction.manual? + # Update variance for manual recurring transactions + update_manual_recurring_variance(recurring_transaction, pattern) + next + end + + # Set the name or merchant_id on new records + if recurring_transaction.new_record? + if pattern[:merchant_id].present? + recurring_transaction.merchant_id = pattern[:merchant_id] + else + recurring_transaction.name = pattern[:name] + end + # New auto-detected recurring transactions are not manual + recurring_transaction.manual = false + end + + recurring_transaction.assign_attributes( + expected_day_of_month: pattern[:expected_day_of_month], + last_occurrence_date: pattern[:last_occurrence_date], + next_expected_date: calculate_next_expected_date(pattern[:last_occurrence_date], pattern[:expected_day_of_month]), + occurrence_count: pattern[:occurrence_count], + status: recurring_transaction.new_record? ? "active" : recurring_transaction.status + ) + + recurring_transaction.save! + end + + # Also check for manual recurring transactions that might need variance updates + update_manual_recurring_transactions(three_months_ago) + + recurring_patterns.size + end + + # Update variance for existing manual recurring transactions + def update_manual_recurring_transactions(since_date) + family.recurring_transactions.where(manual: true, status: "active").find_each do |recurring| + # Find matching transactions in the recent period + matching_entries = RecurringTransaction.find_matching_transaction_entries( + family: family, + merchant_id: recurring.merchant_id, + name: recurring.name, + currency: recurring.currency, + expected_day: recurring.expected_day_of_month, + lookback_months: 6 + ) + + next if matching_entries.empty? + + # Extract amounts and dates from all matching entries + matching_amounts = matching_entries.map(&:amount) + last_entry = matching_entries.max_by(&:date) + + # Recalculate variance from all occurrences (including identical amounts) + recurring.update!( + expected_amount_min: matching_amounts.min, + expected_amount_max: matching_amounts.max, + expected_amount_avg: matching_amounts.sum / matching_amounts.size, + occurrence_count: matching_amounts.size, + last_occurrence_date: last_entry.date, + next_expected_date: calculate_next_expected_date(last_entry.date, recurring.expected_day_of_month) + ) + end + end + + # Update variance for a manual recurring transaction when pattern is found + def update_manual_recurring_variance(recurring_transaction, pattern) + # Check if this transaction's date is more recent + if pattern[:last_occurrence_date] > recurring_transaction.last_occurrence_date + # Find all matching transactions to recalculate variance + matching_entries = RecurringTransaction.find_matching_transaction_entries( + family: family, + merchant_id: recurring_transaction.merchant_id, + name: recurring_transaction.name, + currency: recurring_transaction.currency, + expected_day: recurring_transaction.expected_day_of_month, + lookback_months: 6 + ) + + # Update if we have any matching transactions + if matching_entries.any? + matching_amounts = matching_entries.map(&:amount) + + recurring_transaction.update!( + expected_amount_min: matching_amounts.min, + expected_amount_max: matching_amounts.max, + expected_amount_avg: matching_amounts.sum / matching_amounts.size, + occurrence_count: matching_amounts.size, + last_occurrence_date: pattern[:last_occurrence_date], + next_expected_date: calculate_next_expected_date(pattern[:last_occurrence_date], recurring_transaction.expected_day_of_month) + ) + end + end + end + + private + # Check if days cluster together (within ~5 days variance) + # Uses circular distance to handle month-boundary wrapping (e.g., 28, 29, 30, 31, 1, 2) + def days_cluster_together?(days) + return false if days.empty? + + # Calculate median as reference point + median = calculate_expected_day(days) + + # Calculate circular distances from median + circular_distances = days.map { |day| circular_distance(day, median) } + + # Calculate standard deviation of circular distances + mean_distance = circular_distances.sum.to_f / circular_distances.size + variance = circular_distances.map { |dist| (dist - mean_distance)**2 }.sum / circular_distances.size + std_dev = Math.sqrt(variance) + + # Allow up to 5 days standard deviation + std_dev <= 5 + end + + # Calculate circular distance between two days on a 31-day circle + # Examples: + # circular_distance(1, 31) = 2 (wraps around: 31 -> 1 is 1 day forward) + # circular_distance(28, 2) = 5 (wraps: 28, 29, 30, 31, 1, 2) + def circular_distance(day1, day2) + linear_distance = (day1 - day2).abs + wrap_distance = 31 - linear_distance + [ linear_distance, wrap_distance ].min + end + + # Calculate the expected day based on the most common day + # Uses circular rotation to handle month-wrapping sequences (e.g., [29, 30, 31, 1, 2]) + def calculate_expected_day(days) + return days.first if days.size == 1 + + # Convert to 0-indexed (0-30 instead of 1-31) for modular arithmetic + days_0 = days.map { |d| d - 1 } + + # Find the rotation (pivot) that minimizes span, making the cluster contiguous + # This handles month-wrapping sequences like [29, 30, 31, 1, 2] + best_pivot = 0 + min_span = Float::INFINITY + + (0..30).each do |pivot| + rotated = days_0.map { |d| (d - pivot) % 31 } + span = rotated.max - rotated.min + + if span < min_span + min_span = span + best_pivot = pivot + end + end + + # Rotate days using best pivot to create contiguous array + rotated_days = days_0.map { |d| (d - best_pivot) % 31 }.sort + + # Calculate median on rotated, contiguous array + mid = rotated_days.size / 2 + rotated_median = if rotated_days.size.odd? + rotated_days[mid] + else + # For even count, average and round + ((rotated_days[mid - 1] + rotated_days[mid]) / 2.0).round + end + + # Map median back to original day space (unrotate) and convert to 1-indexed + original_day = (rotated_median + best_pivot) % 31 + 1 + + original_day + end + + # Calculate next expected date + def calculate_next_expected_date(last_date, expected_day) + next_month = last_date.next_month + + begin + Date.new(next_month.year, next_month.month, expected_day) + rescue ArgumentError + # If day doesn't exist in month, use last day of month + next_month.end_of_month + end + end + end +end diff --git a/app/models/rule/action_executor/set_transaction_category.rb b/app/models/rule/action_executor/set_transaction_category.rb index 6360e45a2..2da95bbac 100644 --- a/app/models/rule/action_executor/set_transaction_category.rb +++ b/app/models/rule/action_executor/set_transaction_category.rb @@ -4,7 +4,7 @@ class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor end def options - family.categories.pluck(:name, :id) + family.categories.alphabetically.pluck(:name, :id) end def execute(transaction_scope, value: nil, ignore_attribute_locks: false) diff --git a/app/models/rule/action_executor/set_transaction_merchant.rb b/app/models/rule/action_executor/set_transaction_merchant.rb index f343a79f7..f9693ddac 100644 --- a/app/models/rule/action_executor/set_transaction_merchant.rb +++ b/app/models/rule/action_executor/set_transaction_merchant.rb @@ -4,7 +4,7 @@ class Rule::ActionExecutor::SetTransactionMerchant < Rule::ActionExecutor end def options - family.merchants.pluck(:name, :id) + family.merchants.alphabetically.pluck(:name, :id) end def execute(transaction_scope, value: nil, ignore_attribute_locks: false) diff --git a/app/models/rule/action_executor/set_transaction_tags.rb b/app/models/rule/action_executor/set_transaction_tags.rb index d74029ca1..f317f9602 100644 --- a/app/models/rule/action_executor/set_transaction_tags.rb +++ b/app/models/rule/action_executor/set_transaction_tags.rb @@ -4,7 +4,7 @@ class Rule::ActionExecutor::SetTransactionTags < Rule::ActionExecutor end def options - family.tags.pluck(:name, :id) + family.tags.alphabetically.pluck(:name, :id) end def execute(transaction_scope, value: nil, ignore_attribute_locks: false) diff --git a/app/models/rule/condition_filter/transaction_merchant.rb b/app/models/rule/condition_filter/transaction_merchant.rb index db1522268..581db1cdf 100644 --- a/app/models/rule/condition_filter/transaction_merchant.rb +++ b/app/models/rule/condition_filter/transaction_merchant.rb @@ -4,7 +4,7 @@ class Rule::ConditionFilter::TransactionMerchant < Rule::ConditionFilter end def options - family.assigned_merchants.pluck(:name, :id) + family.assigned_merchants.alphabetically.pluck(:name, :id) end def prepare(scope) diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index bcee3762d..6fb064cbe 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -51,7 +51,10 @@ class Security::Price::Importer end # Gap-fill using LOCF (last observation carried forward) - chosen_price ||= prev_price_value + # Treat nil or zero prices as invalid and use previous price + if chosen_price.nil? || chosen_price.to_f <= 0 + chosen_price = prev_price_value + end prev_price_value = chosen_price { diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 4ff84425e..58b7f50e6 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -5,7 +5,7 @@ module Security::Provided class_methods do def provider - provider = ENV["SECURITIES_PROVIDER"] || "twelve_data" + provider = ENV["SECURITIES_PROVIDER"].presence || Setting.securities_provider registry = Provider::Registry.for_concept(:securities) registry.get_provider(provider.to_sym) end diff --git a/app/models/series.rb b/app/models/series.rb index b50a38eec..501d78fba 100644 --- a/app/models/series.rb +++ b/app/models/series.rb @@ -57,6 +57,7 @@ class Series end def trend + return nil if values.blank? @trend ||= Trend.new( current: values.last&.value, previous: values.first&.value, diff --git a/app/models/setting.rb b/app/models/setting.rb index fbbe65256..94a2bdfc1 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -4,15 +4,121 @@ class Setting < RailsSettings::Base cache_prefix { "v1" } + # Third-party API keys field :twelve_data_api_key, type: :string, default: ENV["TWELVE_DATA_API_KEY"] field :openai_access_token, type: :string, default: ENV["OPENAI_ACCESS_TOKEN"] field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"] field :openai_model, type: :string, default: ENV["OPENAI_MODEL"] field :brand_fetch_client_id, type: :string, default: ENV["BRAND_FETCH_CLIENT_ID"] + # Provider selection + 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") + + # Dynamic fields are now stored as individual entries with "dynamic:" prefix + # This prevents race conditions and ensures each field is independently managed + + # Onboarding and app settings + ONBOARDING_STATES = %w[open closed invite_only].freeze + DEFAULT_ONBOARDING_STATE = begin + env_value = ENV["ONBOARDING_STATE"].to_s.presence || "open" + ONBOARDING_STATES.include?(env_value) ? env_value : "open" + end + + field :onboarding_state, type: :string, default: DEFAULT_ONBOARDING_STATE field :require_invite_for_signup, type: :boolean, default: false field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true" + def self.validate_onboarding_state!(state) + return if ONBOARDING_STATES.include?(state) + + raise ValidationError, I18n.t("settings.hostings.update.invalid_onboarding_state") + end + + class << self + alias_method :raw_onboarding_state, :onboarding_state + alias_method :raw_onboarding_state=, :onboarding_state= + + def onboarding_state + value = raw_onboarding_state + return "invite_only" if value.blank? && require_invite_for_signup + + value.presence || DEFAULT_ONBOARDING_STATE + end + + def onboarding_state=(state) + validate_onboarding_state!(state) + self.require_invite_for_signup = state == "invite_only" + self.raw_onboarding_state = state + end + + # Support dynamic field access via bracket notation + # First checks if it's a declared field, then falls back to individual dynamic entries + def [](key) + key_str = key.to_s + + # Check if it's a declared field first + if respond_to?(key_str) + public_send(key_str) + else + # Fall back to individual dynamic entry lookup + find_by(var: dynamic_key_name(key_str))&.value + end + end + + def []=(key, value) + key_str = key.to_s + + # If it's a declared field, use the setter + if respond_to?("#{key_str}=") + public_send("#{key_str}=", value) + else + # Store as individual dynamic entry + dynamic_key = dynamic_key_name(key_str) + if value.nil? + where(var: dynamic_key).destroy_all + clear_cache + else + # Use upsert for atomic insert/update to avoid race conditions + upsert({ var: dynamic_key, value: value.to_yaml }, unique_by: :var) + clear_cache + end + end + end + + # Check if a dynamic field exists (useful to distinguish nil value vs missing key) + def key?(key) + key_str = key.to_s + return true if respond_to?(key_str) + + # Check if dynamic entry exists + where(var: dynamic_key_name(key_str)).exists? + end + + # Delete a dynamic field + def delete(key) + key_str = key.to_s + return nil if respond_to?(key_str) # Can't delete declared fields + + dynamic_key = dynamic_key_name(key_str) + value = self[key_str] + where(var: dynamic_key).destroy_all + clear_cache + value + end + + # List all dynamic field keys (excludes declared fields) + def dynamic_keys + where("var LIKE ?", "dynamic:%").pluck(:var).map { |var| var.sub(/^dynamic:/, "") } + end + + private + + def dynamic_key_name(key_str) + "dynamic:#{key_str}" + end + end + # Validates OpenAI configuration requires model when custom URI base is set def self.validate_openai_config!(uri_base: nil, model: nil) # Use provided values or current settings diff --git a/app/models/simplefin/account_type_mapper.rb b/app/models/simplefin/account_type_mapper.rb new file mode 100644 index 000000000..e01f4adb0 --- /dev/null +++ b/app/models/simplefin/account_type_mapper.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# Fallback-only inference for SimpleFIN-provided accounts. +# Conservative, used only to suggest a default type during setup/creation. +# Never overrides a user-selected type. +module Simplefin + class AccountTypeMapper + Inference = Struct.new(:accountable_type, :subtype, :confidence, keyword_init: true) + + RETIREMENT_KEYWORDS = /\b(401k|401\(k\)|403b|403\(b\)|tsp|ira|roth|retirement)\b/i.freeze + BROKERAGE_KEYWORD = /\bbrokerage\b/i.freeze + CREDIT_NAME_KEYWORDS = /\b(credit|card)\b/i.freeze + CREDIT_BRAND_KEYWORDS = /\b(visa|mastercard|amex|american express|discover|apple card|freedom unlimited|quicksilver)\b/i.freeze + LOAN_KEYWORDS = /\b(loan|mortgage|heloc|line of credit|loc)\b/i.freeze + + # Explicit investment subtype tokens mapped to known SUBTYPES keys + EXPLICIT_INVESTMENT_TOKENS = { + /\btraditional\s+ira\b/i => "ira", + /\broth\s+ira\b/i => "roth_ira", + /\broth\s+401\(k\)\b|\broth\s*401k\b/i => "roth_401k", + /\b401\(k\)\b|\b401k\b/i => "401k", + /\b529\s*plan\b|\b529\b/i => "529_plan", + /\bhsa\b|\bhealth\s+savings\s+account\b/i => "hsa", + /\bpension\b/i => "pension", + /\bmutual\s+fund\b/i => "mutual_fund", + /\b403b\b|\b403\(b\)\b/i => "403b", + /\btsp\b/i => "tsp" + }.freeze + + # Public API + # @param name [String, nil] + # @param holdings [Array, nil] + # @param extra [Hash, nil] - provider extras when present + # @param balance [Numeric, String, nil] + # @param available_balance [Numeric, String, nil] + # @return [Inference] e.g. Inference.new(accountable_type: "Investment", subtype: "retirement", confidence: :high) + def self.infer(name:, holdings: nil, extra: nil, balance: nil, available_balance: nil, institution: nil) + nm_raw = name.to_s + nm = nm_raw + # Normalized form to catch variants like RothIRA, Traditional-IRA, 401(k) + nm_norm = nm_raw.downcase.gsub(/[^a-z0-9]+/, " ").squeeze(" ").strip + inst = institution.to_s + holdings_present = holdings.is_a?(Array) && holdings.any? + bal = (balance.to_d rescue nil) + avail = (available_balance.to_d rescue nil) + + # 0) Explicit retirement/plan tokens → Investment with explicit subtype (match against normalized name) + if (explicit_sub = EXPLICIT_INVESTMENT_TOKENS.find { |rx, _| nm_norm.match?(rx) }&.last) + if defined?(Investment::SUBTYPES) && Investment::SUBTYPES.key?(explicit_sub) + return Inference.new(accountable_type: "Investment", subtype: explicit_sub, confidence: :high) + else + return Inference.new(accountable_type: "Investment", subtype: nil, confidence: :high) + end + end + + # 1) Holdings present => Investment (high confidence) + if holdings_present + # Do not guess generic retirement; explicit tokens handled above + return Inference.new(accountable_type: "Investment", subtype: nil, confidence: :high) + end + + # 2) Name suggests LOAN (high confidence) + if LOAN_KEYWORDS.match?(nm) + return Inference.new(accountable_type: "Loan", confidence: :high) + end + + # 3) Credit card signals + # - Name contains credit/card (medium to high) + # - Card brands (Visa/Mastercard/Amex/Discover/Apple Card) → high + # - Or negative balance with available-balance present (medium) + if CREDIT_NAME_KEYWORDS.match?(nm) || CREDIT_BRAND_KEYWORDS.match?(nm) || CREDIT_BRAND_KEYWORDS.match?(inst) + return Inference.new(accountable_type: "CreditCard", confidence: :high) + end + # Strong combined signal for credit card: negative balance and positive available-balance + if bal && bal < 0 && avail && avail > 0 + return Inference.new(accountable_type: "CreditCard", confidence: :high) + end + + # 4) Retirement keywords without holdings still point to Investment (retirement) + if RETIREMENT_KEYWORDS.match?(nm) + # If the name contains 'brokerage', avoid forcing retirement subtype + subtype = BROKERAGE_KEYWORD.match?(nm) ? nil : "retirement" + return Inference.new(accountable_type: "Investment", subtype: subtype, confidence: :high) + end + + # 5) Default + Inference.new(accountable_type: "Depository", confidence: :low) + end + end +end diff --git a/app/models/simplefin_account.rb b/app/models/simplefin_account.rb index 3b2089c68..3961bea63 100644 --- a/app/models/simplefin_account.rb +++ b/app/models/simplefin_account.rb @@ -1,11 +1,39 @@ class SimplefinAccount < ApplicationRecord belongs_to :simplefin_item - has_one :account, dependent: :destroy + # Legacy association via foreign key (will be removed after migration) + has_one :account, dependent: :nullify, foreign_key: :simplefin_account_id + + # New association through account_providers + has_one :account_provider, as: :provider, dependent: :destroy + has_one :linked_account, through: :account_provider, source: :account validates :name, :account_type, :currency, presence: true + validates :account_id, uniqueness: { scope: :simplefin_item_id, allow_nil: true } validate :has_balance + # Helper to get account using new system first, falling back to legacy + def current_account + linked_account || account + 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! + acct = current_account + return nil unless acct + + AccountProvider + .find_or_initialize_by(provider_type: "SimplefinAccount", provider_id: id) + .tap do |provider| + provider.account = acct + provider.save! + end + rescue => e + Rails.logger.warn("SimplefinAccount##{id}: failed to ensure AccountProvider link: #{e.class} - #{e.message}") + nil + end + def upsert_simplefin_snapshot!(account_snapshot) # Convert to symbol keys or handle both string and symbol keys snapshot = account_snapshot.with_indifferent_access diff --git a/app/models/simplefin_account/investments/holdings_processor.rb b/app/models/simplefin_account/investments/holdings_processor.rb index 8cf370860..ae7723206 100644 --- a/app/models/simplefin_account/investments/holdings_processor.rb +++ b/app/models/simplefin_account/investments/holdings_processor.rb @@ -5,61 +5,69 @@ class SimplefinAccount::Investments::HoldingsProcessor def process return if holdings_data.empty? - return unless account&.accountable_type == "Investment" + return unless [ "Investment", "Crypto" ].include?(account&.accountable_type) holdings_data.each do |simplefin_holding| begin symbol = simplefin_holding["symbol"] holding_id = simplefin_holding["id"] - next unless symbol.present? && holding_id.present? + Rails.logger.debug({ event: "simplefin.holding.start", sfa_id: simplefin_account.id, account_id: account&.id, id: holding_id, symbol: symbol, raw: simplefin_holding }.to_json) - security = resolve_security(symbol, simplefin_holding["description"]) - next unless security.present? - - # Use external_id for precise matching - external_id = "simplefin_#{holding_id}" - - # Use the created timestamp as the holding date, fallback to current date - holding_date = parse_holding_date(simplefin_holding["created"]) || Date.current - - holding = account.holdings.find_or_initialize_by( - external_id: external_id - ) do |h| - # Set required fields on initialization - h.security = security - h.date = holding_date - h.currency = simplefin_holding["currency"] || "USD" + unless symbol.present? && holding_id.present? + Rails.logger.debug({ event: "simplefin.holding.skip", reason: "missing_symbol_or_id", id: holding_id, symbol: symbol }.to_json) + next end - # Parse all the data SimpleFin provides - qty = parse_decimal(simplefin_holding["shares"]) - market_value = parse_decimal(simplefin_holding["market_value"]) - cost_basis = parse_decimal(simplefin_holding["cost_basis"]) + security = resolve_security(symbol, simplefin_holding["description"]) + unless security.present? + Rails.logger.debug({ event: "simplefin.holding.skip", reason: "unresolved_security", id: holding_id, symbol: symbol }.to_json) + next + end - # Calculate price from market_value if we have shares, fallback to purchase_price + # Parse provider data with robust fallbacks across SimpleFin sources + qty = parse_decimal(any_of(simplefin_holding, %w[shares quantity qty units])) + market_value = parse_decimal(any_of(simplefin_holding, %w[market_value value current_value])) + cost_basis = parse_decimal(any_of(simplefin_holding, %w[cost_basis basis total_cost])) + + # 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])) price = if qty > 0 && market_value > 0 market_value / qty else - parse_decimal(simplefin_holding["purchase_price"]) || 0 + fallback_price || 0 end - holding.assign_attributes( + # Compute an amount we can persist (some providers omit market_value) + computed_amount = if market_value > 0 + market_value + elsif qty > 0 && price > 0 + qty * price + else + 0 + end + + # Use best-known date: created -> updated_at -> as_of -> date -> today + holding_date = parse_holding_date(any_of(simplefin_holding, %w[created updated_at as_of date])) || Date.current + + # Skip zero positions with no value to avoid invisible rows + next if qty.to_d.zero? && computed_amount.to_d.zero? + + saved = import_adapter.import_holding( security: security, - date: holding_date, + quantity: qty, + amount: computed_amount, currency: simplefin_holding["currency"] || "USD", - qty: qty, + date: holding_date, price: price, - amount: market_value, - cost_basis: cost_basis + cost_basis: cost_basis, + external_id: "simplefin_#{holding_id}", + account_provider_id: simplefin_account.account_provider&.id, + source: "simplefin", + delete_future_holdings: false # SimpleFin tracks each holding uniquely ) - ActiveRecord::Base.transaction do - holding.save! - - # With external_id matching, each holding is uniquely tracked - # No need to delete other holdings since each has its own lifecycle - end + Rails.logger.debug({ event: "simplefin.holding.saved", account_id: account&.id, holding_id: saved.id, security_id: saved.security_id, qty: saved.qty.to_s, amount: saved.amount.to_s, currency: saved.currency, date: saved.date, external_id: saved.external_id }.to_json) rescue => e ctx = (defined?(symbol) && symbol.present?) ? " #{symbol}" : "" Rails.logger.error "Error processing SimpleFin holding#{ctx}: #{e.message}" @@ -70,8 +78,12 @@ class SimplefinAccount::Investments::HoldingsProcessor private attr_reader :simplefin_account + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + def account - simplefin_account.account + simplefin_account.current_account end def holdings_data @@ -80,11 +92,26 @@ class SimplefinAccount::Investments::HoldingsProcessor end def resolve_security(symbol, description) - # Use Security::Resolver to find or create the security - Security::Resolver.new(symbol).resolve - rescue ArgumentError => e - Rails.logger.error "Failed to resolve SimpleFin security #{symbol}: #{e.message}" - nil + # Normalize crypto tickers to a distinct namespace so they don't collide with equities + sym = symbol.to_s.upcase + is_crypto_account = account&.accountable_type == "Crypto" || simplefin_account.name.to_s.downcase.include?("crypto") + is_crypto_symbol = %w[BTC ETH SOL DOGE LTC BCH].include?(sym) + mentions_crypto = description.to_s.downcase.include?("crypto") + + if !sym.include?(":") && (is_crypto_account || is_crypto_symbol || mentions_crypto) + sym = "CRYPTO:#{sym}" + end + # Use Security::Resolver to find or create the security, but be resilient + begin + Security::Resolver.new(sym).resolve + rescue => e + # If provider search fails or any unexpected error occurs, fall back to an offline security + Rails.logger.warn "SimpleFin: resolver failed for symbol=#{sym}: #{e.class} - #{e.message}; falling back to offline security" + Security.find_or_initialize_by(ticker: sym).tap do |sec| + sec.offline = true if sec.respond_to?(:offline) && sec.offline != true + sec.save! if sec.changed? + end + end end def parse_holding_date(created_timestamp) @@ -103,6 +130,19 @@ class SimplefinAccount::Investments::HoldingsProcessor nil end + # Returns the first non-empty value for any of the provided keys in the given hash + def any_of(hash, keys) + return nil unless hash.respond_to?(:[]) + Array(keys).each do |k| + # Support symbol or string keys + v = hash[k] + v = hash[k.to_s] if v.nil? + v = hash[k.to_sym] if v.nil? + return v if !v.nil? && v.to_s.strip != "" + end + nil + end + def parse_decimal(value) return 0 unless value.present? diff --git a/app/models/simplefin_account/investments/transactions_processor.rb b/app/models/simplefin_account/investments/transactions_processor.rb index eb76503d1..6644a64dc 100644 --- a/app/models/simplefin_account/investments/transactions_processor.rb +++ b/app/models/simplefin_account/investments/transactions_processor.rb @@ -6,7 +6,7 @@ class SimplefinAccount::Investments::TransactionsProcessor end def process - return unless simplefin_account.account&.accountable_type == "Investment" + return unless simplefin_account.current_account&.accountable_type == "Investment" return unless simplefin_account.raw_transactions_payload.present? transactions_data = simplefin_account.raw_transactions_payload @@ -20,7 +20,7 @@ class SimplefinAccount::Investments::TransactionsProcessor attr_reader :simplefin_account def account - simplefin_account.account + simplefin_account.current_account end def process_investment_transaction(transaction_data) @@ -30,28 +30,23 @@ class SimplefinAccount::Investments::TransactionsProcessor posted_date = parse_date(data[:posted]) external_id = "simplefin_#{data[:id]}" - # Check if entry already exists - existing_entry = Entry.find_by(plaid_id: external_id) - - unless existing_entry - # For investment accounts, create as regular transaction - # In the future, we could detect trade patterns and create Trade entries - transaction = Transaction.new(external_id: external_id) - - Entry.create!( - account: account, - name: data[:description] || "Investment transaction", - amount: amount, - date: posted_date, - currency: account.currency, - entryable: transaction, - plaid_id: external_id - ) - end + # Use the unified import adapter for consistent handling + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: account.currency, + date: posted_date, + name: data[:description] || "Investment transaction", + source: "simplefin" + ) rescue => e Rails.logger.error("Failed to process SimpleFin investment transaction #{data[:id]}: #{e.message}") end + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + def parse_amount(amount_value) parsed_amount = case amount_value when String diff --git a/app/models/simplefin_account/liabilities/credit_processor.rb b/app/models/simplefin_account/liabilities/credit_processor.rb index 7534fa7ed..efe061b59 100644 --- a/app/models/simplefin_account/liabilities/credit_processor.rb +++ b/app/models/simplefin_account/liabilities/credit_processor.rb @@ -5,7 +5,7 @@ class SimplefinAccount::Liabilities::CreditProcessor end def process - return unless simplefin_account.account&.accountable_type == "CreditCard" + return unless simplefin_account.current_account&.accountable_type == "CreditCard" # Update credit card specific attributes if available update_credit_attributes @@ -14,18 +14,26 @@ class SimplefinAccount::Liabilities::CreditProcessor private attr_reader :simplefin_account + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + def account - simplefin_account.account + simplefin_account.current_account end def update_credit_attributes # SimpleFin provides available_balance which could be credit limit for cards available_balance = simplefin_account.raw_payload&.dig("available-balance") - if available_balance.present? && account.accountable.respond_to?(:available_credit=) + if available_balance.present? credit_limit = parse_decimal(available_balance) - account.accountable.available_credit = credit_limit if credit_limit > 0 - account.accountable.save! + if credit_limit > 0 + import_adapter.update_accountable_attributes( + attributes: { available_credit: credit_limit }, + source: "simplefin" + ) + end end end diff --git a/app/models/simplefin_account/liabilities/loan_processor.rb b/app/models/simplefin_account/liabilities/loan_processor.rb index 7bb8854de..53e1b92f8 100644 --- a/app/models/simplefin_account/liabilities/loan_processor.rb +++ b/app/models/simplefin_account/liabilities/loan_processor.rb @@ -5,7 +5,7 @@ class SimplefinAccount::Liabilities::LoanProcessor end def process - return unless simplefin_account.account&.accountable_type == "Loan" + return unless simplefin_account.current_account&.accountable_type == "Loan" # Update loan specific attributes if available update_loan_attributes @@ -15,7 +15,7 @@ class SimplefinAccount::Liabilities::LoanProcessor attr_reader :simplefin_account def account - simplefin_account.account + simplefin_account.current_account end def update_loan_attributes diff --git a/app/models/simplefin_account/processor.rb b/app/models/simplefin_account/processor.rb index a41575212..c59020f8d 100644 --- a/app/models/simplefin_account/processor.rb +++ b/app/models/simplefin_account/processor.rb @@ -9,11 +9,19 @@ class SimplefinAccount::Processor # Processing the account is the first step and if it fails, we halt # Each subsequent step can fail independently, but we continue processing def process - unless simplefin_account.account.present? + # If the account is missing (e.g., user deleted the connection and re‑linked later), + # do not auto‑link. Relinking is now a manual, user‑confirmed flow via the Relink modal. + unless simplefin_account.current_account.present? return end process_account! + # Ensure provider link exists after processing the account/balance + begin + simplefin_account.ensure_account_provider! + rescue => e + Rails.logger.warn("SimpleFin provider link ensure failed for #{simplefin_account.id}: #{e.class} - #{e.message}") + end process_transactions process_investments process_liabilities @@ -24,20 +32,19 @@ class SimplefinAccount::Processor def process_account! # This should not happen in normal flow since accounts are created manually # during setup, but keeping as safety check - if simplefin_account.account.blank? + if simplefin_account.current_account.blank? Rails.logger.error("SimpleFin account #{simplefin_account.id} has no associated Account - this should not happen after manual setup") return end # Update account balance and cash balance from latest SimpleFin data - account = simplefin_account.account + account = simplefin_account.current_account balance = simplefin_account.current_balance || simplefin_account.available_balance || 0 - # SimpleFin returns negative balances for credit cards (liabilities) - # But Maybe expects positive balances for liabilities - if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" - balance = balance.abs - end + # SimpleFIN 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) # Calculate cash balance correctly for investment accounts cash_balance = if account.accountable_type == "Investment" @@ -49,7 +56,8 @@ class SimplefinAccount::Processor account.update!( balance: balance, - cash_balance: cash_balance + cash_balance: cash_balance, + currency: simplefin_account.currency ) end @@ -60,7 +68,7 @@ class SimplefinAccount::Processor end def process_investments - return unless simplefin_account.account&.accountable_type == "Investment" + return unless simplefin_account.current_account&.accountable_type == "Investment" SimplefinAccount::Investments::TransactionsProcessor.new(simplefin_account).process SimplefinAccount::Investments::HoldingsProcessor.new(simplefin_account).process rescue => e @@ -68,7 +76,7 @@ class SimplefinAccount::Processor end def process_liabilities - case simplefin_account.account&.accountable_type + case simplefin_account.current_account&.accountable_type when "CreditCard" SimplefinAccount::Liabilities::CreditProcessor.new(simplefin_account).process when "Loan" diff --git a/app/models/simplefin_account/transactions/merchant_detector.rb b/app/models/simplefin_account/transactions/merchant_detector.rb index abc2cb893..a7eabb48e 100644 --- a/app/models/simplefin_account/transactions/merchant_detector.rb +++ b/app/models/simplefin_account/transactions/merchant_detector.rb @@ -1,3 +1,5 @@ +require "digest/md5" + # Detects and creates merchant records from SimpleFin transaction data # SimpleFin provides clean payee data that works well for merchant identification class SimplefinAccount::Transactions::MerchantDetector diff --git a/app/models/simplefin_account/transactions/processor.rb b/app/models/simplefin_account/transactions/processor.rb index 16caba2ba..ea4725066 100644 --- a/app/models/simplefin_account/transactions/processor.rb +++ b/app/models/simplefin_account/transactions/processor.rb @@ -37,6 +37,6 @@ class SimplefinAccount::Transactions::Processor end def account - simplefin_account.account + simplefin_account.current_account end end diff --git a/app/models/simplefin_entry/processor.rb b/app/models/simplefin_entry/processor.rb index 51225520c..d176d4857 100644 --- a/app/models/simplefin_entry/processor.rb +++ b/app/models/simplefin_entry/processor.rb @@ -1,4 +1,7 @@ +require "digest/md5" + class SimplefinEntry::Processor + include CurrencyNormalizable # simplefin_transaction is the raw hash fetched from SimpleFin API and converted to JSONB def initialize(simplefin_transaction, simplefin_account:) @simplefin_transaction = simplefin_transaction @@ -6,42 +9,41 @@ class SimplefinEntry::Processor end def process - SimplefinAccount.transaction do - entry = account.entries.find_or_initialize_by(plaid_id: external_id) do |e| - e.entryable = Transaction.new - end - - entry.assign_attributes( - amount: amount, - currency: currency, - date: date - ) - - entry.enrich_attribute( - :name, - name, - source: "simplefin" - ) - - # SimpleFin provides no category data - categories will be set by AI or rules - - if merchant - entry.transaction.enrich_attribute( - :merchant_id, - merchant.id, - source: "simplefin" - ) - end - - entry.save! - end + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "simplefin", + merchant: merchant, + notes: notes, + extra: extra_metadata + ) end private attr_reader :simplefin_transaction, :simplefin_account + def extra_metadata + sf = {} + # Preserve raw strings from provider so nothing is lost + sf["payee"] = data[:payee] if data.key?(:payee) + sf["memo"] = data[:memo] if data.key?(:memo) + sf["description"] = data[:description] if data.key?(:description) + # Include provider-supplied extra hash if present + sf["extra"] = data[:extra] if data[:extra].is_a?(Hash) + + return nil if sf.empty? + { "simplefin" => sf } + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + def account - simplefin_account.account + simplefin_account.current_account end def data @@ -91,31 +93,76 @@ class SimplefinEntry::Processor end def currency - data[:currency] || account.currency + parse_currency(data[:currency]) || account.currency end + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' in SimpleFIN transaction #{external_id}, falling back to account currency") + end + + # UI/entry date selection by account type: + # - Credit cards/loans: prefer transaction date (matches statements), then posted + # - Others: prefer posted date, then transaction date + # Epochs parsed as UTC timestamps via DateUtils def date - case data[:posted] - when String - Date.parse(data[:posted]) - when Integer, Float - # Unix timestamp - Time.at(data[:posted]).to_date - when Time, DateTime - data[:posted].to_date - when Date - data[:posted] + # Prefer transaction date for revolving debt (credit cards/loans); otherwise prefer posted date + acct_type = simplefin_account&.account_type.to_s.strip.downcase.tr(" ", "_") + if %w[credit_card credit loan mortgage].include?(acct_type) + t = transacted_date + return t if t + p = posted_date + return p if p else - Rails.logger.error("SimpleFin transaction has invalid date value: #{data[:posted].inspect}") - raise ArgumentError, "Invalid date format: #{data[:posted].inspect}" + p = posted_date + return p if p + t = transacted_date + return t if t end - rescue ArgumentError, TypeError => e - Rails.logger.error("Failed to parse SimpleFin transaction date '#{data[:posted]}': #{e.message}") - raise ArgumentError, "Unable to parse transaction date: #{data[:posted].inspect}" + Rails.logger.error("SimpleFin transaction missing posted/transacted date: #{data.inspect}") + raise ArgumentError, "Invalid date format: #{data[:posted].inspect} / #{data[:transacted_at].inspect}" end + def posted_date + val = data[:posted] + Simplefin::DateUtils.parse_provider_date(val) + end + + def transacted_date + val = data[:transacted_at] + Simplefin::DateUtils.parse_provider_date(val) + end def merchant - @merchant ||= SimplefinAccount::Transactions::MerchantDetector.new(data).detect_merchant + # Use SimpleFin's clean payee data for merchant detection + payee = data[:payee]&.strip + return nil unless payee.present? + + @merchant ||= import_adapter.find_or_create_merchant( + provider_merchant_id: generate_merchant_id(payee), + name: payee, + source: "simplefin" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "SimplefinEntry::Processor - Failed to create merchant '#{payee}': #{e.message}" + nil + end + + def generate_merchant_id(merchant_name) + # Generate a consistent ID for merchants without explicit IDs + "simplefin_#{Digest::MD5.hexdigest(merchant_name.downcase)}" + end + + def notes + # Prefer memo if present; include payee when it differs from description for richer context + memo = data[:memo].to_s.strip + payee = data[:payee].to_s.strip + description = data[:description].to_s.strip + + parts = [] + parts << memo if memo.present? + if payee.present? && payee != description + parts << "Payee: #{payee}" + end + parts.presence&.join(" | ") end end diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index 1e194f76b..cfad9c19d 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -1,16 +1,28 @@ class SimplefinItem < ApplicationRecord include Syncable, Provided + include SimplefinItem::Unlinking enum :status, { good: "good", requires_update: "requires_update" }, default: :good # Virtual attribute for the setup token form field attr_accessor :setup_token - if Rails.application.credentials.active_record_encryption.present? + # Helper to detect if ActiveRecord Encryption is configured for this app + 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 :access_url, deterministic: true end - validates :name, :access_url, presence: true + validates :name, presence: true + validates :access_url, presence: true, on: :create before_destroy :remove_simplefin_item @@ -18,19 +30,29 @@ class SimplefinItem < ApplicationRecord has_one_attached :logo has_many :simplefin_accounts, dependent: :destroy - has_many :accounts, through: :simplefin_accounts + has_many :legacy_accounts, through: :simplefin_accounts, source: :account scope :active, -> { where(scheduled_for_deletion: false) } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } + # Get accounts from both new and legacy systems + def accounts + # Preload associations to avoid N+1 queries + simplefin_accounts + .includes(:account, account_provider: :account) + .map(&:current_account) + .compact + .uniq + end + def destroy_later update!(scheduled_for_deletion: true) DestroyJob.perform_later(self) end - def import_latest_simplefin_data - SimplefinItem::Importer.new(self, simplefin_provider: simplefin_provider).import + def import_latest_simplefin_data(sync: nil) + SimplefinItem::Importer.new(self, simplefin_provider: simplefin_provider, sync: sync).import end def process_accounts @@ -54,13 +76,8 @@ class SimplefinItem < ApplicationRecord raw_payload: accounts_snapshot, ) - # Extract institution data from the first account if available - snapshot = accounts_snapshot.to_h.with_indifferent_access - if snapshot[:accounts].present? - first_account = snapshot[:accounts].first - org = first_account[:org] - upsert_institution_data!(org) if org.present? - end + # Do not populate item-level institution fields from account data. + # Institution metadata belongs to each simplefin_account (in org_data). save! end @@ -153,6 +170,28 @@ class SimplefinItem < ApplicationRecord end end + + + # Detect a recent rate-limited sync and return a friendly message, else nil + def rate_limited_message + latest = latest_sync + return nil unless latest + + # Some Sync records may not have a status_text column; guard with respond_to? + parts = [] + parts << latest.error if latest.respond_to?(:error) + parts << latest.status_text if latest.respond_to?(:status_text) + msg = parts.compact.join(" — ") + return nil if msg.blank? + + down = msg.downcase + if down.include?("make fewer requests") || down.include?("only refreshed once every 24 hours") || down.include?("rate limit") + "You've hit SimpleFin's daily refresh limit. Please try again after the bridge refreshes (up to 24 hours)." + else + nil + end + end + private def remove_simplefin_item # SimpleFin doesn't require server-side cleanup like Plaid diff --git a/app/models/simplefin_item/importer.rb b/app/models/simplefin_item/importer.rb index d17b7df2a..0e36af726 100644 --- a/app/models/simplefin_item/importer.rb +++ b/app/models/simplefin_item/importer.rb @@ -1,9 +1,13 @@ +require "set" class SimplefinItem::Importer - attr_reader :simplefin_item, :simplefin_provider + class RateLimitedError < StandardError; end + attr_reader :simplefin_item, :simplefin_provider, :sync - def initialize(simplefin_item, simplefin_provider:) + def initialize(simplefin_item, simplefin_provider:, sync: nil) @simplefin_item = simplefin_item @simplefin_provider = simplefin_provider + @sync = sync + @enqueued_holdings_job_ids = Set.new end def import @@ -11,19 +15,195 @@ class SimplefinItem::Importer Rails.logger.info "SimplefinItem::Importer - last_synced_at: #{simplefin_item.last_synced_at.inspect}" Rails.logger.info "SimplefinItem::Importer - sync_start_date: #{simplefin_item.sync_start_date.inspect}" - if simplefin_item.last_synced_at.nil? - # First sync - use chunked approach to get full history - Rails.logger.info "SimplefinItem::Importer - Using chunked history import" - import_with_chunked_history - else - # Regular sync - use single request with buffer - Rails.logger.info "SimplefinItem::Importer - Using regular sync" - import_regular_sync + begin + if simplefin_item.last_synced_at.nil? + # First sync - use chunked approach to get full history + Rails.logger.info "SimplefinItem::Importer - Using chunked history import" + import_with_chunked_history + else + # Regular sync - use single request with buffer + Rails.logger.info "SimplefinItem::Importer - Using regular sync" + import_regular_sync + end + rescue RateLimitedError => e + stats["rate_limited"] = true + stats["rate_limited_at"] = Time.current.iso8601 + persist_stats! + raise e + end + end + + # Balances-only import: discover accounts and update account balances without transactions/holdings + def import_balances_only + Rails.logger.info "SimplefinItem::Importer - Balances-only import for item #{simplefin_item.id}" + stats["balances_only"] = true + + # Fetch accounts without date filters + accounts_data = fetch_accounts_data(start_date: nil) + return if accounts_data.nil? + + # Store snapshot for observability + simplefin_item.upsert_simplefin_snapshot!(accounts_data) + + # Update counts (set to discovered for this run rather than accumulating) + discovered = accounts_data[:accounts]&.size.to_i + stats["total_accounts"] = discovered + persist_stats! + + # Upsert SimpleFin accounts minimal attributes and update linked Account balances + accounts_data[:accounts].to_a.each do |account_data| + begin + import_account_minimal_and_balance(account_data) + rescue => e + stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1 + cat = classify_error(e) + register_error(message: e.message, category: cat, account_id: account_data[:id], name: account_data[:name]) + ensure + persist_stats! + end end end private + # Minimal upsert and balance update for balances-only mode + def import_account_minimal_and_balance(account_data) + account_id = account_data[:id].to_s + return if account_id.blank? + + sfa = simplefin_item.simplefin_accounts.find_or_initialize_by(account_id: account_id) + sfa.assign_attributes( + name: account_data[:name], + account_type: (account_data["type"].presence || account_data[:type].presence || sfa.account_type.presence || "unknown"), + currency: (account_data[:currency].presence || account_data["currency"].presence || sfa.currency.presence || sfa.current_account&.currency.presence || simplefin_item.family&.currency.presence || "USD"), + current_balance: account_data[:balance], + available_balance: account_data[:"available-balance"], + balance_date: (account_data["balance-date"] || account_data[:"balance-date"]), + raw_payload: account_data, + org_data: account_data[:org] + ) + begin + sfa.save! + rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e + # Surface a friendly duplicate/validation signal in sync stats and continue + stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1 + msg = e.message.to_s + if msg.downcase.include?("already been taken") || msg.downcase.include?("unique") + msg = "Duplicate upstream account detected for SimpleFin (account_id=#{account_id}). Try relinking to an existing manual account." + end + register_error(message: msg, category: "other", account_id: account_id, name: account_data[:name]) + persist_stats! + return + end + # In pre-prompt balances-only discovery, do NOT auto-create provider-linked accounts. + # Only update balance for already-linked accounts (if any), to avoid creating duplicates in setup. + if (acct = sfa.current_account) + adapter = Account::ProviderImportAdapter.new(acct) + adapter.update_balance( + balance: account_data[:balance], + cash_balance: account_data[:"available-balance"], + source: "simplefin" + ) + end + end + def stats + @stats ||= {} + end + + # Heuristics to set a SimpleFIN account inactive when upstream indicates closure/hidden + # or when we repeatedly observe zero balances and zero holdings. This should not block + # import and only sets a flag and suggestion via sync stats. + def update_inactive_state(simplefin_account, account_data) + payload = (account_data || {}).with_indifferent_access + raw = (simplefin_account.raw_payload || {}).with_indifferent_access + + # Flags from payloads + closed = [ payload[:closed], payload[:hidden], payload.dig(:extra, :closed), raw[:closed], raw[:hidden] ].compact.any? { |v| v == true || v.to_s == "true" } + + balance = payload[:balance] + avail = payload[:"available-balance"] + holdings = payload[:holdings] + amounts = [ balance, avail ].compact + zeroish_balance = amounts.any? && amounts.all? { |x| x.to_d.zero? rescue false } + no_holdings = !(holdings.is_a?(Array) && holdings.any?) + + stats["zero_runs"] ||= {} + stats["inactive"] ||= {} + key = simplefin_account.account_id.presence || simplefin_account.id + key = key.to_s + # Ensure key exists and defaults to false (so tests don't read nil) + stats["inactive"][key] = false unless stats["inactive"].key?(key) + + if closed + stats["inactive"][key] = true + stats["hints"] = Array(stats["hints"]) + [ "Some accounts appear closed/hidden upstream. You can relink or hide them." ] + return + end + + if zeroish_balance && no_holdings + stats["zero_runs"][key] = stats["zero_runs"][key].to_i + 1 + # Cap to avoid unbounded growth + stats["zero_runs"][key] = [ stats["zero_runs"][key], 10 ].min + else + stats["zero_runs"][key] = 0 + stats["inactive"][key] = false + end + + if stats["zero_runs"][key].to_i >= 3 + stats["inactive"][key] = true + stats["hints"] = Array(stats["hints"]) + [ "One or more accounts show no balance/holdings for multiple syncs — consider relinking or marking inactive." ] + end + end + + # Track seen error fingerprints during a single importer run to avoid double counting + def seen_errors + @seen_errors ||= Set.new + end + + # Register an error into stats with de-duplication and bucketing + def register_error(message:, category:, account_id: nil, name: nil) + msg = message.to_s.strip + cat = (category.presence || "other").to_s + fp = [ account_id.to_s.presence, cat, msg ].compact.join("|") + first_time = !seen_errors.include?(fp) + seen_errors.add(fp) + + if first_time + Rails.logger.warn( + "SimpleFin sync error (unique this run): category=#{cat} account_id=#{account_id.inspect} name=#{name.inspect} msg=#{msg}" + ) + # Emit an instrumentation event for observability dashboards + ActiveSupport::Notifications.instrument( + "simplefin.error", + item_id: simplefin_item.id, + account_id: account_id, + account_name: name, + category: cat, + message: msg + ) + else + # Keep logs tame; don't spam on repeats in the same run + end + + stats["errors"] ||= [] + buckets = stats["error_buckets"] ||= { "auth" => 0, "api" => 0, "network" => 0, "other" => 0 } + if first_time + stats["total_errors"] = stats.fetch("total_errors", 0) + 1 + buckets[cat] = buckets.fetch(cat, 0) + 1 + end + + # Maintain a small rolling sample (not de-duped so users can see most recent context) + stats["errors"] << { account_id: account_id, name: name, message: msg, category: cat } + stats["errors"] = stats["errors"].last(5) + persist_stats! + end + + def persist_stats! + return unless sync && sync.respond_to?(:sync_stats) + merged = (sync.sync_stats || {}).merge(stats) + sync.update_columns(sync_stats: merged) # avoid callbacks/validations during tight loops + end + def import_with_chunked_history # SimpleFin's actual limit is 60 days (not 365 as documented) # Use 60-day chunks to stay within limits @@ -44,6 +224,10 @@ class SimplefinItem::Importer target_start_date = max_lookback_date end + # Pre-step: Unbounded discovery to ensure we see all accounts even if the + # chunked window would otherwise filter out newly added, inactive accounts. + perform_account_discovery + total_accounts_imported = 0 chunk_count = 0 @@ -80,11 +264,30 @@ class SimplefinItem::Importer simplefin_item.upsert_simplefin_snapshot!(accounts_data) end - # Import accounts and transactions for this chunk + # Tally accounts returned for stats + chunk_accounts = accounts_data[:accounts]&.size.to_i + total_accounts_imported += chunk_accounts + # Treat total as max unique accounts seen this run, not per-chunk accumulation + stats["total_accounts"] = [ stats["total_accounts"].to_i, chunk_accounts ].max + + # Import accounts and transactions for this chunk with per-account error skipping accounts_data[:accounts]&.each do |account_data| - import_account(account_data) + begin + import_account(account_data) + rescue => e + stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1 + # Collect lightweight error info for UI stats + cat = classify_error(e) + begin + register_error(message: e.message.to_s, category: cat, account_id: account_data[:id], name: account_data[:name]) + rescue + # no-op if account_data is missing keys + end + Rails.logger.warn("SimpleFin: Skipping account due to error: #{e.class} - #{e.message}") + ensure + persist_stats! + end end - total_accounts_imported += accounts_data[:accounts]&.size || 0 # Stop if we've reached our target start date if chunk_start_date <= target_start_date @@ -100,31 +303,117 @@ class SimplefinItem::Importer end def import_regular_sync - start_date = determine_sync_start_date + perform_account_discovery - accounts_data = fetch_accounts_data(start_date: start_date) + # Step 2: Fetch transactions/holdings using the regular window. + start_date = determine_sync_start_date + accounts_data = fetch_accounts_data(start_date: start_date, pending: true) return if accounts_data.nil? # Error already handled # Store raw payload simplefin_item.upsert_simplefin_snapshot!(accounts_data) - # Import accounts + # Tally accounts for stats + count = accounts_data[:accounts]&.size.to_i + # Treat total as max unique accounts seen this run, not accumulation + stats["total_accounts"] = [ stats["total_accounts"].to_i, count ].max + + # Import accounts (merges transactions/holdings into existing rows), skipping failures per-account accounts_data[:accounts]&.each do |account_data| - import_account(account_data) + begin + import_account(account_data) + rescue => e + stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1 + cat = classify_error(e) + begin + register_error(message: e.message.to_s, category: cat, account_id: account_data[:id], name: account_data[:name]) + rescue + # no-op if account_data is missing keys + end + Rails.logger.warn("SimpleFin: Skipping account during regular sync due to error: #{e.class} - #{e.message}") + ensure + persist_stats! + end end end - def fetch_accounts_data(start_date:, end_date: nil) + # + # Performs discovery of accounts in an unbounded way so providers that + # filter by date windows cannot hide newly created upstream accounts. + # + # Steps: + # - Request `/accounts` without dates; count results + # - If zero, retry with `pending: true` (some bridges only reveal new/pending) + # - If any accounts are returned, upsert a snapshot and import each account + # + # Returns nothing; side-effects are snapshot + account upserts. + def perform_account_discovery + discovery_data = fetch_accounts_data(start_date: nil) + discovered_count = discovery_data&.dig(:accounts)&.size.to_i + Rails.logger.info "SimpleFin discovery (no params) returned #{discovered_count} accounts" + + if discovered_count.zero? + discovery_data = fetch_accounts_data(start_date: nil, pending: true) + discovered_count = discovery_data&.dig(:accounts)&.size.to_i + Rails.logger.info "SimpleFin discovery (pending=1) returned #{discovered_count} accounts" + end + + if discovery_data && discovered_count > 0 + simplefin_item.upsert_simplefin_snapshot!(discovery_data) + # Treat total as max unique accounts seen this run, not accumulation + stats["total_accounts"] = [ stats["total_accounts"].to_i, discovered_count ].max + discovery_data[:accounts]&.each do |account_data| + begin + import_account(account_data) + rescue => e + stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1 + cat = classify_error(e) + begin + register_error(message: e.message.to_s, category: cat, account_id: account_data[:id], name: account_data[:name]) + rescue + # no-op if account_data is missing keys + end + Rails.logger.warn("SimpleFin discovery: Skipping account due to error: #{e.class} - #{e.message}") + ensure + persist_stats! + end + end + end + end + + # Fetches accounts (and optionally transactions/holdings) from SimpleFin. + # + # Params: + # - start_date: Date or nil — when provided, provider may filter by date window + # - end_date: Date or nil — optional end of window + # - pending: Boolean or nil — when true, ask provider to include pending/new + # + # Returns a Hash payload with keys like :accounts, or nil when an error is + # handled internally via `handle_errors`. + def fetch_accounts_data(start_date:, end_date: nil, pending: nil) # Debug logging to track exactly what's being sent to SimpleFin API - days_requested = end_date ? (end_date.to_date - start_date.to_date).to_i : "unknown" - Rails.logger.info "SimplefinItem::Importer - API Request: #{start_date.strftime('%Y-%m-%d')} to #{end_date&.strftime('%Y-%m-%d') || 'current'} (#{days_requested} days)" + start_str = start_date.respond_to?(:strftime) ? start_date.strftime("%Y-%m-%d") : "none" + end_str = end_date.respond_to?(:strftime) ? end_date.strftime("%Y-%m-%d") : "current" + days_requested = if start_date && end_date + (end_date.to_date - start_date.to_date).to_i + else + "unknown" + end + Rails.logger.info "SimplefinItem::Importer - API Request: #{start_str} to #{end_str} (#{days_requested} days)" begin + # Track API request count for quota awareness + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 accounts_data = simplefin_provider.get_accounts( simplefin_item.access_url, start_date: start_date, - end_date: end_date + end_date: end_date, + pending: pending ) + # Soft warning when approaching SimpleFin daily refresh guidance + if stats["api_requests"].to_i >= 20 + stats["rate_limit_warning"] = true + end rescue Provider::Simplefin::SimplefinError => e # Handle authentication errors by marking item as requiring update if e.error_type == :access_forbidden @@ -137,8 +426,24 @@ class SimplefinItem::Importer # Handle errors if present in response if accounts_data[:errors] && accounts_data[:errors].any? - handle_errors(accounts_data[:errors]) - return nil + if accounts_data[:accounts].to_a.any? + # Partial failure: record errors for visibility but continue processing accounts + record_errors(accounts_data[:errors]) + else + # Global failure: no accounts were returned; treat as fatal + handle_errors(accounts_data[:errors]) + return nil + end + end + + # Some servers return a top-level message/string rather than an errors array + if accounts_data[:error].present? + if accounts_data[:accounts].to_a.any? + record_errors([ accounts_data[:error] ]) + else + handle_errors([ accounts_data[:error] ]) + return nil + end end accounts_data @@ -157,7 +462,7 @@ class SimplefinItem::Importer end def import_account(account_data) - account_id = account_data[:id] + account_id = account_data[:id].to_s # Validate required account_id to prevent duplicate creation return if account_id.blank? @@ -173,11 +478,11 @@ class SimplefinItem::Importer # Update all attributes; only update transactions if present to avoid wiping prior data attrs = { name: account_data[:name], - account_type: account_data["type"] || account_data[:type] || "unknown", - currency: account_data[:currency] || "USD", + account_type: (account_data["type"].presence || account_data[:type].presence || "unknown"), + currency: (account_data[:currency].presence || account_data["currency"].presence || simplefin_account.currency.presence || simplefin_account.current_account&.currency.presence || simplefin_item.family&.currency.presence || "USD"), current_balance: account_data[:balance], available_balance: account_data[:"available-balance"], - balance_date: account_data[:"balance-date"], + balance_date: (account_data["balance-date"] || account_data[:"balance-date"]), raw_payload: account_data, org_data: account_data[:org] } @@ -192,21 +497,143 @@ class SimplefinItem::Importer attrs[:raw_transactions_payload] = merged_transactions end - # Preserve most recent holdings (don't overwrite current positions with older data) - if holdings.is_a?(Array) && holdings.any? && simplefin_account.raw_holdings_payload.blank? - attrs[:raw_holdings_payload] = holdings + # Track whether incoming holdings are new/changed so we can materialize and refresh balances + holdings_changed = false + if holdings.is_a?(Array) && holdings.any? + prior = simplefin_account.raw_holdings_payload.to_a + if prior != holdings + attrs[:raw_holdings_payload] = holdings + # Also mirror into raw_payload['holdings'] so downstream calculators can use it + raw = simplefin_account.raw_payload.is_a?(Hash) ? simplefin_account.raw_payload.deep_dup : {} + raw = raw.with_indifferent_access + raw[:holdings] = holdings + attrs[:raw_payload] = raw + holdings_changed = true + end end + simplefin_account.assign_attributes(attrs) + # Inactive detection/toggling (non-blocking) + begin + update_inactive_state(simplefin_account, account_data) + rescue => e + Rails.logger.warn("SimpleFin: inactive-state evaluation failed for sfa=#{simplefin_account.id || account_id}: #{e.class} - #{e.message}") + end + # Final validation before save to prevent duplicates if simplefin_account.account_id.blank? simplefin_account.account_id = account_id end - simplefin_account.save! + begin + simplefin_account.save! + + # Post-save side effects + acct = simplefin_account.current_account + if acct + # Refresh credit attributes when available-balance present + if acct.accountable_type == "CreditCard" && account_data[:"available-balance"].present? + begin + SimplefinAccount::Liabilities::CreditProcessor.new(simplefin_account).process + rescue => e + Rails.logger.warn("SimpleFin: credit post-import refresh failed for sfa=#{simplefin_account.id}: #{e.class} - #{e.message}") + end + end + + # If holdings changed for an investment/crypto account, enqueue holdings apply job and recompute cash balance + if holdings_changed && [ "Investment", "Crypto" ].include?(acct.accountable_type) + # Debounce per importer run per SFA + unless @enqueued_holdings_job_ids.include?(simplefin_account.id) + SimplefinHoldingsApplyJob.perform_later(simplefin_account.id) + @enqueued_holdings_job_ids << simplefin_account.id + end + + # Recompute cash balance using existing calculator; avoid altering canonical ledger balances + begin + calculator = SimplefinAccount::Investments::BalanceCalculator.new(simplefin_account) + new_cash = calculator.cash_balance + acct.update!(cash_balance: new_cash) + rescue => e + Rails.logger.warn("SimpleFin: cash balance recompute failed for sfa=#{simplefin_account.id}: #{e.class} - #{e.message}") + end + end + end + rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e + # Treat duplicates/validation failures as partial success: count and surface friendly error, then continue + stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1 + msg = e.message.to_s + if msg.downcase.include?("already been taken") || msg.downcase.include?("unique") + msg = "Duplicate upstream account detected for SimpleFin (account_id=#{account_id}). Try relinking to an existing manual account." + end + register_error(message: msg, category: "other", account_id: account_id, name: account_data[:name]) + persist_stats! + nil + ensure + # Ensure stats like zero_runs/inactive are persisted even when no errors occur, + # particularly helpful for focused unit tests that call import_account directly. + persist_stats! + end end + # 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. + 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", + item_id: simplefin_item.id, + count: arr.size + ) + + arr.each do |error| + msg = if error.is_a?(String) + error + else + error[:description] || error[:message] || error[:error] || error.to_s + end + down = msg.to_s.downcase + category = if down.include?("timeout") || down.include?("timed out") + "network" + elsif down.include?("auth") || down.include?("reauth") || down.include?("forbidden") || down.include?("unauthorized") || down.include?("2fa") || down.include?("two-factor") + "auth" + elsif down.include?("429") || down.include?("rate limit") + "api" + else + "other" + end + register_error(message: msg, category: category) + end + end + def handle_errors(errors) error_messages = errors.map { |error| error.is_a?(String) ? error : (error[:description] || error[:message]) }.join(", ") @@ -221,15 +648,55 @@ class SimplefinItem::Importer end if needs_update + Rails.logger.warn("SimpleFin: marking item ##{simplefin_item.id} requires_update due to fatal auth error(s): #{error_messages}") simplefin_item.update!(status: :requires_update) end + down = error_messages.downcase + # Detect and surface rate-limit specifically with a friendlier exception + if down.include?("make fewer requests") || + down.include?("only refreshed once every 24 hours") || + down.include?("rate limit") + Rails.logger.info("SimpleFin: raising RateLimitedError for item ##{simplefin_item.id}: #{error_messages}") + ActiveSupport::Notifications.instrument( + "simplefin.rate_limited", + item_id: simplefin_item.id, + message: error_messages + ) + raise RateLimitedError, "SimpleFin rate limit: data refreshes at most once every 24 hours. Try again later." + end + + # Fall back to generic SimpleFin error classified as :api_error + Rails.logger.error("SimpleFin fatal API error for item ##{simplefin_item.id}: #{error_messages}") + ActiveSupport::Notifications.instrument( + "simplefin.fatal_error", + item_id: simplefin_item.id, + message: error_messages + ) raise Provider::Simplefin::SimplefinError.new( "SimpleFin API errors: #{error_messages}", :api_error ) end + # Classify exceptions into simple buckets for UI stats + def classify_error(e) + msg = e.message.to_s.downcase + klass = e.class.name.to_s + # Avoid referencing Net::OpenTimeout/ReadTimeout constants (may not be loaded) + is_timeout = msg.include?("timeout") || msg.include?("timed out") || klass.include?("Timeout") + case + when is_timeout + "network" + when msg.include?("auth") || msg.include?("reauth") || msg.include?("forbidden") || msg.include?("unauthorized") + "auth" + when msg.include?("429") || msg.include?("too many requests") || msg.include?("rate limit") || msg.include?("5xx") || msg.include?("502") || msg.include?("503") || msg.include?("504") + "api" + else + "other" + end + end + def initial_sync_lookback_period # Default to 7 days for initial sync to avoid API limits 7 diff --git a/app/models/simplefin_item/syncer.rb b/app/models/simplefin_item/syncer.rb index da0275d93..ae250ed0b 100644 --- a/app/models/simplefin_item/syncer.rb +++ b/app/models/simplefin_item/syncer.rb @@ -6,37 +6,36 @@ class SimplefinItem::Syncer end def perform_sync(sync) - # Phase 1: Import data from SimpleFin API - sync.update!(status_text: "Importing accounts from SimpleFin...") if sync.respond_to?(:status_text) - simplefin_item.import_latest_simplefin_data - - # Phase 2: Check account setup status and collect sync statistics - sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) - total_accounts = simplefin_item.simplefin_accounts.count - linked_accounts = simplefin_item.simplefin_accounts.joins(:account) - unlinked_accounts = simplefin_item.simplefin_accounts.includes(:account).where(accounts: { id: nil }) - - # Store sync statistics for display - sync_stats = { - total_accounts: total_accounts, - linked_accounts: linked_accounts.count, - unlinked_accounts: unlinked_accounts.count - } - - # Set pending_account_setup if there are unlinked accounts - if unlinked_accounts.any? - simplefin_item.update!(pending_account_setup: true) - sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text) - else - simplefin_item.update!(pending_account_setup: false) + # Balances-only fast path + if sync.respond_to?(:sync_stats) && (sync.sync_stats || {})["balances_only"] + sync.update!(status_text: "Refreshing balances only...") if sync.respond_to?(:status_text) + begin + # Use the Importer to run balances-only path + SimplefinItem::Importer.new(simplefin_item, simplefin_provider: simplefin_item.simplefin_provider, sync: sync).import_balances_only + # Update last_synced_at for UI freshness if the column exists + if simplefin_item.has_attribute?(:last_synced_at) + simplefin_item.update!(last_synced_at: Time.current) + end + finalize_setup_counts(sync) + mark_completed(sync) + rescue => e + mark_failed(sync, e) + end + return end - # Phase 3: Process transactions and holdings for linked accounts only + # Full sync path + sync.update!(status_text: "Importing accounts from SimpleFin...") if sync.respond_to?(:status_text) + simplefin_item.import_latest_simplefin_data(sync: sync) + + finalize_setup_counts(sync) + + # Process transactions/holdings only for linked accounts + linked_accounts = simplefin_item.simplefin_accounts.joins(:account) if linked_accounts.any? sync.update!(status_text: "Processing transactions and holdings...") if sync.respond_to?(:status_text) simplefin_item.process_accounts - # Phase 4: Schedule balance calculations for linked accounts sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) simplefin_item.schedule_account_syncs( parent_sync: sync, @@ -45,13 +44,162 @@ class SimplefinItem::Syncer ) end - # Store sync statistics in the sync record for status display - if sync.respond_to?(:sync_stats) - sync.update!(sync_stats: sync_stats) - end + mark_completed(sync) end + # Public: called by Sync after finalization; keep no-op def perform_post_sync # no-op end + + private + def finalize_setup_counts(sync) + sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + total_accounts = simplefin_item.simplefin_accounts.count + linked_accounts = simplefin_item.simplefin_accounts.joins(:account) + unlinked_accounts = simplefin_item.simplefin_accounts + .left_joins(:account, :account_provider) + .where(accounts: { id: nil }, account_providers: { id: nil }) + + if unlinked_accounts.any? + simplefin_item.update!(pending_account_setup: true) + sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text) + else + simplefin_item.update!(pending_account_setup: false) + end + + if sync.respond_to?(:sync_stats) + existing = (sync.sync_stats || {}) + setup_stats = { + "total_accounts" => total_accounts, + "linked_accounts" => linked_accounts.count, + "unlinked_accounts" => unlinked_accounts.count + } + sync.update!(sync_stats: existing.merge(setup_stats)) + end + end + + def mark_completed(sync) + if sync.may_start? + sync.start! + end + if sync.may_complete? + sync.complete! + else + # If aasm not used, at least set status text + sync.update!(status: :completed) if sync.status != "completed" + end + + # After completion, compute and persist compact post-run stats for the summary panel + begin + post_stats = compute_post_run_stats(sync) + if post_stats.present? + existing = (sync.sync_stats || {}) + sync.update!(sync_stats: existing.merge(post_stats)) + end + rescue => e + Rails.logger.warn("SimplefinItem::Syncer#mark_completed stats error: #{e.class} - #{e.message}") + end + + # If all recorded errors are duplicate-skips, do not surface a generic failure message + begin + stats = (sync.sync_stats || {}) + errors = Array(stats["errors"]).map { |e| (e.is_a?(Hash) ? e["message"] || e[:message] : e.to_s) } + if errors.present? && errors.all? { |m| m.to_s.downcase.include?("duplicate upstream account detected") } + sync.update_columns(error: nil) if sync.respond_to?(:error) + # Provide a gentle status hint instead + if sync.respond_to?(:status_text) + sync.update_columns(status_text: "Some accounts skipped as duplicates — try Link existing accounts to merge.") + end + end + rescue => e + Rails.logger.warn("SimplefinItem::Syncer duplicate-only error normalization failed: #{e.class} - #{e.message}") + end + + # Bump item freshness timestamp (guard column existence and skip for balances-only) + if simplefin_item.has_attribute?(:last_synced_at) && !(sync.sync_stats || {})["balances_only"].present? + simplefin_item.update!(last_synced_at: Time.current) + end + + # Broadcast UI updates so Providers/Accounts pages refresh without manual reload + begin + # Replace the SimpleFin card + card_html = ApplicationController.render( + partial: "simplefin_items/simplefin_item", + formats: [ :html ], + locals: { simplefin_item: simplefin_item } + ) + target_id = ActionView::RecordIdentifier.dom_id(simplefin_item) + Turbo::StreamsChannel.broadcast_replace_to(simplefin_item.family, target: target_id, html: card_html) + + # Also refresh the Manual Accounts group so duplicates clear without a full page reload + begin + manual_accounts = simplefin_item.family.accounts + .visible_manual + .order(:name) + if manual_accounts.any? + manual_html = ApplicationController.render( + partial: "accounts/index/manual_accounts", + formats: [ :html ], + locals: { accounts: manual_accounts } + ) + Turbo::StreamsChannel.broadcast_update_to(simplefin_item.family, target: "manual-accounts", html: manual_html) + else + manual_html = ApplicationController.render(inline: '
') + Turbo::StreamsChannel.broadcast_replace_to(simplefin_item.family, target: "manual-accounts", html: manual_html) + end + rescue => inner + Rails.logger.warn("SimplefinItem::Syncer manual-accounts broadcast failed: #{inner.class} - #{inner.message}") + end + + # Intentionally do not broadcast modal reloads here to avoid unexpected auto-pop after sync. + # Modal opening is controlled explicitly via controller redirects with actionable conditions. + rescue => e + Rails.logger.warn("SimplefinItem::Syncer broadcast failed: #{e.class} - #{e.message}") + end + end + + # Computes transaction/holding counters between sync start and completion + def compute_post_run_stats(sync) + window_start = sync.created_at || 30.minutes.ago + window_end = Time.current + + account_ids = simplefin_item.simplefin_accounts.joins(:account).pluck("accounts.id") + return {} if account_ids.empty? + + tx_scope = Entry.where(account_id: account_ids, source: "simplefin", entryable_type: "Transaction") + tx_imported = tx_scope.where(created_at: window_start..window_end).count + tx_updated = tx_scope.where(updated_at: window_start..window_end).where.not(created_at: window_start..window_end).count + tx_seen = tx_imported + tx_updated + + holdings_scope = Holding.where(account_id: account_ids) + holdings_processed = holdings_scope.where(created_at: window_start..window_end).count + + { + "tx_imported" => tx_imported, + "tx_updated" => tx_updated, + "tx_seen" => tx_seen, + "holdings_processed" => holdings_processed, + "window_start" => window_start, + "window_end" => window_end + } + end + + def mark_failed(sync, error) + # If already completed, do not attempt to fail to avoid AASM InvalidTransition + if sync.respond_to?(:status) && sync.status.to_s == "completed" + Rails.logger.warn("SimplefinItem::Syncer#mark_failed called after completion: #{error.class} - #{error.message}") + return + end + if sync.may_start? + sync.start! + end + if sync.may_fail? + sync.fail! + else + # Avoid forcing failed if transitions are not allowed + sync.update!(status: :failed) if !sync.respond_to?(:aasm) || sync.status.to_s != "failed" + end + sync.update!(error: error.message) if sync.respond_to?(:error) + end end diff --git a/app/models/simplefin_item/unlinking.rb b/app/models/simplefin_item/unlinking.rb new file mode 100644 index 000000000..a4113cb36 --- /dev/null +++ b/app/models/simplefin_item/unlinking.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module SimplefinItem::Unlinking + # Concern that encapsulates unlinking logic for a SimpleFin item. + # Mirrors the previous SimplefinItem::Unlinker service behavior. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this SimpleFin item and local accounts. + # - Detaches any AccountProvider links for each SimplefinAccount + # - Nullifies legacy Account.simplefin_account_id backrefs + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-SFA result payload for observability + def unlink_all!(dry_run: false) + results = [] + + simplefin_accounts.includes(:account).find_each do |sfa| + links = AccountProvider.where(provider_type: "SimplefinAccount", provider_id: sfa.id).to_a + link_ids = links.map(&:id) + result = { + sfa_id: sfa.id, + name: sfa.name, + account_id: sfa.account_id, + 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 + + # Legacy FK fallback: ensure any legacy link is cleared + if sfa.account_id.present? + sfa.update!(account: nil) + end + end + rescue => e + Rails.logger.warn( + "Unlinker: failed to fully unlink SFA ##{sfa.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + # Record error for observability; continue with other SFAs + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/sync.rb b/app/models/sync.rb index 3e2abfe6e..d1ba07a26 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -67,6 +67,24 @@ class Sync < ApplicationRecord return end + # Guard: syncable may have been deleted while job was queued + unless syncable.present? + Rails.logger.warn("Sync #{id} - syncable #{syncable_type}##{syncable_id} no longer exists. Marking as failed.") + start! if may_start? + fail! + update(error: "Syncable record was deleted") + return + end + + # Guard: syncable may be scheduled for deletion + if syncable.respond_to?(:scheduled_for_deletion?) && syncable.scheduled_for_deletion? + Rails.logger.warn("Sync #{id} - syncable #{syncable_type}##{syncable_id} is scheduled for deletion. Skipping sync.") + start! if may_start? + fail! + update(error: "Syncable record is scheduled for deletion") + return + end + start! begin diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index d233c2e42..5a1adbafa 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -3,32 +3,76 @@ class TransactionImport < Import transaction do mappings.each(&:create_mappable!) - transactions = rows.map do |row| + new_transactions = [] + updated_entries = [] + claimed_entry_ids = Set.new # Track entries we've already claimed in this import + + rows.each_with_index do |row, index| mapped_account = if account account else mappings.accounts.mappable_for(row.account) end + # Guard against nil account - this happens when an account name in CSV is not mapped + if mapped_account.nil? + row_number = index + 1 + account_name = row.account.presence || "(blank)" + error_message = "Row #{row_number}: Account '#{account_name}' is not mapped to an existing account. " \ + "Please map this account in the import configuration." + errors.add(:base, error_message) + raise Import::MappingError, error_message + end + category = mappings.categories.mappable_for(row.category) tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact - Transaction.new( - category: category, - tags: tags, - entry: Entry.new( - account: mapped_account, - date: row.date_iso, - amount: row.signed_amount, - name: row.name, - currency: row.currency, - notes: row.notes, - import: self - ) + # Check for duplicate transactions using the adapter's deduplication logic + # Pass claimed_entry_ids to exclude entries we've already matched in this import + # This ensures identical rows within the CSV are all imported as separate transactions + adapter = Account::ProviderImportAdapter.new(mapped_account) + duplicate_entry = adapter.find_duplicate_transaction( + date: row.date_iso, + amount: row.signed_amount, + currency: row.currency, + name: row.name, + exclude_entry_ids: claimed_entry_ids ) + + if duplicate_entry + # Update existing transaction instead of creating a new one + duplicate_entry.transaction.category = category if category.present? + duplicate_entry.transaction.tags = tags if tags.any? + duplicate_entry.notes = row.notes if row.notes.present? + duplicate_entry.import = self + updated_entries << duplicate_entry + claimed_entry_ids.add(duplicate_entry.id) + else + # Create new transaction (no duplicate found) + new_transactions << Transaction.new( + category: category, + tags: tags, + entry: Entry.new( + account: mapped_account, + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: row.currency, + notes: row.notes, + import: self + ) + ) + end end - Transaction.import!(transactions, recursive: true) + # Save updated entries first + updated_entries.each do |entry| + entry.transaction.save! + entry.save! + end + + # Bulk import new transactions + Transaction.import!(new_transactions, recursive: true) if new_transactions.any? end end diff --git a/app/models/user.rb b/app/models/user.rb index a3ca25ada..57e57ac04 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -45,7 +45,6 @@ class User < ApplicationRecord def initiate_email_change(new_email) return false if new_email == email - return false if new_email == unconfirmed_email if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation update(email: new_email) @@ -59,6 +58,15 @@ class User < ApplicationRecord end end + def resend_confirmation_email + if pending_email_change? + EmailConfirmationMailer.with(user: self).confirmation_email.deliver_later + true + else + false + end + end + def request_impersonation_for(user_id) impersonated = User.find(user_id) impersonator_support_sessions.create!(impersonated: impersonated) @@ -169,7 +177,71 @@ class User < ApplicationRecord AccountOrder.find(default_account_order) || AccountOrder.default end + # Dashboard preferences management + def dashboard_section_collapsed?(section_key) + preferences&.dig("collapsed_sections", section_key) == true + end + + def dashboard_section_order + preferences&.[]("section_order") || default_dashboard_section_order + end + + def update_dashboard_preferences(prefs) + # Use pessimistic locking to ensure atomic read-modify-write + # This prevents race conditions when multiple sections are collapsed quickly + transaction do + lock! # Acquire row-level lock (SELECT FOR UPDATE) + + updated_prefs = (preferences || {}).deep_dup + prefs.each do |key, value| + if value.is_a?(Hash) + updated_prefs[key] ||= {} + updated_prefs[key] = updated_prefs[key].merge(value) + else + updated_prefs[key] = value + end + end + + update!(preferences: updated_prefs) + end + end + + # Reports preferences management + def reports_section_collapsed?(section_key) + preferences&.dig("reports_collapsed_sections", section_key) == true + end + + def reports_section_order + preferences&.[]("reports_section_order") || default_reports_section_order + end + + def update_reports_preferences(prefs) + # Use pessimistic locking to ensure atomic read-modify-write + transaction do + lock! + + updated_prefs = (preferences || {}).deep_dup + prefs.each do |key, value| + if value.is_a?(Hash) + updated_prefs[key] ||= {} + updated_prefs[key] = updated_prefs[key].merge(value) + else + updated_prefs[key] = value + end + end + + update!(preferences: updated_prefs) + end + end + private + def default_dashboard_section_order + %w[cashflow_sankey outflows_donut net_worth_chart balance_sheet] + end + + def default_reports_section_order + %w[trends_insights transactions_breakdown] + end def ensure_valid_profile_image return unless profile_image.attached? diff --git a/app/services/simplefin_item/unlinker.rb b/app/services/simplefin_item/unlinker.rb new file mode 100644 index 000000000..d676af999 --- /dev/null +++ b/app/services/simplefin_item/unlinker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# DEPRECATED: This thin wrapper remains only for backward compatibility. +# Business logic has moved into `SimplefinItem::Unlinking` (model concern). +# Prefer calling `item.unlink_all!(dry_run: ...)` directly. +class SimplefinItem::Unlinker + attr_reader :item, :dry_run + + def initialize(item, dry_run: false) + @item = item + @dry_run = dry_run + end + + def unlink_all! + item.unlink_all!(dry_run: dry_run) + end +end diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index b4b88c1ee..b4651c64f 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -32,6 +32,22 @@ <%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %> <%= icon("pencil-line", size: "sm") %> <% end %> + + <% if !account.linked? && ["Depository", "CreditCard", "Investment"].include?(account.accountable_type) %> + <%= link_to select_provider_account_path(account), + data: { turbo_frame: :modal }, + class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1", + title: t("accounts.account.link_provider") do %> + <%= icon("link", size: "sm") %> + <% end %> + <% elsif account.linked? %> + <%= link_to confirm_unlink_account_path(account), + data: { turbo_frame: :modal }, + class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1", + title: t("accounts.account.unlink_provider") do %> + <%= icon("unlink", size: "sm") %> + <% end %> + <% end %> <% end %>
diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index 8aba2d7c9..ae6f793d8 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -6,16 +6,15 @@
<%= icon "triangle-alert", size: "sm", color: "warning" %> -

Missing historical data

+

<%= t("accounts.sidebar.missing_data") %>

<%= icon("chevron-down", color: "warning", class: "group-open:transform group-open:rotate-180") %>
-

<%= product_name %> uses third party providers to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.

- +

<%= t("accounts.sidebar.missing_data_description", product: product_name) %>

- <%= link_to "Configure your providers here.", settings_hosting_path, class: "text-yellow-600 underline" %> + <%= link_to t("accounts.sidebar.configure_providers"), settings_hosting_path, class: "text-yellow-600 underline" %>

@@ -23,15 +22,15 @@ <%= render DS::Tabs.new(active_tab: active_tab, session_key: "account_sidebar_tab", testid: "account-sidebar-tabs") do |tabs| %> <% tabs.with_nav do |nav| %> - <% nav.with_btn(id: "all", label: "All") %> - <% nav.with_btn(id: "asset", label: "Assets") %> - <% nav.with_btn(id: "liability", label: "Debts") %> + <% nav.with_btn(id: "all", label: t("accounts.sidebar.tabs.all")) %> + <% nav.with_btn(id: "asset", label: t("accounts.sidebar.tabs.assets")) %> + <% nav.with_btn(id: "liability", label: t("accounts.sidebar.tabs.debts")) %> <% end %> <% tabs.with_panel(tab_id: "asset") do %>
<%= render DS::Link.new( - text: "New asset", + text: t("accounts.sidebar.new_asset"), variant: "ghost", href: new_account_path(step: "method_select", classification: "asset"), icon: "plus", @@ -51,7 +50,7 @@ <% tabs.with_panel(tab_id: "liability") do %>
<%= render DS::Link.new( - text: "New debt", + text: t("accounts.sidebar.new_debt"), variant: "ghost", href: new_account_path(step: "method_select", classification: "liability"), icon: "plus", @@ -71,7 +70,7 @@ <% tabs.with_panel(tab_id: "all") do %>
<%= render DS::Link.new( - text: "New account", + text: t("accounts.sidebar.new_account"), variant: "ghost", full_width: true, href: new_account_path(step: "method_select"), diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index e3ae8d5a0..934f2bb3e 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -53,7 +53,7 @@
<%= render DS::Link.new( href: new_polymorphic_path(account_group.key, step: "method_select"), - text: "New #{account_group.name.downcase.singularize}", + text: t("accounts.sidebar.new_account_group", account_group: account_group.name.downcase.singularize), icon: "plus", full_width: true, variant: "ghost", diff --git a/app/views/accounts/_logo.html.erb b/app/views/accounts/_logo.html.erb index 2893b1772..364e7ccb5 100644 --- a/app/views/accounts/_logo.html.erb +++ b/app/views/accounts/_logo.html.erb @@ -7,7 +7,7 @@ "full" => "w-full h-full" } %> -<% if account.plaid_account_id? && account.institution_domain.present? && Setting.brand_fetch_client_id.present? %> +<% if account.linked? && account.institution_domain.present? && Setting.brand_fetch_client_id.present? %> <%= image_tag "https://cdn.brandfetch.io/#{account.institution_domain}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}", class: "shrink-0 rounded-full #{size_classes[size]}" %> <% elsif account.logo.attached? %> <%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %> diff --git a/app/views/accounts/confirm_unlink.html.erb b/app/views/accounts/confirm_unlink.html.erb new file mode 100644 index 000000000..5b77c4808 --- /dev/null +++ b/app/views/accounts/confirm_unlink.html.erb @@ -0,0 +1,31 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("accounts.confirm_unlink.title")) %> + + <% dialog.with_body do %> +

+ <%= 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::Button.new( + text: t("accounts.confirm_unlink.confirm_button"), + href: unlink_account_path(@account), + method: :delete, + full_width: true, + data: { turbo_frame: "_top" }) %> + <% end %> +<% end %> diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index c77557bd9..c8adc0bf0 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -21,7 +21,7 @@
-<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? %> <%= render "empty" %> <% else %>
@@ -33,8 +33,17 @@ <%= render @simplefin_items.sort_by(&:created_at) %> <% end %> + <% if @lunchflow_items.any? %> + <%= render @lunchflow_items.sort_by(&:created_at) %> + <% end %> + <% if @manual_accounts.any? %> - <%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> +
+ <%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> +
+ <% else %> +
<% end %>
<% end %> + diff --git a/app/views/accounts/index/_account_groups.erb b/app/views/accounts/index/_account_groups.erb index 841e60c23..75bd48840 100644 --- a/app/views/accounts/index/_account_groups.erb +++ b/app/views/accounts/index/_account_groups.erb @@ -13,7 +13,7 @@
<% accounts.each_with_index do |account, index| %> - <%= render account %> + <%= render "accounts/account", account: account %> <% unless index == accounts.count - 1 %> <%= render "shared/ruler" %> <% end %> diff --git a/app/views/accounts/index/_manual_accounts.html.erb b/app/views/accounts/index/_manual_accounts.html.erb index 0dd8f66dd..97c08ac07 100644 --- a/app/views/accounts/index/_manual_accounts.html.erb +++ b/app/views/accounts/index/_manual_accounts.html.erb @@ -1,7 +1,7 @@ <%# locals: (accounts:) %>
- + <%= icon("chevron-right", class: "group-open:transform group-open:rotate-90") %>
diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index 354075611..01b02a575 100644 --- a/app/views/accounts/new.html.erb +++ b/app/views/accounts/new.html.erb @@ -1,5 +1,8 @@ <%= render layout: "accounts/new/container", locals: { title: t(".title") } do %> -
+
+ data-controller="lunchflow-preload" + <% end %>> <% unless params[:classification] == "liability" %> <%= render "account_type", accountable: Depository.new %> <%= render "account_type", accountable: Investment.new %> diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 5f26c1514..135cd0104 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -1,7 +1,15 @@ -<%# locals: (path:, accountable_type:, show_us_link: true, show_eu_link: true) %> +<%# locals: (path:, accountable_type:, provider_configs:) %> <%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %> -
+
+ data-controller="lunchflow-preload" + data-lunchflow-preload-accountable-type-value="<%= h(accountable_type) %>" + <% if params[:return_to] %> + data-lunchflow-preload-return-to-value="<%= h(params[:return_to]) %>" + <% 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 %> <%= icon("keyboard") %> @@ -9,27 +17,31 @@ <%= t("accounts.new.method_selector.manual_entry") %> <% end %> - <% if show_us_link %> - <%# Default US-only Link %> - <%= link_to new_plaid_item_path(region: "us", accountable_type: accountable_type), - class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2", - data: { turbo_frame: "modal" } do %> - - <%= icon("link-2") %> - - <%= t("accounts.new.method_selector.connected_entry") %> - <% end %> - <% end %> + <%# Dynamic provider links %> + <% provider_configs.each do |config| %> + <% link_path = config[:new_account_path].call(accountable_type, params[:return_to]) %> + <% is_lunchflow = config[:key] == "lunchflow" %> - <%# EU Link %> - <% if show_eu_link %> - <%= link_to new_plaid_item_path(region: "eu", accountable_type: accountable_type), + <%= link_to link_path, class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2", - data: { turbo_frame: "modal" } do %> + data: is_lunchflow ? { + turbo_frame: "modal", + turbo_action: "advance", + lunchflow_preload_target: "link" + } : { turbo_frame: "modal" } do %> <%= icon("link-2") %> - <%= t("accounts.new.method_selector.connected_entry_eu") %> + <% if is_lunchflow %> + + <%= t("accounts.new.method_selector.link_with_provider", provider: config[:name]) %> + + + <% else %> + <%= t("accounts.new.method_selector.link_with_provider", provider: config[:name]) %> + <% end %> <% end %> <% end %> diff --git a/app/views/accounts/select_provider.html.erb b/app/views/accounts/select_provider.html.erb new file mode 100644 index 000000000..5d19a966c --- /dev/null +++ b/app/views/accounts/select_provider.html.erb @@ -0,0 +1,23 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("accounts.select_provider.title")) %> + + <% dialog.with_body do %> +

+ <%= t("accounts.select_provider.description", account_name: @account.name) %> +

+ +
+ <% @available_providers.each do |provider| %> + <%= link_to provider[:path], data: { turbo_frame: :modal }, class: "block p-4 border border-primary rounded-lg hover:bg-container-hover transition-colors" do %> +
+
+

<%= provider[:name] %>

+

<%= provider[:description] %>

+
+ <%= icon("chevron-right", size: "sm", class: "text-secondary") %> +
+ <% end %> + <% end %> +
+ <% end %> +<% end %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index da5e5ecb1..ce9e74cd8 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -4,7 +4,7 @@
<%= tag.h2 t(".title"), class: "font-medium text-lg" %> - <% unless @account.plaid_account_id.present? %> + <% unless @account.linked? %> <%= render DS::Menu.new(variant: "button") do |menu| %> <% menu.with_button(text: "New", variant: "secondary", icon: "plus") %> diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 81e857a2f..b867783a7 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -1,54 +1,118 @@ <%# locals: (budget_category:) %> -<%= turbo_frame_tag dom_id(budget_category), class: "w-full" do %> - <%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group w-full p-4 flex items-center gap-3 bg-container", data: { turbo_frame: "drawer" } do %> +<%= turbo_frame_tag dom_id(budget_category), class: "w-full block" do %> + <%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group block w-full p-4 bg-container hover:bg-surface-inset transition-colors", data: { turbo_frame: "drawer" } do %> <% if budget_category.initialized? %> -
- <%= render "budget_categories/budget_category_donut", budget_category: budget_category %> + <%# Category Header with Status Badge %> +
+
+
+

<%= budget_category.category.name %>

+
+ +
+ <% if budget_category.over_budget? %> + + <%= icon("alert-circle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.over") %> + + <% elsif budget_category.near_limit? %> + + <%= icon("alert-triangle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.warning") %> + + <% else %> + + <%= icon("check-circle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.good") %> + + <% end %> + + + <%= budget_category.percent_of_budget_spent.round(0) %>% + +
+ + <%# Progress Bar %> +
+
+ <% bar_color = budget_category.over_budget? ? "bg-danger" : (budget_category.near_limit? ? "bg-warning" : "bg-success") %> +
+
+
+ + <%# Budget Details %> +
+
+ <%= t("reports.budget_performance.spent") %>: + + <%= format_money(budget_category.actual_spending_money) %> + +
+
+ <%= t("reports.budget_performance.budgeted") %>: + + <%= format_money(budget_category.budgeted_spending_money) %> + +
+
+ <% if budget_category.available_to_spend >= 0 %> + <%= t("reports.budget_performance.remaining") %>: + + <%= format_money(budget_category.available_to_spend_money) %> + + <% else %> + <%= t("reports.budget_performance.over_by") %>: + + <%= format_money(budget_category.available_to_spend_money.abs) %> + + <% end %> +
+
+ + <%# 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 %> -
- <% if budget_category.category.lucide_icon %> - <%= icon(budget_category.category.lucide_icon, color: "current") %> - <% else %> - <%= render DS::FilledIcon.new( - variant: :text, - hex_color: budget_category.category.color, - text: budget_category.category.name, - size: "sm", - rounded: true - ) %> - <% end %> + <%# Uninitialized budget - show simple view %> +
+
+ <% if budget_category.category.lucide_icon %> + <%= icon(budget_category.category.lucide_icon, color: "current") %> + <% else %> + <%= render DS::FilledIcon.new( + variant: :text, + hex_color: budget_category.category.color, + text: budget_category.category.name, + size: "sm", + rounded: true + ) %> + <% end %> +
+ +
+

<%= budget_category.category.name %>

+

+ <%= budget_category.median_monthly_expense_money.format %> avg +

+
+ +
+

<%= format_money(budget_category.actual_spending_money) %>

+
<% end %> - -
-

<%= budget_category.category.name %>

- - <% if budget_category.initialized? %> - <% if budget_category.available_to_spend.negative? %> -

<%= format_money(budget_category.available_to_spend_money.abs) %> over

- <% elsif budget_category.available_to_spend.zero? %> -

"> - <%= format_money(budget_category.available_to_spend_money) %> left -

- <% else %> -

<%= format_money(budget_category.available_to_spend_money) %> left

- <% end %> - <% else %> -

- <%= budget_category.median_monthly_expense_money.format %> avg -

- <% end %> -
- -
-

<%= format_money(budget_category.actual_spending_money) %>

- - <% if budget_category.initialized? %> -

from <%= format_money(budget_category.budgeted_spending_money) %>

- <% end %> -
<% end %> <% end %> diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb index de47b27c2..57eee66ad 100644 --- a/app/views/budgets/show.html.erb +++ b/app/views/budgets/show.html.erb @@ -5,8 +5,10 @@ next_budget: @next_budget, latest_budget: @latest_budget %> -
-
+
+ <%# Top Section: Donut and Summary side by side %> +
+ <%# Budget Donut %>
<% if @budget.available_to_allocate.negative? %> <%= render "budgets/over_allocation_warning", budget: @budget %> @@ -15,8 +17,8 @@ <% end %>
-
- + <%# Actuals Summary %> +
<% if @budget.initialized? && @budget.available_to_allocate.positive? %> <%= render DS::Tabs.new(active_tab: params[:tab].presence || "budgeted") do |tabs| %> <% tabs.with_nav do |nav| %> @@ -37,14 +39,13 @@ <% end %> <% end %> <% else %> -
- <%= render "budgets/actuals_summary", budget: @budget %> -
+ <%= render "budgets/actuals_summary", budget: @budget %> <% end %>
-
+ <%# Bottom Section: Categories full width %> +

Categories

diff --git a/app/views/chats/_chat.html.erb b/app/views/chats/_chat.html.erb index 9eb35a0f5..fc082f377 100644 --- a/app/views/chats/_chat.html.erb +++ b/app/views/chats/_chat.html.erb @@ -1,8 +1,8 @@ <%# locals: (chat:) %> <%= tag.div class: "flex items-center justify-between px-4 py-3 bg-container shadow-border-xs rounded-lg" do %> -
- <%= turbo_frame_tag dom_id(chat, :title) do %> +
+ <%= turbo_frame_tag dom_id(chat, :title), title: chat.title do %> <%= render "chats/chat_title", chat: chat, ctx: "list" %> <% end %> diff --git a/app/views/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb index a81805f24..97c6472b7 100644 --- a/app/views/credit_cards/new.html.erb +++ b/app/views/credit_cards/new.html.erb @@ -1,8 +1,7 @@ <% if params[:step] == "method_select" %> <%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]), - show_us_link: @show_us_link, - show_eu_link: @show_eu_link, + provider_configs: @provider_configs, accountable_type: "CreditCard" %> <% else %> <%= render DS::Dialog.new do |dialog| %> diff --git a/app/views/cryptos/new.html.erb b/app/views/cryptos/new.html.erb index 97fcccdc1..dd455aa1e 100644 --- a/app/views/cryptos/new.html.erb +++ b/app/views/cryptos/new.html.erb @@ -1,8 +1,7 @@ <% if params[:step] == "method_select" %> <%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]), - show_us_link: @show_us_link, - show_eu_link: @show_eu_link, + provider_configs: @provider_configs, accountable_type: "Crypto" %> <% else %> <%= render DS::Dialog.new do |dialog| %> diff --git a/app/views/depositories/new.html.erb b/app/views/depositories/new.html.erb index f75ed00b5..f0f06828c 100644 --- a/app/views/depositories/new.html.erb +++ b/app/views/depositories/new.html.erb @@ -1,8 +1,7 @@ <% if params[:step] == "method_select" %> <%= render "accounts/new/method_selector", path: new_depository_path(return_to: params[:return_to]), - show_us_link: @show_us_link, - show_eu_link: @show_eu_link, + provider_configs: @provider_configs, accountable_type: "Depository" %> <% else %> <%= render DS::Dialog.new do |dialog| %> diff --git a/app/views/family_exports/_list.html.erb b/app/views/family_exports/_list.html.erb index 339c0c039..e73889dfb 100644 --- a/app/views/family_exports/_list.html.erb +++ b/app/views/family_exports/_list.html.erb @@ -38,13 +38,13 @@ <%= button_to family_export_path(export), method: :delete, class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", - data: { + data: { turbo_confirm: "Are you sure you want to delete this export? This action cannot be undone.", turbo_frame: "_top" } do %> <%= icon "trash-2", class: "w-5 h-5 text-destructive" %> <% end %> - + <%= link_to download_family_export_path(export), class: "flex items-center gap-2 text-primary hover:text-primary-hover", data: { turbo_frame: "_top" } do %> @@ -56,11 +56,11 @@
<%= icon "alert-circle", class: "w-4 h-4" %>
- + <%= button_to family_export_path(export), method: :delete, class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", - data: { + data: { turbo_confirm: "Are you sure you want to delete this failed export?", turbo_frame: "_top" } do %> diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index 7c03d86da..7daa151ff 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -45,11 +45,13 @@
- <% if holding.trend %> + <%# Show Total Return (unrealized G/L) when cost basis exists %> + <% if holding.trades.any? && holding.trend %> <%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %> <%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %> <% else %> - <%= tag.p "--", class: "text-secondary mb-4" %> + <%= tag.p "--", class: "text-secondary" %> + <%= tag.p "No cost basis", class: "text-xs text-secondary" %> <% end %>
diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb index 4c1341ea6..9e84a43f0 100644 --- a/app/views/holdings/show.html.erb +++ b/app/views/holdings/show.html.erb @@ -85,7 +85,7 @@
<% end %> - <% unless @holding.account.plaid_account_id.present? %> + <% if @holding.account.can_delete_holdings? %> <% dialog.with_section(title: t(".settings"), open: true) do %>
diff --git a/app/views/import/configurations/_category_import.html.erb b/app/views/import/configurations/_category_import.html.erb new file mode 100644 index 000000000..4f0cf2279 --- /dev/null +++ b/app/views/import/configurations/_category_import.html.erb @@ -0,0 +1,14 @@ +<%# locals: (import:) %> + +
+

<%= t("import.configurations.category_import.description") %>

+ + <%= styled_form_with model: import, + url: import_configuration_path(import), + scope: :import, + method: :patch, + class: "space-y-3" do |form| %> +

<%= t("import.configurations.category_import.instructions") %>

+ <%= form.submit t("import.configurations.category_import.button_label"), disabled: import.complete? %> + <% end %> +
diff --git a/app/views/imports/_import.html.erb b/app/views/imports/_import.html.erb index 026e52fb8..bfb411da9 100644 --- a/app/views/imports/_import.html.erb +++ b/app/views/imports/_import.html.erb @@ -38,25 +38,21 @@
- <% if import.complete? || import.revert_failed? %> <%= button_to revert_import_path(import), method: :put, class: "flex items-center gap-2 text-orange-500 hover:text-orange-600", - data: { + data: { turbo_confirm: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time." } do %> <%= icon "rotate-ccw", class: "w-5 h-5 text-destructive" %> <% end %> - - - <% else %> <%= button_to import_path(import), method: :delete, class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", - data: { + data: { turbo_confirm: CustomConfirm.for_resource_deletion("import") } do %> <%= icon "trash-2", class: "w-5 h-5 text-destructive" %> diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index 321d5f809..8e01454bd 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -7,7 +7,7 @@ <%= render partial: "imports/import", collection: @imports.ordered %>
<% end %> - + <%= link_to new_import_path, class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center", data: { turbo_frame: :modal } do %> @@ -27,7 +27,7 @@
<% end %>
- + <%= link_to new_family_export_path, class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center", data: { turbo_frame: :modal } do %> diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index c436cb256..8d84ef1a4 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -85,6 +85,26 @@ <% end %> + <% if params[:type].nil? || params[:type] == "CategoryImport" %> +
  • + <%= button_to imports_path(import: { type: "CategoryImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> +
    +
    + + <%= icon("shapes", color: "current") %> + +
    + + <%= t(".import_categories") %> + +
    + <%= icon("chevron-right") %> + <% end %> + + <%= render "shared/ruler" %> +
  • + <% end %> + <% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == "MintImport" || params[:type] == "TransactionImport") %>
  • <%= button_to imports_path(import: { type: "MintImport" }), class: "flex items-center justify-between p-4 group w-full", data: { turbo: false } do %> diff --git a/app/views/investments/new.html.erb b/app/views/investments/new.html.erb index acfaa6edd..5a89f5eaf 100644 --- a/app/views/investments/new.html.erb +++ b/app/views/investments/new.html.erb @@ -1,8 +1,7 @@ <% if params[:step] == "method_select" %> <%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]), - show_us_link: @show_us_link, - show_eu_link: @show_eu_link, + provider_configs: @provider_configs, accountable_type: "Investment" %> <% else %> <%= render DS::Dialog.new do |dialog| %> diff --git a/app/views/invitation_mailer/invite_email.html.erb b/app/views/invitation_mailer/invite_email.html.erb index 0f45f9d44..b6741f236 100644 --- a/app/views/invitation_mailer/invite_email.html.erb +++ b/app/views/invitation_mailer/invite_email.html.erb @@ -5,7 +5,7 @@ ".body", inviter: @invitation.inviter.display_name, family: @invitation.family.name, - product: product_name + product_name: product_name ).html_safe %>

    diff --git a/app/views/invitations/new.html.erb b/app/views/invitations/new.html.erb index 9dfebe959..31c2a8add 100644 --- a/app/views/invitations/new.html.erb +++ b/app/views/invitations/new.html.erb @@ -1,5 +1,5 @@ <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: t(".title"), subtitle: t(".subtitle", product: product_name)) %> + <% dialog.with_header(title: t(".title"), subtitle: t(".subtitle", product_name: product_name)) %> <% dialog.with_body do %> <%= styled_form_with model: @invitation, class: "space-y-4", data: { turbo: false } do |form| %> diff --git a/app/views/layouts/_dark_mode_check.html.erb b/app/views/layouts/_dark_mode_check.html.erb index 0a0ccac2c..949891f2b 100644 --- a/app/views/layouts/_dark_mode_check.html.erb +++ b/app/views/layouts/_dark_mode_check.html.erb @@ -2,4 +2,4 @@ if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.setAttribute("data-theme", "dark"); } - \ No newline at end of file + diff --git a/app/views/layouts/auth.html.erb b/app/views/layouts/auth.html.erb index 5622cf46a..f12f5b2a0 100644 --- a/app/views/layouts/auth.html.erb +++ b/app/views/layouts/auth.html.erb @@ -4,12 +4,9 @@
    - <%= image_tag "logo-color.png", class: "w-16 mb-6" %> + <%= image_tag "logomark.svg", class: "w-16 mb-6" %>
    -

    - <%= content_for?(:header_title) ? yield(:header_title).html_safe : t(".your_account") %> -

    <% if (controller_name == "sessions" && action_name == "new") || (controller_name == "registrations" && action_name == "new") %>
    @@ -26,7 +23,7 @@ <% end %> <% if controller_name == "sessions" %> <% elsif controller_name == "registrations" %>
    +
    <%= render "settings/settings_nav" %>
    diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index dd2b064d1..036030d3a 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -35,5 +35,9 @@ + <% if Rails.env.production? && (posthog_config = Rails.configuration.x.posthog).try(:api_key).present? %> + <%= render "shared/posthog", posthog_api_key: posthog_config.api_key, posthog_host: posthog_config.host %> + <% end %> + <%= yield :head %> diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb index 74d66496a..569782c4f 100644 --- a/app/views/loans/new.html.erb +++ b/app/views/loans/new.html.erb @@ -1,8 +1,7 @@ <% if params[:step] == "method_select" %> <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), - show_us_link: @show_us_link, - show_eu_link: @show_eu_link, + provider_configs: @provider_configs, accountable_type: "Loan" %> <% else %> <%= render DS::Dialog.new do |dialog| %> diff --git a/app/views/lunchflow_items/_api_error.html.erb b/app/views/lunchflow_items/_api_error.html.erb new file mode 100644 index 000000000..1a1b49b3e --- /dev/null +++ b/app/views/lunchflow_items/_api_error.html.erb @@ -0,0 +1,35 @@ +<%# locals: (error_message:, return_path:) %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Lunch Flow Connection Error") %> + <% dialog.with_body do %> +
    +
    + <%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %> +
    +

    Unable to connect to Lunch Flow

    +

    <%= error_message %>

    +
    +
    + +
    +

    Common Issues:

    +
      +
    • Invalid API Key: Check your API key in Provider Settings
    • +
    • Expired Credentials: Generate a new API key from Lunch Flow
    • +
    • Network Issue: Check your internet connection
    • +
    • Service Down: Lunch Flow API may be temporarily unavailable
    • +
    +
    + +
    + <%= link_to settings_providers_path, + 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", + data: { turbo: false } do %> + Check Provider Settings + <% end %> +
    +
    + <% end %> + <% end %> +<% end %> diff --git a/app/views/lunchflow_items/_loading.html.erb b/app/views/lunchflow_items/_loading.html.erb new file mode 100644 index 000000000..e0ea126c1 --- /dev/null +++ b/app/views/lunchflow_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/lunchflow_items/_lunchflow_item.html.erb b/app/views/lunchflow_items/_lunchflow_item.html.erb new file mode 100644 index 000000000..fefb2f910 --- /dev/null +++ b/app/views/lunchflow_items/_lunchflow_item.html.erb @@ -0,0 +1,118 @@ +<%# locals: (lunchflow_item:) %> + +<%= tag.div id: dom_id(lunchflow_item) do %> +
    + +
    + <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
    + <% if lunchflow_item.logo.attached? %> + <%= image_tag lunchflow_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> + <% else %> +
    + <%= tag.p lunchflow_item.name.first.upcase, class: "text-orange-600 text-xs font-medium" %> +
    + <% end %> +
    + +
    +
    + <%= tag.p lunchflow_item.name, class: "font-medium text-primary" %> + <% if lunchflow_item.scheduled_for_deletion? %> +

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

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

    + <%= lunchflow_item.institution_summary %> +

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

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

    + <% end %> +
    +
    + +
    + <% if Rails.env.development? %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_lunchflow_item_path(lunchflow_item) + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: lunchflow_item_path(lunchflow_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(lunchflow_item.name, high_severity: true) + ) %> + <% end %> +
    +
    + + <% unless lunchflow_item.scheduled_for_deletion? %> +
    + <% if lunchflow_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: lunchflow_item.accounts %> + <% end %> + + <%# Use model methods for consistent counts %> + <% unlinked_count = lunchflow_item.unlinked_accounts_count %> + <% linked_count = lunchflow_item.linked_accounts_count %> + <% total_count = lunchflow_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_lunchflow_item_path(lunchflow_item), + frame: :modal + ) %> +
    + <% elsif lunchflow_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_lunchflow_item_path(lunchflow_item), + frame: :modal + ) %> +
    + <% end %> +
    + <% end %> +
    +<% end %> diff --git a/app/views/lunchflow_items/_setup_required.html.erb b/app/views/lunchflow_items/_setup_required.html.erb new file mode 100644 index 000000000..6a717dcae --- /dev/null +++ b/app/views/lunchflow_items/_setup_required.html.erb @@ -0,0 +1,34 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Lunch Flow Setup Required") %> + <% dialog.with_body do %> +
    +
    + <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> +
    +

    API Key Not Configured

    +

    Before you can link Lunch Flow accounts, you need to configure your Lunch Flow API key.

    +
    +
    + +
    +

    Setup Steps:

    +
      +
    1. Go to Settings → Bank Sync Providers
    2. +
    3. Find the Lunch Flow section
    4. +
    5. Enter your Lunch Flow API key
    6. +
    7. Return here to link your accounts
    8. +
    +
    + +
    + <%= link_to settings_providers_path, + 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", + data: { turbo: false } do %> + Go to Provider Settings + <% end %> +
    +
    + <% end %> + <% end %> +<% end %> diff --git a/app/views/lunchflow_items/_subtype_select.html.erb b/app/views/lunchflow_items/_subtype_select.html.erb new file mode 100644 index 000000000..5d99dbcb8 --- /dev/null +++ b/app/views/lunchflow_items/_subtype_select.html.erb @@ -0,0 +1,23 @@ + diff --git a/app/views/lunchflow_items/select_accounts.html.erb b/app/views/lunchflow_items/select_accounts.html.erb new file mode 100644 index 000000000..9399bef0f --- /dev/null +++ b/app/views/lunchflow_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| %> + <% has_blank_name = account[:name].blank? %> + + <% end %> +
    + +
    + <%= link_to t(".cancel"), @return_to || new_account_path, + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover", + data: { turbo_frame: "_top", action: "DS--dialog#close" } %> + <%= submit_tag t(".link_accounts"), + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %> +
    +
    +
    + <% end %> + <% end %> +<% end %> diff --git a/app/views/lunchflow_items/select_existing_account.html.erb b/app/views/lunchflow_items/select_existing_account.html.erb new file mode 100644 index 000000000..3dc778517 --- /dev/null +++ b/app/views/lunchflow_items/select_existing_account.html.erb @@ -0,0 +1,56 @@ +<%= 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") %> +

    + +
    + <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :return_to, @return_to %> + +
    + <% @available_accounts.each do |account| %> + <% has_blank_name = account[:name].blank? %> + + <% end %> +
    + +
    + <%= link_to t(".cancel"), @return_to || accounts_path, + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover", + data: { turbo_frame: "_top", action: "DS--dialog#close" } %> + <%= submit_tag t(".link_account"), + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %> +
    +
    +
    + <% end %> + <% end %> +<% end %> diff --git a/app/views/lunchflow_items/setup_accounts.html.erb b/app/views/lunchflow_items/setup_accounts.html.erb new file mode 100644 index 000000000..63886fbe2 --- /dev/null +++ b/app/views/lunchflow_items/setup_accounts.html.erb @@ -0,0 +1,111 @@ +<% content_for :title, "Set Up Lunch Flow 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_lunchflow_item_path(@lunchflow_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 @lunchflow_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 %> +
    +
    +
    +
    + + <% @lunchflow_accounts.each do |lunchflow_account| %> +
    +
    +
    +

    + <%= lunchflow_account.name %> + <% if lunchflow_account.institution_metadata.present? && lunchflow_account.institution_metadata['name'].present? %> + • <%= lunchflow_account.institution_metadata["name"] %> + <% end %> +

    +

    + <%= t(".balance") %>: <%= number_to_currency(lunchflow_account.current_balance || 0, unit: lunchflow_account.currency) %> +

    +
    +
    + +
    +
    + <%= label_tag "account_types[#{lunchflow_account.id}]", t(".account_type_label"), + class: "block text-sm font-medium text-primary mb-2" %> + <%= select_tag "account_types[#{lunchflow_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 "lunchflow_items/subtype_select", account_type: account_type, subtype_config: subtype_config, lunchflow_account: lunchflow_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? || @lunchflow_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/onboardings/show.html.erb b/app/views/onboardings/show.html.erb index 43510796c..4ebe9bd37 100644 --- a/app/views/onboardings/show.html.erb +++ b/app/views/onboardings/show.html.erb @@ -49,4 +49,3 @@
    <%= render "layouts/shared/footer" %> -
    diff --git a/app/views/other_assets/edit.html.erb b/app/views/other_assets/edit.html.erb index 930ce27aa..4ec4161f5 100644 --- a/app/views/other_assets/edit.html.erb +++ b/app/views/other_assets/edit.html.erb @@ -1,6 +1,9 @@ <%= render DS::Dialog.new do |dialog| %> <% dialog.with_header(title: t(".edit", account: @account.name)) %> <% dialog.with_body do %> - <%= render "form", account: @account, url: other_asset_path(@account) %> +
    + <%= render DS::Alert.new(message: t(".balance_tracking_info"), variant: :info) %> + <%= render "form", account: @account, url: other_asset_path(@account) %> +
    <% end %> <% end %> diff --git a/app/views/other_assets/new.html.erb b/app/views/other_assets/new.html.erb index 75737adab..7377c88e0 100644 --- a/app/views/other_assets/new.html.erb +++ b/app/views/other_assets/new.html.erb @@ -1,6 +1,9 @@ <%= render DS::Dialog.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %> - <%= render "form", account: @account, url: other_assets_path %> +
    + <%= render DS::Alert.new(message: t(".balance_tracking_info"), variant: :info) %> + <%= render "form", account: @account, url: other_assets_path %> +
    <% end %> <% end %> diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 3951d6106..daf6454b4 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -1,13 +1,17 @@ <% content_for :page_header do %>
    -

    Welcome back, <%= Current.user.first_name %>

    -

    Here's what's happening with your finances

    +

    + <%= t("pages.dashboard.welcome", name: Current.user.first_name) %> +

    +

    + <%= t("pages.dashboard.subtitle") %> +

    <%= render DS::Link.new( icon: "plus", - text: "New", + text: t("pages.dashboard.new"), href: new_account_path, frame: :modal, class: "hidden lg:inline-flex" @@ -23,38 +27,56 @@
    <% end %> -
    +
    <% if Current.family.accounts.any? %> - <%= turbo_frame_tag "cashflow_sankey_section" do %> -
    - <%= render partial: "pages/dashboard/cashflow_sankey", locals: { - sankey_data: @cashflow_sankey_data, - period: @cashflow_period - } %> + <% @dashboard_sections.each do |section| %> + <% next unless section[:visible] %> +
    +
    +
    + +

    + <%= t(section[:title]) %> +

    +
    + +
    +
    + <%= render partial: section[:partial], locals: section[:locals] %> +
    <% end %> - - <% if @outflows_data[:categories].present? %> - <%= turbo_frame_tag "outflows_donut_section" do %> -
    - <%= render partial: "pages/dashboard/outflows_donut", locals: { - outflows_data: @outflows_data, - period: @outflows_period - } %> -
    - <% end %> - <% end %> - -
    - <%= render partial: "pages/dashboard/net_worth_chart", locals: { - balance_sheet: @balance_sheet, - period: @period - } %> -
    - -
    - <%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %> -
    <% else %>
    <%= render "pages/dashboard/no_accounts_graph_placeholder" %> diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 305733242..5bacd50c5 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -1,8 +1,8 @@ <%# locals: (balance_sheet:, **args) %> -
    +
    <% balance_sheet.classification_groups.each do |classification_group| %> -
    +

    "> @@ -35,7 +35,7 @@

    -
    +
    Name
    @@ -48,13 +48,13 @@
    -
    +
    <% classification_group.account_groups.each_with_index do |account_group, idx| %> -
    <%= idx == classification_group.account_groups.size - 1 ? "rounded-b-lg" : "" %> "> - +
    <%= icon("chevron-right", class: "group-open:rotate-90") %> @@ -117,8 +117,12 @@ icon: classification_group.icon, ) %> -

    No <%= classification_group.name %> yet

    -

    <%= "Add your #{classification_group.name} accounts to see a full breakdown" %>

    +

    + <%= t("pages.dashboard.balance_sheet.no_items", name: classification_group.name) %> +

    +

    + <%= t("pages.dashboard.balance_sheet.add_accounts", name: classification_group.name) %> +

    <% end %>
    diff --git a/app/views/pages/dashboard/_cashflow_sankey.html.erb b/app/views/pages/dashboard/_cashflow_sankey.html.erb index ec03f8418..b47ca5739 100644 --- a/app/views/pages/dashboard/_cashflow_sankey.html.erb +++ b/app/views/pages/dashboard/_cashflow_sankey.html.erb @@ -1,12 +1,8 @@ <%# locals: (sankey_data:, period:) %>
    -
    -

    - Cashflow -

    - - <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "cashflow_sankey_section" } do |form| %> - <%= form.select :cashflow_period, +
    + <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> + <%= form.select :period, Period.as_options, { selected: period.key }, data: { "auto-submit-form-target": "auto" }, @@ -30,10 +26,10 @@ icon: "activity" # cashflow placeholder icon ) %> -

    No cash flow data for this time period

    -

    Add transactions to display cash flow data or expand the time period

    +

    <%= t("pages.dashboard.cashflow_sankey.no_data_title") %>

    +

    <%= t("pages.dashboard.cashflow_sankey.no_data_description") %>

    <%= render DS::Link.new( - text: "Add transaction", + text: t("pages.dashboard.cashflow_sankey.add_transaction"), icon: "plus", href: new_transaction_path, frame: :modal diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb index 56a31f249..2ac67416b 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -4,24 +4,17 @@ <% series = balance_sheet.net_worth_series(period: period) %>
    -
    -
    -

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

    -
    - + <% if series.trend.present? %>

    "> <%= series.trend.current.format %>

    - - <% if series.trend.nil? %> -

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

    - <% else %> - <%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %> - <% end %> -
    + <%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %> + <% else %> +

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

    + <% end %>
    - <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form" } do |form| %> + <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> <%= form.select :period, Period.as_options, { selected: period.key }, diff --git a/app/views/pages/dashboard/_no_accounts_graph_placeholder.html.erb b/app/views/pages/dashboard/_no_accounts_graph_placeholder.html.erb index 5b0909c1f..b8cca831b 100644 --- a/app/views/pages/dashboard/_no_accounts_graph_placeholder.html.erb +++ b/app/views/pages/dashboard/_no_accounts_graph_placeholder.html.erb @@ -5,10 +5,10 @@ icon: "layers", ) %> -

    No accounts yet

    -

    Add accounts to display net worth data

    +

    <%= t("pages.dashboard.no_accounts.title") %>

    +

    <%= t("pages.dashboard.no_accounts.description") %>

    <%= render DS::Link.new( - text: "Add account", + text: t("pages.dashboard.no_accounts.add_account"), icon: "plus", href: new_account_path, frame: :modal diff --git a/app/views/pages/dashboard/_outflows_donut.html.erb b/app/views/pages/dashboard/_outflows_donut.html.erb index e89f4d4fe..c046a0a88 100644 --- a/app/views/pages/dashboard/_outflows_donut.html.erb +++ b/app/views/pages/dashboard/_outflows_donut.html.erb @@ -1,12 +1,8 @@ <%# locals: (outflows_data:, period:) %>
    -
    -

    - Outflows -

    - - <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "outflows_donut_section" } do |form| %> - <%= form.select :outflows_period, +
    + <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> + <%= form.select :period, Period.as_options, { selected: period.key }, data: { "auto-submit-form-target": "auto" }, @@ -33,11 +29,11 @@
    - Total Outflows + <%= t("pages.dashboard.outflows_donut.total_outflows") %>
    - <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(outflows_data[:total], delimiter: ',') %> + <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(outflows_data[:total], delimiter: ",") %>
    @@ -47,7 +43,7 @@

    <%= category[:name] %>

    - <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %> + <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ",") %>

    <%= category[:percentage] %>%

    @@ -59,11 +55,24 @@
    -
    -
    - <% outflows_data[:categories].each do |category| %> +
    +
    +
    +

    <%= t("pages.dashboard.outflows_donut.categories") %>·<%= outflows_data[:categories].count %>

    +
    +
    +
    +

    <%= t("pages.dashboard.outflows_donut.value") %>

    +
    +
    +

    <%= t("pages.dashboard.outflows_donut.weight") %>

    +
    +
    +
    +
    + <% outflows_data[:categories].each_with_index do |category, idx| %> <%= link_to transactions_path(q: { categories: [category[:name]], start_date: period.date_range.first, end_date: period.date_range.last }), - class: "flex items-center justify-between p-3 rounded-lg hover:bg-container-inset transition-colors cursor-pointer group", + class: "flex items-center justify-between mx-3 p-3 rounded-lg cursor-pointer group", data: { turbo_frame: "_top", category_id: category[:id], @@ -75,10 +84,13 @@ <%= category[:name] %>
    - <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %> + <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ",") %> <%= category[:percentage] %>%
    <% end %> + <% if idx < outflows_data[:categories].size - 1 %> + <%= render "shared/ruler", classes: "mx-3 lg:mx-4" %> + <% end %> <% end %>
    diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index 9a7397a77..738f7facf 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -2,7 +2,7 @@ <%= tag.div id: dom_id(plaid_item) do %>
    - +
    <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> diff --git a/app/views/plaid_items/select_existing_account.html.erb b/app/views/plaid_items/select_existing_account.html.erb new file mode 100644 index 000000000..8b0cde2bc --- /dev/null +++ b/app/views/plaid_items/select_existing_account.html.erb @@ -0,0 +1,45 @@ +<%= 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") %> +

    + +
    + <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + <%= hidden_field_tag :account_id, @account.id %> + +
    + <% @available_plaid_accounts.each do |plaid_account| %> + + <% end %> +
    + +
    + <%= link_to t(".cancel"), accounts_path, + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover", + data: { turbo_frame: "_top", action: "DS--dialog#close" } %> + <%= submit_tag t(".link_account"), + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %> +
    +
    +
    + <% end %> + <% end %> +<% end %> diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js index 68d5c2ee6..4108a2065 100644 --- a/app/views/pwa/service-worker.js +++ b/app/views/pwa/service-worker.js @@ -1,10 +1,69 @@ +const CACHE_VERSION = 'v1'; +const OFFLINE_ASSETS = [ + '/offline.html', + '/logo-offline.svg' +]; + +// Install event - cache the offline page and assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_VERSION).then((cache) => { + return cache.addAll(OFFLINE_ASSETS); + }) + ); + // Activate immediately + self.skipWaiting(); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_VERSION) { + return caches.delete(cacheName); + } + }) + ); + }).then(() => { + // Take control of all pages immediately + return self.clients.claim(); + }) + ); +}); + +// Fetch event - serve offline page when network fails +self.addEventListener('fetch', (event) => { + // Handle navigation requests (page loads) + if (event.request.mode === 'navigate') { + event.respondWith( + fetch(event.request).catch((error) => { + // Only show offline page for network errors + if (error.name === 'TypeError' || !navigator.onLine) { + return caches.match('/offline.html'); + } + throw error; + }) + ); + } + // Handle offline assets (logo, etc.) + else if (OFFLINE_ASSETS.some(asset => new URL(event.request.url).pathname === asset)) { + event.respondWith( + caches.match(event.request).then((response) => { + return response || fetch(event.request); + }) + ); + } +}); + // Add a service worker for processing Web Push notifications: // // self.addEventListener("push", async (event) => { // const { title, options } = await event.data.json() // event.waitUntil(self.registration.showNotification(title, options)) // }) -// +// // self.addEventListener("notificationclick", function(event) { // event.notification.close() // event.waitUntil( @@ -12,12 +71,12 @@ // for (let i = 0; i < clientList.length; i++) { // let client = clientList[i] // let clientPath = (new URL(client.url)).pathname -// +// // if (clientPath == event.notification.data.path && "focus" in client) { // return client.focus() // } // } -// +// // if (clients.openWindow) { // return clients.openWindow(event.notification.data.path) // } diff --git a/app/views/recurring_transactions/_projected_transaction.html.erb b/app/views/recurring_transactions/_projected_transaction.html.erb new file mode 100644 index 000000000..201161f4c --- /dev/null +++ b/app/views/recurring_transactions/_projected_transaction.html.erb @@ -0,0 +1,60 @@ +<%# locals: (recurring_transaction:) %> + +
    +
    +
    + <%= content_tag :div, class: ["flex items-center gap-2"] do %> + <% if recurring_transaction.merchant.present? %> + <% if recurring_transaction.merchant.logo_url.present? %> + <%= image_tag recurring_transaction.merchant.logo_url, + class: "w-6 h-6 rounded-full", + loading: "lazy" %> + <% else %> + <%= render DS::FilledIcon.new( + variant: :text, + text: recurring_transaction.merchant.name, + size: "sm", + rounded: true + ) %> + <% end %> + <% else %> + <%= render DS::FilledIcon.new( + variant: :text, + text: recurring_transaction.name, + size: "sm", + rounded: true + ) %> + <% end %> + +
    +
    +
    +
    + <%= recurring_transaction.merchant.present? ? recurring_transaction.merchant.name : recurring_transaction.name %> +
    + +
    + + <%= t("recurring_transactions.projected") %> + +
    +
    + +
    + <%= t("recurring_transactions.expected_on", date: l(recurring_transaction.next_expected_date, format: :short)) %> +
    +
    +
    + <% end %> +
    +
    + + + +
    + <% display_amount = recurring_transaction.manual? && recurring_transaction.expected_amount_avg.present? ? recurring_transaction.expected_amount_avg : recurring_transaction.amount %> + <%= content_tag :p, format_money(-Money.new(display_amount, recurring_transaction.currency)), class: ["font-medium", display_amount.negative? ? "text-success" : "text-subdued"] %> +
    +
    diff --git a/app/views/recurring_transactions/index.html.erb b/app/views/recurring_transactions/index.html.erb new file mode 100644 index 000000000..1917a9624 --- /dev/null +++ b/app/views/recurring_transactions/index.html.erb @@ -0,0 +1,162 @@ +
    +
    +

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

    +
    + <%= render DS::Link.new( + text: t("recurring_transactions.identify_patterns"), + icon: "search", + variant: "outline", + href: identify_recurring_transactions_path, + method: :post + ) %> + <%= render DS::Link.new( + text: t("recurring_transactions.cleanup_stale"), + icon: "trash-2", + variant: "outline", + href: cleanup_recurring_transactions_path, + method: :post + ) %> +
    +
    + +
    +
    + <%= icon "info", class: "w-5 h-5 text-link mt-0.5 flex-shrink-0" %> +
    +

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

    +

    <%= t("recurring_transactions.info.manual_description") %>

    +

    <%= t("recurring_transactions.info.automatic_description") %>

    +
      + <% t("recurring_transactions.info.triggers").each do |trigger| %> +
    • <%= trigger %>
    • + <% end %> +
    +
    +
    +
    + +
    + <% if @recurring_transactions.empty? %> +
    +
    + <%= icon "repeat", size: "xl" %> +
    +

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

    +

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

    + <%= render DS::Link.new( + text: t("recurring_transactions.identify_patterns"), + icon: "search", + variant: "primary", + href: identify_recurring_transactions_path, + method: :post + ) %> +
    + <% else %> +
    +
    +

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

    + · +

    <%= @recurring_transactions.count %>

    +
    + +
    + + + + + + + + + + + + + + <% @recurring_transactions.each do |recurring_transaction| %> + "> + + + + + + + + + <% end %> + +
    <%= t("recurring_transactions.table.merchant") %><%= t("recurring_transactions.table.amount") %><%= t("recurring_transactions.table.expected_day") %><%= t("recurring_transactions.table.next_date") %><%= t("recurring_transactions.table.last_occurrence") %><%= t("recurring_transactions.table.status") %><%= t("recurring_transactions.table.actions") %>
    +
    + <% if recurring_transaction.merchant.present? %> + <% if recurring_transaction.merchant.logo_url.present? %> + <%= image_tag recurring_transaction.merchant.logo_url, + class: "w-6 h-6 rounded-full", + loading: "lazy" %> + <% else %> + <%= render DS::FilledIcon.new( + variant: :text, + text: recurring_transaction.merchant.name, + size: "sm", + rounded: true + ) %> + <% end %> + <%= recurring_transaction.merchant.name %> + <% else %> + <%= render DS::FilledIcon.new( + variant: :text, + text: recurring_transaction.name, + size: "sm", + rounded: true + ) %> + <%= recurring_transaction.name %> + <% end %> + <% if recurring_transaction.manual? %> + + <%= t("recurring_transactions.badges.manual") %> + + <% end %> +
    +
    "> + <% if recurring_transaction.manual? && recurring_transaction.has_amount_variance? %> +
    + ~ + <%= format_money(-recurring_transaction.expected_amount_avg_money) %> +
    + <% else %> + <%= format_money(-recurring_transaction.amount_money) %> + <% end %> +
    + <%= t("recurring_transactions.day_of_month", day: recurring_transaction.expected_day_of_month) %> + + <%= l(recurring_transaction.next_expected_date, format: :short) %> + + <%= l(recurring_transaction.last_occurrence_date, format: :short) %> + + <% if recurring_transaction.active? %> + + <%= t("recurring_transactions.status.active") %> + + <% else %> + + <%= t("recurring_transactions.status.inactive") %> + + <% end %> + +
    + <%= link_to toggle_status_recurring_transaction_path(recurring_transaction), + data: { turbo_method: :post }, + class: "text-secondary hover:text-primary" do %> + <%= icon recurring_transaction.active? ? "pause" : "play", size: "sm" %> + <% end %> + <%= link_to recurring_transaction_path(recurring_transaction), + data: { turbo_method: :delete, turbo_confirm: t("recurring_transactions.confirm_delete") }, + class: "text-secondary hover:text-destructive" do %> + <%= icon "trash-2", size: "sm" %> + <% end %> +
    +
    +
    +
    + <% end %> +
    +
    diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index 6d63d0cbb..f1fb414ae 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -4,7 +4,7 @@ <% if self_hosted_first_login? %>
    -

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

    +

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

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

    <% elsif @invitation %> diff --git a/app/views/reports/_budget_performance.html.erb b/app/views/reports/_budget_performance.html.erb new file mode 100644 index 000000000..9e1030a30 --- /dev/null +++ b/app/views/reports/_budget_performance.html.erb @@ -0,0 +1,117 @@ +
    +
    +

    + <%= t("reports.budget_performance.title") %> +

    +

    + <%= start_date.strftime("%B %Y") %> +

    +
    + + <% if budget_data.any? %> +
    + <% budget_data.each do |budget_item| %> +
    + <%# Category Header %> +
    +
    +
    +

    <%= budget_item[:category_name] %>

    +
    + +
    + <% case budget_item[:status] %> + <% when :over %> + + <%= icon("alert-circle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.over") %> + + <% when :warning %> + + <%= icon("alert-triangle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.warning") %> + + <% when :good %> + + <%= icon("check-circle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.good") %> + + <% end %> + + + <%= budget_item[:percent_used].round(0) %>% + +
    +
    + + <%# Progress Bar %> +
    +
    + <% bar_width = [budget_item[:percent_used], 100].min %> + <% bar_color = case budget_item[:status] + when :over then "bg-danger" + when :warning then "bg-warning" + else "bg-success" + end %> +
    +
    +
    + + <%# Budget Details %> +
    +
    +
    + <%= t("reports.budget_performance.spent") %>: + + <%= Money.new(budget_item[:actual], Current.family.currency).format %> + +
    +
    + <%= t("reports.budget_performance.budgeted") %>: + + <%= Money.new(budget_item[:budgeted], Current.family.currency).format %> + +
    +
    + +
    + <% if budget_item[:remaining] >= 0 %> + <%= t("reports.budget_performance.remaining") %>: + + <%= Money.new(budget_item[:remaining], Current.family.currency).format %> + + <% else %> + <%= t("reports.budget_performance.over_by") %>: + + <%= Money.new(budget_item[:remaining].abs, Current.family.currency).format %> + + <% end %> +
    +
    + + <%# Suggested Daily Limit (if remaining days in month) %> + <% if budget_item[:remaining] > 0 && start_date.month == Date.current.month && start_date.year == Date.current.year %> + <% days_remaining = (start_date.end_of_month - Date.current).to_i + 1 %> + <% if days_remaining > 0 %> +
    +

    + <%= t("reports.budget_performance.suggested_daily", + amount: Money.new((budget_item[:remaining] / days_remaining), Current.family.currency).format, + days: days_remaining) %> +

    +
    + <% end %> + <% end %> +
    + <% end %> +
    + <% else %> +
    + <%= icon("gauge", class: "w-12 h-12 text-tertiary mx-auto mb-4") %> +

    + <%= t("reports.budget_performance.no_budgets") %> +

    +
    + <% end %> +
    diff --git a/app/views/reports/_empty_state.html.erb b/app/views/reports/_empty_state.html.erb new file mode 100644 index 000000000..774bc56eb --- /dev/null +++ b/app/views/reports/_empty_state.html.erb @@ -0,0 +1,27 @@ +
    + <%= icon("chart-bar", class: "w-16 h-16 text-tertiary mx-auto mb-6") %> + +

    + <%= t("reports.empty_state.title") %> +

    + +

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

    + +
    + <%= render DS::Link.new( + text: t("reports.empty_state.add_transaction"), + href: new_transaction_path, + variant: "primary", + frame: :modal + ) %> + + <%= render DS::Link.new( + text: t("reports.empty_state.add_account"), + href: new_account_path, + variant: "secondary", + frame: :modal + ) %> +
    +
    diff --git a/app/views/reports/_summary_dashboard.html.erb b/app/views/reports/_summary_dashboard.html.erb new file mode 100644 index 000000000..d83041911 --- /dev/null +++ b/app/views/reports/_summary_dashboard.html.erb @@ -0,0 +1,132 @@ +
    + <%# Total Income Card %> +
    +
    +
    + <%= icon("trending-up", class: "w-5 h-5 text-success") %> +

    + <%= t("reports.summary.total_income") %> +

    +
    +
    + +
    +

    + <%= metrics[:current_income].format %> +

    + + <% if metrics[:income_change] %> +
    + <% if metrics[:income_change] >= 0 %> + <%= icon("arrow-up", class: "w-4 h-4 text-success") %> + + +<%= metrics[:income_change] %>% + + <% else %> + <%= icon("arrow-down", class: "w-4 h-4 text-danger") %> + + <%= metrics[:income_change] %>% + + <% end %> + + <%= t("reports.summary.vs_previous") %> + +
    + <% end %> +
    +
    + + <%# Total Expenses Card %> +
    +
    +
    + <%= icon("trending-down", class: "w-5 h-5 text-danger") %> +

    + <%= t("reports.summary.total_expenses") %> +

    +
    +
    + +
    +

    + <%= metrics[:current_expenses].format %> +

    + + <% if metrics[:expense_change] %> +
    + <% if metrics[:expense_change] >= 0 %> + <%= icon("arrow-up", class: "w-4 h-4 text-danger") %> + + +<%= metrics[:expense_change] %>% + + <% else %> + <%= icon("arrow-down", class: "w-4 h-4 text-success") %> + + <%= metrics[:expense_change] %>% + + <% end %> + + <%= t("reports.summary.vs_previous") %> + +
    + <% end %> +
    +
    + + <%# Net Savings Card %> +
    +
    +
    + <%= icon("piggy-bank", class: "w-5 h-5 text-primary") %> +

    + <%= t("reports.summary.net_savings") %> +

    +
    +
    + +
    +

    "> + <%= metrics[:net_savings].format %> +

    + +

    + <%= t("reports.summary.income_minus_expenses") %> +

    +
    +
    + + <%# Budget Performance Card %> +
    +
    +
    + <%= icon("gauge", class: "w-5 h-5 text-primary") %> +

    + <%= t("reports.summary.budget_performance") %> +

    +
    +
    + +
    + <% if metrics[:budget_percent] %> +

    + <%= metrics[:budget_percent] %>% +

    + +
    +
    +
    = 80 ? "bg-warning" : "bg-success" %> rounded-full transition-all" + style="width: <%= [metrics[:budget_percent], 100].min %>%">
    +
    + +

    + <%= t("reports.summary.of_budget_used") %> +

    +
    + <% else %> +

    + <%= t("reports.summary.no_budget_data") %> +

    + <% end %> +
    +
    +
    diff --git a/app/views/reports/_transactions_breakdown.html.erb b/app/views/reports/_transactions_breakdown.html.erb new file mode 100644 index 000000000..fafb717e1 --- /dev/null +++ b/app/views/reports/_transactions_breakdown.html.erb @@ -0,0 +1,173 @@ +
    + <%# Export Controls %> +
    + <% + # Build params hash for links + base_params = { + period_type: period_type, + start_date: start_date, + end_date: end_date, + sort_by: params[:sort_by], + sort_direction: params[:sort_direction] + }.compact + %> + + <%# Export Options %> +
    + <%= t("reports.transactions_breakdown.export.label") %>: + <%= link_to export_transactions_reports_path(base_params.merge(format: :csv)), + class: "inline-flex items-center gap-1 text-sm px-3 py-1 bg-surface-inset text-secondary hover:bg-surface-hover rounded-lg" do %> + <%= icon("download", class: "w-3 h-3") %> + <%= t("reports.transactions_breakdown.export.csv") %> + <% end %> + <%= link_to google_sheets_instructions_reports_path(base_params), + class: "inline-flex items-center gap-1 text-sm px-3 py-1 bg-surface-inset text-secondary hover:bg-surface-hover rounded-lg", + data: { turbo_frame: "modal" } do %> + <%= icon("external-link", class: "w-3 h-3") %> + <%= t("reports.transactions_breakdown.export.google_sheets") %> + <% end %> +
    +
    + + <%# Transactions Tables - Split by Income and Expenses %> + <% if transactions.any? %> + <% + # Separate income and expenses + income_groups = transactions.select { |g| g[:type] == "income" } + expense_groups = transactions.select { |g| g[:type] == "expense" } + + # Calculate totals + income_total = income_groups.sum { |g| g[:total] } + expense_total = expense_groups.sum { |g| g[:total] } + + # Determine sort direction for Amount column + current_sort_by = params[:sort_by] + current_sort_direction = params[:sort_direction] + + # Toggle sort direction: if currently sorting by amount desc, switch to asc; otherwise default to desc + next_sort_direction = (current_sort_by == "amount" && current_sort_direction == "desc") ? "asc" : "desc" + + # Build params for amount sort link + amount_sort_params = base_params.merge(sort_by: "amount", sort_direction: next_sort_direction) + %> + +
    + <%# Income Section %> + <% if income_groups.any? %> +
    +

    + <%= icon("trending-up", class: "w-5 h-5") %> + <%= t("reports.transactions_breakdown.table.income") %> + (<%= Money.new(income_total, Current.family.currency).format %>) +

    + +
    + + + + + + + + + + <% income_groups.each do |group| %> + <% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(1) %> + + + + + + <% end %> + +
    <%= t("reports.transactions_breakdown.table.category") %> + <%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %> + <%= t("reports.transactions_breakdown.table.amount") %> + <% if current_sort_by == "amount" %> + <%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %> + <% end %> + <% end %> + <%= t("reports.transactions_breakdown.table.percentage") %>
    +
    + + <%= group[:category_name] %> + (<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>) +
    +
    + + <%= Money.new(group[:total], Current.family.currency).format %> + + + + <%= percentage %>% + +
    +
    +
    + <% end %> + + <%# Expenses Section %> + <% if expense_groups.any? %> +
    +

    + <%= icon("trending-down", class: "w-5 h-5") %> + <%= t("reports.transactions_breakdown.table.expense") %> + (<%= Money.new(expense_total, Current.family.currency).format %>) +

    + +
    + + + + + + + + + + <% expense_groups.each do |group| %> + <% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(1) %> + + + + + + <% end %> + +
    <%= t("reports.transactions_breakdown.table.category") %> + <%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %> + <%= t("reports.transactions_breakdown.table.amount") %> + <% if current_sort_by == "amount" %> + <%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %> + <% end %> + <% end %> + <%= t("reports.transactions_breakdown.table.percentage") %>
    +
    + + <%= group[:category_name] %> + (<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>) +
    +
    + + <%= Money.new(group[:total], Current.family.currency).format %> + + + + <%= percentage %>% + +
    +
    +
    + <% end %> +
    + + <%# Summary Stats %> +
    + <%= t("reports.transactions_breakdown.pagination.showing", count: transactions.sum { |g| g[:count] }) %> +
    + <% else %> +
    + <%= t("reports.transactions_breakdown.no_transactions") %> +
    + <% end %> +
    diff --git a/app/views/reports/_trends_insights.html.erb b/app/views/reports/_trends_insights.html.erb new file mode 100644 index 000000000..1b3d83b53 --- /dev/null +++ b/app/views/reports/_trends_insights.html.erb @@ -0,0 +1,198 @@ +
    + <%# Month-over-Month Trends %> +
    +

    + <%= t("reports.trends.monthly_breakdown") %> +

    + + <% if trends_data.any? %> +
    + + + + + + + + + + + + <% trends_data.each_with_index do |trend, index| %> + "> + + + + + + + <% end %> + +
    <%= t("reports.trends.month") %><%= t("reports.trends.income") %><%= t("reports.trends.expenses") %><%= t("reports.trends.net") %><%= t("reports.trends.savings_rate") %>
    +
    + + <%# Trend Insights %> +
    + <% avg_income = trends_data.sum { |t| t[:income] } / trends_data.length %> + <% avg_expenses = trends_data.sum { |t| t[:expenses] } / trends_data.length %> + <% avg_net = trends_data.sum { |t| t[:net] } / trends_data.length %> + +
    +

    <%= t("reports.trends.avg_monthly_income") %>

    +

    + <%= Money.new(avg_income, Current.family.currency).format %> +

    +
    + +
    +

    <%= t("reports.trends.avg_monthly_expenses") %>

    +

    + <%= Money.new(avg_expenses, Current.family.currency).format %> +

    +
    + +
    +

    <%= t("reports.trends.avg_monthly_savings") %>

    +

    "> + <%= Money.new(avg_net, Current.family.currency).format %> +

    +
    +
    + <% else %> +
    + <%= t("reports.trends.no_data") %> +
    + <% end %> +
    + + <%# Spending Patterns %> +
    +

    + <%= t("reports.trends.spending_patterns") %> +

    + + <% if spending_patterns[:weekday_count] + spending_patterns[:weekend_count] > 0 %> +
    + <%# Weekday Spending %> +
    +
    + <%= icon("calendar", class: "w-5 h-5 text-primary") %> +

    <%= t("reports.trends.weekday_spending") %>

    +
    + +
    +
    +

    <%= t("reports.trends.total") %>

    +

    + <%= Money.new(spending_patterns[:weekday_total], Current.family.currency).format %> +

    +
    + +
    +

    <%= t("reports.trends.avg_per_transaction") %>

    +

    + <%= Money.new(spending_patterns[:weekday_avg], Current.family.currency).format %> +

    +
    + +
    +

    <%= t("reports.trends.transactions") %>

    +

    + <%= spending_patterns[:weekday_count] %> +

    +
    +
    +
    + + <%# Weekend Spending %> +
    +
    + <%= icon("calendar-check", class: "w-5 h-5 text-primary") %> +

    <%= t("reports.trends.weekend_spending") %>

    +
    + +
    +
    +

    <%= t("reports.trends.total") %>

    +

    + <%= Money.new(spending_patterns[:weekend_total], Current.family.currency).format %> +

    +
    + +
    +

    <%= t("reports.trends.avg_per_transaction") %>

    +

    + <%= Money.new(spending_patterns[:weekend_avg], Current.family.currency).format %> +

    +
    + +
    +

    <%= t("reports.trends.transactions") %>

    +

    + <%= spending_patterns[:weekend_count] %> +

    +
    +
    +
    +
    + + <%# Comparison Insight %> + <% if spending_patterns[:weekday_avg] > 0 && spending_patterns[:weekend_avg] > 0 %> +
    +
    + <%= icon("lightbulb", class: "w-5 h-5 text-warning mt-0.5") %> +
    +

    + <%= t("reports.trends.insight_title") %> +

    +

    + <% + weekday = spending_patterns[:weekday_avg].to_f + weekend = spending_patterns[:weekend_avg].to_f + + if weekend > weekday + percent_diff = ((weekend - weekday) / weekday * 100).round(0) + if percent_diff > 20 + message = t("reports.trends.insight_higher_weekend", percent: percent_diff) + else + message = t("reports.trends.insight_similar") + end + elsif weekday > weekend + percent_diff = ((weekday - weekend) / weekend * 100).round(0) + if percent_diff > 20 + message = t("reports.trends.insight_higher_weekday", percent: percent_diff) + else + message = t("reports.trends.insight_similar") + end + else + message = t("reports.trends.insight_similar") + end + %> + <%= message %> +

    +
    +
    +
    + <% end %> + <% else %> +
    + <%= t("reports.trends.no_spending_data") %> +
    + <% end %> +
    +
    +
    diff --git a/app/views/reports/google_sheets_instructions.html.erb b/app/views/reports/google_sheets_instructions.html.erb new file mode 100644 index 000000000..ba8e79a9f --- /dev/null +++ b/app/views/reports/google_sheets_instructions.html.erb @@ -0,0 +1,67 @@ +<%= render DS::Dialog.new(variant: "modal", width: "md") do |dialog| %> + <% dialog.with_body do %> +
    +
    +

    + <% if @api_key_present %> + <%= t("reports.google_sheets_instructions.title_with_key") %> + <% else %> + <%= t("reports.google_sheets_instructions.title_no_key") %> + <% end %> +

    + <%= icon("x", as_button: true, data: { action: "DS--dialog#close" }, class: "text-subdued hover:text-primary") %> +
    + +
    + <% if @api_key_present %> +

    <%= t("reports.google_sheets_instructions.ready") %>

    +

    <%= t("reports.google_sheets_instructions.steps") %>

    +
    + =IMPORTDATA("<%= @csv_url %>") +
    +

    <%= icon("alert-triangle", class: "w-4 h-4 inline") %> <%= t("reports.google_sheets_instructions.security_warning") %>

    + <% else %> +

    <%= t("reports.google_sheets_instructions.need_key") %>

    +
      +
    1. <%= t("reports.google_sheets_instructions.step1") %>
    2. +
    3. <%= t("reports.google_sheets_instructions.step2") %>
    4. +
    5. <%= t("reports.google_sheets_instructions.step3") %>
    6. +
    7. <%= t("reports.google_sheets_instructions.step4") %>
    8. +
    +

    <%= t("reports.google_sheets_instructions.example") %>:

    +
    + <%= @csv_url %>&api_key=YOUR_API_KEY_HERE +
    +

    <%= t("reports.google_sheets_instructions.then_use") %>

    + <% end %> +
    + +
    + <% if @api_key_present %> + <%= render DS::Button.new( + text: t("reports.google_sheets_instructions.open_sheets"), + variant: "primary", + full_width: true, + href: "https://sheets.google.com/create", + target: "_blank", + data: { action: "click->DS--dialog#close" } + ) %> + <% else %> + <%= render DS::Button.new( + text: t("reports.google_sheets_instructions.go_to_api_keys"), + variant: "primary", + full_width: true, + href: settings_api_key_path, + frame: "_top" + ) %> + <% end %> + <%= render DS::Button.new( + text: t("reports.google_sheets_instructions.close"), + variant: "outline", + full_width: true, + data: { action: "DS--dialog#close" } + ) %> +
    +
    + <% end %> +<% end %> diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb new file mode 100644 index 000000000..57cd109ba --- /dev/null +++ b/app/views/reports/index.html.erb @@ -0,0 +1,154 @@ +<% content_for :page_header do %> +
    +
    +

    + <%= t("reports.index.title") %> +

    +

    + <%= t("reports.index.subtitle") %> +

    +
    + + <%# Flash messages %> + <% if flash[:alert].present? %> +
    + <%= flash[:alert] %> +
    + <% end %> + + <%# Period Navigation Tabs %> +
    + <%= render DS::Link.new( + text: t("reports.index.periods.monthly"), + variant: @period_type == :monthly ? "secondary" : "ghost", + href: reports_path(period_type: :monthly), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.quarterly"), + variant: @period_type == :quarterly ? "secondary" : "ghost", + href: reports_path(period_type: :quarterly), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.ytd"), + variant: @period_type == :ytd ? "secondary" : "ghost", + href: reports_path(period_type: :ytd), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.last_6_months"), + variant: @period_type == :last_6_months ? "secondary" : "ghost", + href: reports_path(period_type: :last_6_months), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.custom"), + variant: @period_type == :custom ? "secondary" : "ghost", + href: reports_path(period_type: :custom), + size: :sm + ) %> +
    + + <%# Custom Date Range Picker (only shown when custom is selected) %> + <% if @period_type == :custom %> + <%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %> + <%= f.hidden_field :period_type, value: :custom %> + +
    + + <%= f.date_field :start_date, + value: @start_date.strftime("%Y-%m-%d"), + data: { auto_submit_form_target: "auto" }, + autocomplete: "off", + class: "px-3 py-1.5 border border-primary rounded-lg text-sm bg-container-inset text-primary" %> +
    + +
    + + <%= f.date_field :end_date, + value: @end_date.strftime("%Y-%m-%d"), + data: { auto_submit_form_target: "auto" }, + autocomplete: "off", + class: "px-3 py-1.5 border border-primary rounded-lg text-sm bg-container-inset text-primary" %> +
    + <% end %> + <% end %> + + <%# Period Display %> +
    + <%= t("reports.index.showing_period", + start: @start_date.strftime("%b %-d, %Y"), + end: @end_date.strftime("%b %-d, %Y")) %> +
    +
    +<% end %> + +
    + <% if Current.family.transactions.any? %> + <%# Summary Dashboard - Always visible, not collapsible %> +
    + <%= render partial: "reports/summary_dashboard", locals: { + metrics: @summary_metrics, + period_type: @period_type + } %> +
    + + <%# Collapsible & Reorderable Sections %> +
    + <% @reports_sections.each do |section| %> + <% next unless section[:visible] %> +
    +
    +
    + +

    + <%= t(section[:title]) %> +

    +
    + +
    +
    + <%= render partial: section[:partial], locals: section[:locals] %> +
    +
    + <% end %> +
    + <% else %> + <%# Empty State %> +
    + <%= render partial: "reports/empty_state" %> +
    + <% end %> +
    diff --git a/app/views/rules/_form.html.erb b/app/views/rules/_form.html.erb index d15ad4718..2f4f1fe18 100644 --- a/app/views/rules/_form.html.erb +++ b/app/views/rules/_form.html.erb @@ -49,9 +49,10 @@ <% end %> -
    - <%= 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: "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" }) %> +
    @@ -74,7 +75,8 @@ <% end %> - <%= render DS::Button.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %> + <%= render DS::Button.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %> +
    diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 5d5774349..72afcaf49 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,11 +1,35 @@ -<% - header_title t(".title") -%> +<% if @prefill_demo_credentials %> +
    +
    +
    + <%= icon "info", size: "sm", color: "blue-600" %> +
    +
    +

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

    +

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

    +
    +
    +
    +<% end %> <%= styled_form_with url: sessions_path, class: "space-y-4", data: { turbo: false } do |form| %> - <%= form.email_field :email, label: t(".email"), autofocus: false, autocomplete: "email", required: "required", placeholder: t(".email_placeholder") %> + <%= form.email_field :email, + label: t(".email"), + autofocus: false, + autocomplete: "email", + required: "required", + placeholder: t(".email_placeholder"), + value: @email %> - <%= form.password_field :password, label: t(".password"), required: "required", placeholder: t(".password_placeholder") %> + <%= form.password_field :password, + label: t(".password"), + required: "required", + placeholder: t(".password_placeholder"), + value: @password %> <%= form.submit t(".submit") %> <% end %> diff --git a/app/views/settings/_section.html.erb b/app/views/settings/_section.html.erb index 5a8c9633a..0c9a4f586 100644 --- a/app/views/settings/_section.html.erb +++ b/app/views/settings/_section.html.erb @@ -1,12 +1,31 @@ -<%# locals: (title:, subtitle: nil, content:) %> -
    -
    -

    <%= title %>

    - <% if subtitle.present? %> -

    <%= subtitle %>

    - <% end %> -
    -
    - <%= content %> -
    -
    +<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true) %> +<% if collapsible %> +
    class="group bg-container shadow-border-xs rounded-xl p-4"> + +
    + <%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %> +
    +

    <%= title %>

    + <% if subtitle.present? %> +

    <%= subtitle %>

    + <% end %> +
    +
    +
    +
    + <%= content %> +
    +
    +<% else %> +
    +
    +

    <%= title %>

    + <% if subtitle.present? %> +

    <%= subtitle %>

    + <% end %> +
    +
    + <%= content %> +
    +
    +<% end %> diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index c2803156e..20ac659cd 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -17,19 +17,20 @@ nav_sections = [ { label: t(".categories_label"), path: categories_path, icon: "shapes" }, { 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(".merchants_label"), path: family_merchants_path, icon: "store" }, + { label: t(".recurring_transactions_label"), path: recurring_transactions_path, icon: "repeat" } ] }, ( - Current.user.admin? ? { + Current.user&.admin? ? { 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(".api_keys_label"), path: settings_api_key_path, icon: "key" }, { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, - { label: t(".imports_label"), path: imports_path, icon: "download" }, - { label: "SimpleFin", path: simplefin_items_path, icon: "building-2" } + { label: "Providers", path: settings_providers_path, icon: "plug" }, + { label: t(".imports_label"), path: imports_path, icon: "download" } ] } : nil ), diff --git a/app/views/settings/ai_prompts/show.html.erb b/app/views/settings/ai_prompts/show.html.erb index 9a494d4c4..6847b0481 100644 --- a/app/views/settings/ai_prompts/show.html.erb +++ b/app/views/settings/ai_prompts/show.html.erb @@ -32,7 +32,7 @@

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

    - +
    @@ -60,7 +60,7 @@

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

    - +
    @@ -88,7 +88,7 @@

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

    - +
    @@ -105,4 +105,4 @@
    -
  • \ No newline at end of file +
    diff --git a/app/views/settings/api_keys/show.html.erb b/app/views/settings/api_keys/show.html.erb index 42dde73de..abddad549 100644 --- a/app/views/settings/api_keys/show.html.erb +++ b/app/views/settings/api_keys/show.html.erb @@ -148,9 +148,9 @@
    <% else %>
    -

    API Key

    +

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

    <%= render DS::Link.new( - text: "Create API Key", + text: t(".no_api_key.create_api_key"), href: new_settings_api_key_path, variant: "primary" ) %> @@ -166,33 +166,34 @@ size: "lg" ) %>
    -

    Access your account data programmatically

    -

    Generate an API key to integrate with your applications and access your financial data securely.

    +

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

    +

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

    -

    What you can do with API keys:

    +

    <%= t(".no_api_key.what_you_can_do") %>

    • <%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %> - Access your accounts and balances + <%= t(".no_api_key.feature_1") %>
    • <%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %> - View transaction history + <%= t(".no_api_key.feature_2") %>
    • <%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %> - Create new transactions -
    • -
    • - <%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %> - Integrate with third-party applications + <%= t(".no_api_key.feature_3") %>
    + +
    +

    <%= t(".no_api_key.security_note_title") %>

    +

    <%= t(".no_api_key.security_note") %>

    +
    <% end %> diff --git a/app/views/settings/bank_sync/_provider_link.html.erb b/app/views/settings/bank_sync/_provider_link.html.erb index 8a2d524cd..41a69d9ff 100644 --- a/app/views/settings/bank_sync/_provider_link.html.erb +++ b/app/views/settings/bank_sync/_provider_link.html.erb @@ -3,13 +3,13 @@ <%# Assign distinct colors to each provider %> <% provider_colors = { "Lunch Flow" => "#6471eb", - "Plaid" => "#4da568", + "Plaid" => "#4da568", "SimpleFin" => "#e99537" } %> <% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %> -<%= link_to provider_link[:path], - target: provider_link[:target], +<%= 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 %>
    @@ -28,5 +28,3 @@ <%= 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 index 56f634743..51c42bfcb 100644 --- a/app/views/settings/bank_sync/show.html.erb +++ b/app/views/settings/bank_sync/show.html.erb @@ -1,6 +1,5 @@ <%= content_for :page_title, "Bank Sync" %> -
    <% if @providers.any? %>
    @@ -25,5 +24,3 @@
    <% end %>
    - - diff --git a/app/views/settings/hostings/_brand_fetch_settings.html.erb b/app/views/settings/hostings/_brand_fetch_settings.html.erb index 47c305766..b2e2960ee 100644 --- a/app/views/settings/hostings/_brand_fetch_settings.html.erb +++ b/app/views/settings/hostings/_brand_fetch_settings.html.erb @@ -4,7 +4,23 @@ <% if ENV["BRAND_FETCH_CLIENT_ID"].present? %>

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

    <% else %> -

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

    +
    + <%= t(".description") %> +
    + (show details) +
      +
    1. + Visit brandfetch.com and create a free Brand Fetch Developer account. +
    2. +
    3. + Go to the Logo API page. +
    4. +
    5. + Tap the eye icon under the "Your Client ID" section to reveal your Client ID and paste it below. +
    6. +
    +
    +
    <% end %>
    diff --git a/app/views/settings/hostings/_invite_code_settings.html.erb b/app/views/settings/hostings/_invite_code_settings.html.erb index 3a89f214d..14e4439e3 100644 --- a/app/views/settings/hostings/_invite_code_settings.html.erb +++ b/app/views/settings/hostings/_invite_code_settings.html.erb @@ -9,7 +9,19 @@ url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> - <%= form.toggle :require_invite_for_signup, { data: { auto_submit_form_target: "auto" } } %> +
    + <%= form.select :onboarding_state, + options_for_select( + [ + [ t(".states.open"), "open" ], + [ t(".states.closed"), "closed" ], + [ t(".states.invite_only"), "invite_only" ] + ], + Setting.onboarding_state + ), + { label: false }, + { data: { auto_submit_form_target: "auto" } } %> +
    <% end %>
    @@ -27,7 +39,7 @@ <% end %>
    - <% if Setting.require_invite_for_signup %> + <% if Setting.onboarding_state == "invite_only" %>
    <%= t(".generated_tokens") %> diff --git a/app/views/settings/hostings/_provider_selection.html.erb b/app/views/settings/hostings/_provider_selection.html.erb new file mode 100644 index 000000000..55dde1e88 --- /dev/null +++ b/app/views/settings/hostings/_provider_selection.html.erb @@ -0,0 +1,51 @@ +
    +
    +

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

    +

    <%= t(".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| %> +
    + <%= form.select :exchange_rate_provider, + [ + [t(".providers.twelve_data"), "twelve_data"], + [t(".providers.yahoo_finance"), "yahoo_finance"] + ], + { label: t(".exchange_rate_provider_label") }, + { + value: ENV.fetch("EXCHANGE_RATE_PROVIDER", Setting.exchange_rate_provider), + disabled: ENV["EXCHANGE_RATE_PROVIDER"].present?, + data: { "auto-submit-form-target": "auto" } + } %> + + <%= 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" } + } %> +
    + + <% 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") %> +

    +
    +
    + <% end %> + <% end %> +
    diff --git a/app/views/settings/hostings/_twelve_data_settings.html.erb b/app/views/settings/hostings/_twelve_data_settings.html.erb index 4559f6521..90b524f2f 100644 --- a/app/views/settings/hostings/_twelve_data_settings.html.erb +++ b/app/views/settings/hostings/_twelve_data_settings.html.erb @@ -4,7 +4,23 @@ <% if ENV["TWELVE_DATA_API_KEY"].present? %>

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

    <% else %> -

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

    +
    + <%= t(".description") %> +
    + (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. +
    +
    +
    <% end %>
    diff --git a/app/views/settings/hostings/_yahoo_finance_settings.html.erb b/app/views/settings/hostings/_yahoo_finance_settings.html.erb new file mode 100644 index 000000000..33676f547 --- /dev/null +++ b/app/views/settings/hostings/_yahoo_finance_settings.html.erb @@ -0,0 +1,38 @@ +
    +
    +

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

    +

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

    +
    + <% if @yahoo_finance_provider&.healthy? %> +
    +
    +
    +

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

    +
    +
    + <% else %> +
    +
    +
    +

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

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

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

    +
    +

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

    +
    +
    +
    +
    +
    + <% end %> +
    diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index 077cd445a..0d67f4436 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -1,17 +1,24 @@ <%= content_for :page_title, t(".title") %> - <%= settings_section title: t(".general") do %>
    <%= render "settings/hostings/openai_settings" %> <%= render "settings/hostings/brand_fetch_settings" %> - <%= render "settings/hostings/twelve_data_settings" %>
    <% end %> - +<%= settings_section title: t(".financial_data_providers") do %> +
    + <%= render "settings/hostings/provider_selection" %> + <% if @show_yahoo_finance_settings %> + <%= render "settings/hostings/yahoo_finance_settings" %> + <% end %> + <% if @show_twelve_data_settings %> + <%= render "settings/hostings/twelve_data_settings" %> + <% end %> +
    +<% end %> <%= settings_section title: t(".invites") do %> <%= render "settings/hostings/invite_code_settings" %> <% end %> - <%= settings_section title: t(".danger_zone") do %> <%= render "settings/hostings/danger_zone_settings" %> <% end %> diff --git a/app/views/settings/llm_usages/show.html.erb b/app/views/settings/llm_usages/show.html.erb index 91e565cd3..172ca8519 100644 --- a/app/views/settings/llm_usages/show.html.erb +++ b/app/views/settings/llm_usages/show.html.erb @@ -9,13 +9,13 @@ <%= form_with url: settings_llm_usage_path, method: :get, class: "flex gap-4 items-end" do |f| %>
    <%= f.label :start_date, "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" %> + <%= 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" %>
    <%= f.label :end_date, "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" %> + <%= 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" %>
    - <%= f.submit "Filter", class: "rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800" %> + <%= render DS::Button.new(variant: :secondary, size: :md, type: "submit", text: "Filter") %> <% end %>
    @@ -117,7 +117,7 @@ <% @llm_usages.each do |usage| %> - + <%= usage.created_at.strftime("%b %d, %Y %I:%M %p") %> @@ -128,10 +128,27 @@ <%= usage.model %> - <%= number_with_delimiter(usage.total_tokens) %> - - (<%= number_with_delimiter(usage.prompt_tokens) %>/<%= number_with_delimiter(usage.completion_tokens) %>) - + <% if usage.failed? %> +
    +
    + <%= icon "alert-circle", class: "w-4 h-4 text-red-600 theme-dark:text-red-400" %> + Failed +
    + +
    + <% else %> + <%= number_with_delimiter(usage.total_tokens) %> + + (<%= number_with_delimiter(usage.prompt_tokens) %>/<%= number_with_delimiter(usage.completion_tokens) %>) + + <% end %> <%= usage.formatted_cost %> diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index e22ab0aa0..8911e8178 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -61,7 +61,7 @@ ].each do |theme| %> <%= form.label :"theme_#{theme[:value]}", class: "group" do %>
    - <%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "h-44 mb-2") %> + <%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "max-h-44 mb-2") %>
    "> <%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only", data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change", action: "theme#updateTheme" } %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 2e78beec9..ccdeefb2b 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -1,6 +1,6 @@ <%= content_for :page_title, t(".page_title") %> -<%= settings_section title: t(".profile_title"), subtitle: t(".profile_subtitle", product: product_name) do %> +<%= settings_section title: t(".profile_title"), subtitle: t(".profile_subtitle", product_name: product_name) do %> <%= styled_form_with model: @user, url: user_path(@user), class: "space-y-4" do |form| %> <%= render "settings/user_avatar_field", form: form, user: @user %> @@ -9,7 +9,7 @@ <% 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. + 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 %> diff --git a/app/views/settings/providers/_lunchflow_panel.html.erb b/app/views/settings/providers/_lunchflow_panel.html.erb new file mode 100644 index 000000000..eb7199668 --- /dev/null +++ b/app/views/settings/providers/_lunchflow_panel.html.erb @@ -0,0 +1,65 @@ +
    +
    +

    Setup instructions:

    +
      +
    1. Visit Lunch Flow to get your API key
    2. +
    3. Paste your API key below and click the Save button
    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:

    +
      +
    • API Key: Your Lunch Flow API key for authentication (required)
    • +
    • Base URL: Base URL for Lunch Flow API (optional, defaults to https://lunchflow.app/api/v1)
    • +
    +
    + + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
    + <%= error_msg %> +
    + <% end %> + + <% + # Get or initialize a lunchflow_item for this family + # - If family has an item WITH credentials, use it (for updates) + # - If family has an item WITHOUT credentials, use it (to add credentials) + # - If family has no items at all, create a new one + lunchflow_item = Current.family.lunchflow_items.first_or_initialize(name: "Lunch Flow Connection") + is_new_record = lunchflow_item.new_record? + %> + + <%= styled_form_with model: lunchflow_item, + url: is_new_record ? lunchflow_items_path : lunchflow_item_path(lunchflow_item), + scope: :lunchflow_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :api_key, + label: "API Key", + placeholder: is_new_record ? "Paste API key here" : "Enter new API key to update", + type: :password %> + + <%= form.text_field :base_url, + label: "Base URL (Optional)", + placeholder: "https://lunchflow.app/api/v1 (default)", + value: lunchflow_item.base_url %> + +
    + <%= form.submit is_new_record ? "Save Configuration" : "Update 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" %> +
    + <% end %> + + <% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: [nil, ""]) %> +
    + <% if 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/_provider_form.html.erb b/app/views/settings/providers/_provider_form.html.erb new file mode 100644 index 000000000..cb2384a9f --- /dev/null +++ b/app/views/settings/providers/_provider_form.html.erb @@ -0,0 +1,86 @@ +<% + # Parameters: + # - configuration: Provider::Configurable::Configuration object +%> + +
    +
    + <% if configuration.provider_description.present? %> +
    + <%= markdown(configuration.provider_description).html_safe %> +
    + <% end %> + + <% env_configured = configuration.fields.any? { |f| f.env_key && ENV[f.env_key].present? } %> + <% if env_configured %> +

    + Configuration can be set via environment variables or overridden below. +

    + <% end %> + + <% if configuration.fields.any? { |f| f.description.present? } %> +

    Field descriptions:

    +
      + <% configuration.fields.each do |field| %> + <% if field.description.present? %> +
    • <%= field.label %>: <%= field.description %>
    • + <% end %> + <% end %> +
    + <% end %> +
    + + <%= styled_form_with model: Setting.new, + url: settings_providers_path, + method: :patch do |form| %> +
    + <% configuration.fields.each do |field| %> + <% + env_value = ENV[field.env_key] if field.env_key + # Use dynamic hash-style access - works without explicit field declaration + setting_value = Setting[field.setting_key] + + # Show the setting value if it exists, otherwise show ENV value + # This allows users to see what they've overridden + current_value = setting_value.presence || env_value + + # Mask secret values if they exist + display_value = if field.secret && current_value.present? + "********" + else + current_value + end + + # Determine input type + input_type = field.secret ? "password" : "text" + + # Don't disable fields - allow overriding ENV variables + disabled = false + %> + + <%= form.text_field field.setting_key, + label: field.label, + type: input_type, + placeholder: field.default || (field.required ? "" : "Optional"), + value: display_value, + disabled: disabled %> + <% end %> + +
    + <%= 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" %> +
    +
    + <% end %> + + <%# Show configuration status %> +
    + <% if configuration.configured? %> +
    +

    Configured and ready to use

    + <% else %> +
    +

    Not configured

    + <% end %> +
    +
    diff --git a/app/views/settings/providers/_simplefin_panel.html.erb b/app/views/settings/providers/_simplefin_panel.html.erb new file mode 100644 index 000000000..db36085d8 --- /dev/null +++ b/app/views/settings/providers/_simplefin_panel.html.erb @@ -0,0 +1,48 @@ +
    +
    +

    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)
    • +
    +
    + + <% if defined?(@error_message) && @error_message.present? %> +
    + <%= @error_message %> +
    + <% end %> + + <%= styled_form_with model: SimplefinItem.new, + url: simplefin_items_path, + scope: :simplefin_item, + method: :post, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :setup_token, + label: "Setup Token", + placeholder: "Paste SimpleFIN setup token", + 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" %> +
    + <% 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/show.html.erb b/app/views/settings/providers/show.html.erb new file mode 100644 index 000000000..ccd19cb6b --- /dev/null +++ b/app/views/settings/providers/show.html.erb @@ -0,0 +1,29 @@ +<%= content_for :page_title, "Bank Sync Providers" %> + +
    +
    +

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

    +
    + + <% @provider_configurations.each do |config| %> + <% next if config.provider_key.to_s.casecmp("simplefin").zero? %> + <%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %> + <%= render "settings/providers/provider_form", configuration: config %> + <% end %> + <% end %> + + <%= 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 %> + +
    diff --git a/app/views/shared/_posthog.html.erb b/app/views/shared/_posthog.html.erb new file mode 100644 index 000000000..0b73624a3 --- /dev/null +++ b/app/views/shared/_posthog.html.erb @@ -0,0 +1,9 @@ + + diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb index 3f2768d3f..f8c916f61 100644 --- a/app/views/simplefin_items/_simplefin_item.html.erb +++ b/app/views/simplefin_items/_simplefin_item.html.erb @@ -2,7 +2,7 @@ <%= tag.div id: dom_id(simplefin_item) do %>
    - +
    <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> @@ -27,7 +27,46 @@

    <%= simplefin_item.institution_summary %>

    + <%# Extra inline badges from latest sync stats %> + <% stats = (@simplefin_sync_stats_map || {})[simplefin_item.id] || {} %> + <% if stats.present? %> +
    + <% if stats["unlinked_accounts"].to_i > 0 %> + <%= render DS::Tooltip.new(text: "Accounts need setup", icon: "link-2", size: "sm") %> + Unlinked: <%= stats["unlinked_accounts"].to_i %> + <% end %> + + <% if stats["accounts_skipped"].to_i > 0 %> + <%= render DS::Tooltip.new(text: "Some accounts were skipped due to errors during sync", icon: "alert-triangle", size: "sm", color: "warning") %> + Skipped: <%= stats["accounts_skipped"].to_i %> + <% end %> + + <% if stats["rate_limited"].present? || stats["rate_limited_at"].present? %> + <% ts = stats["rate_limited_at"] %> + <% ago = (ts.present? ? (begin; time_ago_in_words(Time.parse(ts)); rescue StandardError; nil; end) : nil) %> + <%= render DS::Tooltip.new( + text: (ago ? "Rate limited (" + ago + " ago)" : "Rate limited recently"), + icon: "clock", + size: "sm", + color: "warning" + ) %> + <% end %> + + <% if stats["total_errors"].to_i > 0 || (stats["errors"].is_a?(Array) && stats["errors"].any?) %> + <% tooltip_text = simplefin_error_tooltip(stats) %> + <% if tooltip_text.present? %> + <%= render DS::Tooltip.new(text: tooltip_text, icon: "alert-octagon", size: "sm", color: "warning") %> + <% end %> + <% end %> + + <% if stats["total_accounts"].to_i > 0 %> + Total: <%= stats["total_accounts"].to_i %> + <% end %> +
    + <% end %> <% end %> + <%# Determine if all reported errors are benign duplicate-skips (suppress scary banner). Computed in controller for testability. %> + <% duplicate_only_errors = (@simplefin_duplicate_only_map || {})[simplefin_item.id] || false %> <% if simplefin_item.syncing? %>
    <%= icon "loader", size: "sm", class: "animate-spin" %> @@ -38,11 +77,21 @@ <%= icon "alert-triangle", size: "sm", color: "warning" %> <%= tag.span t(".requires_update") %>
    - <% elsif simplefin_item.sync_error.present? %> + <% elsif simplefin_item.rate_limited_message.present? %> +
    + <%= icon "clock", size: "sm", color: "warning" %> + <%= tag.span simplefin_item.rate_limited_message %> +
    + <% elsif simplefin_item.sync_error.present? && !duplicate_only_errors %>
    <%= render DS::Tooltip.new(text: simplefin_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> <%= tag.span t(".error"), class: "text-destructive" %>
    + <% elsif duplicate_only_errors %> +
    + <%= icon "info", size: "sm" %> + <%= tag.span "Some accounts were skipped as duplicates — use ‘Link existing accounts’ to merge.", class: "text-secondary" %> +
    <% else %>

    <% if simplefin_item.last_synced_at %> @@ -76,6 +125,8 @@ ) %> <% end %> + + <%= render DS::Menu.new do |menu| %> <% menu.with_item( variant: "button", @@ -95,7 +146,85 @@ <%= render "accounts/index/account_groups", accounts: simplefin_item.accounts %> <% end %> - <% if simplefin_item.pending_account_setup? %> + + <%# Sync summary (collapsible) %> + <% stats = (@simplefin_sync_stats_map || {})[simplefin_item.id] || {} %> + <% if stats.present? %> +

    + +
    + <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + Sync summary +
    +
    + <% if simplefin_item.last_synced_at %> + Last sync: <%= time_ago_in_words(simplefin_item.last_synced_at) %> ago + <% end %> +
    +
    +
    +
    +

    Accounts

    +
    + Total: <%= stats["total_accounts"].to_i %> + Linked: <%= stats["linked_accounts"].to_i %> + Unlinked: <%= stats["unlinked_accounts"].to_i %> + <% institutions = simplefin_item.connected_institutions %> + Institutions: <%= institutions.size %> +
    +
    +
    +

    Transactions

    +
    + Seen: <%= stats["tx_seen"].to_i %> + Imported: <%= stats["tx_imported"].to_i %> + Updated: <%= stats["tx_updated"].to_i %> + Skipped: <%= stats["tx_skipped"].to_i %> +
    +
    +
    +

    Holdings

    +
    + Processed: <%= stats["holdings_processed"].to_i %> +
    +
    +
    +

    Health

    +
    + <% if stats["rate_limited"].present? || stats["rate_limited_at"].present? %> + <% ts = stats["rate_limited_at"] %> + <% ago = (ts.present? ? (begin; time_ago_in_words(Time.parse(ts)); rescue StandardError; nil; end) : nil) %> + Rate limited <%= ago ? "(#{ago} ago)" : "recently" %> + <% end %> + <% total_errors = stats["total_errors"].to_i %> + <% if total_errors > 0 %> + Errors: <%= total_errors %> + <% else %> + Errors: 0 + <% end %> +
    +
    +
    +
    + <% end %> + + <%# Compute unlinked SimpleFin accounts (no legacy account and no AccountProvider link) + # Prefer controller-provided map; fallback to a local query so the card stays accurate after Turbo broadcasts %> + <% unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map + @simplefin_unlinked_count_map[simplefin_item.id] || 0 + else + begin + simplefin_item.simplefin_accounts + .left_joins(:account, :account_provider) + .where(accounts: { id: nil }, account_providers: { id: nil }) + .count + rescue => e + Rails.logger.warn("SimpleFin card: unlinked_count fallback failed: #{e.class} - #{e.message}") + 0 + end + end %> + + <% if unlinked_count.to_i > 0 %>

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

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

    @@ -103,7 +232,8 @@ text: t(".setup_action"), icon: "settings", variant: "primary", - href: setup_accounts_simplefin_item_path(simplefin_item) + href: setup_accounts_simplefin_item_path(simplefin_item), + frame: :modal ) %>
    <% elsif simplefin_item.accounts.empty? %> diff --git a/app/views/simplefin_items/_subtype_select.html.erb b/app/views/simplefin_items/_subtype_select.html.erb index f8c5378af..f5c30548f 100644 --- a/app/views/simplefin_items/_subtype_select.html.erb +++ b/app/views/simplefin_items/_subtype_select.html.erb @@ -2,9 +2,23 @@ <% if subtype_config[:options].present? %> <%= label_tag "account_subtypes[#{simplefin_account.id}]", subtype_config[:label], class: "block text-sm font-medium text-primary mb-2" %> - <% selected_value = account_type == "Depository" ? - (simplefin_account.name.downcase.include?("checking") ? "checking" : - simplefin_account.name.downcase.include?("savings") ? "savings" : "") : "" %> + <% selected_value = "" %> + <% if account_type == "Depository" %> + <% n = simplefin_account.name.to_s.downcase %> + <% selected_value = "" %> + <% if n =~ /\bchecking\b|\bchequing\b|\bck\b|demand\s+deposit/ %> + <% selected_value = "checking" %> + <% elsif n =~ /\bsavings\b|\bsv\b/ %> + <% selected_value = "savings" %> + <% elsif n =~ /money\s+market|\bmm\b/ %> + <% selected_value = "money_market" %> + <% end %> + <% elsif account_type == "Investment" %> + <% inferred = @inferred_map&.dig(simplefin_account.id) || {} %> + <% if inferred[:confidence] == :high && inferred[:type] == "Investment" && inferred[:subtype].present? %> + <% selected_value = inferred[:subtype] %> + <% end %> + <% end %> <%= select_tag "account_subtypes[#{simplefin_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/simplefin_items/edit.html.erb b/app/views/simplefin_items/edit.html.erb index cecc98e8e..84c4a6238 100644 --- a/app/views/simplefin_items/edit.html.erb +++ b/app/views/simplefin_items/edit.html.erb @@ -1,6 +1,7 @@ <% content_for :title, "Update SimpleFin Connection" %> -<%= render DS::Dialog.new do |dialog| %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> <% dialog.with_header(title: "Update SimpleFin Connection") do %>
    <%= icon "building-2", class: "text-primary" %> @@ -59,4 +60,5 @@
    <% end %> <% end %> + <% end %> <% end %> diff --git a/app/views/simplefin_items/index.html.erb b/app/views/simplefin_items/index.html.erb deleted file mode 100644 index 1b53079cd..000000000 --- a/app/views/simplefin_items/index.html.erb +++ /dev/null @@ -1,42 +0,0 @@ -<% content_for :title, "SimpleFin Connections" %> - -
    -
    -
    -

    SimpleFin Connections

    -

    Manage your SimpleFin bank account connections

    -
    - - <%= render DS::Link.new( - text: "Add Connection", - icon: "plus", - variant: "primary", - href: new_simplefin_item_path - ) %> -
    - - <% if @simplefin_items.any? %> -
    - <% @simplefin_items.each do |simplefin_item| %> - <%= render "simplefin_item", simplefin_item: simplefin_item %> - <% end %> -
    - <% else %> -
    -
    - <%= render DS::FilledIcon.new( - variant: :container, - icon: "building-2", - ) %> - -

    No SimpleFin connections

    -

    Connect your bank accounts through SimpleFin to automatically sync transactions.

    - <%= render DS::Link.new( - text: "Add your first connection", - variant: "primary", - href: new_simplefin_item_path - ) %> -
    -
    - <% end %> -
    diff --git a/app/views/simplefin_items/new.html.erb b/app/views/simplefin_items/new.html.erb index 8d1f72d7d..ade7c448c 100644 --- a/app/views/simplefin_items/new.html.erb +++ b/app/views/simplefin_items/new.html.erb @@ -1,46 +1,26 @@ -<% content_for :title, "Add SimpleFin Connection" %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> -<%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: "Add SimpleFin Connection") %> - <% dialog.with_body do %> - <% if @error_message.present? %> - <%= render DS::Alert.new(message: @error_message, variant: :error) %> - <% end %> - <%= styled_form_with model: @simplefin_item, local: true, data: { turbo: false }, class: "flex flex-col gap-4 justify-between grow text-primary" do |form| %> -
    - <%= form.text_area :setup_token, - label: "SimpleFin Setup Token", - placeholder: "Paste your SimpleFin setup token here...", - rows: 4, - required: true %> + <% dialog.with_body do %> + <% if @error_message.present? %> +
    + <%= @error_message %> +
    + <% end %> -

    - Get your setup token from - <%= link_to "SimpleFin Bridge", "https://bridge.simplefin.org/simplefin/create", - target: "_blank", - class: "text-link underline" %> -

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

    How to get your setup token:

    -
      -
    1. Visit <%= link_to "SimpleFin Bridge", "https://bridge.simplefin.org/simplefin/create", target: "_blank", class: "text-link underline" %>
    2. -
    3. Connect your bank account using your online banking credentials
    4. -
    5. Copy the SimpleFin setup token that appears (it will be a long Base64-encoded string)
    6. -
    7. Paste it above and click "Add Connection"
    8. -
    -

    - Note: Setup tokens can only be used once. If the connection fails, you'll need to create a new token. -

    -
    + <%= 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" %>
    -
    - - <%= form.submit "Add Connection" %> + <% end %> <% end %> <% end %> <% end %> diff --git a/app/views/simplefin_items/select_existing_account.html.erb b/app/views/simplefin_items/select_existing_account.html.erb new file mode 100644 index 000000000..76cecef96 --- /dev/null +++ b/app/views/simplefin_items/select_existing_account.html.erb @@ -0,0 +1,40 @@ +<%# Modal: Link an existing manual account to a SimpleFIN account %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Link SimpleFIN account") %> + + <% dialog.with_body do %> + <% if @available_simplefin_accounts.blank? %> +
    +

    All SimpleFIN accounts appear to be linked already.

    +
      +
    • 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.
    • +
    +
    + <% else %> + <%= form_with url: link_existing_account_simplefin_items_path, method: :post, class: "space-y-4" do %> + <%= hidden_field_tag :account_id, @account.id %> +
    + <% @available_simplefin_accounts.each do |sfa| %> + + <% end %> +
    + +
    + <%= 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" }) %> +
    + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/simplefin_items/setup_accounts.html.erb b/app/views/simplefin_items/setup_accounts.html.erb index 48d1661b4..a18dbaff1 100644 --- a/app/views/simplefin_items/setup_accounts.html.erb +++ b/app/views/simplefin_items/setup_accounts.html.erb @@ -78,8 +78,10 @@
    <%= label_tag "account_types[#{simplefin_account.id}]", "Account Type:", class: "block text-sm font-medium text-primary mb-2" %> + <% inferred = @inferred_map[simplefin_account.id] || {} %> + <% selected_type = inferred[:confidence] == :high ? inferred[:type] : "skip" %> <%= select_tag "account_types[#{simplefin_account.id}]", - options_for_select(@account_type_options), + options_for_select(@account_type_options, selected_type), { 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" @@ -109,7 +111,7 @@ <%= render DS::Link.new( text: "Cancel", variant: "secondary", - href: simplefin_items_path + href: accounts_path ) %>
    <% end %> diff --git a/app/views/simplefin_items/show.html.erb b/app/views/simplefin_items/show.html.erb deleted file mode 100644 index eb28bd687..000000000 --- a/app/views/simplefin_items/show.html.erb +++ /dev/null @@ -1,105 +0,0 @@ -<% content_for :title, @simplefin_item.name %> - -
    - <%= link_to simplefin_items_path, class: "text-secondary hover:text-primary" do %> - ← Back to SimpleFin Connections - <% end %> -

    <%= @simplefin_item.name %>

    -
    - <%= button_to sync_simplefin_item_path(@simplefin_item), method: :post, class: "inline-flex items-center gap-2 px-4 py-2 bg-surface border border-primary rounded-lg text-primary font-medium hover:bg-surface-hover focus:ring-2 focus:ring-primary focus:ring-offset-2" do %> - <%= icon "refresh-cw", size: "sm" %> - Sync - <% end %> - <%= button_to simplefin_item_path(@simplefin_item), method: :delete, data: { confirm: "Are you sure?" }, class: "inline-flex items-center gap-2 px-4 py-2 bg-destructive border border-destructive rounded-lg text-white font-medium hover:bg-destructive-hover focus:ring-2 focus:ring-destructive focus:ring-offset-2" do %> - <%= icon "trash", size: "sm" %> - Delete - <% end %> -
    -
    - -
    - <% if @simplefin_item.syncing? %> -
    -
    - <%= icon "loader-2", class: "w-5 h-5 text-primary animate-spin mr-2" %> -

    Syncing accounts...

    -
    -
    - <% end %> - - <% if @simplefin_item.accounts.any? %> - <%= render "accounts/index/account_groups", accounts: @simplefin_item.accounts %> - <% elsif @simplefin_item.simplefin_accounts.any? %> -
    -
    -

    SimpleFin Accounts

    - · -

    <%= @simplefin_item.simplefin_accounts.count %>

    -
    -
    - <% @simplefin_item.simplefin_accounts.each_with_index do |simplefin_account, index| %> -
    -
    - <%= render DS::FilledIcon.new( - variant: :container, - text: simplefin_account.name.first.upcase, - size: "md" - ) %> -
    -

    - <%= simplefin_account.name %> - <% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %> - • <%= simplefin_account.org_data["name"] %> - <% elsif @simplefin_item.institution_name.present? %> - • <%= @simplefin_item.institution_name %> - <% end %> -

    -

    - <%= simplefin_account.account_type&.humanize || "Unknown Type" %> -

    -
    -
    -
    -

    - <%= number_to_currency(simplefin_account.current_balance || 0) %> -

    - <% if simplefin_account.account %> - <%= render DS::Link.new( - text: "View Account", - href: account_path(simplefin_account.account), - variant: :outline - ) %> - <% else %> - <%= render DS::Link.new( - text: "Set Up Account", - href: setup_accounts_simplefin_item_path(@simplefin_item), - variant: :primary, - icon: "settings" - ) %> - <% end %> -
    -
    - <% unless index == @simplefin_item.simplefin_accounts.count - 1 %> - <%= render "shared/ruler" %> - <% end %> - <% end %> -
    -
    - <% else %> -
    -
    - <%= render DS::FilledIcon.new( - variant: :container, - icon: "building-2", - ) %> - -

    No accounts found

    -

    Try syncing again to import your accounts.

    - <%= button_to sync_simplefin_item_path(@simplefin_item), method: :post, class: "inline-flex items-center gap-2 px-4 py-2 bg-primary border border-primary rounded-lg text-white font-medium hover:bg-primary-hover focus:ring-2 focus:ring-primary focus:ring-offset-2" do %> - <%= icon "refresh-cw", size: "sm" %> - Sync Now - <% end %> -
    -
    - <% end %> -
    diff --git a/app/views/trades/_form.html.erb b/app/views/trades/_form.html.erb index cb248ece9..034e890bf 100644 --- a/app/views/trades/_form.html.erb +++ b/app/views/trades/_form.html.erb @@ -50,7 +50,7 @@ <% if %w[buy sell].include?(type) %> <%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any", required: true %> - <%= form.money_field :price, label: t(".price"), step: 'any', precision: 10, required: true %> + <%= form.money_field :price, label: t(".price"), step: "any", precision: 10, required: true %> <% end %>
    diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index 48f27d1af..4375ae0d3 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -36,8 +36,7 @@ { include_blank: t(".none"), multiple: true, - label: t(".tags_label"), - container_class: "h-40" + label: t(".tags_label") }, { "data-controller": "multi-select" } %> <% end %> diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index bf0e3331c..9b87c7aff 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -89,11 +89,11 @@
    -