diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index efe9bec08..59021eb9b 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -10,9 +10,8 @@ def index @manual_accounts = family.accounts .listable_manual .where(id: @accessible_account_ids) - .includes(:accountable, :account_providers, :plaid_account, :simplefin_account) + .includes(:accountable, :account_providers, :provider_accounts, :simplefin_account) .order(:name) - @plaid_items = visible_provider_items(family.plaid_items.ordered.includes(:syncs, :plaid_accounts)) @simplefin_items = visible_provider_items(family.simplefin_items.ordered.includes(:syncs)) @lunchflow_items = visible_provider_items(family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts)) @enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs)) @@ -22,6 +21,7 @@ def index @snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts)) @indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts)) @sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts)) + @provider_connections = family.provider_connections.order(:created_at).includes(provider_accounts: :account) # Build sync stats maps for all providers build_sync_stats_maps @@ -32,10 +32,11 @@ def index 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 + + @provider_configs += Provider::ConnectionRegistry.all_connection_configs(family: family) end def sync_all @@ -155,8 +156,15 @@ def unlink # wasting a slot on reconnect. @account.account_providers.destroy_all + # Remove Provider::Connection-framework link (Plaid, TrueLayer, etc.) + # The Provider::Account row is preserved (account_id nullified) so the + # connection's setup page shows it as available to relink. Use + # update_all to bypass association caches. + Provider::Account.where(account_id: @account.id).update_all(account_id: nil) + @account.association(:provider_account).reset + # Remove legacy system links (foreign keys) - @account.update!(plaid_account_id: nil, simplefin_account_id: nil) + @account.update!(simplefin_account_id: nil) # Destroy the SimplefinAccount record so it doesn't cause stale account issues # This is safe because: @@ -189,6 +197,13 @@ def select_provider family: family ) + eligible_keys = Provider::ConnectionRegistry.keys.select { |key| + Provider::ConnectionRegistry.adapter_for(key).supported_account_types.include?(account_type_name) + } + provider_configs += eligible_keys + .flat_map { |key| Provider::ConnectionRegistry.adapter_for(key).connection_configs(family: family) } + .uniq { |c| c[:key] } + # Build available providers list with paths resolved for this specific account # Filter out providers that don't support linking to existing accounts @available_providers = provider_configs.filter_map do |config| @@ -271,13 +286,6 @@ def build_sync_stats_maps @simplefin_duplicate_only_map[item.id] = false end - # Plaid sync stats - @plaid_sync_stats_map = {} - @plaid_items.each do |item| - latest_sync = item.syncs.ordered.first - @plaid_sync_stats_map[item.id] = latest_sync&.sync_stats || {} - end - # Lunchflow sync stats @lunchflow_sync_stats_map = {} @lunchflow_items.each do |item| diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 121fded34..8db824c7e 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -75,7 +75,9 @@ def reset_target_counts(family) categories: family.categories.count, tags: family.tags.count, merchants: family.merchants.count, - plaid_items: family.plaid_items.count, + # Framework-managed bank/sync connections (Plaid, TrueLayer, ...). + # Legacy plaid_items / plaid_accounts were dropped in this branch. + provider_connections: family.provider_connections.count, imports: family.imports.count, budgets: family.budgets.count } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7604ed654..4f9727dee 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -15,7 +15,18 @@ def pundit_user before_action :set_default_chat before_action :set_active_storage_url_options - helper_method :demo_config, :demo_host_match?, :show_demo_warning? + helper_method :demo_config, :demo_host_match?, :show_demo_warning?, :configured_host + + # Host used to construct upstream-registered redirect URIs (TrueLayer Console, + # Plaid Dashboard, etc.). Prefers Rails.application.routes.default_url_options[:host] + # — set explicitly per environment — over request.host, which is subject to + # Host-header injection on misconfigured deploys. Both controllers (when + # building the redirect_uri sent at OAuth exchange) AND views (when showing + # the URL the admin pastes into the upstream dashboard) use this so the + # values cannot diverge. + def configured_host + Rails.application.routes.default_url_options[:host] || request.host + end private def accept_pending_invitation_for(user) diff --git a/app/controllers/binance_items_controller.rb b/app/controllers/binance_items_controller.rb index 7f3471e58..11e552fb7 100644 --- a/app/controllers/binance_items_controller.rb +++ b/app/controllers/binance_items_controller.rb @@ -142,7 +142,7 @@ def link_existing_account return end - if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present? + if @account.account_providers.any? || @account.simplefin_account_id.present? alert_msg = t(".errors.only_manual") if turbo_frame_request? flash.now[:alert] = alert_msg diff --git a/app/controllers/coinbase_items_controller.rb b/app/controllers/coinbase_items_controller.rb index 498853574..68f07ef5d 100644 --- a/app/controllers/coinbase_items_controller.rb +++ b/app/controllers/coinbase_items_controller.rb @@ -155,7 +155,7 @@ def link_existing_account end # Guard: only manual accounts can be linked (no existing provider links) - if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present? + if @account.account_providers.any? || @account.simplefin_account_id.present? flash[:alert] = t(".errors.only_manual") if turbo_frame_request? return render turbo_stream: Array(flash_notification_stream_items) diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 479ad9efc..8e68da22d 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -80,11 +80,17 @@ def update def set_link_options 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 ) + + eligible_keys = Provider::ConnectionRegistry.keys.select { |key| + Provider::ConnectionRegistry.adapter_for(key).supported_account_types.include?(account_type_name) + } + @provider_configs += eligible_keys + .flat_map { |key| Provider::ConnectionRegistry.adapter_for(key).connection_configs(family: Current.family) } + .uniq { |c| c[:key] } end def accountable_type diff --git a/app/controllers/concerns/provider_auth_flow_session.rb b/app/controllers/concerns/provider_auth_flow_session.rb new file mode 100644 index 000000000..ca791ab36 --- /dev/null +++ b/app/controllers/concerns/provider_auth_flow_session.rb @@ -0,0 +1,65 @@ +# Shared session-bridged flow state helpers for provider auth controllers. +# +# Auth flows that need to bridge cross-request state (OAuth2 redirect grants, +# Plaid Link OAuth-bank resumes, future protocols) stash the state in +# session[:provider_flows] keyed by a server-generated flow_id. The connection +# itself is created at flow completion when valid credentials exist. +# +# Each flow record is a Hash with at least { "created_at" => Time.current.to_i }. +# Records older than FLOW_TTL are pruned on write. The MAX_FLOWS cap drops the +# oldest entries first to prevent unbounded session growth. +module ProviderAuthFlowSession + extend ActiveSupport::Concern + + FLOW_TTL = 1.hour + MAX_FLOWS = 20 + + private + + def write_flow!(flow_id, state) + session[:provider_flows] ||= {} + pruned = session[:provider_flows].reject { |_, v| flow_expired?(v) } + pruned = pruned.merge(flow_id => state) + + # Cap at MAX_FLOWS most-recent. Sort by created_at descending, keep the top N. + if pruned.size > MAX_FLOWS + pruned = pruned.sort_by { |_, v| -v["created_at"].to_i }.first(MAX_FLOWS).to_h + end + session[:provider_flows] = pruned + end + + def peek_flow(flow_id) + return nil if flow_id.blank? + flow = session[:provider_flows]&.dig(flow_id) + return nil unless flow.is_a?(Hash) + return nil if flow_expired?(flow) + flow + end + + def consume_flow(flow_id) + flow = peek_flow(flow_id) + return nil unless flow + session[:provider_flows] = (session[:provider_flows] || {}).except(flow_id) + flow + end + + def flow_expired?(flow) + return true unless flow.is_a?(Hash) + flow["created_at"].to_i < FLOW_TTL.seconds.ago.to_i + end + + def write_active_link_flow!(provider_key, flow_id) + session[:active_link_flows] ||= {} + session[:active_link_flows][provider_key.to_s] = flow_id + end + + def peek_active_link_flow(provider_key) + session.dig(:active_link_flows, provider_key.to_s) + end + + def consume_active_link_flow!(provider_key) + flow_id = peek_active_link_flow(provider_key) + session[:active_link_flows]&.delete(provider_key.to_s) + flow_id + end +end diff --git a/app/controllers/embedded_link_callbacks_controller.rb b/app/controllers/embedded_link_callbacks_controller.rb new file mode 100644 index 000000000..01ae2792e --- /dev/null +++ b/app/controllers/embedded_link_callbacks_controller.rb @@ -0,0 +1,114 @@ +# Generic controller for EmbeddedLink-style auth flows (Plaid Link, MX Connect +# Widget, Yodlee FastLink, Akoya Connect — vendor-hosted modal in-page that +# returns an opaque public_token to JS, exchanged once server-side for a +# long-lived access_token). +# +# Routes (parameterized by provider_key, all under /auth): +# GET /provider_connections/:provider_key/auth/new +# POST /provider_connections/:provider_key/auth/finish/:flow_id +# +# Provider-specific work is delegated to the adapter via the +# Provider::ConnectionAdapter EmbeddedLink contract: +# - .start_link_flow(family:, flow_id:, params:, resume_url:, oauth_redirect_url:, webhooks_url:) +# - .complete_link_flow(family:, flow:, params:) +# - .js_controller_name +# - .js_data_for(flow:, is_resume:, urls:) +# +# Cross-request flow state lives in session[:provider_flows] (see +# ProviderAuthFlowSession concern). No Provider::Connection is created until +# #create completes the exchange with valid credentials. +class EmbeddedLinkCallbacksController < ApplicationController + include ProviderAuthFlowSession + + before_action :require_admin! + before_action :resolve_adapter! + + def new + flow_id = SecureRandom.hex(16) + flow = @adapter.start_link_flow( + family: Current.family, + flow_id: flow_id, + params: params, + resume_url: new_provider_link_url(provider_key: provider_key), + oauth_redirect_url: provider_auth_url(provider_key: provider_key, host: configured_host), + webhooks_url: webhooks_provider_url(provider_key: provider_key) + ) + write_flow!(flow_id, flow) + write_active_link_flow!(provider_key, flow_id) + render_link_view(flow_id: flow_id, flow: flow, is_resume: false) + rescue ArgumentError => e + Rails.logger.warn("[EmbeddedLinkCallbacksController] #{provider_key}#new rejected: #{e.message}") + head :bad_request + rescue StandardError => e + # Ask the adapter to translate the upstream error into a user-actionable + # alert. If it can (e.g. Plaid recognises "OAuth redirect URI must be + # configured" and tells the user the exact URL to paste), we redirect + # back to settings/providers with a structured flash entry — rendered + # there as a prominent inline block (not the tiny toast that flash[:alert] + # would otherwise become — those are line-clamped at 3 lines and 320px). + # If the adapter returns nil we re-raise so the dev-mode error page + # surfaces the underlying bug. + result = @adapter.humanize_link_error(e, redirect_uri: provider_auth_url(provider_key: provider_key, host: configured_host)) + raise unless result + Rails.logger.warn("[EmbeddedLinkCallbacksController] #{provider_key}#new translated #{e.class}: #{e.message}") + flash[:provider_setup_error] = result.merge("provider_key" => provider_key) + redirect_to settings_providers_path + end + + def create + flow = consume_flow(params[:flow_id]) + consume_active_link_flow!(provider_key) + unless flow + Rails.logger.warn("[EmbeddedLinkCallbacksController] #{provider_key} flow expired/missing for family=#{Current.family&.id}") + redirect_to settings_providers_path, alert: t("provider.connections.connection_failed") + return + end + + @connection = @adapter.complete_link_flow(family: Current.family, flow: flow, params: params) + + begin + @connection.discover_accounts! + rescue => e + Rails.logger.warn("[EmbeddedLinkCallbacksController] discover_accounts! failed for connection=#{@connection.id}: #{e.class}: #{e.message}") + end + + redirect_to setup_provider_connection_path(@connection), + notice: t("provider.connections.connected") + rescue Provider::Auth::TransientError, + Provider::Auth::ConsentExpiredError, + Provider::Auth::ReauthRequiredError, + Provider::Error, + ActiveRecord::RecordInvalid, + ActiveRecord::RecordNotFound => e + Rails.logger.warn("[EmbeddedLinkCallbacksController] #{provider_key}#create failed: #{e.class}: #{e.message}") + redirect_to settings_providers_path, alert: t("provider.connections.connection_failed") + end + + private + + def provider_key + params[:provider_key].to_s + end + + def resolve_adapter! + @adapter = Provider::ConnectionRegistry.adapter_for(provider_key) + unless @adapter.auth_class == Provider::Auth::EmbeddedLink + head :not_found + end + rescue NotImplementedError + head :not_found + end + + def render_link_view(flow_id:, flow:, is_resume:) + # Compute every URL the adapter's JS may need here — adapters MUST NOT + # touch Rails.application.routes themselves. Keeps routing knowledge + # in one layer. + urls = { + complete: finish_provider_link_path(provider_key: provider_key, flow_id: flow_id), + sync: (flow["connection_id"] ? sync_provider_connection_path(flow["connection_id"]) : nil), + post_sync_redirect: accounts_path + } + @js_data = @adapter.js_data_for(flow: flow, is_resume: is_resume, urls: urls) + render :new + end +end diff --git a/app/controllers/enable_banking_items_controller.rb b/app/controllers/enable_banking_items_controller.rb index 0bfc0dabb..69cc5e00f 100644 --- a/app/controllers/enable_banking_items_controller.rb +++ b/app/controllers/enable_banking_items_controller.rb @@ -487,7 +487,7 @@ def link_existing_account enable_banking_account = EnableBankingAccount.find(params[:enable_banking_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? + if @account.account_providers.any? || @account.simplefin_account_id.present? flash[:alert] = t("enable_banking_items.link_existing_account.errors.only_manual") if turbo_frame_request? return render turbo_stream: Array(flash_notification_stream_items) diff --git a/app/controllers/migration_notices_controller.rb b/app/controllers/migration_notices_controller.rb new file mode 100644 index 000000000..67cb20519 --- /dev/null +++ b/app/controllers/migration_notices_controller.rb @@ -0,0 +1,12 @@ +class MigrationNoticesController < ApplicationController + before_action :require_admin! + + # DELETE /migration_notices/:key — admin acknowledges an action-required + # banner, hiding it from this family until a future migration re-registers + # it under a new key. + def destroy + Current.family.dismiss_migration_notice!(params[:key]) + redirect_back fallback_location: root_path, + notice: t("migration_notices.dismissed") + end +end diff --git a/app/controllers/oauth_callbacks_controller.rb b/app/controllers/oauth_callbacks_controller.rb new file mode 100644 index 000000000..c88d00d01 --- /dev/null +++ b/app/controllers/oauth_callbacks_controller.rb @@ -0,0 +1,61 @@ +class OauthCallbacksController < ApplicationController + include ProviderAuthFlowSession + + before_action :require_admin! + + # POST /provider_connections/:provider_key/auth/start — initiates OAuth. + def new + config = Current.family.provider_family_configs.find_by!(provider_key: params[:provider_key]) + # Build redirect_uri using the configured host rather than the request host. + # OAuth servers require the EXACT redirect_uri at exchange — and a Host- + # header-injection on a misconfigured deployment would otherwise seed an + # attacker-controlled redirect_uri here. Falls back to request-derived URL + # only if nothing is configured (dev defaults). + redirect_uri = provider_auth_url(provider_key: config.provider_key, host: configured_host) + + adapter_class = Provider::ConnectionRegistry.adapter_for(config.provider_key) + sandbox = adapter_class.sandbox_for(config) + flow_id = SecureRandom.hex(16) + write_flow!(flow_id, { + "provider_key" => config.provider_key, + "provider_family_config_id" => config.id, + "redirect_uri" => redirect_uri, + "psu_ip" => public_client_ip, + "sandbox" => sandbox, + "created_at" => Time.current.to_i + }) + + # config_for returns adapter.new(nil) — the stateless helper instance used + # by authorize_url / scopes / token_client (no @connection required). + adapter = Provider::ConnectionRegistry.config_for(config.provider_key) + auth_url = adapter.authorize_url( + client_id: config.client_id, + redirect_uri: redirect_uri, + state: flow_id, + scope: adapter.scopes, + sandbox: sandbox + ) + redirect_to auth_url, allow_other_host: true + end + + private + + # IPv4 carrier-grade NAT range (RFC 6598) — IPAddr#private? misses these. + CGNAT_RANGE = IPAddr.new("100.64.0.0/10").freeze + private_constant :CGNAT_RANGE + + # Filters out IPs that aren't public-routable. PSU IP is forwarded to + # TrueLayer; leaking an internal/CGNAT/cloud-metadata address is a privacy + # issue. IPAddr#private? alone misses link-local (incl. cloud metadata + # 169.254.169.254) and CGNAT (100.64.0.0/10). + def public_client_ip + ip = request.remote_ip + return nil if ip.blank? + addr = IPAddr.new(ip) + return nil if addr.private? || addr.loopback? || addr.link_local? + return nil if addr.ipv4? && CGNAT_RANGE.include?(addr) + ip + rescue IPAddr::InvalidAddressError + nil + end +end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb deleted file mode 100644 index bfe8c5bd5..000000000 --- a/app/controllers/plaid_items_controller.rb +++ /dev/null @@ -1,118 +0,0 @@ -class PlaidItemsController < ApplicationController - before_action :set_plaid_item, only: %i[edit destroy sync] - before_action :require_admin!, only: %i[new create select_existing_account link_existing_account edit destroy sync] - - def new - region = params[:region] == "eu" ? :eu : :us - webhooks_url = region == :eu ? plaid_eu_webhooks_url : plaid_us_webhooks_url - - @link_token = Current.family.get_link_token( - webhooks_url: webhooks_url, - redirect_url: accounts_url, - accountable_type: params[:accountable_type] || "Depository", - region: region - ) - end - - def edit - webhooks_url = @plaid_item.plaid_region == "eu" ? plaid_eu_webhooks_url : plaid_us_webhooks_url - - @link_token = @plaid_item.get_update_link_token( - webhooks_url: webhooks_url, - redirect_url: accounts_url, - ) - end - - def create - Current.family.create_plaid_item!( - public_token: plaid_item_params[:public_token], - item_name: item_name, - region: plaid_item_params[:region] - ) - - redirect_to accounts_path, notice: t(".success") - end - - def destroy - @plaid_item.destroy_later - redirect_to accounts_path, notice: t(".success") - end - - def sync - unless @plaid_item.syncing? - @plaid_item.sync_later - end - - respond_to do |format| - format.html { redirect_back_or_to accounts_path } - format.json { head :ok } - 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]) - end - - def plaid_item_params - params.require(:plaid_item).permit(:public_token, :region, metadata: {}) - end - - def item_name - plaid_item_params.dig(:metadata, :institution, :name) - end - - def plaid_us_webhooks_url - return webhooks_plaid_url if Rails.env.production? - - ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid" - end - - def plaid_eu_webhooks_url - return webhooks_plaid_eu_url if Rails.env.production? - - ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu" - end -end diff --git a/app/controllers/provider_auth_callbacks_controller.rb b/app/controllers/provider_auth_callbacks_controller.rb new file mode 100644 index 000000000..5b149793b --- /dev/null +++ b/app/controllers/provider_auth_callbacks_controller.rb @@ -0,0 +1,111 @@ +# Unified callback for all provider auth flows. +# Handles GET /provider_connections/:provider_key/auth — the single URL +# every provider registers in their upstream developer dashboard. +# +# Dispatches on adapter.auth_class: +# OAuth2 → token exchange (code + state params from provider) +# EmbeddedLink → re-render Link view with is_resume: true so the Plaid/MX +# JS controller sets receivedRedirectUri = window.location.href +class ProviderAuthCallbacksController < ApplicationController + include ProviderAuthFlowSession + + before_action :require_admin! + before_action :resolve_adapter! + + def show + if @adapter.auth_class == Provider::Auth::OAuth2 + handle_oauth2_callback + elsif @adapter.auth_class == Provider::Auth::EmbeddedLink + handle_embedded_link_oauth_return + else + head :not_found + end + end + + private + + def provider_key + params[:provider_key].to_s + end + + def resolve_adapter! + @adapter = Provider::ConnectionRegistry.adapter_for(provider_key) + rescue NotImplementedError + head :not_found + end + + def handle_oauth2_callback + flow = consume_flow(params[:state]) + unless flow + Rails.logger.warn("[ProviderAuthCallbacksController] state mismatch or expired for provider=#{provider_key} family=#{Current.family&.id}") + redirect_to settings_providers_path, alert: t("provider.connections.connection_failed") + return + end + + if flow["kind"] == "reauth" + @connection = Current.family.provider_connections.find(flow["connection_id"]) + @connection.auth.exchange_code(code: params[:code]) + @connection.update!(status: :healthy, sync_error: nil) + redirect_to provider_connection_path(@connection), notice: t("provider.connections.connected") + return + end + + config = Current.family.provider_family_configs.find(flow["provider_family_config_id"]) + + @connection = Provider::Connection.transaction do + conn = Current.family.provider_connections.create!( + provider_key: flow["provider_key"], + provider_family_config: config, + auth_type: "oauth2", + status: :healthy, + credentials: {}, + metadata: { + "psu_ip" => flow["psu_ip"], + "redirect_uri" => flow["redirect_uri"], + "sandbox" => flow["sandbox"] + }.compact + ) + conn.auth.exchange_code(code: params[:code]) + conn + end + + begin + @connection.discover_accounts! + rescue => e + Rails.logger.warn("[ProviderAuthCallbacksController] discover_accounts! failed for connection=#{@connection.id}: #{e.class}: #{e.message}") + end + + redirect_to setup_provider_connection_path(@connection), notice: t("provider.connections.connected") + rescue Provider::Auth::TransientError, + Provider::Auth::ConsentExpiredError, + Provider::Auth::ReauthRequiredError, + Provider::Error, + ActiveRecord::RecordNotFound => e + Rails.logger.warn("[ProviderAuthCallbacksController] OAuth2 callback failed: #{e.class}: #{e.message}") + redirect_to settings_providers_path, alert: t("provider.connections.connection_failed") + end + + def handle_embedded_link_oauth_return + flow_id = peek_active_link_flow(provider_key) + flow = peek_flow(flow_id) + + unless flow + Rails.logger.warn("[ProviderAuthCallbacksController] no active EmbeddedLink flow for provider=#{provider_key} family=#{Current.family&.id}") + redirect_to settings_providers_path, alert: t("provider.connections.connection_failed") + return + end + + # Flow confirmed valid — refresh the active-link pointer so it survives + # the re-render and remains findable until POST finish. + consume_active_link_flow!(provider_key) + write_active_link_flow!(provider_key, flow_id) + + urls = { + complete: finish_provider_link_path(provider_key: provider_key, flow_id: flow_id), + sync: (flow["connection_id"] ? sync_provider_connection_path(flow["connection_id"]) : nil), + post_sync_redirect: accounts_path + } + @js_data = @adapter.js_data_for(flow: flow, is_resume: true, urls: urls) + render template: "embedded_link_callbacks/new" + end +end diff --git a/app/controllers/provider_connections_controller.rb b/app/controllers/provider_connections_controller.rb new file mode 100644 index 000000000..c15a7518b --- /dev/null +++ b/app/controllers/provider_connections_controller.rb @@ -0,0 +1,155 @@ +class ProviderConnectionsController < ApplicationController + include ProviderAuthFlowSession + + before_action :require_admin! + before_action :set_connection, except: [ :select, :link, :skip ] + + def select + @provider_key = params[:provider_key] || params[:provider] + @adapter = Provider::ConnectionRegistry.adapter_for(@provider_key) + # connect_actions can be expensive (Plaid hits Provider::Registry per + # region). Compute once; the view re-uses @connect_actions. + @connect_actions = @adapter.connect_actions(family: Current.family) + @configured = if @adapter.requires_family_config? + Current.family.provider_family_configs.exists?(provider_key: @provider_key) + else + # Global-cred providers (e.g. Plaid) — "configured" iff the adapter + # actually has actionable connect_actions. Empty actions means no + # region has its app credentials set, so we route the user to the + # setup-required branch (which links to Settings → Providers). + @connect_actions.any? + end + rescue NotImplementedError + head :not_found + end + + def show + @provider_accounts = @connection.provider_accounts.includes(:account).order(:external_name) + @has_unlinked = @provider_accounts.any? { |pa| !pa.linked? && !pa.skipped? } + @family_accounts = Current.family.accounts.alphabetically + end + + def setup + @unlinked = @connection.provider_accounts.where(account_id: nil).reject { |pa| pa.disappeared? } + @stale = @connection.provider_accounts.select(&:disappeared?) + @accounts = Current.family.accounts.alphabetically + end + + def save_setup + mappings = params.permit(mappings: {}).fetch(:mappings, {}) + provider_accounts = @connection.provider_accounts.index_by { |pa| pa.id.to_s } + + ActiveRecord::Base.transaction do + if (raw = params[:sync_start_date].presence) + # Coerce to Date explicitly so a hand-crafted POST with a malformed + # value cannot raise ActiveRecord::ValueValidatorFailed and 500 the + # whole save_setup. Silently ignore unparseable input — the type=date + # input on the form makes most malformed values a deliberate POC. + parsed = (Date.parse(raw) rescue nil) + @connection.update!(sync_start_date: parsed) if parsed + end + + mappings.each do |pa_id, account_id| + pa = provider_accounts[pa_id] + next unless pa + + if account_id.blank? + pa.update!(skipped: true) + elsif account_id == "new" + account = pa.build_sure_account(family: Current.family) + account.save! + pa.update!(account_id: account.id, skipped: false) + else + target = Current.family.accounts.find_by(id: account_id) + next unless target + pa.update!(account_id: target.id, skipped: false) + end + end + end + + @connection.sync_later + redirect_to provider_connection_path(@connection), + notice: t("provider.connections.setup_saved") + end + + def destroy + @connection.destroy + redirect_to settings_providers_path, notice: t("provider.connections.disconnected") + end + + def reauth + # EmbeddedLink reauth opens the JS-driven widget in UPDATE mode rather + # than redirecting to an OAuth endpoint. Other auth backends write a + # reauth flow record into the same session map the OAuth callback consumes, + # so the callback knows to update an existing connection rather than create + # a new one. Dispatch off adapter.auth_class to mirror the unified callback + # controller (ProviderAuthCallbacksController#show). + adapter = Provider::ConnectionRegistry.adapter_for(@connection.provider_key) + if adapter.auth_class == Provider::Auth::EmbeddedLink + redirect_to new_provider_link_path(provider_key: @connection.provider_key, + connection_id: @connection.id) + else + flow_id = SecureRandom.hex(16) + write_flow!(flow_id, { + "kind" => "reauth", + "connection_id" => @connection.id, + "provider_key" => @connection.provider_key, + "redirect_uri" => @connection.metadata["redirect_uri"], + "created_at" => Time.current.to_i + }) + redirect_to @connection.auth.reauth_url(state: flow_id), allow_other_host: true + end + end + + def sync + @connection.sync_later + redirect_to provider_connection_path(@connection), + notice: t("provider.connections.sync_queued") + end + + def link + pa = find_provider_account_for_family(params[:provider_account_id]) + return head :not_found unless pa + + if params[:account_id] == "new" + ActiveRecord::Base.transaction do + account = pa.build_sure_account(family: Current.family) + account.save! + pa.update!(account_id: account.id, skipped: false) + end + else + target = Current.family.accounts.find_by(id: params[:account_id]) + return head :unprocessable_entity unless target + pa.update!(account_id: target.id, skipped: false) + end + + pa.provider_connection.sync_later + redirect_to provider_connection_path(pa.provider_connection), + notice: t("provider.connections.account_linked") + end + + def skip + pa = find_provider_account_for_family(params[:provider_account_id]) + return head :not_found unless pa + + pa.update!(skipped: true) + # Once the user has finished decision-making (link or skip everything), + # any siblings that were already linked may be ready to sync. + pa.provider_connection.sync_later + redirect_to provider_connection_path(pa.provider_connection), + notice: t("provider.connections.account_skipped") + end + + private + + def set_connection + @connection = Current.family.provider_connections.find(params[:id]) + end + + def find_provider_account_for_family(pa_id) + Provider::Account + .joins(:provider_connection) + .where(provider_connections: { family_id: Current.family.id }) + .find_by(id: pa_id) + end +end diff --git a/app/controllers/provider_family_configs_controller.rb b/app/controllers/provider_family_configs_controller.rb new file mode 100644 index 000000000..65345f644 --- /dev/null +++ b/app/controllers/provider_family_configs_controller.rb @@ -0,0 +1,39 @@ +class ProviderFamilyConfigsController < ApplicationController + before_action :require_admin! + before_action :set_config, only: [ :update ] + + def create + @config = Current.family.provider_family_configs.build(config_params) + if @config.save + redirect_to settings_providers_path, notice: t("provider.family_configs.saved") + else + render :create, status: :unprocessable_entity + end + end + + def update + attrs = config_params + attrs = attrs.except(:client_secret) if attrs[:client_secret].blank? && @config.client_secret.present? + + if @config.update(attrs) + redirect_to settings_providers_path, notice: t("provider.family_configs.updated") + else + render :update, status: :unprocessable_entity + end + end + + def destroy + Current.family.provider_family_configs.find(params[:id]).destroy + redirect_to settings_providers_path, notice: t("provider.family_configs.removed") + end + + private + + def set_config + @config = Current.family.provider_family_configs.find(params[:id]) + end + + def config_params + params.require(:provider_family_config).permit(:provider_key, :client_id, :client_secret, :sandbox) + end +end diff --git a/app/controllers/settings/bank_sync_controller.rb b/app/controllers/settings/bank_sync_controller.rb index 91e55a3ef..de4eded3c 100644 --- a/app/controllers/settings/bank_sync_controller.rb +++ b/app/controllers/settings/bank_sync_controller.rb @@ -2,10 +2,11 @@ class Settings::BankSyncController < ApplicationController layout "settings" def show - @providers = [ + static_providers = [ { name: "Lunch Flow", description: "US, Canada, UK, EU, Brazil and Asia through multiple open banking providers.", + color: "#6471eb", path: "https://lunchflow.app/features/sure-integration?atp=BiDIYS", target: "_blank", rel: "noopener noreferrer" @@ -13,6 +14,7 @@ def show { name: "Plaid", description: "US & Canada bank connections with transactions, investments, and liabilities.", + color: "#4da568", path: "https://github.com/we-promise/sure/blob/main/docs/hosting/plaid.md", target: "_blank", rel: "noopener noreferrer" @@ -20,24 +22,42 @@ def show { name: "SimpleFIN", description: "US & Canada connections via SimpleFIN protocol.", + color: "#e99537", path: "https://beta-bridge.simplefin.org", target: "_blank", rel: "noopener noreferrer" }, { - name: "Enable Banking (beta)", + name: "Enable Banking", description: "European bank connections via open banking APIs across multiple countries.", + color: "#6471eb", + beta: true, path: "https://enablebanking.com", target: "_blank", rel: "noopener noreferrer" }, { - name: "Sophtron (alpha)", + name: "Sophtron", description: "US & Canada bank, credit card, investment, loan, insurance, utility, and other connections.", + color: "#1E90FF", + beta: true, path: "https://www.sophtron.com/", target: "_blank", rel: "noopener noreferrer" } ] + + connection_providers = Provider::ConnectionRegistry.keys.map do |key| + adapter = Provider::ConnectionRegistry.adapter_for(key) + { + name: adapter.display_name, + description: adapter.respond_to?(:description) ? adapter.description : nil, + color: adapter.respond_to?(:brand_color) ? adapter.brand_color : "#6B7280", + beta: adapter.beta?, + path: Current.user&.admin? ? settings_providers_path : nil + } + end + + @providers = static_providers + connection_providers end end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 7c6428376..51d6abe08 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -123,15 +123,28 @@ def reload_provider_configs(updated_fields) 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 + # New-framework adapters render their own connection_provider_panel further down. + # Filter out: + # - the framework's own provider_key (e.g. "truelayer") + # - any legacy ConfigurationRegistry keys the adapter declares it owns + # via .legacy_config_keys (e.g. Plaid renders "plaid" + "plaid_eu" + # inline in its framework card) + framework_keys = Provider::ConnectionRegistry.keys.map(&:to_s) + framework_owned_legacy_keys = Provider::ConnectionRegistry.keys.flat_map do |key| + Provider::ConnectionRegistry.adapter_for(key).legacy_config_keys.map(&:to_s) + end + excluded_keys = (framework_keys + framework_owned_legacy_keys).uniq @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| - config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \ - config.provider_key.to_s.casecmp("enable_banking").zero? || \ - config.provider_key.to_s.casecmp("sophtron").zero? || \ - config.provider_key.to_s.casecmp("coinstats").zero? || \ - config.provider_key.to_s.casecmp("mercury").zero? || \ - config.provider_key.to_s.casecmp("coinbase").zero? || \ - config.provider_key.to_s.casecmp("snaptrade").zero? || \ - config.provider_key.to_s.casecmp("indexa_capital").zero? + key = config.provider_key.to_s + excluded_keys.include?(key) || \ + key.casecmp("simplefin").zero? || key.casecmp("lunchflow").zero? || \ + key.casecmp("enable_banking").zero? || \ + key.casecmp("sophtron").zero? || \ + key.casecmp("coinstats").zero? || \ + key.casecmp("mercury").zero? || \ + key.casecmp("coinbase").zero? || \ + key.casecmp("snaptrade").zero? || \ + key.casecmp("indexa_capital").zero? end # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials diff --git a/app/controllers/simplefin_items_controller.rb b/app/controllers/simplefin_items_controller.rb index 836f7e10f..e96700b9b 100644 --- a/app/controllers/simplefin_items_controller.rb +++ b/app/controllers/simplefin_items_controller.rb @@ -394,7 +394,7 @@ def link_existing_account # swapping to a new card sfa) are now allowed below. has_foreign_provider = @account.account_providers .where.not(provider_type: "SimplefinAccount").exists? || - @account.plaid_account_id.present? + @account.provider_account.present? if has_foreign_provider flash[:alert] = t("simplefin_items.link_existing_account.errors.different_provider") if turbo_frame_request? diff --git a/app/controllers/webhooks/provider_controller.rb b/app/controllers/webhooks/provider_controller.rb new file mode 100644 index 000000000..503381ee0 --- /dev/null +++ b/app/controllers/webhooks/provider_controller.rb @@ -0,0 +1,57 @@ +# Generic webhook receiver for Provider::Connection adapters. +# +# Routes: +# POST /webhooks/providers/:provider_key +# +# Responsibilities are deliberately minimal — verify the signature via the +# adapter, instantiate the adapter's webhook_handler_class, dispatch. All +# provider-specific logic (signature scheme, payload parsing, event handling) +# lives on the adapter, not in this controller. +# +# Response codes: +# 200 — signature verified; handler ran (any handler-side errors are +# captured to Sentry but the endpoint still returns 200 so upstream +# providers don't 24-hour-retry on transient/in-progress bugs) +# 400 — signature verification failed, or the provider doesn't accept webhooks +# 404 — unknown provider_key (registry lookup failed) +class Webhooks::ProviderController < ApplicationController + skip_before_action :verify_authenticity_token + skip_authentication + + def receive + adapter = begin + Provider::ConnectionRegistry.adapter_for(params[:provider_key]) + rescue NotImplementedError + head :not_found and return + end + + raw_body = request.body.read + + # Signature verification is the gate. If this raises, the request is + # rejected — providers should not retry an invalid signature. + begin + adapter.verify_webhook!(headers: request.headers, raw_body: raw_body) + rescue NotImplementedError => e + Sentry.capture_exception(e) + render json: { error: "Provider does not accept webhooks" }, status: :bad_request + return + rescue => e + Sentry.capture_exception(e) + Rails.logger.warn("[Webhooks::ProviderController] #{params[:provider_key]} signature verification failed: #{e.class}: #{e.message}") + render json: { error: "Invalid signature" }, status: :bad_request + return + end + + # Handler runs post-signature. Any error here is a bug in our code, NOT a + # bad webhook — return 200 so the provider doesn't retry, but capture to + # Sentry so the bug surfaces. + begin + adapter.webhook_handler_class.new(raw_body: raw_body, headers: request.headers).process + rescue => e + Sentry.capture_exception(e) + Rails.logger.error("[Webhooks::ProviderController] #{params[:provider_key]} handler failed: #{e.class}: #{e.message}") + end + + render json: { received: true }, status: :ok + end +end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index e50325dcd..b588519b9 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -2,40 +2,6 @@ class WebhooksController < ApplicationController skip_before_action :verify_authenticity_token skip_authentication - def plaid - webhook_body = request.body.read - plaid_verification_header = request.headers["Plaid-Verification"] - - client = Provider::Registry.plaid_provider_for_region(:us) - - client.validate_webhook!(plaid_verification_header, webhook_body) - - PlaidItem::WebhookProcessor.new(webhook_body).process - - render json: { received: true }, status: :ok - rescue => error - Sentry.capture_exception(error) - Rails.logger.error("Webhook error: #{error.class} - #{error.message}") - render json: { error: "Invalid webhook" }, status: :bad_request - end - - def plaid_eu - webhook_body = request.body.read - plaid_verification_header = request.headers["Plaid-Verification"] - - client = Provider::Registry.plaid_provider_for_region(:eu) - - client.validate_webhook!(plaid_verification_header, webhook_body) - - PlaidItem::WebhookProcessor.new(webhook_body).process - - render json: { received: true }, status: :ok - rescue => error - Sentry.capture_exception(error) - Rails.logger.error("Webhook error: #{error.class} - #{error.message}") - render json: { error: "Invalid webhook" }, status: :bad_request - end - def stripe stripe_provider = Provider::Registry.get_provider(:stripe) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d46415e57..f573738ca 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -113,6 +113,16 @@ def currency_label(currency_or_code) "#{currency.name} (#{currency.iso_code})" end + # Renders all MigrationNotice entries the current family hasn't dismissed. + # Pass scope: to filter (e.g. :providers, :billing). With no scope, renders + # everything currently active for this family across the platform. + def render_migration_notices(scope: nil) + return unless Current.family + notices = MigrationNotice.active_for(family: Current.family, view: self, scope: scope) + return if notices.empty? + safe_join(notices.map { |n| render("migration_notices/notice", notice: n) }) + end + def show_super_admin_bar? if params[:admin].present? cookies.permanent[:admin] = params[:admin] diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 79a3c248a..413fa21ee 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -45,6 +45,24 @@ def adjacent_setting(current_path, offset) } end + # Renders the generic setup-instructions partial driven by: + # - I18n at settings.providers.instructions. + # Adding a provider = one locale block. No new view files. + # + # Accepts a framework key OR a legacy config_key (e.g. "plaid_eu") — both + # resolve to the same framework adapter, and one shared instructions block. + def provider_setup_instructions(provider_key) + framework_key = Provider::ConnectionRegistry.framework_key_for(provider_key) + return nil unless framework_key + scope = "settings.providers.instructions.#{framework_key}" + return nil unless I18n.exists?("#{scope}.title") + + # URL the admin registers in the provider's dashboard — same for all auth types + # since all providers now redirect to the unified /auth endpoint. + redirect_uri = provider_auth_url(provider_key: framework_key, host: configured_host) + render "settings/providers/setup_instructions", scope: scope, redirect_uri: redirect_uri + end + def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, &block) content = capture(&block) render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param } diff --git a/app/javascript/controllers/account_mapping_controller.js b/app/javascript/controllers/account_mapping_controller.js new file mode 100644 index 000000000..aa88a0c49 --- /dev/null +++ b/app/javascript/controllers/account_mapping_controller.js @@ -0,0 +1,9 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["mapping"] + + createAll() { + this.mappingTargets.forEach(select => { select.value = "new" }) + } +} diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index e24031f77..43b4098cb 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -6,7 +6,13 @@ export default class extends Controller { linkToken: String, region: { type: String, default: "us" }, isUpdate: { type: Boolean, default: false }, - itemId: String, + isResume: { type: Boolean, default: false }, + connectionId: String, + // Server-supplied URLs — never hardcoded here. See + // Provider::Plaid::Adapter#js_data_for for what gets injected. + completeUrl: String, + syncUrl: String, + postSyncRedirect: { type: String, default: "/accounts" }, }; connect() { @@ -78,21 +84,35 @@ export default class extends Controller { await this.waitForPlaid(); if (connectionToken !== this._connectionToken) return; - this._handler = Plaid.create({ + // Let the browser complete any pending top-layer transitions (e.g. a + // that was removed from the DOM during the frame swap that + // triggered this connect()) before Plaid injects its overlay. + await new Promise(resolve => requestAnimationFrame(resolve)); + if (connectionToken !== this._connectionToken) return; + + const config = { token: this.linkTokenValue, onSuccess: this.handleSuccess, onLoad: this.handleLoad, onExit: this.handleExit, onEvent: this.handleEvent, - }); + }; + // OAuth-bank resume: Plaid landed the browser back at our resume URL + // with ?oauth_state_id=...; passing receivedRedirectUri tells Plaid Link + // to resume the in-progress session rather than starting a new one. + if (this.isResumeValue) { + config.receivedRedirectUri = window.location.href; + } + + this._handler = Plaid.create(config); this._handler.open(); } handleSuccess = (public_token, metadata) => { if (this.isUpdateValue) { - // Trigger a sync to verify the connection and update status - fetch(`/plaid_items/${this.itemIdValue}/sync`, { + // Reauth flow — trigger a sync on the existing Provider::Connection. + fetch(this.syncUrlValue, { method: "POST", headers: { Accept: "application/json", @@ -100,26 +120,18 @@ export default class extends Controller { "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, }, }).then(() => { - // Refresh the page to show the updated status - window.location.href = "/accounts"; + window.location.href = this.postSyncRedirectValue; }); return; } - // For new connections, create a new Plaid item - fetch("/plaid_items", { + fetch(this.completeUrlValue, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, }, - body: JSON.stringify({ - plaid_item: { - public_token: public_token, - metadata: metadata, - region: this.regionValue, - }, - }), + body: JSON.stringify({ public_token: public_token }), }).then((response) => { if (response.redirected) { window.location.href = response.url; @@ -130,7 +142,7 @@ export default class extends Controller { handleExit = (err, metadata) => { // If there was an error during update mode, refresh the page to show latest status if (err && metadata.status === "requires_credentials") { - window.location.href = "/accounts"; + window.location.href = this.postSyncRedirectValue; } }; diff --git a/app/jobs/family_reset_job.rb b/app/jobs/family_reset_job.rb index 60d4916f7..fe26ae5c4 100644 --- a/app/jobs/family_reset_job.rb +++ b/app/jobs/family_reset_job.rb @@ -9,9 +9,9 @@ def perform(family, load_sample_data_for_email: nil) family.categories.destroy_all family.tags.destroy_all family.merchants.destroy_all - family.plaid_items.destroy_all family.imports.destroy_all family.budgets.destroy_all + family.provider_connections.destroy_all end if load_sample_data_for_email.present? diff --git a/app/jobs/identify_recurring_transactions_job.rb b/app/jobs/identify_recurring_transactions_job.rb index 9bb2c4156..0ddbc646b 100644 --- a/app/jobs/identify_recurring_transactions_job.rb +++ b/app/jobs/identify_recurring_transactions_job.rb @@ -47,7 +47,7 @@ def family_has_incomplete_syncs?(family) return true if family.syncs.incomplete.exists? # Check all provider items' syncs - return true if family.plaid_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:plaid_items) + return true if family.provider_connections.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:provider_connections) return true if family.simplefin_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:simplefin_items) return true if family.lunchflow_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:lunchflow_items) return true if family.enable_banking_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:enable_banking_items) diff --git a/app/jobs/provider/consent_expiry_check_job.rb b/app/jobs/provider/consent_expiry_check_job.rb new file mode 100644 index 000000000..e6571a01d --- /dev/null +++ b/app/jobs/provider/consent_expiry_check_job.rb @@ -0,0 +1,21 @@ +class Provider::ConsentExpiryCheckJob < ApplicationJob + queue_as :scheduled + sidekiq_options lock: :until_executed, on_conflict: :log + + EXPIRY_WARNING_WINDOW = 7.days + + def perform + Provider::Connection.where(status: :healthy).find_each do |connection| + raw = connection.metadata["consent_expires_at"] + next unless raw.present? + + expiry = Time.zone.parse(raw) + if expiry <= EXPIRY_WARNING_WINDOW.from_now + connection.update!(status: :requires_update, sync_error: "consent_expiring") + Rails.logger.info("[ConsentExpiryCheckJob] Marked connection #{connection.id} as requires_update (expires #{expiry})") + end + rescue => e + Rails.logger.error("[ConsentExpiryCheckJob] Failed to check connection #{connection.id}: #{e.message}") + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 8a31a8d5e..b7f83f2d2 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -10,6 +10,7 @@ class Account < ApplicationRecord belongs_to :owner, class_name: "User", optional: true belongs_to :import, optional: true + has_many :provider_accounts, class_name: "Provider::Account", dependent: :nullify has_many :account_shares, dependent: :destroy has_many :shared_users, through: :account_shares, source: :user has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" @@ -31,8 +32,10 @@ class Account < ApplicationRecord scope :alphabetically, -> { order(:name) } scope :manual, -> { left_joins(:account_providers) + .left_joins(:provider_accounts) .where(account_providers: { id: nil }) - .where(plaid_account_id: nil, simplefin_account_id: nil) + .where(provider_accounts: { id: nil }) + .where(simplefin_account_id: nil) } scope :visible_manual, -> { @@ -305,6 +308,8 @@ def logo_url "https://cdn.brandfetch.io/#{institution_domain}/icon/fallback/lettermark/w/#{logo_size}/h/#{logo_size}?c=#{Setting.brand_fetch_client_id}" elsif provider&.logo_url.present? provider.logo_url + elsif (uri = provider_accounts.first&.safe_logo_uri).present? + uri elsif logo.attached? Rails.application.routes.url_helpers.rails_blob_path(logo, only_path: true) end @@ -340,7 +345,7 @@ def current_holdings end def latest_provider_holdings_snapshot_date - holdings.where.not(account_provider_id: nil).maximum(:date) + holdings.from_provider.maximum(:date) end def start_date diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index b91d4ed83..a058e28ac 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -4,26 +4,30 @@ module Account::Linkable included do # New generic provider association has_many :account_providers, dependent: :destroy + # Provider::Connection-framework link (Plaid, TrueLayer, etc.) + has_one :provider_account, class_name: "Provider::Account", dependent: :nullify - # Legacy provider associations - kept for backward compatibility during migration - belongs_to :plaid_account, optional: true + # Legacy SimpleFIN association — kept for backward compatibility until the + # SimpleFIN migration. Plaid's legacy association was removed in the + # Plaid framework cutover. belongs_to :simplefin_account, optional: true # SQL-level mirror of `linked?`. Use this for set-based checks (e.g. bulk # `EXISTS`) so both definitions stay in sync. If `linked?` adds a new # provider source, update this scope too. scope :linked, -> { - left_outer_joins(:account_providers) + left_outer_joins(:account_providers, :provider_account) .where( - "account_providers.id IS NOT NULL OR accounts.plaid_account_id IS NOT NULL OR accounts.simplefin_account_id IS NOT NULL" + "account_providers.id IS NOT NULL OR provider_accounts.id IS NOT NULL OR accounts.simplefin_account_id IS NOT NULL" ) .distinct } end - # A "linked" account gets transaction and balance data from a third party like Plaid or SimpleFin + # A "linked" account gets transaction and balance data from a third party + # (Plaid, TrueLayer, SimpleFIN, etc.). def linked? - account_providers.any? || plaid_account.present? || simplefin_account.present? + account_providers.any? || provider_account.present? || simplefin_account.present? end # An "offline" or "unlinked" account is one where the user tracks values and @@ -57,8 +61,7 @@ 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? + # Fall back to legacy SimpleFIN system return "simplefin" if simplefin_account.present? nil diff --git a/app/models/account/market_data_importer.rb b/app/models/account/market_data_importer.rb index 5e7ae6182..28ed16395 100644 --- a/app/models/account/market_data_importer.rb +++ b/app/models/account/market_data_importer.rb @@ -99,7 +99,7 @@ def batch_first_required_price_dates(security_ids) provider_holding_security_ids = account.holdings .where(security_id: security_ids) - .where.not(account_provider_id: nil) + .from_provider .pluck(:security_id) .to_set diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 633c26f1b..d78b263ec 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -84,7 +84,8 @@ def import_transaction(external_id:, amount:, currency:, date:, name:, source:, ActiveModel::Type::Boolean.new.cast(pending_extra.dig("simplefin", "pending")) || ActiveModel::Type::Boolean.new.cast(pending_extra.dig("plaid", "pending")) || ActiveModel::Type::Boolean.new.cast(pending_extra.dig("lunchflow", "pending")) || - ActiveModel::Type::Boolean.new.cast(pending_extra.dig("enable_banking", "pending")) + ActiveModel::Type::Boolean.new.cast(pending_extra.dig("enable_banking", "pending")) || + ActiveModel::Type::Boolean.new.cast(pending_extra.dig("truelayer", "pending")) end if entry.new_record? && !incoming_pending @@ -423,7 +424,8 @@ def import_holding(security:, quantity:, amount:, currency:, date:, price: nil, price: price, amount: amount, account_provider_id: account_provider_id, - external_id: external_id + external_id: external_id, + source: source } # Only update security if not locked by user @@ -697,6 +699,7 @@ def find_pending_transaction(date:, amount:, currency:, source:, date_window: 8) OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true + OR (transactions.extra -> 'truelayer' ->> 'pending')::boolean = true SQL .order(date: :desc) # Prefer most recent pending transaction @@ -744,6 +747,7 @@ def find_pending_transaction_fuzzy(date:, amount:, currency:, source:, merchant_ OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true + OR (transactions.extra -> 'truelayer' ->> 'pending')::boolean = true SQL # If merchant_id is provided, prioritize matching by merchant @@ -814,6 +818,7 @@ def find_pending_transaction_low_confidence(date:, amount:, currency:, source:, OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true + OR (transactions.extra -> 'truelayer' ->> 'pending')::boolean = true SQL # For low confidence, require BOTH merchant AND name match (stronger signal needed) diff --git a/app/models/balance/linked_investment_series_normalizer.rb b/app/models/balance/linked_investment_series_normalizer.rb index 10b7aee36..7fad2dd7c 100644 --- a/app/models/balance/linked_investment_series_normalizer.rb +++ b/app/models/balance/linked_investment_series_normalizer.rb @@ -49,7 +49,7 @@ def common_supported_history_start_date(account_ids) def stable_provider_holding_start_dates(account_ids) rows = Holding.where(account_id: account_ids) - .where.not(account_provider_id: nil) + .from_provider .group(:account_id, :date) .order(account_id: :asc, date: :desc) .pluck(:account_id, :date, Arel.sql("array_agg(security_id ORDER BY security_id)")) @@ -105,7 +105,7 @@ def first_provider_activity_date end def provider_holdings_scope - @provider_holdings_scope ||= account.holdings.where.not(account_provider_id: nil) + @provider_holdings_scope ||= account.holdings.from_provider end def stable_provider_holding_start_date diff --git a/app/models/balance_sheet/account_totals.rb b/app/models/balance_sheet/account_totals.rb index 5c767687f..fc918e2cb 100644 --- a/app/models/balance_sheet/account_totals.rb +++ b/app/models/balance_sheet/account_totals.rb @@ -31,7 +31,7 @@ def visible_accounts .includes( :account_shares, :accountable, - :plaid_account, + :provider_accounts, :simplefin_account, account_providers: :provider ) diff --git a/app/models/concerns/sync_stats/collector.rb b/app/models/concerns/sync_stats/collector.rb index abcf43bc4..e0957037e 100644 --- a/app/models/concerns/sync_stats/collector.rb +++ b/app/models/concerns/sync_stats/collector.rb @@ -7,7 +7,7 @@ # can report consistent sync summaries. # # @example Include in a syncer class -# class PlaidItem::Syncer +# class SimplefinItem::Syncer # include SyncStats::Collector # # def perform_sync(sync) @@ -25,7 +25,7 @@ module Collector # Collects account setup statistics (total, linked, unlinked counts). # # @param sync [Sync] The sync record to update - # @param provider_accounts [ActiveRecord::Relation] The provider accounts (e.g., SimplefinAccount, PlaidAccount) + # @param provider_accounts [ActiveRecord::Relation] The provider accounts (e.g., SimplefinAccount, LunchflowAccount) # @param linked_check [Proc, nil] Optional proc to check if an account is linked. If nil, uses default logic. # @return [Hash] The setup stats that were collected def collect_setup_stats(sync, provider_accounts:, linked_check: nil) diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index db09f81b5..7e492f4fe 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", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital", sophtron: "sophtron" } + validates :source, presence: true end diff --git a/app/models/family.rb b/app/models/family.rb index 3a3547b58..f75bdb66a 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,6 +1,6 @@ class Family < ApplicationRecord include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable - include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable + include SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, SophtronConnectable include IndexaCapitalConnectable @@ -26,6 +26,9 @@ class Family < ApplicationRecord has_many :accounts, dependent: :destroy has_many :invitations, dependent: :destroy + has_many :provider_family_configs, class_name: "Provider::FamilyConfig", dependent: :destroy + has_many :provider_connections, class_name: "Provider::Connection", dependent: :destroy + has_many :imports, dependent: :destroy has_many :family_exports, dependent: :destroy @@ -328,6 +331,18 @@ def self_hoster? Rails.application.config.app_mode.self_hosted? end + # Action-required banner state. See MigrationNotice + ApplicationHelper# + # render_migration_notices for how these are surfaced. + def dismissed_migration_notice?(key) + Array(dismissed_migration_notices).include?(key.to_s) + end + + def dismiss_migration_notice!(key) + key = key.to_s + return if dismissed_migration_notice?(key) + update!(dismissed_migration_notices: Array(dismissed_migration_notices) + [ key ]) + end + private def normalize_enabled_currencies! if enabled_currencies.blank? diff --git a/app/models/family/plaid_connectable.rb b/app/models/family/plaid_connectable.rb deleted file mode 100644 index b0f1f80d6..000000000 --- a/app/models/family/plaid_connectable.rb +++ /dev/null @@ -1,48 +0,0 @@ -module Family::PlaidConnectable - extend ActiveSupport::Concern - - included do - has_many :plaid_items, dependent: :destroy - end - - def can_connect_plaid_us? - plaid(:us).present? - end - - # If Plaid provider is configured and user is in the EU region - def can_connect_plaid_eu? - plaid(:eu).present? && self.eu? - end - - def create_plaid_item!(public_token:, item_name:, region:) - public_token_response = plaid(region).exchange_public_token(public_token) - - plaid_item = plaid_items.create!( - name: item_name, - plaid_id: public_token_response.item_id, - access_token: public_token_response.access_token, - plaid_region: region - ) - - plaid_item.sync_later - - plaid_item - end - - def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil) - return nil unless plaid(region) - - plaid(region).get_link_token( - user_id: self.id, - webhooks_url: webhooks_url, - redirect_url: redirect_url, - accountable_type: accountable_type, - access_token: access_token - ).link_token - end - - private - def plaid(region) - Provider::Registry.plaid_provider_for_region(region) - end -end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 3eace0b06..be23cb462 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -9,7 +9,6 @@ class Family::Syncer # To add a new provider: add its association name here. # The model handles its own "ready to sync" logic via the syncable scope. SYNCABLE_ITEM_ASSOCIATIONS = %i[ - plaid_items simplefin_items lunchflow_items enable_banking_items @@ -19,6 +18,7 @@ class Family::Syncer mercury_items snaptrade_items sophtron_items + provider_connections ].freeze def initialize(family) diff --git a/app/models/holding.rb b/app/models/holding.rb index 0b18b3e9c..6f3c31faa 100644 --- a/app/models/holding.rb +++ b/app/models/holding.rb @@ -28,6 +28,19 @@ class Holding < ApplicationRecord scope :with_locked_cost_basis, -> { where(cost_basis_locked: true) } scope :with_unlocked_cost_basis, -> { where(cost_basis_locked: false) } + # "Provider-sourced" = this holding was written by a provider sync rather than + # calculated from local trades. Two storage paths exist: + # 1. Legacy: holdings.account_provider_id (polymorphic FK to account_providers). + # Used by Coinbase, SimpleFIN, and any provider not yet on the framework. + # 2. Framework: holdings.source (string, e.g. "plaid"). Written by + # Account::ProviderImportAdapter#import_holding for framework providers. + scope :from_provider, -> { where("account_provider_id IS NOT NULL OR source IS NOT NULL") } + scope :not_from_provider, -> { where(account_provider_id: nil, source: nil) } + + def from_provider? + account_provider_id.present? || source.present? + end + delegate :ticker, to: :security def name diff --git a/app/models/holding/materializer.rb b/app/models/holding/materializer.rb index 522508599..47c7cc836 100644 --- a/app/models/holding/materializer.rb +++ b/app/models/holding/materializer.rb @@ -59,8 +59,8 @@ def persist_holdings existing = existing_holdings_map[key] # Skip provider-sourced holdings - they have authoritative data from the provider - # (e.g., Coinbase, SimpleFIN) and should not be overwritten by calculated holdings - if existing&.account_provider_id.present? + # (e.g., Coinbase, SimpleFIN, Plaid) and should not be overwritten by calculated holdings + if existing&.from_provider? Rails.logger.debug( "Holding::Materializer - Skipping provider-sourced holding id=#{existing.id} " \ "security_id=#{existing.security_id} date=#{existing.date}" @@ -113,12 +113,12 @@ def persist_holdings def load_existing_holdings_map # Load holdings that might affect reconciliation: # - Locked holdings (must preserve their cost_basis) - # - Holdings with a source (need to check priority) + # - Holdings with a cost_basis source (need to check priority) # - Provider-sourced holdings (must not be overwritten) account.holdings .where(cost_basis_locked: true) .or(account.holdings.where.not(cost_basis_source: nil)) - .or(account.holdings.where.not(account_provider_id: nil)) + .or(account.holdings.from_provider) .index_by { |h| holding_key(h) } end @@ -126,7 +126,7 @@ def load_existing_holdings_map # on the exact same key. This preserves reverse-calculated history for linked accounts. def cleanup_shadowed_calculated_holdings deleted_count = account.holdings - .where(account_provider_id: nil) + .not_from_provider .where(<<~SQL) EXISTS ( SELECT 1 @@ -135,7 +135,8 @@ def cleanup_shadowed_calculated_holdings AND provider_holdings.security_id = holdings.security_id AND provider_holdings.date = holdings.date AND provider_holdings.currency = holdings.currency - AND provider_holdings.account_provider_id IS NOT NULL + AND (provider_holdings.account_provider_id IS NOT NULL + OR provider_holdings.source IS NOT NULL) ) SQL .delete_all @@ -148,13 +149,14 @@ def cleanup_stale_calculated_rows_on_latest_provider_snapshot return unless provider_snapshot_date provider_security_ids = account.holdings - .where.not(account_provider_id: nil) + .from_provider .where(date: provider_snapshot_date) .distinct .pluck(:security_id) scope = account.holdings - .where(account_provider_id: nil, date: provider_snapshot_date) + .not_from_provider + .where(date: provider_snapshot_date) scope = if provider_security_ids.any? scope.where.not(security_id: provider_security_ids) @@ -177,11 +179,11 @@ def purge_stale_holdings # If there are no securities in the portfolio, only delete non-provider holdings if portfolio_security_ids.empty? Rails.logger.info("Clearing non-provider holdings (no securities from trades)") - account.holdings.where(account_provider_id: nil).delete_all + account.holdings.not_from_provider.delete_all else # Keep provider holdings and holdings for known securities within date range deleted_count = account.holdings - .where(account_provider_id: nil) + .not_from_provider .delete_by("date < ? OR security_id NOT IN (?)", account.start_date, portfolio_security_ids) Rails.logger.info("Purged #{deleted_count} stale holdings") if deleted_count > 0 end diff --git a/app/models/holding/portfolio_snapshot.rb b/app/models/holding/portfolio_snapshot.rb index 2bf140efa..4dc53ebff 100644 --- a/app/models/holding/portfolio_snapshot.rb +++ b/app/models/holding/portfolio_snapshot.rb @@ -30,9 +30,7 @@ def build_portfolio def latest_holdings_scope if (provider_snapshot_date = account.latest_provider_holdings_snapshot_date) - account.holdings - .where.not(account_provider_id: nil) - .where(date: provider_snapshot_date) + account.holdings.from_provider.where(date: provider_snapshot_date) else account.holdings .select("DISTINCT ON (security_id) holdings.*") diff --git a/app/models/migration_notice.rb b/app/models/migration_notice.rb new file mode 100644 index 000000000..66f184e0e --- /dev/null +++ b/app/models/migration_notice.rb @@ -0,0 +1,49 @@ +# Platform-wide registry for action-required notices shown to family admins +# in the UI — typically after an upgrade where the operator must take a manual +# step that can't be auto-migrated (Plaid OAuth redirect URI changes, env-var +# requirements, schema-backfill rake tasks, etc.). +# +# Notices are registered in config/initializers/migration_notices.rb. Each +# notice declares: +# - key: stable identifier (used for i18n lookup AND dismissal storage) +# - scope: tag for filtering at render time (e.g. :providers, :billing) +# - condition: ->(family) { ... } — controls when the notice applies +# - copyable_value: ->(view) { ... } — optional; renders the clipboard box +# +# View entry point: ApplicationHelper#render_migration_notices(scope:). +# Family-scoped dismissal: Family#dismiss_migration_notice!(key). +class MigrationNotice + Notice = Struct.new(:key, :scope, :condition, :copyable_value, keyword_init: true) + + ALL = [] + private_constant :ALL + + class << self + def register(key:, condition:, scope: :platform, copyable_value: nil) + key = key.to_s + ALL.reject! { |n| n.key == key } + ALL << Notice.new(key: key, scope: scope, condition: condition, copyable_value: copyable_value) + end + + # Returns hashes (not Notice structs) so views don't need to know the + # internal storage shape. + def active_for(family:, view:, scope: nil) + ALL.filter_map do |notice| + next if scope && notice.scope != scope + next if family.dismissed_migration_notice?(notice.key) + next unless notice.condition.call(family) + { key: notice.key, copyable_value: notice.copyable_value&.call(view) } + end + end + + def all + ALL.dup + end + + # Test-only: clear the registry between examples that register their own + # fixtures. Not used in production code. + def reset! + ALL.clear + end + end +end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb deleted file mode 100644 index ae2ecfeae..000000000 --- a/app/models/plaid_account.rb +++ /dev/null @@ -1,75 +0,0 @@ -class PlaidAccount < ApplicationRecord - include Encryptable - - # Encrypt raw payloads if ActiveRecord encryption is configured - if encryption_ready? - encrypts :raw_payload - encrypts :raw_transactions_payload - # Support reading data encrypted under the old column name after rename - encrypts :raw_holdings_payload, previous: { attribute: :raw_investments_payload } - encrypts :raw_liabilities_payload - end - - belongs_to :plaid_item - - # 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 - validates :plaid_id, uniqueness: { scope: :plaid_item_id } - 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, - available_balance: account_snapshot.balances.available, - currency: account_snapshot.balances.iso_currency_code, - plaid_type: account_snapshot.type, - plaid_subtype: account_snapshot.subtype, - name: account_snapshot.name, - mask: account_snapshot.mask, - raw_payload: account_snapshot - ) - - save! - end - - def upsert_plaid_transactions_snapshot!(transactions_snapshot) - assign_attributes( - raw_transactions_payload: transactions_snapshot - ) - - save! - end - - def upsert_plaid_holdings_snapshot!(holdings_snapshot) - assign_attributes( - raw_holdings_payload: holdings_snapshot - ) - - save! - end - - def upsert_plaid_liabilities_snapshot!(liabilities_snapshot) - assign_attributes( - raw_liabilities_payload: liabilities_snapshot - ) - - save! - end - - private - # Plaid guarantees at least one of these. This validation is a sanity check for that guarantee. - def has_balance - return if current_balance.present? || available_balance.present? - errors.add(:base, "Plaid account must have either current or available balance") - end -end diff --git a/app/models/plaid_account/importer.rb b/app/models/plaid_account/importer.rb deleted file mode 100644 index f12c10bdd..000000000 --- a/app/models/plaid_account/importer.rb +++ /dev/null @@ -1,32 +0,0 @@ -class PlaidAccount::Importer - def initialize(plaid_account, account_snapshot:) - @plaid_account = plaid_account - @account_snapshot = account_snapshot - end - - def import - import_account_info - import_transactions if account_snapshot.transactions_data.present? - import_investments if account_snapshot.investments_data.present? - import_liabilities if account_snapshot.liabilities_data.present? - end - - private - attr_reader :plaid_account, :account_snapshot - - def import_account_info - plaid_account.upsert_plaid_snapshot!(account_snapshot.account_data) - end - - def import_transactions - plaid_account.upsert_plaid_transactions_snapshot!(account_snapshot.transactions_data) - end - - def import_investments - plaid_account.upsert_plaid_holdings_snapshot!(account_snapshot.investments_data) - end - - def import_liabilities - plaid_account.upsert_plaid_liabilities_snapshot!(account_snapshot.liabilities_data) - end -end diff --git a/app/models/plaid_account/investments/balance_calculator.rb b/app/models/plaid_account/investments/balance_calculator.rb deleted file mode 100644 index 852f29a0d..000000000 --- a/app/models/plaid_account/investments/balance_calculator.rb +++ /dev/null @@ -1,71 +0,0 @@ -# Plaid Investment balances have a ton of edge cases. This processor is responsible -# for deriving "brokerage cash" vs. "total value" based on Plaid's reported balances and holdings. -class PlaidAccount::Investments::BalanceCalculator - NegativeCashBalanceError = Class.new(StandardError) - NegativeTotalValueError = Class.new(StandardError) - - def initialize(plaid_account, security_resolver:) - @plaid_account = plaid_account - @security_resolver = security_resolver - end - - def balance - total_value = total_investment_account_value - - if total_value.negative? - Sentry.capture_exception( - NegativeTotalValueError.new("Total value is negative for plaid investment account"), - level: :warning - ) - end - - total_value - end - - # Plaid considers "brokerage cash" and "cash equivalent holdings" to all be part of "cash balance" - # - # Internally, we DO NOT. Sure clearly distinguishes between "brokerage cash" vs. "holdings (i.e. invested cash)" - # For this reason, we must manually calculate the cash balance based on "total value" and "holdings value" - # See PlaidAccount::Investments::SecurityResolver for more details. - def cash_balance - cash_balance = calculate_investment_brokerage_cash - - if cash_balance.negative? - Sentry.capture_exception( - NegativeCashBalanceError.new("Cash balance is negative for plaid investment account"), - level: :warning - ) - end - - cash_balance - end - - private - attr_reader :plaid_account, :security_resolver - - def holdings - plaid_account.raw_holdings_payload&.dig("holdings") || [] - end - - def calculate_investment_brokerage_cash - total_investment_account_value - true_holdings_value - end - - # This is our source of truth. We assume Plaid's `current_balance` reporting is 100% accurate - # Plaid guarantees `current_balance` AND/OR `available_balance` is always present, and based on the docs, - # `current_balance` should represent "total account value". - def total_investment_account_value - plaid_account.current_balance || plaid_account.available_balance - end - - # Plaid holdings summed up, LESS "brokerage cash" holdings (that we've manually identified) - def true_holdings_value - # True holdings are holdings *less* Plaid's "pseudo-securities" (e.g. `CUR:USD` brokerage cash "holding") - true_holdings = holdings.reject do |h| - security = security_resolver.resolve(plaid_security_id: h["security_id"]) - security.brokerage_cash? - end - - true_holdings.sum { |h| h["quantity"] * h["institution_price"] } - end -end diff --git a/app/models/plaid_account/investments/holdings_processor.rb b/app/models/plaid_account/investments/holdings_processor.rb deleted file mode 100644 index 44b967ea2..000000000 --- a/app/models/plaid_account/investments/holdings_processor.rb +++ /dev/null @@ -1,92 +0,0 @@ -class PlaidAccount::Investments::HoldingsProcessor - def initialize(plaid_account, security_resolver:) - @plaid_account = plaid_account - @security_resolver = security_resolver - end - - def process - holdings.each do |plaid_holding| - resolved_security_result = security_resolver.resolve(plaid_security_id: plaid_holding["security_id"]) - - next unless resolved_security_result.security.present? - - security = resolved_security_result.security - - # 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, - price: price_bd, - account_provider_id: plaid_account.account_provider&.id, - source: "plaid", - delete_future_holdings: false # Plaid doesn't allow holdings deletion - ) - end - end - - private - attr_reader :plaid_account, :security_resolver - - def import_adapter - @import_adapter ||= Account::ProviderImportAdapter.new(account) - end - - def account - plaid_account.current_account - end - - def holdings - plaid_account.raw_holdings_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/security_resolver.rb b/app/models/plaid_account/investments/security_resolver.rb deleted file mode 100644 index 5d4fa1d98..000000000 --- a/app/models/plaid_account/investments/security_resolver.rb +++ /dev/null @@ -1,93 +0,0 @@ -# Resolves a Plaid security to an internal Security record, or nil -class PlaidAccount::Investments::SecurityResolver - UnresolvablePlaidSecurityError = Class.new(StandardError) - - def initialize(plaid_account) - @plaid_account = plaid_account - @security_cache = {} - end - - # Resolves an internal Security record for a given Plaid security ID - def resolve(plaid_security_id:) - response = @security_cache[plaid_security_id] - return response if response.present? - - plaid_security = get_plaid_security(plaid_security_id) - - if plaid_security.nil? - report_unresolvable_security(plaid_security_id) - response = Response.new(security: nil, cash_equivalent?: false, brokerage_cash?: false) - elsif brokerage_cash?(plaid_security) - response = Response.new(security: nil, cash_equivalent?: true, brokerage_cash?: true) - else - security = Security::Resolver.new( - plaid_security["ticker_symbol"], - exchange_operating_mic: plaid_security["market_identifier_code"] - ).resolve - - response = Response.new( - security: security, - cash_equivalent?: cash_equivalent?(plaid_security), - brokerage_cash?: false - ) - end - - @security_cache[plaid_security_id] = response - - response - end - - private - attr_reader :plaid_account, :security_cache - - Response = Struct.new(:security, :cash_equivalent?, :brokerage_cash?, keyword_init: true) - - def securities - plaid_account.raw_holdings_payload&.dig("securities") || [] - end - - # Tries to find security, or returns the "proxy security" (common with options contracts that have underlying securities) - def get_plaid_security(plaid_security_id) - security = securities.find { |s| s["security_id"] == plaid_security_id && s["ticker_symbol"].present? } - - return security if security.present? - - securities.find { |s| s["proxy_security_id"] == plaid_security_id } - end - - def report_unresolvable_security(plaid_security_id) - Sentry.capture_exception(UnresolvablePlaidSecurityError.new("Could not resolve Plaid security from provided data")) do |scope| - scope.set_context("plaid_security", { - plaid_security_id: plaid_security_id - }) - end - end - - # Plaid treats "brokerage cash" differently than us. Internally, Sure treats "brokerage cash" - # as "uninvested cash" (i.e. cash that doesn't have a corresponding Security and can be withdrawn). - # - # Plaid treats everything as a "holding" with a corresponding Security. For example, "brokerage cash" (USD) - # in Plaids data model would be represented as: - # - # - A Security with ticker `CUR:USD` - # - A holding, linked to the `CUR:USD` Security, with an institution price of $1 - # - # Internally, we store brokerage cash balance as `account.cash_balance`, NOT as a holding + security. - # This allows us to properly build historical cash balances and holdings values separately and accurately. - # - # These help identify these "special case" securities for various calculations. - # - def known_plaid_brokerage_cash_tickers - [ "CUR:USD" ] - end - - def brokerage_cash?(plaid_security) - return false unless plaid_security["ticker_symbol"].present? - known_plaid_brokerage_cash_tickers.include?(plaid_security["ticker_symbol"]) - end - - def cash_equivalent?(plaid_security) - return false unless plaid_security["type"].present? - plaid_security["type"] == "cash" || plaid_security["is_cash_equivalent"] == true - end -end diff --git a/app/models/plaid_account/investments/transactions_processor.rb b/app/models/plaid_account/investments/transactions_processor.rb deleted file mode 100644 index d683d4ec7..000000000 --- a/app/models/plaid_account/investments/transactions_processor.rb +++ /dev/null @@ -1,120 +0,0 @@ -class PlaidAccount::Investments::TransactionsProcessor - SecurityNotFoundError = Class.new(StandardError) - - # Map Plaid investment transaction types to activity labels - # All values must be valid Transaction::ACTIVITY_LABELS - PLAID_TYPE_TO_LABEL = { - "buy" => "Buy", - "sell" => "Sell", - "cancel" => "Other", - "cash" => "Other", - "fee" => "Fee", - "transfer" => "Transfer", - "dividend" => "Dividend", - "interest" => "Interest", - "contribution" => "Contribution", - "withdrawal" => "Withdrawal", - "dividend reinvestment" => "Reinvestment", - "spin off" => "Other", - "split" => "Other" - }.freeze - - def initialize(plaid_account, security_resolver:) - @plaid_account = plaid_account - @security_resolver = security_resolver - end - - def process - transactions.each do |transaction| - if cash_transaction?(transaction) - find_or_create_cash_entry(transaction) - else - find_or_create_trade_entry(transaction) - end - end - end - - private - attr_reader :plaid_account, :security_resolver - - def import_adapter - @import_adapter ||= Account::ProviderImportAdapter.new(account) - end - - def account - plaid_account.current_account - end - - def cash_transaction?(transaction) - %w[cash fee transfer contribution withdrawal].include?(transaction["type"]) - end - - def find_or_create_trade_entry(transaction) - resolved_security_result = security_resolver.resolve(plaid_security_id: transaction["security_id"]) - - unless resolved_security_result.security.present? - Sentry.capture_exception(SecurityNotFoundError.new("Could not find security for plaid trade")) do |scope| - scope.set_tags(plaid_account_id: plaid_account.id) - end - - return # We can't process a non-cash transaction without a security - end - - external_id = transaction["investment_transaction_id"] - return if external_id.blank? - - 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"], - name: transaction["name"], - source: "plaid", - activity_label: label_from_plaid_type(transaction) - ) - end - - def find_or_create_cash_entry(transaction) - external_id = transaction["investment_transaction_id"] - return if external_id.blank? - - import_adapter.import_transaction( - external_id: external_id, - amount: transaction["amount"], - currency: transaction["iso_currency_code"], - date: transaction["date"], - name: transaction["name"], - source: "plaid", - investment_activity_label: label_from_plaid_type(transaction) - ) - end - - def label_from_plaid_type(transaction) - plaid_type = transaction["type"]&.downcase - PLAID_TYPE_TO_LABEL[plaid_type] || "Other" - end - - def transactions - plaid_account.raw_holdings_payload&.dig("transactions") || [] - end - - # Plaid unfortunately returns incorrect signage on some `quantity` values. They claim all "sell" transactions - # are negative signage, but we have found multiple instances of production data where this is not the case. - # - # This method attempts to use several Plaid data points to derive the true quantity with the correct signage. - def derived_qty(transaction) - reported_qty = transaction["quantity"] - abs_qty = reported_qty.abs - - if transaction["type"] == "sell" || transaction["amount"] < 0 - -abs_qty - elsif transaction["type"] == "buy" || transaction["amount"] > 0 - abs_qty - else - reported_qty - end - end -end diff --git a/app/models/plaid_account/liabilities/credit_processor.rb b/app/models/plaid_account/liabilities/credit_processor.rb deleted file mode 100644 index 7a5b216db..000000000 --- a/app/models/plaid_account/liabilities/credit_processor.rb +++ /dev/null @@ -1,32 +0,0 @@ -class PlaidAccount::Liabilities::CreditProcessor - def initialize(plaid_account) - @plaid_account = plaid_account - end - - def process - return unless credit_data.present? - - 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.current_account - end - - def credit_data - plaid_account.raw_liabilities_payload["credit"] - end -end diff --git a/app/models/plaid_account/liabilities/mortgage_processor.rb b/app/models/plaid_account/liabilities/mortgage_processor.rb deleted file mode 100644 index d5744c9a7..000000000 --- a/app/models/plaid_account/liabilities/mortgage_processor.rb +++ /dev/null @@ -1,25 +0,0 @@ -class PlaidAccount::Liabilities::MortgageProcessor - def initialize(plaid_account) - @plaid_account = plaid_account - end - - def process - return unless mortgage_data.present? - - account.loan.update!( - rate_type: mortgage_data.dig("interest_rate", "type"), - interest_rate: mortgage_data.dig("interest_rate", "percentage") - ) - end - - private - attr_reader :plaid_account - - def account - plaid_account.current_account - end - - def mortgage_data - plaid_account.raw_liabilities_payload["mortgage"] - end -end diff --git a/app/models/plaid_account/liabilities/student_loan_processor.rb b/app/models/plaid_account/liabilities/student_loan_processor.rb deleted file mode 100644 index 61d6f484c..000000000 --- a/app/models/plaid_account/liabilities/student_loan_processor.rb +++ /dev/null @@ -1,50 +0,0 @@ -class PlaidAccount::Liabilities::StudentLoanProcessor - def initialize(plaid_account) - @plaid_account = plaid_account - end - - def process - return unless student_loan_data.present? - - account.loan.update!( - rate_type: "fixed", - interest_rate: student_loan_data["interest_rate_percentage"], - initial_balance: student_loan_data["origination_principal_amount"], - term_months: term_months - ) - end - - private - attr_reader :plaid_account - - def account - plaid_account.current_account - end - - def term_months - return nil unless origination_date && expected_payoff_date - - ((expected_payoff_date - origination_date).to_i / 30).to_i - end - - def origination_date - parse_date(student_loan_data["origination_date"]) - end - - def expected_payoff_date - parse_date(student_loan_data["expected_payoff_date"]) - end - - def parse_date(value) - return value if value.is_a?(Date) - return nil unless value.present? - - Date.parse(value.to_s) - rescue ArgumentError - nil - end - - def student_loan_data - plaid_account.raw_liabilities_payload["student"] - end -end diff --git a/app/models/plaid_account/processor.rb b/app/models/plaid_account/processor.rb deleted file mode 100644 index 4f2af022d..000000000 --- a/app/models/plaid_account/processor.rb +++ /dev/null @@ -1,145 +0,0 @@ -class PlaidAccount::Processor - include PlaidAccount::TypeMappable - - attr_reader :plaid_account - - def initialize(plaid_account) - @plaid_account = plaid_account - end - - # Each step represents a different Plaid API endpoint / "product" - # - # Processing the account is the first step and if it fails, we halt the entire processor - # Each subsequent step can fail independently, but we continue processing the rest of the steps - def process - process_account! - process_transactions - process_investments - process_liabilities - end - - private - def family - plaid_account.plaid_item.family - end - - # Shared securities reader and resolver - def security_resolver - @security_resolver ||= PlaidAccount::Investments::SecurityResolver.new(plaid_account) - end - - def process_account! - PlaidAccount.transaction do - # 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? - accountable = map_accountable(plaid_account.plaid_type) - account.accountable = accountable - end - - # Name and subtype are the attributes a user can override for Plaid accounts - # Use enrichable pattern to respect locked attributes - account.enrich_attributes( - { - name: plaid_account.name - }, - source: "plaid" - ) - - # Enrich subtype on the accountable, respecting locks - account.accountable.enrich_attributes( - { - subtype: map_subtype(plaid_account.plaid_type, plaid_account.plaid_subtype) - }, - source: "plaid" - ) - - account.assign_attributes( - balance: balance_calculator.balance, - currency: plaid_account.currency, - cash_balance: balance_calculator.cash_balance - ) - - new_account = account.new_record? - account.save! - - account.auto_share_with_family! if new_account && account.family.share_all_by_default? - - # 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 - # the total balance in the current anchor. The cash_balance field on the account model - # is still being used for the breakdown. - account.set_current_balance(balance_calculator.balance) - end - end - - def process_transactions - PlaidAccount::Transactions::Processor.new(plaid_account).process - rescue => e - report_exception(e) - end - - def process_investments - PlaidAccount::Investments::TransactionsProcessor.new(plaid_account, security_resolver: security_resolver).process - PlaidAccount::Investments::HoldingsProcessor.new(plaid_account, security_resolver: security_resolver).process - rescue => e - report_exception(e) - end - - def process_liabilities - case [ plaid_account.plaid_type, plaid_account.plaid_subtype ] - when [ "credit", "credit card" ] - PlaidAccount::Liabilities::CreditProcessor.new(plaid_account).process - when [ "loan", "mortgage" ] - PlaidAccount::Liabilities::MortgageProcessor.new(plaid_account).process - when [ "loan", "student" ] - PlaidAccount::Liabilities::StudentLoanProcessor.new(plaid_account).process - end - rescue => e - report_exception(e) - end - - def balance_calculator - if plaid_account.plaid_type == "investment" - @balance_calculator ||= PlaidAccount::Investments::BalanceCalculator.new(plaid_account, security_resolver: security_resolver) - else - balance = plaid_account.current_balance || plaid_account.available_balance || 0 - - # We don't currently distinguish "cash" vs. "non-cash" balances for non-investment accounts. - OpenStruct.new( - balance: balance, - cash_balance: balance - ) - end - end - - def report_exception(error) - Sentry.capture_exception(error) do |scope| - scope.set_tags(plaid_account_id: plaid_account.id) - end - end -end diff --git a/app/models/plaid_account/transactions/category_matcher.rb b/app/models/plaid_account/transactions/category_matcher.rb deleted file mode 100644 index 263ec0445..000000000 --- a/app/models/plaid_account/transactions/category_matcher.rb +++ /dev/null @@ -1,108 +0,0 @@ -# The purpose of this matcher is to auto-match Plaid categories to -# known internal user categories. Since we allow users to define their own -# categories we cannot directly assign Plaid categories as this would overwrite -# user data and create a confusing experience. -# -# Automated category matching in the Sure app has a hierarchy: -# 1. Naive string matching via CategoryAliasMatcher -# 2. Rules-based matching set by user -# 3. AI-powered matching (also enabled by user via rules) -# -# This class is simply a FAST and CHEAP way to match categories that are high confidence. -# Edge cases will be handled by user-defined rules. -class PlaidAccount::Transactions::CategoryMatcher - include PlaidAccount::Transactions::CategoryTaxonomy - - def initialize(user_categories = []) - @user_categories = user_categories - end - - def match(plaid_detailed_category) - plaid_category_details = get_plaid_category_details(plaid_detailed_category) - return nil unless plaid_category_details - - # Try exact name matches first - exact_match = normalized_user_categories.find do |category| - category[:name] == plaid_category_details[:key].to_s - end - return user_categories.find { |c| c.id == exact_match[:id] } if exact_match - - # Try detailed aliases matches with fuzzy matching - alias_match = normalized_user_categories.find do |category| - name = category[:name] - plaid_category_details[:aliases].any? do |a| - alias_str = a.to_s - - # Try exact match - next true if name == alias_str - - # Try plural forms - next true if name.singularize == alias_str || name.pluralize == alias_str - next true if alias_str.singularize == name || alias_str.pluralize == name - - # Try common forms - normalized_name = name.gsub(/(and|&|\s+)/, "").strip - normalized_alias = alias_str.gsub(/(and|&|\s+)/, "").strip - normalized_name == normalized_alias - end - end - return user_categories.find { |c| c.id == alias_match[:id] } if alias_match - - # Try parent aliases matches with fuzzy matching - parent_match = normalized_user_categories.find do |category| - name = category[:name] - plaid_category_details[:parent_aliases].any? do |a| - alias_str = a.to_s - - # Try exact match - next true if name == alias_str - - # Try plural forms - next true if name.singularize == alias_str || name.pluralize == alias_str - next true if alias_str.singularize == name || alias_str.pluralize == name - - # Try common forms - normalized_name = name.gsub(/(and|&|\s+)/, "").strip - normalized_alias = alias_str.gsub(/(and|&|\s+)/, "").strip - normalized_name == normalized_alias - end - end - return user_categories.find { |c| c.id == parent_match[:id] } if parent_match - - nil - end - - private - attr_reader :user_categories - - def get_plaid_category_details(plaid_category_name) - detailed_plaid_categories.find { |c| c[:key] == plaid_category_name.downcase.to_sym } - end - - def detailed_plaid_categories - CATEGORIES_MAP.flat_map do |parent_key, parent_data| - parent_data[:detailed_categories].map do |child_key, child_data| - { - key: child_key, - classification: child_data[:classification], - aliases: child_data[:aliases], - parent_key: parent_key, - parent_aliases: parent_data[:aliases] - } - end - end - end - - def normalized_user_categories - user_categories.map do |user_category| - { - id: user_category.id, - name: normalize_user_category_name(user_category.name) - } - end - end - - def normalize_user_category_name(name) - name.to_s.downcase.gsub(/[^a-z0-9]/, " ").strip - end -end diff --git a/app/models/plaid_account/transactions/processor.rb b/app/models/plaid_account/transactions/processor.rb deleted file mode 100644 index a59045a37..000000000 --- a/app/models/plaid_account/transactions/processor.rb +++ /dev/null @@ -1,73 +0,0 @@ -class PlaidAccount::Transactions::Processor - def initialize(plaid_account) - @plaid_account = plaid_account - end - - def process - # 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. - modified_transactions.each do |transaction| - PlaidEntry::Processor.new( - transaction, - plaid_account: plaid_account, - category_matcher: category_matcher - ).process - end - - PlaidAccount.transaction do - removed_transactions.each do |transaction| - remove_plaid_transaction(transaction) - end - end - end - - private - attr_reader :plaid_account - - def category_matcher - @category_matcher ||= PlaidAccount::Transactions::CategoryMatcher.new(family_categories) - end - - def family_categories - @family_categories ||= begin - if account.family.categories.none? - account.family.categories.bootstrap! - end - - account.family.categories - end - end - - def account - plaid_account.current_account - end - - def remove_plaid_transaction(raw_transaction) - account.entries.find_by(plaid_id: raw_transaction["transaction_id"])&.destroy - end - - # Since we find_or_create_by transactions, we don't need a distinction between added/modified - def modified_transactions - modified = plaid_account.raw_transactions_payload["modified"] || [] - added = plaid_account.raw_transactions_payload["added"] || [] - - transactions = modified + added - - # Filter out pending transactions based on env var or Setting - # Priority: env var > Setting (allows runtime changes via UI) - include_pending = if ENV["PLAID_INCLUDE_PENDING"].present? - Rails.configuration.x.plaid.include_pending - else - Setting.syncs_include_pending - end - unless include_pending - transactions = transactions.reject { |t| t["pending"] == true } - end - - transactions - end - - def removed_transactions - plaid_account.raw_transactions_payload["removed"] || [] - end -end diff --git a/app/models/plaid_account/type_mappable.rb b/app/models/plaid_account/type_mappable.rb deleted file mode 100644 index 1142557b2..000000000 --- a/app/models/plaid_account/type_mappable.rb +++ /dev/null @@ -1,84 +0,0 @@ -module PlaidAccount::TypeMappable - extend ActiveSupport::Concern - - UnknownAccountTypeError = Class.new(StandardError) - - def map_accountable(plaid_type) - accountable_class = TYPE_MAPPING.dig( - plaid_type.to_sym, - :accountable - ) - - unless accountable_class - raise UnknownAccountTypeError, "Unknown account type: #{plaid_type}" - end - - accountable_class.new - end - - def map_subtype(plaid_type, plaid_subtype) - TYPE_MAPPING.dig( - plaid_type.to_sym, - :subtype_mapping, - plaid_subtype - ) || "other" - end - - # Plaid Account Types -> Accountable Types - # https://plaid.com/docs/api/accounts/#account-type-schema - TYPE_MAPPING = { - depository: { - accountable: Depository, - subtype_mapping: { - "checking" => "checking", - "savings" => "savings", - "hsa" => "hsa", - "cd" => "cd", - "money market" => "money_market" - } - }, - credit: { - accountable: CreditCard, - subtype_mapping: { - "credit card" => "credit_card" - } - }, - loan: { - accountable: Loan, - subtype_mapping: { - "mortgage" => "mortgage", - "student" => "student", - "auto" => "auto", - "business" => "business", - "home equity" => "home_equity", - "line of credit" => "line_of_credit" - } - }, - investment: { - accountable: Investment, - subtype_mapping: { - "brokerage" => "brokerage", - "pension" => "pension", - "retirement" => "retirement", - "401k" => "401k", - "roth 401k" => "roth_401k", - "403b" => "403b", - "457b" => "457b", - "529" => "529_plan", - "hsa" => "hsa", - "mutual fund" => "mutual_fund", - "roth" => "roth_ira", - "ira" => "ira", - "sep ira" => "sep_ira", - "simple ira" => "simple_ira", - "trust" => "trust", - "ugma" => "ugma", - "utma" => "utma" - } - }, - other: { - accountable: OtherAsset, - subtype_mapping: {} - } - } -end diff --git a/app/models/plaid_entry/processor.rb b/app/models/plaid_entry/processor.rb deleted file mode 100644 index c0a890038..000000000 --- a/app/models/plaid_entry/processor.rb +++ /dev/null @@ -1,84 +0,0 @@ -class PlaidEntry::Processor - # plaid_transaction is the raw hash fetched from Plaid API and converted to JSONB - def initialize(plaid_transaction, plaid_account:, category_matcher:) - @plaid_transaction = plaid_transaction - @plaid_account = plaid_account - @category_matcher = category_matcher - end - - def process - 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, - pending_transaction_id: pending_transaction_id, # Plaid's linking ID for pending→posted - extra: { - plaid: { - pending: plaid_transaction["pending"], - pending_transaction_id: pending_transaction_id # Also store for reference - } - } - ) - end - - private - attr_reader :plaid_transaction, :plaid_account, :category_matcher - - def import_adapter - @import_adapter ||= Account::ProviderImportAdapter.new(account) - end - - def account - plaid_account.current_account - end - - def external_id - plaid_transaction["transaction_id"] - end - - def name - plaid_transaction["merchant_name"] || plaid_transaction["original_description"] - end - - def amount - plaid_transaction["amount"] - end - - def currency - plaid_transaction["iso_currency_code"] - end - - def date - plaid_transaction["date"] - end - - # Plaid provides this linking ID when a posted transaction matches a pending one - # This is the most reliable way to reconcile pending→posted - def pending_transaction_id - plaid_transaction["pending_transaction_id"] - end - - def detailed_category - 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 ||= import_adapter.find_or_create_merchant( - provider_merchant_id: plaid_transaction["merchant_entity_id"], - name: plaid_transaction["merchant_name"], - source: "plaid", - 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 deleted file mode 100644 index 58e39bae2..000000000 --- a/app/models/plaid_item.rb +++ /dev/null @@ -1,144 +0,0 @@ -class PlaidItem < ApplicationRecord - include Syncable, Provided, Encryptable - - enum :plaid_region, { us: "us", eu: "eu" } - enum :status, { good: "good", requires_update: "requires_update" }, default: :good - - # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured - if encryption_ready? - encrypts :access_token, deterministic: true - encrypts :raw_payload - encrypts :raw_institution_payload - end - - validates :name, presence: true - validates :access_token, presence: true, on: :create - - before_destroy :remove_plaid_item - - belongs_to :family - has_one_attached :logo, dependent: :purge_later - - has_many :plaid_accounts, dependent: :destroy - has_many :legacy_accounts, through: :plaid_accounts, source: :account - - scope :active, -> { where(scheduled_for_deletion: false) } - scope :syncable, -> { active } - 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, - redirect_url: redirect_url, - region: plaid_region, - access_token: access_token - ) - rescue Plaid::ApiError => e - error_body = JSON.parse(e.response_body) - - if error_body["error_code"] == "ITEM_NOT_FOUND" - # Mark the connection as invalid but don't auto-delete - update!(status: :requires_update) - end - - Sentry.capture_exception(e) - nil - end - - def destroy_later - update!(scheduled_for_deletion: true) - DestroyJob.perform_later(self) - end - - def import_latest_plaid_data - PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import - end - - # Reads the fetched data and updates internal domain objects - # Generally, this should only be called within a "sync", but can be called - # manually to "re-sync" the already fetched data - def process_accounts - plaid_accounts.each do |plaid_account| - PlaidAccount::Processor.new(plaid_account).process - end - end - - # Once all the data is fetched, we can schedule account syncs to calculate historical balances - def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) - accounts.each do |account| - account.sync_later( - parent_sync: parent_sync, - window_start_date: window_start_date, - window_end_date: window_end_date - ) - end - end - - # Saves the raw data fetched from Plaid API for this item - def upsert_plaid_snapshot!(item_snapshot) - assign_attributes( - available_products: item_snapshot.available_products, - billed_products: item_snapshot.billed_products, - raw_payload: item_snapshot, - ) - - save! - end - - # Saves the raw data fetched from Plaid API for this item's institution - def upsert_plaid_institution_snapshot!(institution_snapshot) - assign_attributes( - institution_id: institution_snapshot.institution_id, - institution_url: institution_snapshot.url, - institution_color: institution_snapshot.primary_color, - raw_institution_payload: institution_snapshot - ) - - save! - end - - def supports_product?(product) - supported_products.include?(product) - end - - private - def remove_plaid_item - return unless plaid_provider.present? - - plaid_provider.remove_item(access_token) - rescue Plaid::ApiError => e - json_response = JSON.parse(e.response_body) - error_code = json_response["error_code"] - - # 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 - - # Plaid returns mutually exclusive arrays here. If the item has made a request for a product, - # it is put in the billed_products array. If it is supported, but not yet used, it goes in the - # available_products array. - def supported_products - available_products + billed_products - end -end diff --git a/app/models/plaid_item/importer.rb b/app/models/plaid_item/importer.rb deleted file mode 100644 index dbc91f109..000000000 --- a/app/models/plaid_item/importer.rb +++ /dev/null @@ -1,57 +0,0 @@ -class PlaidItem::Importer - def initialize(plaid_item, plaid_provider:) - @plaid_item = plaid_item - @plaid_provider = plaid_provider - end - - def import - fetch_and_import_item_data - fetch_and_import_accounts_data - rescue Plaid::ApiError => e - handle_plaid_error(e) - end - - private - attr_reader :plaid_item, :plaid_provider - - # All errors that should halt the import should be re-raised after handling - # These errors will propagate up to the Sync record and mark it as failed. - def handle_plaid_error(error) - error_body = JSON.parse(error.response_body) - - case error_body["error_code"] - when "ITEM_LOGIN_REQUIRED" - plaid_item.update!(status: :requires_update) - else - raise error - end - end - - def fetch_and_import_item_data - item_data = plaid_provider.get_item(plaid_item.access_token).item - institution_data = plaid_provider.get_institution(item_data.institution_id).institution - - plaid_item.upsert_plaid_snapshot!(item_data) - plaid_item.upsert_plaid_institution_snapshot!(institution_data) - end - - def fetch_and_import_accounts_data - snapshot = PlaidItem::AccountsSnapshot.new(plaid_item, plaid_provider: plaid_provider) - - PlaidItem.transaction do - snapshot.accounts.each do |raw_account| - plaid_account = plaid_item.plaid_accounts.find_or_initialize_by( - plaid_id: raw_account.account_id - ) - - PlaidAccount::Importer.new( - plaid_account, - account_snapshot: snapshot.get_account_data(raw_account.account_id) - ).import - end - - # Once we know all data has been imported, save the cursor to avoid re-fetching the same data next time - plaid_item.update!(next_cursor: snapshot.transactions_cursor) - end - end -end diff --git a/app/models/plaid_item/provided.rb b/app/models/plaid_item/provided.rb deleted file mode 100644 index dc370b231..000000000 --- a/app/models/plaid_item/provided.rb +++ /dev/null @@ -1,7 +0,0 @@ -module PlaidItem::Provided - extend ActiveSupport::Concern - - def plaid_provider - @plaid_provider ||= Provider::Registry.plaid_provider_for_region(self.plaid_region) - end -end diff --git a/app/models/plaid_item/sync_complete_event.rb b/app/models/plaid_item/sync_complete_event.rb deleted file mode 100644 index ca008a0ac..000000000 --- a/app/models/plaid_item/sync_complete_event.rb +++ /dev/null @@ -1,22 +0,0 @@ -class PlaidItem::SyncCompleteEvent - attr_reader :plaid_item - - def initialize(plaid_item) - @plaid_item = plaid_item - end - - def broadcast - plaid_item.accounts.each do |account| - account.broadcast_sync_complete - end - - plaid_item.broadcast_replace_to( - plaid_item.family, - target: "plaid_item_#{plaid_item.id}", - partial: "plaid_items/plaid_item", - locals: { plaid_item: plaid_item } - ) - - plaid_item.family.broadcast_sync_complete - end -end diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb deleted file mode 100644 index 1f90f68c6..000000000 --- a/app/models/plaid_item/syncer.rb +++ /dev/null @@ -1,70 +0,0 @@ -class PlaidItem::Syncer - include SyncStats::Collector - - attr_reader :plaid_item - - def initialize(plaid_item) - @plaid_item = plaid_item - end - - def perform_sync(sync) - # Phase 1: Import data from Plaid API - sync.update!(status_text: "Importing accounts from Plaid...") if sync.respond_to?(:status_text) - plaid_item.import_latest_plaid_data - - # Phase 2: Process the raw Plaid data and create/update internal domain objects - # This must happen before the linked/unlinked check because process_accounts - # is what creates Account and AccountProvider records for new PlaidAccounts. - sync.update!(status_text: "Processing accounts...") if sync.respond_to?(:status_text) - mark_import_started(sync) - plaid_item.process_accounts - - # Phase 3: Collect setup statistics (now that accounts have been processed) - sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) - plaid_item.plaid_accounts.reload - collect_setup_stats(sync, provider_accounts: plaid_item.plaid_accounts) - - # Check for unlinked accounts and update pending_account_setup flag - unlinked_count = plaid_item.plaid_accounts.count { |pa| pa.current_account.nil? } - if unlinked_count > 0 - plaid_item.update!(pending_account_setup: true) if plaid_item.respond_to?(:pending_account_setup=) - sync.update!(status_text: "#{unlinked_count} accounts need setup...") if sync.respond_to?(:status_text) - else - plaid_item.update!(pending_account_setup: false) if plaid_item.respond_to?(:pending_account_setup=) - end - - # Phase 4: Schedule balance calculations for linked accounts - linked_accounts = plaid_item.plaid_accounts.select { |pa| pa.current_account.present? } - if linked_accounts.any? - sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) - plaid_item.schedule_account_syncs( - parent_sync: sync, - window_start_date: sync.window_start_date, - window_end_date: sync.window_end_date - ) - - # Phase 5: Collect transaction and holdings statistics - account_ids = linked_accounts.filter_map { |pa| pa.current_account&.id } - collect_transaction_stats(sync, account_ids: account_ids, source: "plaid") - collect_holdings_stats(sync, holdings_count: count_holdings(linked_accounts), label: "processed") - end - - # Mark sync health - collect_health_stats(sync, errors: nil) - rescue => e - collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) - raise - end - - def perform_post_sync - # no-op - end - - private - - def count_holdings(plaid_accounts) - plaid_accounts.sum do |pa| - pa.raw_holdings_payload&.dig("holdings")&.size || 0 - end - end -end diff --git a/app/models/plaid_item/webhook_processor.rb b/app/models/plaid_item/webhook_processor.rb deleted file mode 100644 index 4d8b70c6f..000000000 --- a/app/models/plaid_item/webhook_processor.rb +++ /dev/null @@ -1,56 +0,0 @@ -class PlaidItem::WebhookProcessor - MissingItemError = Class.new(StandardError) - - def initialize(webhook_body) - parsed = JSON.parse(webhook_body) - @webhook_type = parsed["webhook_type"] - @webhook_code = parsed["webhook_code"] - @item_id = parsed["item_id"] - @error = parsed["error"] - end - - def process - unless plaid_item - handle_missing_item - return - end - - case [ webhook_type, webhook_code ] - when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ] - plaid_item.sync_later - when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ] - plaid_item.sync_later - when [ "HOLDINGS", "DEFAULT_UPDATE" ] - plaid_item.sync_later - when [ "ITEM", "ERROR" ] - if error["error_code"] == "ITEM_LOGIN_REQUIRED" - plaid_item.update!(status: :requires_update) - end - else - Rails.logger.warn("Unhandled Plaid webhook type: #{webhook_type}:#{webhook_code}") - end - rescue => e - # To always ensure we return a 200 to Plaid (to keep endpoint healthy), silently capture and report all errors - Sentry.capture_exception(e) - end - - private - attr_reader :webhook_type, :webhook_code, :item_id, :error - - def plaid_item - @plaid_item ||= PlaidItem.find_by(plaid_id: item_id) - end - - def handle_missing_item - return if plaid_item.present? - - # If we cannot find an item in our DB, that means we've reached an invalid data state where - # the Plaid Item (upstream) still exists (and is being billed), but doesn't exist internally. - # - # Since we don't have the item which has the access token, there is nothing we can do programmatically - # here, so we just need to report it to Sentry and manually handle it. - Sentry.capture_exception(MissingItemError.new("Received Plaid webhook for item no longer in our DB. Manual action required to resolve.")) do |scope| - scope.set_tags(plaid_item_id: item_id) - end - end -end diff --git a/app/models/provider/account.rb b/app/models/provider/account.rb new file mode 100644 index 000000000..5bcaf696e --- /dev/null +++ b/app/models/provider/account.rb @@ -0,0 +1,60 @@ +class Provider::Account < ApplicationRecord + include Encryptable + + self.table_name = "provider_accounts" + + belongs_to :provider_connection, class_name: "Provider::Connection" + belongs_to :account, optional: true + + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + encrypts :raw_holdings_payload + encrypts :raw_liabilities_payload + end + + scope :unlinked_and_unskipped, -> { where(account_id: nil, skipped: false) } + + validates :external_id, uniqueness: { scope: :provider_connection_id } + + class UnsupportedAccountableType < StandardError; end + + def linked? = account_id? + + # Accounts marked "disappeared" by the syncer when an external_id stops + # appearing in upstream account-discovery responses. The flag lives on + # raw_payload so we don't need a DB migration; the syncer clears it when + # the account comes back. UI uses this to surface "no longer at the bank" + # state to the user. + def disappeared? + raw_payload.is_a?(Hash) && raw_payload["disappeared_at"].present? + end + + def disappeared_at + return nil unless disappeared? + Time.parse(raw_payload["disappeared_at"]) + rescue ArgumentError, TypeError + nil + end + + # Logo URL extracted from the upstream provider payload. Restricted to HTTPS + # to prevent mixed-content + downgrade tracking-pixel risks when rendered + # into authenticated pages. + def safe_logo_uri + raw = raw_payload&.dig("provider", "logo_uri") + return nil if raw.blank? + URI.parse(raw).is_a?(URI::HTTPS) ? raw : nil + rescue URI::InvalidURIError + nil + end + + # Delegates to the adapter so each provider owns its own external_type → + # Accountable mapping (and any per-type customisation, e.g. investments + # building Holdings). Adapters raise UnsupportedAccountableType for types + # they don't handle, rather than silently mis-categorising. + def build_sure_account(family:) + Provider::ConnectionRegistry + .adapter_for(provider_connection.provider_key) + .build_sure_account(self, family: family) + end +end diff --git a/app/models/provider/auth.rb b/app/models/provider/auth.rb new file mode 100644 index 000000000..42f562c7b --- /dev/null +++ b/app/models/provider/auth.rb @@ -0,0 +1,7 @@ +module Provider::Auth + ConsentExpiredError = Class.new(StandardError) + TokenRevokedError = Class.new(StandardError) + ReauthRequiredError = Class.new(StandardError) + # Network failures and upstream 5xx — safe to retry, not user-actionable. + TransientError = Class.new(StandardError) +end diff --git a/app/models/provider/auth/embedded_link.rb b/app/models/provider/auth/embedded_link.rb new file mode 100644 index 000000000..295548061 --- /dev/null +++ b/app/models/provider/auth/embedded_link.rb @@ -0,0 +1,44 @@ +# Auth lifecycle for providers that use an embedded link widget — the user +# completes auth in a vendor-hosted modal embedded in our page (no redirect), +# the modal's onSuccess callback returns an opaque public_token, and the +# server exchanges it once for a long-lived access_token. +# +# Concrete instances of this pattern: Plaid Link, MX Connect Widget, +# Yodlee FastLink, Akoya Connect. +# +# Differs from OAuth2 in three ways: +# - No redirect grant — the public_token arrives via XHR, not a callback URL. +# - access_token does not expire and has no refresh_token. +# - requires_update transitions are signalled by upstream webhooks +# (e.g. Plaid's ITEM_LOGIN_REQUIRED), not by failed token refresh. +# +# Callers (controllers) do the actual token exchange via the adapter's HTTP +# client, since the request format is provider-specific. This class manages +# the persisted credential lifecycle. +class Provider::Auth::EmbeddedLink + def initialize(connection) + @connection = connection + end + + # ---- Read-only --------------------------------------------------------- + + def fresh_access_token + @connection.credentials["access_token"] + end + + # ---- Mutators ---------------------------------------------------------- + + # Persists the access_token returned by the upstream public_token exchange. + # Token does not expire, so no refresh metadata is written. + def store_access_token(access_token) + @connection.update!( + credentials: @connection.credentials.merge("access_token" => access_token) + ) + end + + # Webhook-driven transition. Called by webhook handlers when the upstream + # signals the user must re-auth (Plaid: ITEM_LOGIN_REQUIRED / PENDING_EXPIRATION). + def mark_requires_update!(reason: "reauth_required") + @connection.update!(status: :requires_update, sync_error: reason) + end +end diff --git a/app/models/provider/auth/oauth2.rb b/app/models/provider/auth/oauth2.rb new file mode 100644 index 000000000..76edd0acd --- /dev/null +++ b/app/models/provider/auth/oauth2.rb @@ -0,0 +1,122 @@ +# OAuth2 grant lifecycle for a Provider::Connection. +# +# The public surface splits into two categories — callers should know which +# side of the line they're on: +# +# READ-ONLY (build URLs, return strings; never write to @connection): +# authorize_url, reauth_url +# +# MUTATING (persist new tokens / status to @connection.credentials): +# exchange_code, fresh_access_token (via refresh!), store_tokens +# +# Methods below the "Mutators" header all call @connection.update! and may +# transition status to :requires_update on consent revocation. fresh_access_token +# is a read in the happy path but mutates if the access_token is expired. +class Provider::Auth::OAuth2 + def initialize(connection) + @connection = connection + end + + # ---- Read-only --------------------------------------------------------- + + def authorize_url(redirect_uri:, state:) + adapter_config.authorize_url( + client_id: family_credentials[:client_id], + redirect_uri: redirect_uri, + state: state, + scope: adapter_config.scopes, + sandbox: @connection.metadata["sandbox"] + ) + end + + def reauth_url(state:) + adapter = Provider::ConnectionRegistry.adapter_for(@connection.provider_key) + if adapter.respond_to?(:reauth_url) + adapter.reauth_url(@connection, redirect_uri: persisted_redirect_uri, state: state) + else + authorize_url(redirect_uri: persisted_redirect_uri, state: state) + end + end + + # ---- Mutators ---------------------------------------------------------- + + # redirect_uri is persisted on @connection.metadata at first authorize and + # MUST exactly match that value on token exchange — provider OAuth servers + # reject the exchange otherwise (e.g., "invalid_grant"). + def exchange_code(code:) + tokens = adapter_config.token_client(family_credentials, sandbox: sandbox?).exchange(code: code, redirect_uri: persisted_redirect_uri) + consent_expiry = adapter_config.fetch_consent_expiry(@connection, tokens.access_token) + store_tokens(tokens, consent_expires_at: consent_expiry) + end + + # Returns the current access_token. Refreshes (and mutates @connection) + # transparently if the stored token has expired. + def fresh_access_token + refresh! if expired? + @connection.credentials["access_token"] + end + + def store_tokens(tokens, consent_expires_at: nil) + new_metadata = @connection.metadata.dup + new_metadata["consent_expires_at"] = consent_expires_at.iso8601 if consent_expires_at + + @connection.update!( + credentials: @connection.credentials.merge( + "access_token" => tokens.access_token, + # Some providers omit refresh_token on refresh responses (only access_token rotates). + # Preserve the previously stored value rather than nulling future refreshes. + "refresh_token" => tokens.refresh_token.presence || @connection.credentials["refresh_token"], + "expires_at" => (Time.current + tokens.expires_in.seconds).to_i + ), + metadata: new_metadata + ) + end + + private + + def refresh! + tokens = begin + fetch_new_tokens + rescue Provider::Auth::ConsentExpiredError, Provider::Auth::TokenRevokedError + @connection.update!(status: :requires_update, sync_error: "reauth_required") + raise Provider::Auth::ReauthRequiredError + end + store_tokens(tokens) + end + + def fetch_new_tokens + adapter_config.token_client(family_credentials, sandbox: sandbox?) + .refresh(@connection.credentials["refresh_token"]) + end + + def expired? + return true if @connection.credentials["expires_at"].blank? + Time.current >= Time.at(@connection.credentials["expires_at"].to_i - 60) + end + + def sandbox? + @connection.metadata["sandbox"] + end + + def persisted_redirect_uri + @connection.metadata["redirect_uri"].presence || + raise(Provider::Auth::ReauthRequiredError, "missing redirect_uri on connection #{@connection.id}") + end + + # Today every OAuth adapter is BYOK (per-family client_id/secret stored in + # Provider::FamilyConfig). The `provider_family_config_id` FK is nullable + # so future adapters can use globally-configured Rails credentials, but + # those adapters MUST override this method (or this class). Failing loudly + # here documents the contract for the next adapter. + def family_credentials + config = @connection.provider_family_config + raise NotImplementedError, + "Provider '#{@connection.provider_key}' has no provider_family_config; " \ + "non-BYOK OAuth providers must override family_credentials" unless config + { client_id: config.client_id, client_secret: config.client_secret } + end + + def adapter_config + Provider::ConnectionRegistry.config_for(@connection.provider_key) + end +end diff --git a/app/models/provider/category_matcher.rb b/app/models/provider/category_matcher.rb new file mode 100644 index 000000000..381c7ae89 --- /dev/null +++ b/app/models/provider/category_matcher.rb @@ -0,0 +1,43 @@ +# Generic, provider-agnostic category matcher. +# +# Each provider supplies a taxonomy module that responds to: +# +# .resolve(input) -> { aliases: [String, ...], parent_aliases: [String, ...] } | nil +# +# `input` can be any shape the provider chooses (string, hash, array) — the +# taxonomy decides. The matcher does only the user-side fuzzy alias match. +# +# Adding a new provider requires only a taxonomy module with a `.resolve` +# class method. No changes here. +class Provider::CategoryMatcher + def initialize(user_categories, taxonomy:) + @taxonomy = taxonomy + @normalized = user_categories.map { |c| [ c, normalize(c.name) ] } + end + + def match(provider_input) + resolved = @taxonomy.resolve(provider_input) + return nil unless resolved + + find_by_aliases(resolved[:aliases]) || find_by_aliases(resolved[:parent_aliases]) + end + + private + def find_by_aliases(aliases) + return nil if aliases.blank? + normalized_aliases = aliases.map { |a| normalize(a) } + pair = @normalized.find { |_, name| normalized_aliases.any? { |a| matches?(name, a) } } + pair&.first + end + + def matches?(name, aliased) + return true if name == aliased + return true if name.singularize == aliased || aliased.singularize == name + # \band\b avoids stripping "and" inside words: "andover", "hand", "land". + name.gsub(/\band\b|&|\s+/, "") == aliased.gsub(/\band\b|&|\s+/, "") + end + + def normalize(str) + str.to_s.downcase.gsub(/[^a-z0-9]/, " ").strip.squeeze(" ") + end +end diff --git a/app/models/provider/connection.rb b/app/models/provider/connection.rb new file mode 100644 index 000000000..b33b60501 --- /dev/null +++ b/app/models/provider/connection.rb @@ -0,0 +1,86 @@ +class Provider::Connection < ApplicationRecord + include Encryptable, Syncable + + self.table_name = "provider_connections" + + belongs_to :family + belongs_to :provider_family_config, class_name: "Provider::FamilyConfig", optional: true + has_many :provider_accounts, foreign_key: :provider_connection_id, + class_name: "Provider::Account", dependent: :destroy + + if encryption_ready? + encrypts :credentials + end + + # Connections only exist when credentials are real — auth flows persist their + # cross-request state in session (see OauthCallbacksController and + # EmbeddedLinkCallbacksController), not in a pending DB row. + enum :status, { healthy: "healthy", requires_update: "requires_update", disconnected: "disconnected" } + + scope :syncable, -> { healthy.or(requires_update) } + + # Known auth backends. Each Provider::Auth::* class corresponds to one entry. + # Adding a new auth protocol means adding a class under Provider::Auth and + # extending this list. Defense in depth — adapter contracts already enforce + # auth_class selection, but a typo or stale row shouldn't be silently accepted. + AUTH_TYPES = %w[oauth2 embedded_link].freeze + + validates :provider_key, :auth_type, presence: true + validates :auth_type, inclusion: { in: AUTH_TYPES }, if: :auth_type? + + # Memoized — these read once per connection per render and prefer the + # already-loaded association (see _connection_provider_panel.html.erb's + # .includes(:provider_accounts)) over re-issuing LIMIT 1 queries. + def institution_name + @institution_name ||= first_provider_account&.raw_payload&.dig("provider", "display_name")&.titleize.presence || + provider_key.titleize + end + + def logo_uri + return @logo_uri if defined?(@logo_uri) + @logo_uri = first_provider_account&.safe_logo_uri + end + + def pending_setup? + return @pending_setup if defined?(@pending_setup) + @pending_setup = if provider_accounts.loaded? + provider_accounts.any? { |pa| pa.account_id.nil? && !pa.skipped? } + else + provider_accounts.unlinked_and_unskipped.exists? + end + end + + # Adapter syncer protocol contract: every adapter's syncer class MUST + # implement #discover_accounts_only — fetch the upstream account list and + # upsert provider_accounts rows, without syncing transactions or balances. + # Called after auth credentials are first stored. + def discover_accounts! + syncer.discover_accounts_only + end + + # Polymorphic auth backend dispatch. The adapter declares which auth class + # handles its credential lifecycle: Provider::Auth::OAuth2 for OAuth providers + # (TrueLayer, Mercury, etc.), Provider::Auth::EmbeddedLink for Plaid-Link-style + # providers (Plaid, MX, Yodlee). The auth class accepts (connection) on init. + def auth + Provider::ConnectionRegistry.adapter_for(provider_key).auth_class.new(self) + end + + private + + def first_provider_account + return @first_provider_account if defined?(@first_provider_account) + @first_provider_account = provider_accounts.loaded? ? provider_accounts.first : provider_accounts.first + end + + # Overrides Syncable's default `self.class::Syncer.new(self)` dispatch. + # Provider::Connection is shared across providers, so we dispatch by provider_key + # via the registry rather than a hardcoded case statement. + def syncer + Provider::ConnectionRegistry.syncer_class_for(provider_key).new(self) + end + + def sync_broadcaster + Provider::Connection::SyncCompleteEvent.new(self) + end +end diff --git a/app/models/provider/connection/sync_complete_event.rb b/app/models/provider/connection/sync_complete_event.rb new file mode 100644 index 000000000..2c916422f --- /dev/null +++ b/app/models/provider/connection/sync_complete_event.rb @@ -0,0 +1,14 @@ +class Provider::Connection::SyncCompleteEvent + def initialize(connection) + @connection = connection + end + + def broadcast + # Placeholder: full-page refresh. Replace with surgical Turbo stream updates + # (following EnableBankingItem::SyncCompleteEvent) when the TrueLayer UI is built out. + Turbo::StreamsChannel.broadcast_refresh_to( + @connection.family, + requestId: SecureRandom.uuid + ) + end +end diff --git a/app/models/provider/connection_adapter.rb b/app/models/provider/connection_adapter.rb new file mode 100644 index 000000000..8d4b7d4cd --- /dev/null +++ b/app/models/provider/connection_adapter.rb @@ -0,0 +1,190 @@ +# Class-method contract for adapters registered with Provider::ConnectionRegistry. +# +# Adapters extend this module to inherit defaults for optional methods and +# pick up NotImplementedError stubs that document the required surface: +# +# class Provider::Truelayer::Adapter +# extend Provider::ConnectionAdapter +# +# def self.display_name = "TrueLayer" +# def self.supported_account_types = %w[Depository CreditCard] +# def self.syncer_class = Provider::Truelayer::Syncer +# def self.connection_configs(family:) = [...] +# def self.build_sure_account(provider_account, family:) = ... +# end +# +# The module exists so the contract is grep-able (`extend Provider::ConnectionAdapter` +# at the top of every adapter is the entry point a reader can follow) and so adapter +# authors get a clear NotImplementedError pointing at the method they forgot, rather +# than the symptom downstream. +module Provider::ConnectionAdapter + # ---- Required ---------------------------------------------------------- + + # Human-readable provider name (e.g. "TrueLayer"). + def display_name + raise NotImplementedError, "#{self} must define .display_name" + end + + # Sure Accountable subclass names this provider produces (e.g. %w[Depository CreditCard]). + # Used by the Add-Account flow to filter providers per accountable type. + def supported_account_types + raise NotImplementedError, "#{self} must define .supported_account_types" + end + + # Syncer class instantiated as `syncer_class.new(connection)` by Provider::Connection#syncer. + # The syncer must implement #perform_sync(sync) and #discover_accounts_only. + def syncer_class + raise NotImplementedError, "#{self} must define .syncer_class" + end + + # Array of connection-config hashes consumed by the bank-sync directory. + # Each hash describes one entry point (key, name, new_account_path lambda, etc.). + def connection_configs(family:) + raise NotImplementedError, "#{self} must define .connection_configs(family:)" + end + + # Build (do NOT save) a Sure Account record from a Provider::Account. + # Adapters own their external_type → Accountable mapping and any per-type + # customisation (e.g. an investments adapter would build Holdings here). + # Raise Provider::Account::UnsupportedAccountableType for types this adapter + # doesn't handle, rather than silently mis-categorising. + def build_sure_account(provider_account, family:) + raise NotImplementedError, "#{self} must define .build_sure_account(provider_account, family:)" + end + + # Auth backend used by Provider::Connection#auth to handle the credential + # lifecycle (token exchange, refresh, reauth). OAuth2 adapters return + # Provider::Auth::OAuth2; embedded-link adapters (e.g. Plaid Link) return + # Provider::Auth::EmbeddedLink. The class must accept (connection) on init. + def auth_class + raise NotImplementedError, "#{self} must define .auth_class" + end + + # ---- Optional (with defaults) ------------------------------------------ + + def beta? = false + def brand_color = "#6B7280" + def description = nil + + # Whether this provider stores per-family credentials (BYOK) in + # provider_family_configs. OAuth-BYOK providers (e.g. TrueLayer) override + # to true; providers using global app credentials (e.g. Plaid) leave this + # false. The settings panel uses this flag to choose between BYOK config UI + # and direct connect-button UI. + def requires_family_config? = false + + # Buttons rendered in the settings panel's "connect" area. Adapters that + # need to ask the user a question before launching the link flow (e.g. + # Plaid: "US bank or EU bank?") return one entry per choice. Default empty + # — the panel falls back to its OAuth/BYOK flow. + def connect_actions(family:) + [] + end + + # Translates a vendor-specific exception raised during the link flow into + # a structured error rendered on /settings/providers as a prominent block + # (NOT the small toast — these messages can be long and can include a URL + # the admin must copy into the upstream dashboard). + # + # Returns a Hash with string keys, or nil. nil means the adapter can't + # (or shouldn't) translate — the controller re-raises so Rails' error + # page surfaces the bug to the developer. + # + # { + # "message" => "Plaid rejected ...", # required, long-form OK + # "redirect_uri" => "https://app.example.com/.../", # optional, copyable + # } + # + # `redirect_uri` arg is the upstream-facing redirect URL prefix already + # computed by the controller — adapters splice it into the result hash + # so the admin sees exactly what to paste into the upstream dashboard. + def humanize_link_error(error, redirect_uri:) + nil + end + + # OAuth2-only: extracts the sandbox flag (if any) from the family config + # so OauthCallbacksController can pass `sandbox:` into authorize_url. This + # lives on the adapter rather than the controller because the storage + # location of the flag is provider-specific (TrueLayer puts it on + # FamilyConfig.credentials; a future provider might use a different shape). + def sandbox_for(config) = false + + # Per-region setup metadata for adapters that have a regional split with + # distinct app credentials per region (e.g. Plaid: US + EU each have their + # own client_id/secret). Each entry is a Hash with :region, :label, + # :config_key (legacy ConfigurationRegistry provider_key) and :client (a + # -> { } that returns the upstream client when configured, else nil). + # Default empty — single-region adapters don't need this. + def region_setup = [] + + # Legacy ConfigurationRegistry provider_keys that the framework card owns + # (rendered inline). The settings controller filters these out of the + # global cred-form loop. Default empty. + def legacy_config_keys = [] + + # Provider-specific reauth URL (e.g. TrueLayer /v1/reauthuri). Return nil + # to fall back to the standard authorize URL with the persisted redirect_uri. + def reauth_url(connection, redirect_uri:, state:) + nil + end + + # Verifies the upstream webhook signature and raises if invalid. Adapters + # that don't accept webhooks can leave this raising. Webhooks::ProviderController + # calls this before dispatching to the handler. + def verify_webhook!(headers:, raw_body:) + raise NotImplementedError, "#{self} does not accept webhooks" + end + + # Class implementing #process and accepting (connection:, raw_body:, headers:). + # Webhooks::ProviderController instantiates and calls #process after signature + # verification succeeds. + def webhook_handler_class + raise NotImplementedError, "#{self} does not accept webhooks" + end + + # ---- EmbeddedLink contract (for adapters with auth_class == Provider::Auth::EmbeddedLink) + + # Starts a new link-token session. Returns a flow state Hash that + # EmbeddedLinkCallbacksController stashes in session[:provider_flows] under + # flow_id. Must include "link_token". Arbitrary other keys are preserved + # and handed back to .complete_link_flow. + # + # params[:connection_id] (when present) signals update/reauth mode — the + # adapter should issue a link_token bound to the existing connection's + # access_token. + # + # oauth_redirect_url: fixed, pre-registerable callback URL for providers + # whose embedded widget internally redirects to OAuth banks (e.g. Plaid). + # Adapters that don't need this can ignore it. + def start_link_flow(family:, flow_id:, params:, resume_url:, oauth_redirect_url:, webhooks_url:) + raise NotImplementedError, "#{self} must define .start_link_flow for EmbeddedLink flows" + end + + # Completes a link-token session. Receives the consumed flow state hash and + # the request params (notably public_token). Performs the upstream exchange, + # creates and returns a Provider::Connection on the family. + def complete_link_flow(family:, flow:, params:) + raise NotImplementedError, "#{self} must define .complete_link_flow for EmbeddedLink flows" + end + + # Stimulus controller name mounted on the embedded-link view. Different + # vendors ship different SDKs (Plaid Link, MX Connect Widget, Yodlee + # FastLink); each has its own JS controller. + def js_controller_name + raise NotImplementedError, "#{self} must define .js_controller_name for EmbeddedLink flows" + end + + # Hash of data-* attributes the embedded-link view renders on the controller + # mount node — e.g. { controller: "plaid", plaid_link_token_value: "...", + # plaid_is_resume_value: true }. Adapter-owned because each vendor's JS + # controller has its own data-value naming. + # + # `urls` is a Hash of pre-computed route URLs the adapter may need, supplied + # by the controller (which has request context). Keys today: :complete (POST + # public_token), :sync (POST sync on existing connection), :post_sync_redirect + # (where to navigate after a sync completes). Adapters MUST NOT call + # Rails.application.routes themselves — the controller is the routing seam. + def js_data_for(flow:, is_resume:, urls:) + raise NotImplementedError, "#{self} must define .js_data_for for EmbeddedLink flows" + end +end diff --git a/app/models/provider/connection_registry.rb b/app/models/provider/connection_registry.rb new file mode 100644 index 000000000..126694a48 --- /dev/null +++ b/app/models/provider/connection_registry.rb @@ -0,0 +1,65 @@ +# Registry of adapter classes that back Provider::Connection records. +# Auth-type agnostic: OAuth2 adapters (TrueLayer) and non-OAuth adapters +# (e.g. Plaid Link) register here by their provider_key string. +module Provider::ConnectionRegistry + Error = Class.new(StandardError) + + class << self + def register(key, adapter_class) + registry[key.to_s] = adapter_class + end + + def registered?(key) + Provider::Factory.ensure_adapters_loaded + registry.key?(key.to_s) + end + + def keys + Provider::Factory.ensure_adapters_loaded + registry.keys + end + + def adapter_for(key) + Provider::Factory.ensure_adapters_loaded + registry[key.to_s] or raise NotImplementedError, "No connection adapter registered for: #{key}" + end + + # Resolves a framework key from either a framework key OR a legacy + # config_key the adapter declares ownership of. Used by UI code that + # only has the legacy key in hand (e.g. settings panel iterating + # ConfigurationRegistry entries) and needs to find the framework adapter. + def framework_key_for(any_key) + Provider::Factory.ensure_adapters_loaded + k = any_key.to_s + registry.each do |framework_key, adapter| + return framework_key if framework_key == k || adapter.legacy_config_keys.map(&:to_s).include?(k) + end + nil + end + + def syncer_class_for(key) + adapter = adapter_for(key) + unless adapter.respond_to?(:syncer_class) + raise NotImplementedError, "Adapter for '#{key}' (#{adapter}) does not define syncer_class" + end + adapter.syncer_class + end + + def config_for(key) + adapter_for(key).new(nil) + end + + # Aggregates connection_configs across every registered adapter. Each + # adapter is registered once; multi-variant adapters (e.g. Plaid: one + # entry per region) return multiple configs from a single call. + def all_connection_configs(family:) + keys.flat_map { |key| adapter_for(key).connection_configs(family: family) } + end + + private + + def registry + @registry ||= {} + end + end +end diff --git a/app/models/provider/factory.rb b/app/models/provider/factory.rb index f8d5ac688..ce12283ec 100644 --- a/app/models/provider/factory.rb +++ b/app/models/provider/factory.rb @@ -3,14 +3,14 @@ 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) + # @param provider_type [String] The provider account class name (e.g., "SimplefinAccount") + # @param adapter_class [Class] The adapter class (e.g., Provider::SimplefinAdapter) 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 provider_account [SimplefinAccount, LunchflowAccount] The provider-specific account # @param account [Account] Optional account reference # @return [Provider::Base] An adapter instance def create_adapter(provider_account, account: nil) @@ -120,15 +120,23 @@ def find_adapter_class(provider_type) registry[provider_type] end - # Discover all adapter files in the provider directory - # Returns adapter class names (e.g., ["PlaidAdapter", "SimplefinAdapter"]) + # Discover all adapter files in the provider directory. + # Matches two layouts: + # - flat: app/models/provider/_adapter.rb → "Adapter" + # - nested: app/models/provider//adapter.rb → "::Adapter" + # Both forms register themselves at file-load time, so we eager-load them + # by name to trigger registration with Provider::Factory and/or + # Provider::ConnectionRegistry. def adapter_files return [] unless defined?(Rails) - pattern = Rails.root.join("app/models/provider/*_adapter.rb") - Dir[pattern].map do |file| + flat = Dir[Rails.root.join("app/models/provider/*_adapter.rb")].map do |file| File.basename(file, ".rb").camelize end + nested = Dir[Rails.root.join("app/models/provider/*/adapter.rb")].map do |file| + "#{File.basename(File.dirname(file)).camelize}::Adapter" + end + flat + nested end end end diff --git a/app/models/provider/family_config.rb b/app/models/provider/family_config.rb new file mode 100644 index 000000000..2db1224e1 --- /dev/null +++ b/app/models/provider/family_config.rb @@ -0,0 +1,59 @@ +class Provider::FamilyConfig < ApplicationRecord + include Encryptable + + self.table_name = "provider_family_configs" + + # Today every BYOK adapter takes the same shape (client_id + client_secret, + # plus an optional sandbox flag for providers that distinguish test/prod + # endpoints — TrueLayer being the current example). + # When an adapter wants different keys, swap to a per-adapter + # `credential_keys` hook on Provider::Registry. + ALLOWED_CREDENTIAL_KEYS = %w[client_id client_secret sandbox].freeze + + belongs_to :family + has_many :provider_connections, class_name: "Provider::Connection", + foreign_key: :provider_family_config_id, + dependent: :destroy + + if encryption_ready? + encrypts :credentials + end + + validates :provider_key, presence: true, + uniqueness: { scope: :family_id }, + format: { with: /\A[a-z0-9_]+\z/, message: "only allows lowercase letters, numbers, and underscores" } + validate :credential_keys_are_known + validate :credentials_are_complete + + def client_id = credentials&.fetch("client_id", nil) + def client_secret = credentials&.fetch("client_secret", nil) + def sandbox = ActiveModel::Type::Boolean.new.cast(credentials&.fetch("sandbox", false)) + + def client_id=(value) + self.credentials = (credentials || {}).merge("client_id" => value) + end + + def client_secret=(value) + self.credentials = (credentials || {}).merge("client_secret" => value) + end + + def sandbox=(value) + self.credentials = (credentials || {}).merge("sandbox" => ActiveModel::Type::Boolean.new.cast(value)) + end + + private + + def credential_keys_are_known + return if credentials.blank? + unknown = credentials.keys - ALLOWED_CREDENTIAL_KEYS + return if unknown.empty? + errors.add(:credentials, "contains unsupported keys: #{unknown.sort.join(', ')}") + end + + # Save-time guard so the form fails fast rather than silently accepting a + # config that will only blow up when the OAuth flow starts. + def credentials_are_complete + return if client_id.present? && client_secret.present? + errors.add(:credentials, "must include client_id and client_secret") + end +end diff --git a/app/models/provider/plaid/account_importer.rb b/app/models/provider/plaid/account_importer.rb new file mode 100644 index 000000000..75344cc44 --- /dev/null +++ b/app/models/provider/plaid/account_importer.rb @@ -0,0 +1,45 @@ +# Upserts the per-account raw payloads fetched from Plaid onto a +# Provider::Account row. Direct port of PlaidAccount::Importer; the only +# functional change is the target table (provider_accounts vs plaid_accounts) +# and the absence of denormalised columns (current_balance, available_balance, +# plaid_type, plaid_subtype, name, mask) — those live inside raw_payload now +# and are extracted via reader methods on Provider::Plaid::AccountReader. +class Provider::Plaid::AccountImporter + def initialize(provider_account, account_snapshot:) + @provider_account = provider_account + @account_snapshot = account_snapshot + end + + def import + import_account_info + import_transactions if account_snapshot.transactions_data.present? + import_investments if account_snapshot.investments_data.present? + import_liabilities if account_snapshot.liabilities_data.present? + end + + private + attr_reader :provider_account, :account_snapshot + + def import_account_info + raw = account_snapshot.account_data + provider_account.update!( + external_name: raw.name, + external_type: raw.type, + external_subtype: raw.subtype, + currency: raw.balances&.iso_currency_code || raw.balances&.unofficial_currency_code, + raw_payload: raw.to_hash + ) + end + + def import_transactions + provider_account.update!(raw_transactions_payload: account_snapshot.transactions_data.to_h) + end + + def import_investments + provider_account.update!(raw_holdings_payload: account_snapshot.investments_data.to_h) + end + + def import_liabilities + provider_account.update!(raw_liabilities_payload: account_snapshot.liabilities_data.to_h) + end +end diff --git a/app/models/provider/plaid/account_processor.rb b/app/models/provider/plaid/account_processor.rb new file mode 100644 index 000000000..b7f471f87 --- /dev/null +++ b/app/models/provider/plaid/account_processor.rb @@ -0,0 +1,105 @@ +# Per-account orchestrator. Direct port of PlaidAccount::Processor with the +# legacy AccountProvider/plaid_account_id account-resolution path removed — +# the Provider::Account already owns its account_id link via build_sure_account +# (called during the link/setup flow), so by the time we reach here the +# account exists and our job is enrichment + balance + sub-processor dispatch. +class Provider::Plaid::AccountProcessor + TYPE_MAPPING = Provider::Plaid::Adapter::TYPE_MAPPING + + def initialize(provider_account) + @provider_account = provider_account + end + + # Steps mirror the Plaid product surface. account! is the gating step — + # if it fails we abort. Each subsequent step can fail independently. + def process + process_account! + process_transactions + process_investments + process_liabilities + end + + private + attr_reader :provider_account + + def account + provider_account.account + end + + def family + provider_account.provider_connection.family + end + + def security_resolver + @security_resolver ||= Provider::Plaid::Investments::SecurityResolver.new(provider_account) + end + + def process_account! + Provider::Account.transaction do + # The account already exists — created via provider_account.build_sure_account + # during the link flow. Enrich its attributes from the latest payload, + # update balance + cash_balance, and persist. + plaid_type = provider_account.external_type + plaid_subtype = provider_account.external_subtype + + # Enrich name (user-overrideable, locked-aware) + account.enrich_attributes({ name: provider_account.external_name }, source: "plaid") + + # Enrich subtype on the accountable + sure_subtype = TYPE_MAPPING.dig(plaid_type, :subtype_mapping, plaid_subtype) || "other" + account.accountable.enrich_attributes({ subtype: sure_subtype }, source: "plaid") + + account.assign_attributes( + balance: balance_calculator.balance, + currency: provider_account.currency, + cash_balance: balance_calculator.cash_balance + ) + account.save! + + # Anchor balance valuation in event-sourced ledger + account.set_current_balance(balance_calculator.balance) + end + end + + def process_transactions + Provider::Plaid::Transactions::Processor.new(provider_account).process + rescue => e + report_exception(e) + end + + def process_investments + Provider::Plaid::Investments::TransactionsProcessor.new(provider_account, security_resolver: security_resolver).process + Provider::Plaid::Investments::HoldingsProcessor.new(provider_account, security_resolver: security_resolver).process + rescue => e + report_exception(e) + end + + def process_liabilities + case [ provider_account.external_type, provider_account.external_subtype ] + when [ "credit", "credit card" ] + Provider::Plaid::Liabilities::CreditProcessor.new(provider_account).process + when [ "loan", "mortgage" ] + Provider::Plaid::Liabilities::MortgageProcessor.new(provider_account).process + when [ "loan", "student" ] + Provider::Plaid::Liabilities::StudentLoanProcessor.new(provider_account).process + end + rescue => e + report_exception(e) + end + + def balance_calculator + @balance_calculator ||= if provider_account.external_type == "investment" + Provider::Plaid::Investments::BalanceCalculator.new(provider_account, security_resolver: security_resolver) + else + balances = provider_account.raw_payload&.dig("balances") || {} + bal = balances["current"] || balances["available"] || 0 + OpenStruct.new(balance: bal, cash_balance: bal) + end + end + + def report_exception(error) + Sentry.capture_exception(error) do |scope| + scope.set_tags(provider_account_id: provider_account.id) + end + end +end diff --git a/app/models/plaid_item/accounts_snapshot.rb b/app/models/provider/plaid/accounts_snapshot.rb similarity index 51% rename from app/models/plaid_item/accounts_snapshot.rb rename to app/models/provider/plaid/accounts_snapshot.rb index dc62d8ff3..62b3fc786 100644 --- a/app/models/plaid_item/accounts_snapshot.rb +++ b/app/models/provider/plaid/accounts_snapshot.rb @@ -1,22 +1,23 @@ -# All Plaid data is fetched at the item-level. This class is a simple wrapper that -# providers a convenience method, get_account_data which scopes the item-level payload -# to each Plaid Account -class PlaidItem::AccountsSnapshot - def initialize(plaid_item, plaid_provider:) - @plaid_item = plaid_item +# Wraps the item-level data fetched from Plaid (accounts, transactions cursor, +# investments, liabilities) and provides per-account scoping. Direct port of +# PlaidItem::AccountsSnapshot, refactored to operate on Provider::Connection +# rather than PlaidItem. +class Provider::Plaid::AccountsSnapshot + def initialize(connection, plaid_provider:) + @connection = connection @plaid_provider = plaid_provider end def accounts - @accounts ||= plaid_provider.get_item_accounts(plaid_item.access_token).accounts + @accounts ||= plaid_provider.get_item_accounts(access_token).accounts end def get_account_data(account_id) AccountData.new( - account_data: accounts.find { |a| a.account_id == account_id }, + account_data: accounts.find { |a| a.account_id == account_id }, transactions_data: account_scoped_transactions_data(account_id), - investments_data: account_scoped_investments_data(account_id), - liabilities_data: account_scoped_liabilities_data(account_id) + investments_data: account_scoped_investments_data(account_id), + liabilities_data: account_scoped_liabilities_data(account_id) ) end @@ -26,20 +27,32 @@ def transactions_cursor end private - attr_reader :plaid_item, :plaid_provider + attr_reader :connection, :plaid_provider TransactionsData = Data.define(:added, :modified, :removed) LiabilitiesData = Data.define(:credit, :mortgage, :student) InvestmentsData = Data.define(:transactions, :holdings, :securities) AccountData = Data.define(:account_data, :transactions_data, :investments_data, :liabilities_data) + def access_token + connection.credentials["access_token"] + end + + def billed_products + connection.metadata["billed_products"] || [] + end + + def supports_product?(product) + billed_products.include?(product) + end + def account_scoped_transactions_data(account_id) return nil unless transactions_data TransactionsData.new( - added: transactions_data.added.select { |t| t.account_id == account_id }, + added: transactions_data.added.select { |t| t.account_id == account_id }, modified: transactions_data.modified.select { |t| t.account_id == account_id }, - removed: transactions_data.removed.select { |t| t.account_id == account_id } + removed: transactions_data.removed.select { |t| t.account_id == account_id } ) end @@ -47,52 +60,45 @@ def account_scoped_investments_data(account_id) return nil unless investments_data transactions = investments_data.transactions.select { |t| t.account_id == account_id } - holdings = investments_data.holdings.select { |h| h.account_id == account_id } - securities = transactions.count > 0 && holdings.count > 0 ? investments_data.securities : [] + holdings = investments_data.holdings.select { |h| h.account_id == account_id } + securities = transactions.count > 0 && holdings.count > 0 ? investments_data.securities : [] - InvestmentsData.new( - transactions: transactions, - holdings: holdings, - securities: securities - ) + InvestmentsData.new(transactions: transactions, holdings: holdings, securities: securities) end def account_scoped_liabilities_data(account_id) return nil unless liabilities_data LiabilitiesData.new( - credit: liabilities_data.credit&.find { |c| c.account_id == account_id }, + credit: liabilities_data.credit&.find { |c| c.account_id == account_id }, mortgage: liabilities_data.mortgage&.find { |m| m.account_id == account_id }, - student: liabilities_data.student&.find { |s| s.account_id == account_id } + student: liabilities_data.student&.find { |s| s.account_id == account_id } ) end def can_fetch_transactions? - plaid_item.supports_product?("transactions") && accounts.any? + supports_product?("transactions") && accounts.any? end def transactions_data return nil unless can_fetch_transactions? - @transactions_data ||= plaid_provider.get_transactions( - plaid_item.access_token, - next_cursor: plaid_item.next_cursor + access_token, + next_cursor: connection.metadata["next_cursor"] ) end def can_fetch_investments? - plaid_item.supports_product?("investments") && - accounts.any? { |a| a.type == "investment" } + supports_product?("investments") && accounts.any? { |a| a.type == "investment" } end def investments_data return nil unless can_fetch_investments? - @investments_data ||= plaid_provider.get_item_investments(plaid_item.access_token) + @investments_data ||= plaid_provider.get_item_investments(access_token) end def can_fetch_liabilities? - plaid_item.supports_product?("liabilities") && - accounts.any? do |a| + supports_product?("liabilities") && accounts.any? do |a| a.type == "credit" && a.subtype == "credit card" || a.type == "loan" && (a.subtype == "mortgage" || a.subtype == "student") end @@ -100,6 +106,6 @@ def can_fetch_liabilities? def liabilities_data return nil unless can_fetch_liabilities? - @liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item.access_token) + @liabilities_data ||= plaid_provider.get_item_liabilities(access_token) end end diff --git a/app/models/provider/plaid/adapter.rb b/app/models/provider/plaid/adapter.rb new file mode 100644 index 000000000..9695effac --- /dev/null +++ b/app/models/provider/plaid/adapter.rb @@ -0,0 +1,248 @@ +# Connection-framework adapter for Plaid (US + EU regions). +# +# Registered once under provider_key "plaid". Region is a connection-level +# attribute stored on metadata["region"] — the adapter consults it when +# picking the right Provider::Plaid client. Connect-time region selection +# happens in the settings panel (two buttons) and via the bank-sync +# directory entries (one per region, both pointing at provider_key="plaid" +# with a region param). +# +# Provider::PlaidAdapter and Provider::PlaidEuAdapter are the legacy peers +# in app/models/provider/. After the framework cutover (PlaidItem dropped +# in 20260504200000_drop_legacy_plaid_tables) those classes are reduced to +# configuration shells: each holds a Provider::Configurable `configure do` +# block that registers one region's global app credentials with +# Provider::ConfigurationRegistry. All connection / sync / discover logic +# lives here. +class Provider::Plaid::Adapter + extend Provider::ConnectionAdapter + + # Plaid type → Sure Accountable mapping. + # https://plaid.com/docs/api/accounts/#account-type-schema + TYPE_MAPPING = { + "depository" => { accountable: Depository, subtype_mapping: { + "checking" => "checking", "savings" => "savings", "hsa" => "hsa", + "cd" => "cd", "money market" => "money_market" + } }, + "credit" => { accountable: CreditCard, subtype_mapping: { + "credit card" => "credit_card" + } }, + "loan" => { accountable: Loan, subtype_mapping: { + "mortgage" => "mortgage", "student" => "student", "auto" => "auto", + "business" => "business", "home equity" => "home_equity", + "line of credit" => "line_of_credit" + } }, + "investment" => { accountable: Investment, subtype_mapping: { + "brokerage" => "brokerage", "pension" => "pension", "retirement" => "retirement", + "401k" => "401k", "roth 401k" => "roth_401k", "403b" => "403b", "457b" => "457b", + "529" => "529_plan", "hsa" => "hsa", "mutual fund" => "mutual_fund", + "roth" => "roth_ira", "ira" => "ira", "sep ira" => "sep_ira", + "simple ira" => "simple_ira", "trust" => "trust", "ugma" => "ugma", "utma" => "utma" + } } + }.freeze + + def self.display_name = "Plaid" + def self.description = "Connect US and EU banks via Plaid" + def self.brand_color = "#000000" + def self.beta? = false + + # Plaid uses global app credentials (PLAID_CLIENT_ID / PLAID_SECRET via + # Rails.application.config.x.plaid_*), not per-family BYOK. + def self.requires_family_config? = false + + # Per-region cred-form mapping: each region's app credentials live in a + # legacy ConfigurationRegistry entry keyed by provider_key. The framework + # panel renders these forms inline (one per region) when the upstream + # client isn't configured, and a Connect button when it is. + def self.region_setup + [ + { region: "us", label: "US", config_key: "plaid", + client: -> { Provider::Registry.plaid_provider_for_region(:us) } }, + { region: "eu", label: "EU", config_key: "plaid_eu", + client: -> { Provider::Registry.plaid_provider_for_region(:eu) } } + ] + end + + # Legacy ConfigurationRegistry entries owned by this adapter. The settings + # controller filters these out of the global cred-form loop so they don't + # render twice (they appear inline in the framework card instead). + def self.legacy_config_keys = region_setup.map { |r| r[:config_key] } + + def self.connect_actions(family:) + region_setup.filter_map do |r| + next unless r[:client].call.present? + { + region: r[:region], + label: "Connect a #{r[:label]} bank", + path: Rails.application.routes.url_helpers.new_provider_link_path(provider_key: "plaid", region: r[:region]) + } + end + end + + def self.supported_account_types + %w[Depository CreditCard Loan Investment] + end + + def self.syncer_class = Provider::Plaid::Syncer + def self.auth_class = Provider::Auth::EmbeddedLink + def self.webhook_handler_class = Provider::Plaid::WebhookHandler + + # Plaid signs webhooks with a JWT in the Plaid-Verification header and + # publishes the verification key via /webhook_verification_key/get. The + # existing Provider::Plaid#validate_webhook! does the work; we just route + # to the right region's client. + def self.verify_webhook!(headers:, raw_body:) + sig = headers["Plaid-Verification"] || headers["HTTP_PLAID_VERIFICATION"] + raise Provider::Plaid::Adapter::WebhookSignatureMissing, "missing Plaid-Verification header" if sig.blank? + + region = headers["X-Provider-Region"] || extract_region_from_body(raw_body) + Provider::Registry.plaid_provider_for_region(region).validate_webhook!(sig, raw_body) + end + + WebhookSignatureMissing = Class.new(StandardError) + + # Plaid's webhook payload doesn't carry the region, so we infer it from the + # connection (looking up by item_id). If that fails we default to :us — the + # signature check would fail anyway if the wrong region's keys were used. + def self.extract_region_from_body(raw_body) + parsed = JSON.parse(raw_body) rescue {} + item_id = parsed["item_id"] + return :us if item_id.blank? + conn = Provider::Connection.where("metadata->>'plaid_item_id' = ?", item_id).first + (conn&.metadata&.[]("region") || "us").to_sym + end + + # Bank-sync directory entries — one per configured region. Each entry's + # new_account_path uses provider_key "plaid" plus a region query param + # which the EmbeddedLink controller threads into start_link_flow. + def self.connection_configs(family:) + region_setup.filter_map do |r| + next unless r[:client].call.present? + { + key: "plaid_#{r[:region]}_directory", + name: "Plaid (#{r[:label]})", + description: "Connect to your #{r[:label]} bank via Plaid", + new_account_path: ->(_accountable_type, _return_to) { + Rails.application.routes.url_helpers.new_provider_link_path(provider_key: "plaid", region: r[:region]) + }, + existing_account_path: nil + } + end + end + + def self.build_sure_account(provider_account, family:) + type = provider_account.external_type.to_s + subtype = provider_account.external_subtype.to_s + mapping = TYPE_MAPPING[type] || + raise(Provider::Account::UnsupportedAccountableType, + "Provider::Plaid::Adapter does not handle external_type=#{type.inspect}") + + accountable_subtype = mapping[:subtype_mapping][subtype] || "other" + accountable = mapping[:accountable].new(subtype: accountable_subtype) + + family.accounts.build( + name: provider_account.external_name, + currency: provider_account.currency, + balance: 0, + accountable: accountable + ) + end + + # Translates Plaid::ApiError into a structured error the admin can act on. + # Most common case: "OAuth redirect URI must be configured in the developer + # dashboard" — we splice in the exact URL the admin needs to paste, and the + # framework renders it as a copyable code block on /settings/providers. + def self.humanize_link_error(error, redirect_uri:) + return nil unless error.is_a?(Plaid::ApiError) + + body = JSON.parse(error.response_body.to_s) rescue {} + code = body["error_code"] + msg = body["error_message"].to_s + + if code == "INVALID_FIELD" && msg.include?("OAuth redirect URI") + { + "message" => "Plaid rejected the request because your app's OAuth redirect URI " \ + "list doesn't include this app's URL. Add the URL below to your " \ + "Plaid Dashboard under API → Allowed redirect URIs, then try again.", + "redirect_uri" => redirect_uri + } + elsif msg.present? + { "message" => "Plaid: #{msg}" } + else + { "message" => "Plaid returned an error (#{code || error.class}). Check your dashboard configuration and try again." } + end + end + + # ---- EmbeddedLink contract -------------------------------------------- + + def self.js_controller_name = "plaid" + + def self.start_link_flow(family:, flow_id:, params:, resume_url:, oauth_redirect_url:, webhooks_url:) + if params[:connection_id].present? + connection = family.provider_connections.find(params[:connection_id]) + region = connection.metadata["region"] + kind = "update" + access_token = connection.credentials["access_token"] + else + region = params[:region].to_s + raise ArgumentError, "Unknown region: #{region.inspect}" unless %w[us eu].include?(region) + kind = "new" + access_token = nil + end + + link_token = Provider::Registry.plaid_provider_for_region(region.to_sym).get_link_token( + user_id: family.id, + webhooks_url: webhooks_url, + redirect_url: oauth_redirect_url, + accountable_type: params[:accountable_type], + access_token: access_token + ).link_token + + state = { + "kind" => kind, + "region" => region, + "link_token" => link_token, + "created_at" => Time.current.to_i + } + state["connection_id"] = connection.id if kind == "update" + state + end + + def self.complete_link_flow(family:, flow:, params:) + region = flow["region"] + response = Provider::Registry.plaid_provider_for_region(region.to_sym) + .exchange_public_token(params.require(:public_token)) + + Provider::Connection.transaction do + conn = family.provider_connections.create!( + provider_key: "plaid", + auth_type: "embedded_link", + status: :healthy, + credentials: {}, + metadata: { + "region" => region, + "plaid_item_id" => response.item_id + } + ) + conn.auth.store_access_token(response.access_token) + conn + end + end + + def self.js_data_for(flow:, is_resume:, urls:) + { + controller: "plaid", + plaid_link_token_value: flow["link_token"], + plaid_region_value: flow["region"], + plaid_is_update_value: flow["kind"] == "update", + plaid_is_resume_value: is_resume, + plaid_connection_id_value: flow["connection_id"], + # Server-supplied endpoints — the JS controller MUST NOT hardcode any. + plaid_complete_url_value: urls[:complete], + plaid_sync_url_value: urls[:sync], + plaid_post_sync_redirect_value: urls[:post_sync_redirect] + } + end +end + +Provider::ConnectionRegistry.register("plaid", Provider::Plaid::Adapter) diff --git a/app/models/provider/plaid/investments/balance_calculator.rb b/app/models/provider/plaid/investments/balance_calculator.rb new file mode 100644 index 000000000..28b20924f --- /dev/null +++ b/app/models/provider/plaid/investments/balance_calculator.rb @@ -0,0 +1,61 @@ +# Port of PlaidAccount::Investments::BalanceCalculator. Reads balance fields +# from provider_account.raw_payload (which mirrors what Plaid returns from +# /accounts/get) instead of denormalised columns on plaid_accounts. +class Provider::Plaid::Investments::BalanceCalculator + NegativeCashBalanceError = Class.new(StandardError) + NegativeTotalValueError = Class.new(StandardError) + + def initialize(provider_account, security_resolver:) + @provider_account = provider_account + @security_resolver = security_resolver + end + + def balance + total_value = total_investment_account_value + if total_value.negative? + Sentry.capture_exception( + NegativeTotalValueError.new("Total value is negative for plaid investment account"), + level: :warning + ) + end + total_value + end + + # Plaid bundles "brokerage cash" + "cash-equivalent holdings" into reported + # balance; Sure separates "brokerage cash" (account.cash_balance) from + # "invested holdings" (account.balance - cash_balance). See SecurityResolver. + def cash_balance + bal = calculate_investment_brokerage_cash + if bal.negative? + Sentry.capture_exception( + NegativeCashBalanceError.new("Cash balance is negative for plaid investment account"), + level: :warning + ) + end + bal + end + + private + attr_reader :provider_account, :security_resolver + + def holdings + provider_account.raw_holdings_payload&.dig("holdings") || [] + end + + def calculate_investment_brokerage_cash + total_investment_account_value - true_holdings_value + end + + def total_investment_account_value + balances = provider_account.raw_payload&.dig("balances") || {} + balances["current"] || balances["available"] || 0 + end + + def true_holdings_value + true_holdings = holdings.reject do |h| + result = security_resolver.resolve(plaid_security_id: h["security_id"]) + result.brokerage_cash? + end + true_holdings.sum { |h| h["quantity"] * h["institution_price"] } + end +end diff --git a/app/models/provider/plaid/investments/holdings_processor.rb b/app/models/provider/plaid/investments/holdings_processor.rb new file mode 100644 index 000000000..44c0cad64 --- /dev/null +++ b/app/models/provider/plaid/investments/holdings_processor.rb @@ -0,0 +1,78 @@ +# Port of PlaidAccount::Investments::HoldingsProcessor. +# Reads from provider_account.raw_holdings_payload, calls +# import_adapter.import_holding for each resolved holding. +class Provider::Plaid::Investments::HoldingsProcessor + def initialize(provider_account, security_resolver:) + @provider_account = provider_account + @security_resolver = security_resolver + end + + def process + holdings.each do |plaid_holding| + result = security_resolver.resolve(plaid_security_id: plaid_holding["security_id"]) + next unless result.security.present? + + quantity_bd = parse_decimal(plaid_holding["quantity"]) + price_bd = parse_decimal(plaid_holding["institution_price"]) + next if quantity_bd.nil? || price_bd.nil? + + amount_bd = quantity_bd * price_bd + holding_date = parse_date(plaid_holding["institution_price_as_of"]) || Date.current + + import_adapter.import_holding( + security: result.security, + quantity: quantity_bd, + amount: amount_bd, + currency: plaid_holding["iso_currency_code"] || account.currency, + date: holding_date, + price: price_bd, + # NB: account_provider_id intentionally omitted — holdings.account_provider_id + # is an FK to the legacy polymorphic account_providers table; provider_account.id + # lives in the unrelated provider_accounts table. Provider-snapshot identification + # is via holdings.source instead (written from this `source:` arg in + # Account::ProviderImportAdapter#import_holding). See Holding#from_provider?. + source: "plaid", + delete_future_holdings: false + ) + end + end + + private + attr_reader :provider_account, :security_resolver + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + provider_account.account + end + + def holdings + provider_account.raw_holdings_payload&.dig("holdings") || [] + end + + def parse_decimal(value) + return nil if value.nil? + case value + when BigDecimal then value + when String then BigDecimal(value) + when Numeric then BigDecimal(value.to_s) + end + rescue ArgumentError => e + Rails.logger.error("Failed to parse Plaid holding decimal value: #{value.inspect} - #{e.message}") + nil + end + + def parse_date(value) + return nil if value.nil? + case value + when Date then value + when String then Date.parse(value) + when Time, DateTime then value.to_date + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Plaid holding date: #{value.inspect} - #{e.message}") + nil + end +end diff --git a/app/models/provider/plaid/investments/security_resolver.rb b/app/models/provider/plaid/investments/security_resolver.rb new file mode 100644 index 000000000..2202580f0 --- /dev/null +++ b/app/models/provider/plaid/investments/security_resolver.rb @@ -0,0 +1,73 @@ +# Port of PlaidAccount::Investments::SecurityResolver. Reads from +# provider_account.raw_holdings_payload and resolves Plaid securities to +# internal Security records via Security::Resolver (framework-level helper). +class Provider::Plaid::Investments::SecurityResolver + UnresolvablePlaidSecurityError = Class.new(StandardError) + + def initialize(provider_account) + @provider_account = provider_account + @security_cache = {} + end + + def resolve(plaid_security_id:) + cached = @security_cache[plaid_security_id] + return cached if cached.present? + + plaid_security = get_plaid_security(plaid_security_id) + + response = if plaid_security.nil? + report_unresolvable_security(plaid_security_id) + Response.new(security: nil, cash_equivalent?: false, brokerage_cash?: false) + elsif brokerage_cash?(plaid_security) + Response.new(security: nil, cash_equivalent?: true, brokerage_cash?: true) + else + security = Security::Resolver.new( + plaid_security["ticker_symbol"], + exchange_operating_mic: plaid_security["market_identifier_code"] + ).resolve + Response.new( + security: security, + cash_equivalent?: cash_equivalent?(plaid_security), + brokerage_cash?: false + ) + end + + @security_cache[plaid_security_id] = response + response + end + + private + attr_reader :provider_account, :security_cache + + Response = Struct.new(:security, :cash_equivalent?, :brokerage_cash?, keyword_init: true) + + def securities + provider_account.raw_holdings_payload&.dig("securities") || [] + end + + def get_plaid_security(plaid_security_id) + direct = securities.find { |s| s["security_id"] == plaid_security_id && s["ticker_symbol"].present? } + return direct if direct.present? + securities.find { |s| s["proxy_security_id"] == plaid_security_id } + end + + def report_unresolvable_security(plaid_security_id) + Sentry.capture_exception(UnresolvablePlaidSecurityError.new("Could not resolve Plaid security from provided data")) do |scope| + scope.set_context("plaid_security", { plaid_security_id: plaid_security_id }) + end + end + + def known_plaid_brokerage_cash_tickers + [ "CUR:USD" ] + end + + def brokerage_cash?(plaid_security) + return false unless plaid_security["ticker_symbol"].present? + known_plaid_brokerage_cash_tickers.include?(plaid_security["ticker_symbol"]) + end + + def cash_equivalent?(plaid_security) + return false unless plaid_security["type"].present? + plaid_security["type"] == "cash" || plaid_security["is_cash_equivalent"] == true + end +end diff --git a/app/models/provider/plaid/investments/transactions_processor.rb b/app/models/provider/plaid/investments/transactions_processor.rb new file mode 100644 index 000000000..6930ddcc6 --- /dev/null +++ b/app/models/provider/plaid/investments/transactions_processor.rb @@ -0,0 +1,96 @@ +# Port of PlaidAccount::Investments::TransactionsProcessor. +class Provider::Plaid::Investments::TransactionsProcessor + SecurityNotFoundError = Class.new(StandardError) + + PLAID_TYPE_TO_LABEL = { + "buy" => "Buy", "sell" => "Sell", "cancel" => "Other", "cash" => "Other", + "fee" => "Fee", "transfer" => "Transfer", "dividend" => "Dividend", + "interest" => "Interest", "contribution" => "Contribution", + "withdrawal" => "Withdrawal", "dividend reinvestment" => "Reinvestment", + "spin off" => "Other", "split" => "Other" + }.freeze + + def initialize(provider_account, security_resolver:) + @provider_account = provider_account + @security_resolver = security_resolver + end + + def process + transactions.each do |t| + cash_transaction?(t) ? find_or_create_cash_entry(t) : find_or_create_trade_entry(t) + end + end + + private + attr_reader :provider_account, :security_resolver + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(provider_account.account) + end + + def cash_transaction?(t) + %w[cash fee transfer contribution withdrawal].include?(t["type"]) + end + + def find_or_create_trade_entry(t) + result = security_resolver.resolve(plaid_security_id: t["security_id"]) + unless result.security.present? + Sentry.capture_exception(SecurityNotFoundError.new("Could not find security for plaid trade")) do |scope| + scope.set_tags(provider_account_id: provider_account.id) + end + return + end + + external_id = t["investment_transaction_id"] + return if external_id.blank? + + import_adapter.import_trade( + external_id: external_id, + security: result.security, + quantity: derived_qty(t), + price: t["price"], + amount: derived_qty(t) * t["price"], + currency: t["iso_currency_code"], + date: t["date"], + name: t["name"], + source: "plaid", + activity_label: label_from_plaid_type(t) + ) + end + + def find_or_create_cash_entry(t) + external_id = t["investment_transaction_id"] + return if external_id.blank? + + import_adapter.import_transaction( + external_id: external_id, + amount: t["amount"], + currency: t["iso_currency_code"], + date: t["date"], + name: t["name"], + source: "plaid", + investment_activity_label: label_from_plaid_type(t) + ) + end + + def label_from_plaid_type(t) + PLAID_TYPE_TO_LABEL[t["type"]&.downcase] || "Other" + end + + def transactions + provider_account.raw_holdings_payload&.dig("transactions") || [] + end + + # Plaid's quantity signage is unreliable on sells — derive from type+amount. + def derived_qty(t) + reported_qty = t["quantity"] + abs_qty = reported_qty.abs + if t["type"] == "sell" || t["amount"] < 0 + -abs_qty + elsif t["type"] == "buy" || t["amount"] > 0 + abs_qty + else + reported_qty + end + end +end diff --git a/app/models/provider/plaid/liabilities/credit_processor.rb b/app/models/provider/plaid/liabilities/credit_processor.rb new file mode 100644 index 000000000..b6a11b33c --- /dev/null +++ b/app/models/provider/plaid/liabilities/credit_processor.rb @@ -0,0 +1,29 @@ +# Port of PlaidAccount::Liabilities::CreditProcessor. +class Provider::Plaid::Liabilities::CreditProcessor + def initialize(provider_account) + @provider_account = provider_account + end + + def process + return unless credit_data.present? + + import_adapter.update_accountable_attributes( + attributes: { + minimum_payment: credit_data["minimum_payment_amount"], + apr: credit_data.dig("aprs", 0, "apr_percentage") + }, + source: "plaid" + ) + end + + private + attr_reader :provider_account + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(provider_account.account) + end + + def credit_data + provider_account.raw_liabilities_payload&.dig("credit") + end +end diff --git a/app/models/provider/plaid/liabilities/mortgage_processor.rb b/app/models/provider/plaid/liabilities/mortgage_processor.rb new file mode 100644 index 000000000..90c39eac6 --- /dev/null +++ b/app/models/provider/plaid/liabilities/mortgage_processor.rb @@ -0,0 +1,22 @@ +# Port of PlaidAccount::Liabilities::MortgageProcessor. +class Provider::Plaid::Liabilities::MortgageProcessor + def initialize(provider_account) + @provider_account = provider_account + end + + def process + return unless mortgage_data.present? + + provider_account.account.loan.update!( + rate_type: mortgage_data.dig("interest_rate", "type"), + interest_rate: mortgage_data.dig("interest_rate", "percentage") + ) + end + + private + attr_reader :provider_account + + def mortgage_data + provider_account.raw_liabilities_payload&.dig("mortgage") + end +end diff --git a/app/models/provider/plaid/liabilities/student_loan_processor.rb b/app/models/provider/plaid/liabilities/student_loan_processor.rb new file mode 100644 index 000000000..e6176b8f3 --- /dev/null +++ b/app/models/provider/plaid/liabilities/student_loan_processor.rb @@ -0,0 +1,45 @@ +# Port of PlaidAccount::Liabilities::StudentLoanProcessor. +class Provider::Plaid::Liabilities::StudentLoanProcessor + def initialize(provider_account) + @provider_account = provider_account + end + + def process + return unless data.present? + + provider_account.account.loan.update!( + rate_type: "fixed", + interest_rate: data["interest_rate_percentage"], + initial_balance: data["origination_principal_amount"], + term_months: term_months + ) + end + + private + attr_reader :provider_account + + def term_months + return nil unless origination_date && expected_payoff_date + ((expected_payoff_date - origination_date).to_i / 30).to_i + end + + def origination_date + parse_date(data["origination_date"]) + end + + def expected_payoff_date + parse_date(data["expected_payoff_date"]) + end + + def parse_date(value) + return value if value.is_a?(Date) + return nil unless value.present? + Date.parse(value.to_s) + rescue ArgumentError + nil + end + + def data + provider_account.raw_liabilities_payload&.dig("student") + end +end diff --git a/app/models/provider/plaid/syncer.rb b/app/models/provider/plaid/syncer.rb new file mode 100644 index 000000000..baa2f5884 --- /dev/null +++ b/app/models/provider/plaid/syncer.rb @@ -0,0 +1,165 @@ +# Connection-framework syncer for Plaid. Mirrors Provider::Truelayer::Syncer's +# shape but calls into Plaid-specific sub-processors (which read from the +# provider_accounts.raw_*_payload columns populated by the importer). +class Provider::Plaid::Syncer + include SyncStats::Collector + + def initialize(connection) + @connection = connection + end + + # Lightweight discovery for the post-OAuth callback — populates + # provider_accounts but doesn't run sync. Called by Provider::Connection + # via discover_accounts!. + def discover_accounts_only + @connection.update!(metadata: @connection.metadata.merge( + "billed_products" => item_response.item.billed_products, + "available_products" => item_response.item.available_products, + "institution_id" => item_response.item.institution_id + )) + + snapshot = Provider::Plaid::AccountsSnapshot.new(@connection, plaid_provider: client) + seen_external_ids = [] + snapshot.accounts.each do |raw_account| + seen_external_ids << raw_account.account_id + provider_account = @connection.provider_accounts.find_or_initialize_by(external_id: raw_account.account_id) + payload = raw_account.to_hash || {} + payload = payload.except("disappeared_at", :disappeared_at) if payload.is_a?(Hash) + provider_account.update!( + external_name: raw_account.name, + external_type: raw_account.type, + external_subtype: raw_account.subtype, + currency: raw_account.balances&.iso_currency_code || raw_account.balances&.unofficial_currency_code, + raw_payload: payload + ) + end + mark_disappeared_accounts(seen_external_ids) + end + + # Flags provider_accounts whose external_id no longer appears in the + # upstream accounts response. See Provider::Truelayer::Syncer for the same + # pattern; UI consumers (setup, show) check Provider::Account#disappeared?. + def mark_disappeared_accounts(seen_external_ids) + # `.where.not(col: [])` returns ALL rows in Rails 7.2 (`WHERE TRUE`). + # Without this guard a malformed or empty upstream response would flip + # every existing provider_account to "disappeared". Trade-off: a user + # who legitimately closed every Plaid-linked account in one go won't + # see them flagged here — they'd still see the connection sitting + # account-less, which is the dominant signal anyway. + return if seen_external_ids.empty? && @connection.provider_accounts.exists? + + stale = @connection.provider_accounts.where.not(external_id: seen_external_ids) + stale.find_each do |pa| + next if pa.raw_payload.is_a?(Hash) && pa.raw_payload["disappeared_at"].present? + pa.update!(raw_payload: (pa.raw_payload || {}).merge("disappeared_at" => Time.current.iso8601)) + end + end + + def perform_sync(sync) + token = @connection.auth.fresh_access_token + + # Phase 1: Import latest item-level data (institution metadata, item state) + item = client.get_item(token).item + inst = client.get_institution(item.institution_id).institution + @connection.update!(metadata: @connection.metadata.merge( + "billed_products" => item.billed_products, + "available_products" => item.available_products, + "institution_id" => item.institution_id, + "raw_item_payload" => item.to_hash, + "raw_institution_payload" => inst.to_hash + )) + + # Phase 2: Pull all per-account data (transactions, investments, liabilities) + # and upsert into raw_*_payload columns on provider_accounts. + snapshot = Provider::Plaid::AccountsSnapshot.new(@connection, plaid_provider: client) + + Provider::Connection.transaction do + snapshot.accounts.each do |raw_account| + provider_account = @connection.provider_accounts.find_or_initialize_by(external_id: raw_account.account_id) + Provider::Plaid::AccountImporter.new( + provider_account, + account_snapshot: snapshot.get_account_data(raw_account.account_id) + ).import + end + # Persist the next cursor so subsequent syncs are incremental. + cursor = snapshot.transactions_cursor + if cursor.present? + @connection.update!(metadata: @connection.metadata.merge("next_cursor" => cursor)) + end + end + + collect_setup_stats(sync, provider_accounts: @connection.provider_accounts) + + # Phase 3: Run the per-account processor on every linked provider_account. + # Unlinked (account_id nil) and skipped accounts are ignored. + linked = @connection.provider_accounts.where.not(account_id: nil).where(skipped: false).includes(:account) + linked.each do |pa| + Provider::Plaid::AccountProcessor.new(pa).process + pa.update!(last_synced_at: Time.current) + collect_transaction_stats(sync, account_ids: [ pa.account_id ], source: "plaid") + end + + @connection.update!(status: :healthy, last_synced_at: Time.current, sync_error: nil) + rescue Provider::Auth::ReauthRequiredError + @connection.update!(status: :requires_update, sync_error: "reauth_required") + rescue Provider::Auth::TransientError => e + Rails.logger.warn("[#{self.class.name}] transient sync failure for connection=#{@connection.id}: #{e.message}") + raise + rescue *TRANSIENT_NETWORK_ERRORS => e + Rails.logger.warn("[#{self.class.name}] transient network failure for connection=#{@connection.id}: #{e.class}: #{e.message}") + raise Provider::Auth::TransientError, "#{e.class}: #{e.message}" + rescue Plaid::ApiError => e + handle_plaid_error(e) + rescue => e + @connection.update!(sync_error: e.message) + raise + ensure + collect_health_stats(sync) + end + + def perform_post_sync; end + + private + + TRANSIENT_NETWORK_ERRORS = [ + Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, + Errno::ECONNRESET, Errno::EHOSTUNREACH, SocketError + ].freeze + private_constant :TRANSIENT_NETWORK_ERRORS + + def client + Provider::Registry.plaid_provider_for_region(region) + end + + def region + (@connection.metadata["region"] || "us").to_sym + end + + def item_response + @item_response ||= client.get_item(@connection.credentials["access_token"]) + end + + # Plaid surfaces login-required as an ITEM_LOGIN_REQUIRED error code in + # the response body. Mark the connection requires_update and return + # without raising — Sidekiq retry would just fail again. 5xx upstream + # errors are reclassified as Provider::Auth::TransientError so the + # generic syncer contract holds (no UI surface, just Sidekiq retry). + def handle_plaid_error(error) + body = JSON.parse(error.response_body) rescue {} + if body["error_code"] == "ITEM_LOGIN_REQUIRED" + @connection.auth.mark_requires_update!(reason: "ITEM_LOGIN_REQUIRED") + elsif transient_plaid_error?(error) + Rails.logger.warn("[#{self.class.name}] transient Plaid 5xx for connection=#{@connection.id}: #{error.message}") + raise Provider::Auth::TransientError, error.message + else + @connection.update!(sync_error: error.message) + raise error + end + end + + # Plaid SDK exposes the upstream HTTP status as `code` on Plaid::ApiError. + def transient_plaid_error?(error) + code = error.try(:code) || error.try(:status_code) + code.is_a?(Integer) && code >= 500 + end +end diff --git a/app/models/plaid_account/transactions/category_taxonomy.rb b/app/models/provider/plaid/transactions/category_taxonomy.rb similarity index 97% rename from app/models/plaid_account/transactions/category_taxonomy.rb rename to app/models/provider/plaid/transactions/category_taxonomy.rb index 84b4b8c10..2619a15cb 100644 --- a/app/models/plaid_account/transactions/category_taxonomy.rb +++ b/app/models/provider/plaid/transactions/category_taxonomy.rb @@ -1,5 +1,5 @@ # https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv -module PlaidAccount::Transactions::CategoryTaxonomy +module Provider::Plaid::Transactions::CategoryTaxonomy CATEGORIES_MAP = { income: { classification: :income, @@ -458,4 +458,19 @@ module PlaidAccount::Transactions::CategoryTaxonomy } } } + + def self.resolve(detailed_category) + return nil if detailed_category.blank? + key = detailed_category.to_s.downcase.to_sym + + CATEGORIES_MAP.each do |_parent_key, parent_data| + child = parent_data[:detailed_categories][key] + next unless child + return { + aliases: child[:aliases], + parent_aliases: parent_data[:aliases] + } + end + nil + end end diff --git a/app/models/provider/plaid/transactions/entry_processor.rb b/app/models/provider/plaid/transactions/entry_processor.rb new file mode 100644 index 000000000..8fca36128 --- /dev/null +++ b/app/models/provider/plaid/transactions/entry_processor.rb @@ -0,0 +1,90 @@ +# Imports a single Plaid transaction into a Sure Account via import_adapter. +# Direct port of PlaidEntry::Processor; takes provider_account instead of +# plaid_account, otherwise identical. +class Provider::Plaid::Transactions::EntryProcessor + def initialize(plaid_transaction, provider_account:, category_matcher:) + @plaid_transaction = plaid_transaction + @provider_account = provider_account + @category_matcher = category_matcher + end + + def process + 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, + pending_transaction_id: pending_transaction_id, + extra: { + plaid: { + pending: plaid_transaction["pending"], + pending_transaction_id: pending_transaction_id + } + } + ) + end + + private + attr_reader :plaid_transaction, :provider_account, :category_matcher + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(provider_account.account) + end + + def external_id + plaid_transaction["transaction_id"] + end + + def name + plaid_transaction["merchant_name"] || plaid_transaction["original_description"] + end + + def amount + plaid_transaction["amount"] + end + + def currency + plaid_transaction["iso_currency_code"] + end + + def date + plaid_transaction["date"] + end + + def pending_transaction_id + plaid_transaction["pending_transaction_id"] + end + + def detailed_category + 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 ||= import_adapter.find_or_create_merchant( + provider_merchant_id: plaid_transaction["merchant_entity_id"], + name: plaid_transaction["merchant_name"], + source: "plaid", + website_url: plaid_transaction["website"], + logo_url: safe_https(plaid_transaction["logo_url"]) + ) + end + + # Restrict ingested logo URLs to HTTPS — same guard Provider::Account#safe_logo_uri + # applies to institution logos. Defends against malformed/malicious upstream + # payloads writing http: or javascript: schemes into merchants.logo_url. + def safe_https(url) + return nil if url.blank? + URI.parse(url).is_a?(URI::HTTPS) ? url : nil + rescue URI::InvalidURIError + nil + end +end diff --git a/app/models/provider/plaid/transactions/processor.rb b/app/models/provider/plaid/transactions/processor.rb new file mode 100644 index 000000000..fb68a066e --- /dev/null +++ b/app/models/provider/plaid/transactions/processor.rb @@ -0,0 +1,69 @@ +# Iterates added/modified/removed transactions on a Provider::Account's +# raw_transactions_payload and dispatches to EntryProcessor. Direct port of +# PlaidAccount::Transactions::Processor. +class Provider::Plaid::Transactions::Processor + def initialize(provider_account) + @provider_account = provider_account + end + + def process + modified_transactions.each do |transaction| + Provider::Plaid::Transactions::EntryProcessor.new( + transaction, + provider_account: provider_account, + category_matcher: category_matcher + ).process + end + + Provider::Account.transaction do + removed_transactions.each do |transaction| + remove_plaid_transaction(transaction) + end + end + end + + private + attr_reader :provider_account + + def category_matcher + @category_matcher ||= Provider::CategoryMatcher.new( + family_categories, + taxonomy: Provider::Plaid::Transactions::CategoryTaxonomy + ) + end + + def family_categories + @family_categories ||= begin + if account.family.categories.none? + account.family.categories.bootstrap! + end + account.family.categories + end + end + + def account + provider_account.account + end + + def remove_plaid_transaction(raw_transaction) + account.entries.find_by(plaid_id: raw_transaction["transaction_id"])&.destroy + end + + # find_or_create_by upserts, so added + modified are processed identically. + def modified_transactions + modified = provider_account.raw_transactions_payload&.dig("modified") || [] + added = provider_account.raw_transactions_payload&.dig("added") || [] + transactions = modified + added + + include_pending = if ENV["PLAID_INCLUDE_PENDING"].present? + Rails.configuration.x.plaid.include_pending + else + Setting.syncs_include_pending + end + include_pending ? transactions : transactions.reject { |t| t["pending"] == true } + end + + def removed_transactions + provider_account.raw_transactions_payload&.dig("removed") || [] + end +end diff --git a/app/models/provider/plaid/webhook_handler.rb b/app/models/provider/plaid/webhook_handler.rb new file mode 100644 index 000000000..90c44d54e --- /dev/null +++ b/app/models/provider/plaid/webhook_handler.rb @@ -0,0 +1,57 @@ +# Plaid webhook event dispatch. Direct port of PlaidItem::WebhookProcessor; +# locates the Provider::Connection by plaid_item_id stored on metadata +# (instead of PlaidItem.plaid_id) and uses the framework's auth abstraction +# to flip status to requires_update. +class Provider::Plaid::WebhookHandler + MissingConnectionError = Class.new(StandardError) + + def initialize(connection: nil, raw_body:, headers: {}) + @raw_body = raw_body + @headers = headers + parsed = JSON.parse(raw_body) + @webhook_type = parsed["webhook_type"] + @webhook_code = parsed["webhook_code"] + @item_id = parsed["item_id"] + @error = parsed["error"] + # Connection lookup may be supplied by the controller (when known) or + # resolved here from metadata.plaid_item_id. + @connection = connection || Provider::Connection + .where("metadata->>'plaid_item_id' = ?", @item_id) + .first + end + + def process + unless @connection + report_missing + return + end + + case [ webhook_type, webhook_code ] + when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ], + [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ], + [ "HOLDINGS", "DEFAULT_UPDATE" ] + @connection.sync_later + when [ "ITEM", "ERROR" ] + if error && error["error_code"] == "ITEM_LOGIN_REQUIRED" + @connection.auth.mark_requires_update!(reason: "ITEM_LOGIN_REQUIRED") + end + else + Rails.logger.warn("Unhandled Plaid webhook type: #{webhook_type}:#{webhook_code}") + end + rescue => e + # Always return 200 to Plaid; capture failures via Sentry rather than 5xx. + Sentry.capture_exception(e) + end + + private + + attr_reader :webhook_type, :webhook_code, :item_id, :error + + def report_missing + Sentry.capture_exception( + MissingConnectionError.new("Received Plaid webhook for item with no matching Provider::Connection") + ) do |scope| + scope.set_tags(plaid_item_id: item_id) + end + end +end diff --git a/app/models/provider/plaid_adapter.rb b/app/models/provider/plaid_adapter.rb index ac4fd8187..455229b98 100644 --- a/app/models/provider/plaid_adapter.rb +++ b/app/models/provider/plaid_adapter.rb @@ -1,91 +1,24 @@ -# PlaidAdapter serves dual purposes: +# Plaid global configuration (admin-managed Rails.application.config.plaid). # -# 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 +# Holds the Provider::Configurable DSL block that registers Plaid in the +# Settings → Providers UI for client_id / secret / environment management. +# This is global config (one set for the app), distinct from per-family BYOK +# credentials managed via Provider::FamilyConfig. # -# 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 +# After the Plaid framework cutover, the connection-side adapter logic lives in +# Provider::Plaid::Adapter (registered with Provider::ConnectionRegistry). +# This file is reduced to its remaining role: hosting the configuration DSL +# block that manages Rails.application.config.plaid for the US region. +# Provider::PlaidEuAdapter handles the EU region equivalently. +class Provider::PlaidAdapter 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 + # Setup instructions render via SettingsHelper#provider_setup_instructions + # (driven by I18n at settings.providers.instructions.plaid). The framework + # panel inserts that block above this form — single source of truth. field :client_id, label: "Client ID", @@ -107,33 +40,23 @@ def self.connection_configs(family:) 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 + # 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 + # 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"] + 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? @@ -145,41 +68,4 @@ def self.reload_configuration 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 index 497bb13c4..7b25d1109 100644 --- a/app/models/provider/plaid_eu_adapter.rb +++ b/app/models/provider/plaid_eu_adapter.rb @@ -19,12 +19,9 @@ class Provider::PlaidEuAdapter # Configuration for Plaid EU configure do - description <<~DESC - Setup instructions: - 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials - 2. Your Client ID and Secret Key are required to enable Plaid bank sync for European banks - 3. For production use, set environment to 'production', for testing use 'sandbox' - DESC + # Setup instructions render via SettingsHelper#provider_setup_instructions + # (driven by I18n at settings.providers.instructions.plaid). The framework + # panel inserts that block above this form — single source of truth. field :client_id, label: "Client ID", diff --git a/app/models/provider/syncable.rb b/app/models/provider/syncable.rb index 918325448..af6d34e8c 100644 --- a/app/models/provider/syncable.rb +++ b/app/models/provider/syncable.rb @@ -10,7 +10,7 @@ def sync_path end # Returns the provider's item/connection object - # @return [Object] The item object (e.g., PlaidItem, SimplefinItem) + # @return [Object] The item object (e.g., SimplefinItem, LunchflowItem) def item raise NotImplementedError, "#{self.class} must implement #item" end diff --git a/app/models/provider/truelayer.rb b/app/models/provider/truelayer.rb new file mode 100644 index 000000000..57b6c6853 --- /dev/null +++ b/app/models/provider/truelayer.rb @@ -0,0 +1,254 @@ +class Provider::Truelayer + include HTTParty + extend SslConfigurable + + Error = Class.new(Provider::Error) + + class RateLimitError < Error + attr_reader :retry_after + + def initialize(retry_after: nil) + super("TrueLayer rate limit exceeded") + @retry_after = retry_after + end + end + + PRODUCTION_API = "https://api.truelayer.com".freeze + PRODUCTION_AUTH = "https://auth.truelayer.com".freeze + SANDBOX_API = "https://api.truelayer-sandbox.com".freeze + SANDBOX_AUTH = "https://auth.truelayer-sandbox.com".freeze + + MAX_RETRIES = 3 + MAX_RETRY_AFTER_SECONDS = 30 + + headers "User-Agent" => "Sure Finance TrueLayer Client" + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) + + TokenResponse = Struct.new(:access_token, :refresh_token, :expires_in, keyword_init: true) + + # Wrap any HTTP POST so that socket/timeout failures surface as TransientError — + # matches the GET path in #with_rate_limit_retry. Without this, OAuth flows + # raise raw Net errors on provider-side blips and the caller has no signal to + # treat them as retryable. + def self.with_transient_classification + yield + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise Provider::Auth::TransientError, "TrueLayer request failed: #{e.message}" + end + + # Handles OAuth2 token exchange and refresh. + # Instantiated by Provider::Auth::OAuth2 via adapter_config.token_client(family_credentials). + class TokenClient + def initialize(credentials, sandbox: false) + @credentials = credentials + @sandbox = sandbox + end + + def exchange(code:, redirect_uri:) + response = Provider::Truelayer.with_transient_classification do + Provider::Truelayer.post( + "#{auth_base}/connect/token", + headers: { "Content-Type" => "application/x-www-form-urlencoded" }, + body: { + grant_type: "authorization_code", + client_id: @credentials[:client_id], + client_secret: @credentials[:client_secret], + code: code, + redirect_uri: redirect_uri + } + ) + end + parse_token_response(response) + end + + def refresh(refresh_token) + response = Provider::Truelayer.with_transient_classification do + Provider::Truelayer.post( + "#{auth_base}/connect/token", + headers: { "Content-Type" => "application/x-www-form-urlencoded" }, + body: { + grant_type: "refresh_token", + client_id: @credentials[:client_id], + client_secret: @credentials[:client_secret], + refresh_token: refresh_token + } + ) + end + parse_token_response(response) + end + + private + + def parse_token_response(response) + case response.code + when 200 + data = JSON.parse(response.body) + TokenResponse.new( + access_token: data["access_token"], + refresh_token: data["refresh_token"], + expires_in: data["expires_in"].to_i + ) + when 400 + body = safe_parse(response.body) + raise Provider::Auth::ConsentExpiredError if body["error"] == "invalid_grant" + raise Provider::Truelayer::Error, body["error_description"] || "Token request failed" + when 500..599 + raise Provider::Auth::TransientError, "TrueLayer auth #{response.code}" + else + raise Provider::Truelayer::Error, "TrueLayer auth error #{response.code}" + end + end + + def auth_base + @sandbox ? SANDBOX_AUTH : PRODUCTION_AUTH + end + + def safe_parse(body) + JSON.parse(body) + rescue JSON::ParserError + {} + end + end + + def self.token_client(credentials, sandbox: false) + TokenClient.new(credentials, sandbox: sandbox) + end + + def self.reauth_uri(refresh_token:, redirect_uri:, state:, client_id:, client_secret:, sandbox: false) + auth_base = sandbox ? SANDBOX_AUTH : PRODUCTION_AUTH + response = with_transient_classification do + post( + "#{auth_base}/v1/reauthuri", + headers: { "Content-Type" => "application/json" }, + body: { + response_type: "code", + client_id: client_id, + client_secret: client_secret, + refresh_token: refresh_token, + redirect_uri: redirect_uri, + state: state + }.to_json + ) + end + + case response.code + when 200..299 + body = JSON.parse(response.body) rescue {} + body["result"] + when 429, 500..599 + raise Provider::Auth::TransientError, "TrueLayer reauth #{response.code}" + else + body = JSON.parse(response.body) rescue {} + raise Provider::Truelayer::Error, body["error_description"] || "Reauth URI request failed (#{response.code})" + end + end + + def initialize(access_token, psu_ip: nil, sandbox: false) + @access_token = access_token + @psu_ip = psu_ip + @sandbox = sandbox + end + + def me + get("/data/v1/me") + end + + def get_accounts + get("/data/v1/accounts")["results"] || [] + end + + def get_cards + get("/data/v1/cards")["results"] || [] + end + + # kind: "account" or "card" — routes to the correct TrueLayer endpoint. + def get_balance(account_id, kind: "account") + path = kind == "card" ? "/data/v1/cards/#{account_id}/balance" : "/data/v1/accounts/#{account_id}/balance" + get(path)["results"]&.first + end + + def get_transactions(account_id, kind: "account", from: 90.days.ago, to: Time.current) + path = kind == "card" ? "/data/v1/cards/#{account_id}/transactions" : "/data/v1/accounts/#{account_id}/transactions" + get(path, query: { from: from.iso8601, to: to.iso8601 })["results"] || [] + end + + def get_pending_transactions(account_id, kind: "account") + path = kind == "card" ? "/data/v1/cards/#{account_id}/transactions/pending" : "/data/v1/accounts/#{account_id}/transactions/pending" + get(path)["results"] || [] + end + + private + + def get(path, query: {}) + with_rate_limit_retry do + response = self.class.get( + "#{api_base}#{path}", + query: query.presence, + headers: request_headers + ) + handle_response(response) + end + end + + def handle_response(response) + case response.code + when 200, 201 + parse_body(response) + when 400 + body = safe_parse(response.body) + raise Provider::Auth::ConsentExpiredError if body["error"] == "invalid_grant" + raise Error, body["error_description"] || "Bad request" + when 401, 403 + raise Provider::Auth::ReauthRequiredError + when 404 + raise Error, "Resource not found" + when 429 + raise RateLimitError.new(retry_after: response.headers["Retry-After"].to_i) + when 501 + raise Error, "Endpoint not supported by this bank" + when 500..599 + raise Provider::Auth::TransientError, "TrueLayer API #{response.code}" + else + raise Error, "TrueLayer API error #{response.code}" + end + end + + def parse_body(response) + return {} if response.body.blank? + JSON.parse(response.body) + rescue JSON::ParserError => e + raise Error, "Failed to parse response: #{e.message}" + end + + def with_rate_limit_retry(max_retries: MAX_RETRIES) + attempts = 0 + begin + yield + rescue RateLimitError => e + attempts += 1 + raise if attempts > max_retries + wait = [ e.retry_after.to_i, MAX_RETRY_AFTER_SECONDS ].min + sleep(wait) if wait > 0 + retry + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise Provider::Auth::TransientError, "TrueLayer request failed: #{e.message}" + end + end + + def request_headers + headers = { + "Authorization" => "Bearer #{@access_token}", + "Accept" => "application/json" + } + headers["X-PSU-IP"] = @psu_ip if @psu_ip.present? + headers + end + + def api_base = @sandbox ? SANDBOX_API : PRODUCTION_API + + def safe_parse(body) + JSON.parse(body) + rescue JSON::ParserError + {} + end +end diff --git a/app/models/provider/truelayer/adapter.rb b/app/models/provider/truelayer/adapter.rb new file mode 100644 index 000000000..716b60172 --- /dev/null +++ b/app/models/provider/truelayer/adapter.rb @@ -0,0 +1,249 @@ +class Provider::Truelayer::Adapter + extend Provider::ConnectionAdapter + + def self.display_name = "TrueLayer" + def self.description = "UK & European bank connections via open banking." + def self.brand_color = "#00D64A" + def self.beta? = true + + # TrueLayer requires per-family BYOK credentials (each family enters their + # own client_id/secret in provider_family_configs). + def self.requires_family_config? = true + + # TrueLayer Console exposes a "sandbox" flag stored on FamilyConfig.credentials. + # OauthCallbacksController calls this to thread it through authorize_url. + def self.sandbox_for(config) + return false unless config.credentials.is_a?(Hash) + !!(config.credentials["sandbox"] || config.credentials[:sandbox]) + end + + def self.supported_account_types + %w[Depository CreditCard] + end + + def self.syncer_class = Provider::Truelayer::Syncer + + def self.auth_class = Provider::Auth::OAuth2 + + def self.reauth_url(connection, redirect_uri:, state:) + config = connection.provider_family_config + Provider::Truelayer.reauth_uri( + refresh_token: connection.credentials["refresh_token"], + redirect_uri: redirect_uri, + state: state, + client_id: config.client_id, + client_secret: config.client_secret, + sandbox: connection.metadata["sandbox"] + ) + end + + def self.connection_configs(family:) + [ { + key: "truelayer", + name: display_name, + new_account_path: ->(_accountable_type, _return_to) { + Rails.application.routes.url_helpers.select_provider_connections_path(provider_key: "truelayer") + }, + existing_account_path: nil + } ] + end + + ACCOUNTABLE_MAP = { + "depository" => Depository, + "credit" => CreditCard + }.freeze + + def self.build_sure_account(provider_account, family:) + accountable_class = ACCOUNTABLE_MAP[provider_account.external_type.to_s] || + raise(Provider::Account::UnsupportedAccountableType, + "Truelayer::Adapter does not handle external_type=#{provider_account.external_type.inspect}") + accountable = accountable_class.new(subtype: provider_account.external_subtype) + family.accounts.build( + name: provider_account.external_name, + currency: provider_account.currency, + balance: 0, + accountable: accountable + ) + end + + ACCOUNT_TYPE_MAP = { + "TRANSACTION" => "depository", + "SAVINGS" => "depository", + "BUSINESS_TRANSACTION" => "depository", + "BUSINESS_SAVINGS" => "depository" + }.freeze + + ACCOUNT_SUBTYPE_MAP = { + "TRANSACTION" => "checking", + "SAVINGS" => "savings", + "BUSINESS_TRANSACTION" => "checking", + "BUSINESS_SAVINGS" => "savings" + }.freeze + + def initialize(connection) + @connection = connection + end + + # Called by Provider::ConnectionRegistry.config_for("truelayer") + # These stateless methods (authorize_url, scopes, token_client, fetch_consent_expiry) are + # invoked with connection=nil, so they must not touch @connection. + + def authorize_url(client_id:, redirect_uri:, state:, scope:, sandbox: false) + auth_base = sandbox ? Provider::Truelayer::SANDBOX_AUTH : Provider::Truelayer::PRODUCTION_AUTH + params = { + response_type: "code", + client_id: client_id, + scope: Array(scope).join(" "), + redirect_uri: redirect_uri, + state: state, + providers: sandbox ? "mock" : "uk-ob-all uk-oauth-all ie-ob-all" + } + "#{auth_base}/?#{params.to_query}" + end + + def scopes + %w[info accounts balance transactions cards offline_access] + end + + def token_client(credentials, sandbox: false) + Provider::Truelayer.token_client(credentials, sandbox: sandbox) + end + + def fetch_consent_expiry(connection, access_token) + response = Provider::Truelayer.new(access_token, sandbox: connection.metadata["sandbox"]).me + raw = response.dig("results", 0, "consent_expires_at") + Time.parse(raw) if raw + rescue + nil + end + + # Data-fetching methods — called by Truelayer::Syncer with a real connection. + # + # Returns { accounts: [...], partial: false }. If either the /accounts or + # /cards endpoint errors transiently, we return what we got plus + # `partial: true` so the syncer knows NOT to flip everything-not-in-this-set + # to "disappeared" (transient failure ≠ user closed every account). + # The other call sites (transaction sync) get the same flat array shape via + # `fetch_accounts(token)[:accounts]`. + def fetch_accounts(token) + c = client(token) + partial = false + accounts = begin + c.get_accounts.map { |a| normalise_account(a, kind: "account") } + rescue Provider::Truelayer::Error => e + Rails.logger.warn("[Truelayer::Adapter] /accounts errored: #{e.class}: #{e.message}") + partial = true + [] + end + cards = begin + c.get_cards.map { |a| normalise_account(a, kind: "card") } + rescue Provider::Truelayer::Error => e + Rails.logger.warn("[Truelayer::Adapter] /cards errored: #{e.class}: #{e.message}") + partial = true + [] + end + { accounts: accounts + cards, partial: partial } + end + + def fetch_balance(token, provider_account) + kind = provider_account.external_type == "credit" ? "card" : "account" + client(token).get_balance(provider_account.external_id, kind: kind) + end + + def fetch_transactions(token, provider_account, from: 90.days.ago, to: Time.current) + kind = provider_account.external_type == "credit" ? "card" : "account" + c = client(token) + settled = c.get_transactions(provider_account.external_id, kind: kind, from: from, to: to) + pending = c.get_pending_transactions(provider_account.external_id, kind: kind) + normalise_transactions(settled, pending: false) + normalise_transactions(pending, pending: true) + end + + private + + def client(token) + Provider::Truelayer.new( + token, + psu_ip: @connection.metadata["psu_ip"], + sandbox: @connection.metadata["sandbox"] + ) + end + + def normalise_account(raw, kind:) + account_type = raw["account_type"] || raw["card_type"] || "" + { + external_id: raw["account_id"], # TrueLayer uses "account_id" for both bank accounts and cards + name: raw["display_name"] || raw["card_type"], + type: kind == "card" ? "credit" : ACCOUNT_TYPE_MAP.fetch(account_type, "depository"), + subtype: kind == "card" ? "credit_card" : ACCOUNT_SUBTYPE_MAP.fetch(account_type, "checking"), + currency: raw["currency"], + raw_payload: raw + } + end + + def normalise_transactions(raw_list, pending:) + raw_list.map do |t| + { + external_id: t["transaction_id"], + date: Date.parse(t["timestamp"]), + amount: BigDecimal(t["amount"].to_s), + currency: t["currency"], + name: extract_name(t), + merchant_name: extract_merchant_name(t), + notes: t["description"].presence, + pending: pending, + transaction_category: t["transaction_category"], + transaction_classification: t["transaction_classification"], + normalised_provider_transaction_id: t["normalised_provider_transaction_id"], + meta: t["meta"].presence, + raw: t + } + end + end + + def extract_name(t) + t["merchant_name"].presence || + meta_counterparty_name(t["meta"]) || + category_fallback_name(t["transaction_category"]) || + humanized_description(t["description"]) || + "TrueLayer Transaction" + end + + def extract_merchant_name(t) + t["merchant_name"].presence || meta_counterparty_name(t["meta"]) + end + + def meta_counterparty_name(meta) + return nil unless meta.is_a?(Hash) + + meta["counter_party_preferred_name"].presence || + meta["counterparty_name"].presence || + meta["party_name"].presence || + meta["creditor_name"].presence || + meta["debtor_name"].presence + end + + def category_fallback_name(category) + case category.to_s.upcase + when "TRANSFER" then "Bank Transfer" + when "ATM" then "ATM Withdrawal" + when "DIRECT_DEBIT" then "Direct Debit" + when "DIRECT_CREDIT" then "Direct Credit" + when "STANDING_ORDER" then "Standing Order" + when "REPEAT_PAYMENT" then "Repeat Payment" + when "INTEREST" then "Interest" + when "DIVIDEND" then "Dividend" + when "FEE" then "Fee" + when "CASH" then "Cash" + when "CHECK" then "Cheque" + end + end + + # Rejects bare reference codes (e.g. "R2391", "FP12345678") so they don't pollute the name field. + def humanized_description(desc) + return nil if desc.blank? + return nil if desc.match?(/\A[A-Z]{1,4}[\-_]?\d{2,}\z/i) + desc + end +end + +Provider::ConnectionRegistry.register("truelayer", Provider::Truelayer::Adapter) diff --git a/app/models/provider/truelayer/category_taxonomy.rb b/app/models/provider/truelayer/category_taxonomy.rb new file mode 100644 index 000000000..2c19e0a4b --- /dev/null +++ b/app/models/provider/truelayer/category_taxonomy.rb @@ -0,0 +1,259 @@ +# TrueLayer's category data + resolution logic. +# +# Resolution priority (first hit wins): +# 1. transaction_classification[1] (subcategory) — e.g. "Restaurants" +# 2. transaction_classification[0] (parent) — e.g. "Food & Dining" +# 3. transaction_category enum (whitelist) — FEE_CHARGE / INTEREST / DIVIDEND +# +# Per TrueLayer docs (https://docs.truelayer.com/docs/transactions): +# - transaction_classification is only populated for UK / Ireland / France +# banks and never for transaction_category=CREDIT. +# - Classification can change over time. Re-sync stability is handled by +# Account::ProviderImportAdapter via Enrichable#enrich_attribute, which +# skips locked attributes (user manual edits). +# +# INTEREST is amount-sign-dependent: positive = income, negative = expense. +# All other enum values (PURCHASE / TRANSFER / DEBIT / CREDIT / OTHER / +# UNKNOWN / CHEQUE / CASH / ATM / etc.) carry mechanism, not category, and +# are deliberately not fallbacks. +module Provider::Truelayer::CategoryTaxonomy + # Each entry's `name` is the literal TrueLayer parent string (used for + # exact O(1) lookup in find_parent). `aliases` are matched fuzzily against + # the user's Category names. Subcategory keys are TrueLayer's literal + # subcategory strings. + CATEGORIES_MAP = { + food_and_dining: { + name: "Food & Dining", + aliases: [ "food", "dining", "food and drink", "food & drink", "food and dining" ], + subcategories: { + "Restaurants" => { aliases: [ "restaurant", "dining" ] }, + "Coffee shops" => { aliases: [ "coffee", "cafe" ] }, + "Fast Food" => { aliases: [ "fast food", "takeout" ] }, + "Bars" => { aliases: [ "bar", "pub", "alcohol" ] }, + "Catering" => { aliases: [ "catering" ] }, + "Delivery" => { aliases: [ "delivery", "takeaway" ] } + } + }, + shopping: { + name: "Shopping", + aliases: [ "shopping", "retail" ], + subcategories: { + "Groceries" => { aliases: [ "grocery", "supermarket" ] }, + "Clothing" => { aliases: [ "clothing", "apparel" ] }, + "Electronics & Software" => { aliases: [ "electronic", "computer", "software" ] }, + "Books" => { aliases: [ "book" ] }, + "Home" => { aliases: [ "home", "homeware" ] }, + "Hobbies" => { aliases: [ "hobby" ] }, + "Pets" => { aliases: [ "pet", "pet supply" ] }, + "Sporting Goods" => { aliases: [ "sporting good", "sport" ] }, + "General" => { aliases: [ "shopping", "merchandise" ] } + } + }, + entertainment: { + name: "Entertainment", + aliases: [ "entertainment", "recreation" ], + subcategories: { + "Movies & DVDs" => { aliases: [ "movie", "streaming", "dvd" ] }, + "Music" => { aliases: [ "music", "concert" ] }, + "Games" => { aliases: [ "game", "gaming" ] }, + "Sport" => { aliases: [ "sport", "event" ] }, + "Arts" => { aliases: [ "art", "museum" ] }, + "Newspaper & Magazines" => { aliases: [ "newspaper", "magazine" ] }, + "Social Club" => { aliases: [ "club" ] }, + "Dating" => { aliases: [ "dating" ] } + } + }, + auto_and_transport: { + name: "Auto & Transport", + aliases: [ "transportation", "transport", "auto and transport" ], + subcategories: { + "Gas & Fuel" => { aliases: [ "gas", "fuel", "petrol" ] }, + "Parking" => { aliases: [ "parking" ] }, + "Public transport" => { aliases: [ "transit", "bus", "train" ] }, + "Taxi" => { aliases: [ "taxi", "rideshare" ] }, + "Auto Insurance" => { aliases: [ "auto insurance", "car insurance" ] }, + "Auto Payment" => { aliases: [ "auto payment", "car payment" ] }, + "Service & Auto Parts" => { aliases: [ "auto repair", "mechanic", "service" ] }, + "Rental Car & Taxi" => { aliases: [ "rental car" ] } + } + }, + travel: { + name: "Travel", + aliases: [ "travel", "vacation", "trip" ], + subcategories: { + "Air Travel" => { aliases: [ "flight", "airfare", "air travel" ] }, + "Hotel" => { aliases: [ "hotel", "lodging" ] }, + "Vacation" => { aliases: [ "vacation", "holiday" ] }, + "Rental Car & Taxi" => { aliases: [ "rental car" ] } + } + }, + bills_and_utilities: { + name: "Bills & Utilities", + aliases: [ "utilities", "bills", "bills and utilities" ], + subcategories: { + "Internet" => { aliases: [ "internet", "broadband" ] }, + "Mobile Phone" => { aliases: [ "mobile", "phone", "cell" ] }, + "Home Phone" => { aliases: [ "telephone", "landline" ] }, + "Television" => { aliases: [ "tv", "television", "cable" ] }, + "Utilities" => { aliases: [ "utility", "electric", "gas", "water" ] } + } + }, + home: { + name: "Home", + aliases: [ "home", "house", "mortgage", "rent", "mortgage and rent", "mortgage / rent" ], + subcategories: { + "Rent" => { aliases: [ "rent", "lease" ] }, + "Mortgage" => { aliases: [ "mortgage", "home loan" ] }, + "Secured loans" => { aliases: [ "secured loan" ] } + } + }, + health_and_fitness: { + name: "Health & Fitness", + aliases: [ "health", "healthcare", "fitness", "health and fitness", "sports and fitness" ], + subcategories: { + "Doctor" => { aliases: [ "doctor", "medical" ] }, + "Dentist" => { aliases: [ "dental", "dentist" ] }, + "Eye care" => { aliases: [ "eye", "optometrist" ] }, + "Pharmacy" => { aliases: [ "pharmacy", "prescription" ] }, + "Gym" => { aliases: [ "gym", "fitness", "exercise" ] }, + "Sports" => { aliases: [ "sport" ] }, + "Pets" => { aliases: [ "vet", "veterinary" ] } + } + }, + personal_care: { + name: "Personal Care", + aliases: [ "personal care", "grooming" ], + subcategories: { + "Hair" => { aliases: [ "hair", "salon" ] }, + "Beauty" => { aliases: [ "beauty", "cosmetic" ] }, + "Spa & Massage" => { aliases: [ "spa", "massage" ] }, + "Laundry" => { aliases: [ "laundry", "dry cleaning" ] } + } + }, + education: { + name: "Education", + aliases: [ "education" ], + subcategories: { + "Tuition" => { aliases: [ "tuition", "school" ] }, + "Student Loan" => { aliases: [ "student loan" ] }, + "Books & Supplies" => { aliases: [ "textbook", "school supplies" ] } + } + }, + fees_and_charges: { + name: "Fees & Charges", + aliases: [ "fee", "charge", "fees", "fees and charges" ], + subcategories: { + "Service Fee" => { aliases: [ "service fee" ] }, + "Late Fee" => { aliases: [ "late fee" ] }, + "Finance Charge" => { aliases: [ "finance charge", "interest charge" ] }, + "ATM Fee" => { aliases: [ "atm fee" ] }, + "Bank Fee" => { aliases: [ "bank fee" ] }, + "Commissions" => { aliases: [ "commission" ] } + } + }, + taxes: { + name: "Taxes", + aliases: [ "tax", "taxes" ], + subcategories: { + "Federal Tax" => { aliases: [ "federal tax" ] }, + "State Tax" => { aliases: [ "state tax" ] }, + "Local Tax" => { aliases: [ "local tax", "council tax" ] }, + "Sales Tax" => { aliases: [ "sales tax", "vat" ] }, + "Property Tax" => { aliases: [ "property tax" ] } + } + }, + gifts_and_donations: { + name: "Gifts & Donations", + aliases: [ "gift", "donation", "gifts and donations", "gifts & donations" ], + subcategories: { + "Gift" => { aliases: [ "gift" ] }, + "Charity" => { aliases: [ "charity", "donation" ] } + } + }, + investments: { + name: "Investments", + aliases: [ "investment", "savings and investments", "savings & investments" ], + subcategories: { + "Equities" => { aliases: [ "equity", "stock", "shares" ] }, + "Bonds" => { aliases: [ "bond" ] }, + "Bank products" => { aliases: [ "savings", "isa" ] }, + "Retirement" => { aliases: [ "retirement", "pension" ] }, + "Real-estate" => { aliases: [ "real estate", "property" ] } + } + }, + pensions_and_insurances: { + name: "Pensions and Insurances", + aliases: [ "insurance", "pension" ], + subcategories: { + "Pension payments" => { aliases: [ "pension" ] }, + "Life insurance" => { aliases: [ "life insurance" ] }, + "Buildings and contents insurance" => { aliases: [ "buildings insurance", "contents insurance", "home insurance" ] }, + "Health insurance" => { aliases: [ "health insurance" ] } + } + } + }.freeze + + ENUM_FALLBACK = { + "FEE_CHARGE" => { aliases: [ "fee", "charge" ], parent_aliases: [ "fee", "fees and charges" ] }, + "DIVIDEND" => { aliases: [ "dividend" ], parent_aliases: [ "income" ] } + }.freeze + + def self.resolve(transaction) + return nil if transaction.blank? + + classification = transaction[:transaction_classification] || transaction["transaction_classification"] + if classification.is_a?(Array) && classification.any? + sub_resolved = resolve_subcategory(classification[0], classification[1]) + return sub_resolved if sub_resolved + parent_resolved = resolve_parent(classification[0]) + return parent_resolved if parent_resolved + end + + enum = transaction[:transaction_category] || transaction["transaction_category"] + amount = transaction[:amount] || transaction["amount"] + resolve_enum(enum, amount) + end + + def self.resolve_subcategory(parent_name, subcategory_name) + return nil if parent_name.blank? || subcategory_name.blank? + parent_data = find_parent(parent_name) + return nil unless parent_data + sub_data = parent_data[:subcategories][subcategory_name] + return nil unless sub_data + { + aliases: sub_data[:aliases], + parent_aliases: parent_data[:aliases] + } + end + + def self.resolve_parent(parent_name) + return nil if parent_name.blank? + parent_data = find_parent(parent_name) + return nil unless parent_data + { + aliases: parent_data[:aliases], + parent_aliases: parent_data[:aliases] + } + end + + def self.resolve_enum(enum, amount) + return nil if enum.blank? + case enum.to_s.upcase + when "INTEREST" + if amount.to_f >= 0 + { aliases: [ "interest", "interest earned" ], parent_aliases: [ "income" ] } + else + { aliases: [ "interest charge", "finance charge" ], parent_aliases: [ "fee", "fees and charges" ] } + end + when "FEE_CHARGE", "DIVIDEND" + ENUM_FALLBACK[enum.to_s.upcase] + end + end + + def self.find_parent(parent_name) + return nil if parent_name.blank? + CATEGORIES_MAP.values.find { |data| data[:name].casecmp?(parent_name) } + end + + private_class_method :resolve_subcategory, :resolve_parent, :resolve_enum, :find_parent +end diff --git a/app/models/provider/truelayer/syncer.rb b/app/models/provider/truelayer/syncer.rb new file mode 100644 index 000000000..c90307e37 --- /dev/null +++ b/app/models/provider/truelayer/syncer.rb @@ -0,0 +1,168 @@ +class Provider::Truelayer::Syncer + include SyncStats::Collector + + def initialize(connection) + @connection = connection + @auth = Provider::Auth::OAuth2.new(connection) + @adapter = Provider::Truelayer::Adapter.new(connection) + end + + def perform_sync(sync) + token = @auth.fresh_access_token # pipelock:ignore + discover_accounts(token) + collect_setup_stats(sync, provider_accounts: @connection.provider_accounts) + sync_linked_accounts(token, sync) + @connection.update!(status: :healthy, last_synced_at: Time.current, sync_error: nil) + rescue Provider::Auth::ReauthRequiredError + @connection.update!(status: :requires_update, sync_error: "reauth_required") + rescue Provider::Auth::TransientError => e + # Transient (network/5xx). Don't surface in UI; let Sidekiq retry. + Rails.logger.warn("[#{self.class.name}] transient sync failure for connection=#{@connection.id}: #{e.message}") + raise + rescue => e + @connection.update!(sync_error: e.message) + raise + ensure + collect_health_stats(sync) + end + + def discover_accounts_only + token = @auth.fresh_access_token # pipelock:ignore + discover_accounts(token) + end + + def perform_post_sync; end + + private + + def discover_accounts(token) + result = @adapter.fetch_accounts(token) + result.fetch(:accounts).each do |raw| + # Clear any prior "disappeared" flag — the account is back. + existing = @connection.provider_accounts.find_or_initialize_by(external_id: raw[:external_id]) + payload = raw[:raw_payload] || {} + payload = payload.except("disappeared_at", :disappeared_at) if payload.is_a?(Hash) + existing.update!( + external_name: raw[:name], + external_type: raw[:type], + external_subtype: raw[:subtype], + currency: raw[:currency], + raw_payload: payload + ) + end + + # CRITICAL: only mark-disappeared on a complete upstream response. + # If either /accounts or /cards errored transiently we'd otherwise flag + # every existing row that the failed-half would have returned, scaring + # the user. fetch_accounts signals this via :partial. + return if result[:partial] + seen_external_ids = result[:accounts].map { |r| r[:external_id] } + mark_disappeared_accounts(seen_external_ids) + end + + # Flags provider_accounts whose external_id no longer appears in the + # upstream list (closed bank account, removed from the user's online + # banking, etc.). Stored on raw_payload["disappeared_at"] so we don't + # need a schema change. UI surfaces this in the setup / show views so + # the user can choose to remove them. Caller MUST guarantee a complete + # upstream response — see #discover_accounts comment about :partial. + def mark_disappeared_accounts(seen_external_ids) + # .where.not(col: []) returns ALL rows in Rails 7.2 (`WHERE TRUE`), + # which would flip every account to "disappeared". Guard against it. + return if seen_external_ids.empty? && @connection.provider_accounts.exists? + + stale = @connection.provider_accounts.where.not(external_id: seen_external_ids) + stale.find_each do |pa| + next if pa.raw_payload.is_a?(Hash) && pa.raw_payload["disappeared_at"].present? + pa.update!(raw_payload: (pa.raw_payload || {}).merge("disappeared_at" => Time.current.iso8601)) + end + end + + def sync_linked_accounts(token, sync) + window_start = sync.window_start_date&.beginning_of_day + to = sync.window_end_date&.end_of_day || Time.current + + linked_accounts = @connection.provider_accounts.where.not(account_id: nil).includes(:account) + backfill_floor = @connection.sync_start_date&.beginning_of_day + linked_accounts.each do |pa| + # Per-account window: + # - explicit sync window (sync.window_start_date) wins if set + # - otherwise incremental from pa.last_synced_at (resumes where we left off) + # - otherwise honor connection.sync_start_date (user-chosen backfill) + # - finally fall back to a 90-day window for fresh accounts + from = window_start || pa.last_synced_at || backfill_floor || 90.days.ago + import_adapter = Account::ProviderImportAdapter.new(pa.account) + matcher = build_category_matcher(pa.account) + + @adapter.fetch_transactions(token, pa, from: from, to: to).each do |t| + merchant = build_merchant(t, import_adapter) + + extra = { "truelayer" => { "pending" => t[:pending], "raw" => t[:raw] } } + extra["truelayer"]["normalised_provider_transaction_id"] = t[:normalised_provider_transaction_id] if t[:normalised_provider_transaction_id].present? + extra["truelayer"]["transaction_category"] = t[:transaction_category] if t[:transaction_category].present? + extra["truelayer"]["transaction_classification"] = t[:transaction_classification] if t[:transaction_classification].present? + extra["truelayer"]["meta"] = t[:meta] if t[:meta].present? + + import_adapter.import_transaction( + external_id: t[:external_id], + amount: t[:amount], + currency: t[:currency], + date: t[:date], + name: t[:name], + merchant: merchant, + notes: t[:notes], + source: "truelayer", + category_id: matcher.match(t)&.id, + extra: extra + ) + end + + balance_anchored = anchor_balance(token, pa) + + pa.update!(last_synced_at: Time.current) + collect_transaction_stats(sync, account_ids: [ pa.account_id ], source: "truelayer") + pa.account.sync_later unless balance_anchored + end + end + + def build_merchant(t, import_adapter) + name = t[:merchant_name].to_s.strip.presence + return nil unless name + + merchant_id = Digest::MD5.hexdigest(name.downcase) + import_adapter.find_or_create_merchant( + provider_merchant_id: "truelayer_merchant_#{merchant_id}", + name: name, + source: "truelayer" + ) + end + + def build_category_matcher(account) + account.family.categories.bootstrap! if account.family.categories.none? + Provider::CategoryMatcher.new( + account.family.categories.to_a, + taxonomy: Provider::Truelayer::CategoryTaxonomy + ) + end + + def anchor_balance(token, pa) + raw = @adapter.fetch_balance(token, pa) + return false unless raw && raw["current"].present? + + balance = BigDecimal(raw["current"].to_s) + pa.account.set_current_balance(balance) + + if pa.account.accountable_type == "CreditCard" + avail_raw = raw["available"] || raw["credit_limit"] + if avail_raw.present? + avail = BigDecimal(avail_raw.to_s) + pa.account.credit_card.update!(available_credit: avail) if avail > 0 + end + end + + true + rescue => e + Rails.logger.warn "Truelayer::Syncer: balance fetch failed for provider_account=#{pa.id}: #{e.message}" + false + end +end diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index e1a4b4100..4e80382c6 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class ProviderConnectionStatus + # NOTE: Plaid was removed from this list when it was migrated to the + # Provider::Connection framework. Plaid health is surfaced via the framework + # (Provider::Connection#status) rather than this legacy iterator. SimpleFIN, + # Lunchflow, etc. remain on the legacy per-provider model until they're + # ported. PROVIDERS = [ - { key: "plaid", type: "PlaidItem", association: :plaid_items, accounts: :plaid_accounts }, { key: "simplefin", type: "SimplefinItem", association: :simplefin_items, accounts: :simplefin_accounts }, { key: "lunchflow", type: "LunchflowItem", association: :lunchflow_items, accounts: :lunchflow_accounts }, { key: "enable_banking", type: "EnableBankingItem", association: :enable_banking_items, accounts: :enable_banking_accounts }, diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 089d937eb..535cf23aa 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -1,6 +1,4 @@ class ProviderMerchant < Merchant - enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital", sophtron: "sophtron" } - validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 6ebc807be..b19fe1aec 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -93,7 +93,7 @@ def exchange_rate_must_be_valid INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze # Providers that support pending transaction flags - PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking].freeze + PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking truelayer].freeze # Pre-computed SQL fragment for subqueries that check if a transaction (aliased as "t") is pending. # Stored as a constant so static analysis can verify it contains no user input. diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 2dcd38118..3f95a2215 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -17,12 +17,12 @@ ) %> <% end %> -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? %> +<% if @manual_accounts.empty? && @provider_connections.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? %> <%= render "empty" %> <% else %>
- <% if @plaid_items.any? %> - <%= render @plaid_items.sort_by(&:created_at) %> + <% @provider_connections.each do |connection| %> + <%= render "provider_connections/connection_card", connection: connection %> <% end %> <% if @simplefin_items.any? %> diff --git a/app/views/accounts/index/_account_groups.erb b/app/views/accounts/index/_account_groups.erb index 79709142e..ed9fe533e 100644 --- a/app/views/accounts/index/_account_groups.erb +++ b/app/views/accounts/index/_account_groups.erb @@ -3,7 +3,7 @@ <% if accounts.any? %> <% ActiveRecord::Associations::Preloader.new( records: accounts, - associations: [ :account_shares, :accountable, :plaid_account, :simplefin_account, { account_providers: :provider } ] + associations: [ :account_shares, :accountable, :provider_accounts, :simplefin_account, { account_providers: :provider } ] ).call %> <% end %> <% accounts.group_by(&:accountable_type).sort_by { |group, _| group }.each do |group, accounts| %> diff --git a/app/views/embedded_link_callbacks/new.html.erb b/app/views/embedded_link_callbacks/new.html.erb new file mode 100644 index 000000000..31bbeb6da --- /dev/null +++ b/app/views/embedded_link_callbacks/new.html.erb @@ -0,0 +1,12 @@ +<%# Generic mount for any EmbeddedLink provider. The adapter declares the + Stimulus controller name and data attributes via #js_data_for; the controller + instance hands them in as @js_data. Provider-specific JS controllers + (plaid_controller.js, future mx_connect_controller.js) read their own + data values and drive the vendor SDK. %> +<%= turbo_frame_tag "modal" do %> + <%= tag.div data: @js_data, + class: "flex flex-col items-center justify-center py-12 gap-3" do %> + <%= icon("loader", class: "w-6 h-6 text-secondary animate-spin") %> +

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

