From 2aecddbce9f4a43694889602331e5fa4ab0cdc96 Mon Sep 17 00:00:00 2001 From: bittensorrider <280573057+bittensorrider@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:58:54 +0800 Subject: [PATCH 1/6] Allow CSV backfill into already-linked accounts PSD2/Enable Banking only exposes ~90 days of history. Mapping and upload previously hid linked accounts, so there was no way to import older CSV rows once a connection existed. Target writable accounts (including linked ones) and skip CSV rows that already arrived via provider sync instead of creating duplicates. Co-authored-by: Cursor --- app/controllers/import/mappings_controller.rb | 6 ++- app/controllers/import/uploads_controller.rb | 4 +- app/controllers/imports_controller.rb | 4 +- app/models/import/account_mapping.rb | 14 +++++- app/models/transaction_import.rb | 16 +++++-- app/views/import/uploads/show.html.erb | 6 +-- .../import/mappings_controller_test.rb | 43 +++++++++++++++++ test/controllers/imports_controller_test.rb | 4 +- test/models/transaction_import_test.rb | 46 +++++++++++++++++++ 9 files changed, 128 insertions(+), 15 deletions(-) diff --git a/app/controllers/import/mappings_controller.rb b/app/controllers/import/mappings_controller.rb index 098c401010..06de2b218a 100644 --- a/app/controllers/import/mappings_controller.rb +++ b/app/controllers/import/mappings_controller.rb @@ -24,7 +24,11 @@ def set_import def mappable return nil unless mappable_class.present? - @mappable ||= mappable_class.find_by(id: mapping_params[:mappable_id], family: Current.family) + if mappable_class == Account + Current.family.accounts.writable_by(Current.user).find_by(id: mapping_params[:mappable_id]) + else + mappable_class.find_by(id: mapping_params[:mappable_id], family: Current.family) + end end def create_when_empty diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index 076452a88a..4176b2ea9a 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -19,7 +19,7 @@ def update elsif @import.is_a?(SureImport) update_sure_import_upload elsif csv_valid?(csv_str) - @import.account = import_account_id.present? ? accessible_accounts.find(import_account_id) : nil + @import.account = import_account_id.present? ? Current.family.accounts.writable_by(Current.user).find(import_account_id) : nil @import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep]) @import.save!(validate: false) @@ -78,7 +78,7 @@ def handle_qif_upload end ActiveRecord::Base.transaction do - @import.account = accessible_accounts.find(import_account_id) + @import.account = Current.family.accounts.writable_by(Current.user).find(import_account_id) @import.raw_file_str = QifParser.normalize_encoding(csv_str) @import.save!(validate: false) @import.generate_rows_from_csv diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 3569561682..f802c6c581 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -9,7 +9,7 @@ def update account_id = params.dig(:pdf_import, :account_id) || params.dig(:import, :account_id) if account_id.present? - account = accessible_accounts.find_by(id: account_id) + account = Current.family.accounts.writable_by(Current.user).find_by(id: account_id) unless account redirect_back_or_to import_path(@import), alert: t("imports.update.invalid_account", default: "Account not found.") return @@ -98,7 +98,7 @@ def create type = params.dig(:import, :type).to_s type = "TransactionImport" unless Import::TYPES.include?(type) - account = accessible_accounts.find_by(id: params.dig(:import, :account_id)) + account = Current.family.accounts.writable_by(Current.user).find_by(id: params.dig(:import, :account_id)) import = Current.family.imports.create!( type: type, account: account, diff --git a/app/models/import/account_mapping.rb b/app/models/import/account_mapping.rb index 67280da41a..0c42bfb093 100644 --- a/app/models/import/account_mapping.rb +++ b/app/models/import/account_mapping.rb @@ -4,14 +4,24 @@ class Import::AccountMapping < Import::Mapping class << self def mappables_by_key(import) unique_values = import.rows.map(&:account).uniq - accounts = import.family.accounts.where(name: unique_values).index_by(&:name) + accounts = importable_accounts(import).where(name: unique_values).index_by(&:name) unique_values.index_with { |value| accounts[value] } end + + # Linked (provider-managed) accounts are valid CSV targets so users can + # backfill history after connecting. When Current.user is set, restrict to + # accounts they can write; otherwise fall back to the family (e.g. jobs). + def importable_accounts(import) + scope = import.family.accounts + return scope unless Current.user + + scope.writable_by(Current.user) + end end def selectable_values - family_accounts = import.family.accounts.manual.alphabetically.map { |account| [ account.name, account.id ] } + family_accounts = self.class.importable_accounts(import).visible.alphabetically.map { |account| [ account.name, account.id ] } unless key.blank? family_accounts.unshift [ "Add as new account", CREATE_NEW_KEY ] diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index 8b93431e68..c5d88073ee 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -33,24 +33,34 @@ def import! # Check for duplicate transactions using the adapter's deduplication logic # Pass claimed_entry_ids to exclude entries we've already matched in this import # This ensures identical rows within the CSV are all imported as separate transactions + # + # include_provider_entries: true so CSV backfills into already-linked + # accounts (e.g. Enable Banking's ~90-day window) do not recreate rows + # that already arrived via sync. Mirror PDF statement reconciliation. adapter = Account::ProviderImportAdapter.new(mapped_account) duplicate_entry = adapter.find_duplicate_transaction( date: row.date_iso, amount: row.signed_amount, currency: effective_currency, name: row.name, - exclude_entry_ids: claimed_entry_ids + exclude_entry_ids: claimed_entry_ids, + include_provider_entries: true ) if duplicate_entry - # Update existing transaction instead of creating a new one + claimed_entry_ids.add(duplicate_entry.id) + + # Already synced from a provider — skip creating a CSV duplicate and + # do not mark the provider-owned row import_locked. + next if duplicate_entry.external_id.present? + + # Update existing manual/CSV transaction instead of creating a new one duplicate_entry.transaction.category = category if category.present? duplicate_entry.transaction.tags = tags if tags.any? duplicate_entry.notes = row.notes if row.notes.present? duplicate_entry.import = self duplicate_entry.import_locked = true # Protect from provider sync overwrites updated_entries << duplicate_entry - claimed_entry_ids.add(duplicate_entry.id) else # Create new transaction (no duplicate found) # Mark as import_locked to protect from provider sync overwrites diff --git a/app/views/import/uploads/show.html.erb b/app/views/import/uploads/show.html.erb index 7d19818c68..eaa54301a6 100644 --- a/app/views/import/uploads/show.html.erb +++ b/app/views/import/uploads/show.html.erb @@ -56,7 +56,7 @@ <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-4" do |form| %> <%= form.select :account_id, - Current.user.accessible_accounts.visible.alphabetically.pluck(:name, :id), + Current.family.accounts.writable_by(Current.user).visible.alphabetically.pluck(:name, :id), { label: t(".qif_account_label"), include_blank: t(".qif_account_placeholder"), selected: @import.account_id }, required: true %> @@ -112,7 +112,7 @@ <%= form.select :col_sep, Import.separator_options, label: true %> <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> - <%= form.select :account_id, Current.user.accessible_accounts.visible.alphabetically.pluck(:name, :id), { label: t(".account_optional_label"), include_blank: t(".multi_account_import"), selected: @import.account_id } %> + <%= form.select :account_id, Current.family.accounts.writable_by(Current.user).visible.alphabetically.pluck(:name, :id), { label: t(".account_optional_label"), include_blank: t(".multi_account_import"), selected: @import.account_id } %> <% end %>