+ <% end %> +<% end %> diff --git a/app/views/migration_notices/_notice.html.erb b/app/views/migration_notices/_notice.html.erb new file mode 100644 index 000000000..e8a0fd566 --- /dev/null +++ b/app/views/migration_notices/_notice.html.erb @@ -0,0 +1,45 @@ +<%# locals: (notice:) + Renders one MigrationNotice. `notice` is a hash with: + key: i18n scope under migration_notices..{title,body_html, + copyable_label,copy,copied,dismiss} + copyable_value: optional string for the clipboard block + Driven entirely by i18n; adding a new notice = adding one locale block. %> +<% scope = "migration_notices.#{notice[:key]}" %> + diff --git a/app/views/plaid_items/_auto_link_opener.html.erb b/app/views/plaid_items/_auto_link_opener.html.erb deleted file mode 100644 index 7e9c76950..000000000 --- a/app/views/plaid_items/_auto_link_opener.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -<%# locals: (link_token:, region:, item_id:, is_update: false) %> - -<%= tag.div data: { - controller: "plaid", - plaid_link_token_value: link_token, - plaid_region_value: region, - plaid_item_id_value: item_id, - plaid_is_update_value: is_update - } %> diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb deleted file mode 100644 index e7fd2254f..000000000 --- a/app/views/plaid_items/_plaid_item.html.erb +++ /dev/null @@ -1,107 +0,0 @@ -<%# locals: (plaid_item:) %> - -<%= tag.div id: dom_id(plaid_item) do %> -
- -
- <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> - -
- <% if plaid_item.logo.attached? %> - <%= image_tag plaid_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> - <% else %> -
- <%= tag.p plaid_item.name.first.upcase, class: "text-blue-600 text-xs font-medium" %> -
- <% end %> -
- -
-
- <%= tag.p plaid_item.name, class: "font-medium text-primary" %> - <% if plaid_item.scheduled_for_deletion? %> -

(deletion in progress...)

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

- <%= plaid_item.last_synced_at ? t(".status", timestamp: time_ago_in_words(plaid_item.last_synced_at)) : t(".status_never") %> -

- <% end %> -
-
- - <% if Current.user&.admin? %> -
- <% if plaid_item.requires_update? %> - <%= render DS::Link.new( - text: t(".update"), - icon: "refresh-cw", - variant: "secondary", - href: edit_plaid_item_path(plaid_item), - frame: "modal" - ) %> - <% elsif Rails.env.development? %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_plaid_item_path(plaid_item) - ) %> - <% end %> - - <%= render DS::Menu.new do |menu| %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - icon: "trash-2", - href: plaid_item_path(plaid_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(plaid_item.name, high_severity: true) - ) %> - <% end %> -
- <% end %> -
- - <% unless plaid_item.scheduled_for_deletion? %> -
- <% if plaid_item.accounts.any? %> - <%= render "accounts/index/account_groups", accounts: plaid_item.accounts %> - <% end %> - - <%# Sync summary (collapsible) - using shared ProviderSyncSummary component %> - <% stats = if defined?(@plaid_sync_stats_map) && @plaid_sync_stats_map - @plaid_sync_stats_map[plaid_item.id] || {} - else - plaid_item.syncs.ordered.first&.sync_stats || {} - end %> - <%= render ProviderSyncSummary.new( - stats: stats, - provider_item: plaid_item - ) %> - - <% if plaid_item.accounts.empty? %> -
-

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

-

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

-
- <% end %> -
- <% end %> -
-<% end %> diff --git a/app/views/plaid_items/edit.html.erb b/app/views/plaid_items/edit.html.erb deleted file mode 100644 index 4830ad4e3..000000000 --- a/app/views/plaid_items/edit.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<%# We render this in the empty modal frame so if Plaid flow is closed, user stays on same page they were on %> -<%= turbo_frame_tag "modal" do %> - <%= render "plaid_items/auto_link_opener", - link_token: @link_token, - region: @plaid_item.plaid_region, - item_id: @plaid_item.id, - is_update: true %> -<% end %> diff --git a/app/views/plaid_items/new.html.erb b/app/views/plaid_items/new.html.erb deleted file mode 100644 index b927d375d..000000000 --- a/app/views/plaid_items/new.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<%# We render this in the empty modal frame so if Plaid flow is closed, user stays on same page they were on %> -<%= turbo_frame_tag "modal" do %> - <%= render "plaid_items/auto_link_opener", - link_token: @link_token, - region: params[:region], - item_id: "", - is_update: false %> -<% end %> diff --git a/app/views/plaid_items/select_existing_account.html.erb b/app/views/plaid_items/select_existing_account.html.erb deleted file mode 100644 index 8b0cde2bc..000000000 --- a/app/views/plaid_items/select_existing_account.html.erb +++ /dev/null @@ -1,45 +0,0 @@ -<%= 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/provider_connections/_connection_card.html.erb b/app/views/provider_connections/_connection_card.html.erb new file mode 100644 index 000000000..75ee6abe8 --- /dev/null +++ b/app/views/provider_connections/_connection_card.html.erb @@ -0,0 +1,82 @@ +<%# locals: (connection:) %> + +<% linked_accounts = connection.provider_accounts.filter_map(&:account) %> + +<%= tag.div id: dom_id(connection) do %> +
+ +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+ <% if connection.logo_uri.present? %> + <%= image_tag connection.logo_uri, + class: "w-full h-full object-contain", + loading: "lazy", + alt: connection.institution_name %> + <% else %> + <%= tag.p connection.institution_name.first.upcase, class: "text-success text-xs font-medium" %> + <% end %> +
+ +
+

<%= connection.institution_name %>

+ <% if connection.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t("provider.connections.reauth_required") %> +
+ <% elsif connection.last_synced_at %> +

<%= t("provider.connections.last_synced", time: time_ago_in_words(connection.last_synced_at)) %>

+ <% else %> +

<%= t("provider.connections.never_synced") %>

+ <% end %> +
+
+ + <% if Current.user&.admin? %> +
+ <% if connection.requires_update? %> + <%= button_to t("provider.connections.reconnect"), + reauth_provider_connection_path(connection), + method: :post, + data: { turbo: false }, + class: "text-sm px-3 py-1.5 rounded-lg bg-destructive text-inverse hover:opacity-90 transition-opacity shrink-0" %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_provider_connection_path(connection), + disabled: false + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% if connection.pending_setup? %> + <% menu.with_item( + variant: "link", + text: t("provider.connections.setup_accounts"), + icon: "settings", + href: setup_provider_connection_path(connection), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "link", + text: t("provider.connections.manage"), + icon: "external-link", + href: provider_connection_path(connection), + frame: :_top + ) %> + <% end %> +
+ <% end %> +
+ + <% if linked_accounts.any? %> +
+ <%= render "accounts/index/account_groups", accounts: linked_accounts %> +
+ <% end %> +
+<% end %> diff --git a/app/views/provider_connections/select.html.erb b/app/views/provider_connections/select.html.erb new file mode 100644 index 000000000..7ce8d4d0f --- /dev/null +++ b/app/views/provider_connections/select.html.erb @@ -0,0 +1,55 @@ +<% primary_btn = "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover transition-colors" %> + +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% if @configured %> + <% dialog.with_header(title: t(".title_connect", provider: @adapter.display_name)) %> + <% dialog.with_body do %> + <% if @adapter.auth_class == Provider::Auth::OAuth2 %> +

<%= t(".connect_description_oauth", provider: @adapter.display_name) %>

+ <%= button_to t(".connect_button", provider: @adapter.display_name), + start_provider_oauth_path(provider_key: @provider_key), + method: :post, + data: { turbo: false }, + class: primary_btn %> + <% elsif @adapter.auth_class == Provider::Auth::EmbeddedLink %> +

<%= t(".connect_description_link", provider: @adapter.display_name) %>

+
+ <% @connect_actions.each do |action| %> + <%= link_to action[:label], action[:path], + class: primary_btn, + data: { turbo_frame: "_top" } %> + <% end %> +
+ <% end %> + <% end %> + <% else %> + <% dialog.with_header(title: t(".title_setup_required", provider: @adapter.display_name)) %> + <% dialog.with_body do %> +
+
+ <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> +
+

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

+

<%= t(".not_configured_body", provider: @adapter.display_name) %>

+
+
+ +
+

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

+
    +
  1. <%= t(".setup_step_1") %>
  2. +
  3. <%= t(".setup_step_2", provider: @adapter.display_name) %>
  4. +
  5. <%= t(".setup_step_3") %>
  6. +
+
+ + <%= link_to t(".go_to_settings"), + settings_providers_path, + class: primary_btn, + data: { turbo_frame: "_top" } %> +
+ <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/provider_connections/setup.html.erb b/app/views/provider_connections/setup.html.erb new file mode 100644 index 000000000..a743692c2 --- /dev/null +++ b/app/views/provider_connections/setup.html.erb @@ -0,0 +1,97 @@ +<%= content_for :page_title, t(".title") %> + +
+

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

+

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

+ + <% if @unlinked.empty? %> + <%# discover_accounts! is best-effort post-create; if it failed (network %> + <%# blip, transient upstream error) the connection exists but no provider %> + <%# accounts were ever fetched. Surface the error and offer Retry sync. %> +
+
+ <%= icon("alert-circle", class: "w-5 h-5 text-destructive shrink-0 mt-0.5") %> +
+

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

+

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

+ <% if @connection.read_attribute(:sync_error).present? %> +

<%= @connection.read_attribute(:sync_error) %>

+ <% end %> +
+
+
+
+ <%= link_to t(".cancel"), settings_providers_path, + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-primary border border-primary hover:bg-surface-hover transition-colors" %> + <%= button_to t(".retry_sync"), + sync_provider_connection_path(@connection), + method: :post, + data: { turbo: false }, + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover transition-colors" %> +
+ <% else %> + <% if @stale.any? %> + <%# Accounts whose external_id no longer appears in upstream discovery %> + <%# responses. Could be a closed bank account or removed from the user's %> + <%# online banking. We surface them so the user is aware; the syncer %> + <%# does not auto-delete since the connection still works for siblings. %> +
+
+ <%= icon("alert-circle", class: "w-5 h-5 text-warning shrink-0 mt-0.5") %> +
+

<%= t(".stale_heading", count: @stale.size) %>

+

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

+
    + <% @stale.each do |pa| %> +
  • <%= pa.external_name %> (<%= pa.currency %>)
  • + <% end %> +
+
+
+
+ <% end %> + + <%= form_with url: save_setup_provider_connection_path(@connection), data: { controller: "account-mapping" } do |f| %> +
+ + +

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

+
+ +
+ <% @unlinked.each do |pa| %> +
+ <%= pa.external_name %> (<%= pa.currency %>) + <%= f.select "mappings[#{pa.id}]", + [[ t(".create_new"), "new" ]] + @accounts.map { |a| [a.name, a.id] }, + { include_blank: t(".skip") }, + class: "rounded-md border border-primary text-sm p-1 text-primary bg-container", + data: { account_mapping_target: "mapping" } %> +
+ <% end %> +
+ +
+ <%# `