diff --git a/app/controllers/trades_controller.rb b/app/controllers/trades_controller.rb index f131c47ef..e3061a719 100644 --- a/app/controllers/trades_controller.rb +++ b/app/controllers/trades_controller.rb @@ -1,6 +1,8 @@ class TradesController < ApplicationController include EntryableResource + before_action :set_entry_for_unlock, only: :unlock + # Defaults to a buy trade def new @account = Current.family.accounts.find_by(id: params[:account_id]) @@ -17,6 +19,12 @@ class TradesController < ApplicationController @model = Trade::CreateForm.new(create_params.merge(account: @account)).create if @model.persisted? + # Mark manually created entries as user-modified to protect from sync + if @model.is_a?(Entry) + @model.lock_saved_attributes! + @model.mark_user_modified! + end + flash[:notice] = t("entries.create.success") respond_to do |format| @@ -30,19 +38,28 @@ class TradesController < ApplicationController def update if @entry.update(update_entry_params) + @entry.lock_saved_attributes! @entry.mark_user_modified! @entry.sync_account_later + # Reload to ensure fresh state for turbo stream rendering + @entry.reload + respond_to do |format| format.html { redirect_back_or_to account_path(@entry.account), notice: t("entries.update.success") } format.turbo_stream do render turbo_stream: [ turbo_stream.replace( - "header_entry_#{@entry.id}", + dom_id(@entry, :header), partial: "trades/header", locals: { entry: @entry } ), - turbo_stream.replace("entry_#{@entry.id}", partial: "entries/entry", locals: { entry: @entry }) + turbo_stream.replace( + dom_id(@entry, :protection), + partial: "entries/protection_indicator", + locals: { entry: @entry, unlock_path: unlock_trade_path(@entry.trade) } + ), + turbo_stream.replace(@entry) ] end end @@ -51,7 +68,19 @@ class TradesController < ApplicationController end end + def unlock + @entry.unlock_for_sync! + flash[:notice] = t("entries.unlock.success") + + redirect_back_or_to account_path(@entry.account) + end + private + def set_entry_for_unlock + trade = Current.family.trades.find(params[:id]) + @entry = trade.entry + end + def entry_params params.require(:entry).permit( :name, :date, :amount, :currency, :excluded, :notes, :nature, diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index c288c1986..a2210f3f6 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -1,6 +1,7 @@ class TransactionsController < ApplicationController include EntryableResource + before_action :set_entry_for_unlock, only: :unlock before_action :store_params!, only: :index def new @@ -68,6 +69,7 @@ class TransactionsController < ApplicationController if @entry.save @entry.sync_account_later @entry.lock_saved_attributes! + @entry.mark_user_modified! @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any? flash[:notice] = "Transaction created" @@ -98,6 +100,9 @@ class TransactionsController < ApplicationController @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any? @entry.sync_account_later + # Reload to ensure fresh state for turbo stream rendering + @entry.reload + respond_to do |format| format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" } format.turbo_stream do @@ -107,6 +112,11 @@ class TransactionsController < ApplicationController partial: "transactions/header", locals: { entry: @entry } ), + turbo_stream.replace( + dom_id(@entry, :protection), + partial: "entries/protection_indicator", + locals: { entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) } + ), turbo_stream.replace(@entry), *flash_notification_stream_items ] @@ -206,7 +216,7 @@ class TransactionsController < ApplicationController original_name: @entry.name, original_date: I18n.l(@entry.date, format: :long)) - @entry.account.entries.create!( + new_entry = @entry.account.entries.create!( name: params[:trade_name] || Trade.build_name(is_sell ? "sell" : "buy", qty, security.ticker), date: @entry.date, amount: signed_amount, @@ -221,6 +231,10 @@ class TransactionsController < ApplicationController ) ) + # Mark the new trade as user-modified to protect from sync + new_entry.lock_saved_attributes! + new_entry.mark_user_modified! + # Mark original transaction as excluded (soft delete) @entry.update!(excluded: true) end @@ -235,6 +249,13 @@ class TransactionsController < ApplicationController redirect_back_or_to transactions_path, status: :see_other end + def unlock + @entry.unlock_for_sync! + flash[:notice] = t("entries.unlock.success") + + redirect_back_or_to transactions_path + end + def mark_as_recurring transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) @@ -286,6 +307,11 @@ class TransactionsController < ApplicationController end private + def set_entry_for_unlock + transaction = Current.family.transactions.find(params[:id]) + @entry = transaction.entry + end + def needs_rule_notification?(transaction) return false if Current.user.rule_prompts_disabled diff --git a/app/models/entry.rb b/app/models/entry.rb index 31118db45..40a2cb102 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -260,6 +260,50 @@ class Entry < ApplicationRecord update!(user_modified: true) end + # Returns the reason this entry is protected from sync, or nil if not protected. + # Priority: excluded > user_modified > import_locked + # + # @return [Symbol, nil] :excluded, :user_modified, :import_locked, or nil + def protection_reason + return :excluded if excluded? + return :user_modified if user_modified? + return :import_locked if import_locked? + nil + end + + # Returns array of field names that are locked on entry and entryable. + # + # @return [Array] locked field names + def locked_field_names + entry_keys = locked_attributes&.keys || [] + entryable_keys = entryable&.locked_attributes&.keys || [] + (entry_keys + entryable_keys).uniq + end + + # Returns hash of locked field names to their lock timestamps. + # Combines locked_attributes from both entry and entryable. + # Parses ISO8601 timestamps stored in locked_attributes. + # + # @return [Hash{String => Time}] field name to lock timestamp + def locked_fields_with_timestamps + combined = (locked_attributes || {}).merge(entryable&.locked_attributes || {}) + combined.transform_values do |timestamp| + Time.zone.parse(timestamp.to_s) rescue timestamp + end + end + + # Clears protection flags so provider sync can update this entry again. + # Clears user_modified, import_locked flags, and all locked_attributes + # on both the entry and its entryable. + # + # @return [void] + def unlock_for_sync! + self.class.transaction do + update!(user_modified: false, import_locked: false, locked_attributes: {}) + entryable&.update!(locked_attributes: {}) + end + end + class << self def search(params) EntrySearch.new(params).build_query(all) diff --git a/app/views/entries/_protection_indicator.html.erb b/app/views/entries/_protection_indicator.html.erb new file mode 100644 index 000000000..922408c2b --- /dev/null +++ b/app/views/entries/_protection_indicator.html.erb @@ -0,0 +1,42 @@ +<%# locals: (entry:, unlock_path:) %> + +<%# Protection indicator - shows when entry is protected from sync overwrites %> +<%= turbo_frame_tag dom_id(entry, :protection) do %> + <% if entry.protected_from_sync? && !entry.excluded? %> +
+ + <%= icon "lock", size: "sm", class: "text-secondary" %> + <%= t("entries.protection.title") %> + <%= icon "chevron-down", size: "sm", class: "text-secondary transition-transform [[open]>&]:rotate-180" %> + +
+

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

+ + <% if entry.locked_field_names.any? %> +
+

<%= t("entries.protection.locked_fields_label") %>

+ <% entry.locked_fields_with_timestamps.each do |field, timestamp| %> +
+ <%= field.humanize %> + <%= timestamp.respond_to?(:strftime) ? l(timestamp.to_date, format: :long) : timestamp %> +
+ <% end %> +
+ <% end %> + + <%= link_to unlock_path, + class: "w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-secondary text-primary hover:bg-surface-hover transition-colors", + data: { + turbo_method: :post, + turbo_confirm: t("entries.protection.unlock_confirm"), + turbo_frame: "_top" + } do %> + <%= icon "unlock", size: "sm" %> + <%= t("entries.protection.unlock_button") %> + <% end %> +
+
+ <% end %> +<% end %> diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb index 40ec53d05..b14a7527c 100644 --- a/app/views/trades/_trade.html.erb +++ b/app/views/trades/_trade.html.erb @@ -4,7 +4,7 @@ <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(trade) do %> -
text-sm font-medium p-4"> +
text-sm font-medium p-4">
<%= check_box_tag dom_id(entry, "selection"), class: "checkbox checkbox--light hidden lg:block", @@ -44,7 +44,16 @@ <%= render "investment_activity/quick_edit_badge", entry: entry, entryable: trade %>
-
+
+ <%# Protection indicator - shows on hover when entry is protected from sync %> + <% if entry.protected_from_sync? && !entry.excluded? %> + <%= link_to entry_path(entry), + data: { turbo_frame: "drawer", turbo_prefetch: false }, + class: "invisible group-hover:visible transition-opacity", + title: t("entries.protection.tooltip") do %> + <%= icon "lock", size: "sm", class: "text-secondary" %> + <% end %> + <% end %> <%= content_tag :p, format_money(-entry.amount_money), class: ["text-green-600": entry.amount.negative?] %> diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb index 6bc7f885a..c60543441 100644 --- a/app/views/trades/show.html.erb +++ b/app/views/trades/show.html.erb @@ -6,6 +6,8 @@ <% trade = @entry.trade %> <% dialog.with_body do %> + <%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_trade_path(trade) %> + <% dialog.with_section(title: t(".details"), open: true) do %>
<%= styled_form_with model: @entry, diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index c67c0b9b4..4411cc6af 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -4,7 +4,7 @@ <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(transaction) do %> -
"> +
">
<%= check_box_tag dom_id(entry, "selection"), @@ -145,7 +145,16 @@ <% end %>
-
+
+ <%# Protection indicator - shows on hover when entry is protected from sync %> + <% if entry.protected_from_sync? && !entry.excluded? %> + <%= link_to entry_path(entry), + data: { turbo_frame: "drawer", turbo_prefetch: false }, + class: "invisible group-hover:visible transition-opacity", + title: t("entries.protection.tooltip") do %> + <%= icon "lock", size: "sm", class: "text-secondary" %> + <% end %> + <% end %> <%= content_tag :p, transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money), class: ["text-green-600": entry.amount.negative?] %> diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 4fa75711d..5ec582353 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -45,6 +45,8 @@ <% end %> <% end %> + <%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) %> + <% dialog.with_section(title: t(".overview"), open: true) do %>
<%= styled_form_with model: @entry, diff --git a/config/locales/views/entries/en.yml b/config/locales/views/entries/en.yml index 4303d73f7..f99eb3e6d 100644 --- a/config/locales/views/entries/en.yml +++ b/config/locales/views/entries/en.yml @@ -12,3 +12,12 @@ en: loading: Loading entries... update: success: Entry updated + unlock: + success: Entry unlocked. It may be updated on next sync. + protection: + tooltip: Protected from sync + title: Protected from sync + description: Your edits to this entry won't be overwritten by provider sync. + locked_fields_label: "Locked fields:" + unlock_button: Allow sync to update + unlock_confirm: Allow sync to update this entry? Your changes may be overwritten on the next sync. diff --git a/config/routes.rb b/config/routes.rb index 96ad6a28d..428c589dc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -231,7 +231,11 @@ Rails.application.routes.draw do post :reset_security end end - resources :trades, only: %i[show new create update destroy] + resources :trades, only: %i[show new create update destroy] do + member do + post :unlock + end + end resources :valuations, only: %i[show new create update destroy] do post :confirm_create, on: :collection post :confirm_update, on: :member @@ -257,6 +261,7 @@ Rails.application.routes.draw do post :mark_as_recurring post :merge_duplicate post :dismiss_duplicate + post :unlock end end diff --git a/test/controllers/trades_controller_test.rb b/test/controllers/trades_controller_test.rb index 0cb4d89a9..21e34249a 100644 --- a/test/controllers/trades_controller_test.rb +++ b/test/controllers/trades_controller_test.rb @@ -156,4 +156,111 @@ class TradesControllerTest < ActionDispatch::IntegrationTest assert_enqueued_with job: SyncJob assert_redirected_to account_url(created_entry.account) end + + test "unlock clears protection flags on user-modified entry" do + # Mark as protected with locked_attributes on both entry and entryable + @entry.update!(user_modified: true, locked_attributes: { "name" => Time.current.iso8601 }) + @entry.trade.update!(locked_attributes: { "qty" => Time.current.iso8601 }) + + assert @entry.reload.protected_from_sync? + + post unlock_trade_path(@entry.trade) + + assert_redirected_to account_path(@entry.account) + assert_equal "Entry unlocked. It may be updated on next sync.", flash[:notice] + + @entry.reload + assert_not @entry.user_modified? + assert_empty @entry.locked_attributes, "Entry locked_attributes should be cleared" + assert_empty @entry.trade.locked_attributes, "Trade locked_attributes should be cleared" + assert_not @entry.protected_from_sync? + end + + test "unlock clears import_locked flag" do + @entry.update!(import_locked: true) + + assert @entry.reload.protected_from_sync? + + post unlock_trade_path(@entry.trade) + + assert_redirected_to account_path(@entry.account) + @entry.reload + assert_not @entry.import_locked? + assert_not @entry.protected_from_sync? + end + + test "update locks saved attributes" do + assert_not @entry.user_modified? + assert_empty @entry.trade.locked_attributes + + patch trade_url(@entry), params: { + entry: { + currency: "USD", + entryable_attributes: { + id: @entry.entryable_id, + qty: 50, + price: 25 + } + } + } + + @entry.reload + assert @entry.user_modified? + assert @entry.trade.locked_attributes.key?("qty") + assert @entry.trade.locked_attributes.key?("price") + end + + test "turbo stream update includes lock icon for protected entry" do + assert_not @entry.user_modified? + + patch trade_url(@entry), params: { + entry: { + currency: "USD", + nature: "outflow", + entryable_attributes: { + id: @entry.entryable_id, + qty: 50, + price: 25 + } + } + }, as: :turbo_stream + + assert_response :success + assert_match(/turbo-stream/, response.content_type) + # The turbo stream should contain the lock icon link with protection tooltip + assert_match(/title="Protected from sync"/, response.body) + # And should contain the lock SVG (the path for lock icon) + assert_match(/M7 11V7a5 5 0 0 1 10 0v4/, response.body) + end + + test "quick edit badge update locks activity label" do + assert_not @entry.user_modified? + assert_empty @entry.trade.locked_attributes + original_label = @entry.trade.investment_activity_label + + # Mimic the quick edit badge JSON request + patch trade_url(@entry), + params: { + entry: { + entryable_attributes: { + id: @entry.entryable_id, + investment_activity_label: original_label == "Buy" ? "Sell" : "Buy" + } + } + }.to_json, + headers: { + "Content-Type" => "application/json", + "Accept" => "text/vnd.turbo-stream.html" + } + + assert_response :success + assert_match(/turbo-stream/, response.content_type) + # The turbo stream should contain the lock icon + assert_match(/title="Protected from sync"/, response.body) + + @entry.reload + assert @entry.user_modified?, "Entry should be marked as user_modified" + assert @entry.trade.locked_attributes.key?("investment_activity_label"), "investment_activity_label should be locked" + assert @entry.protected_from_sync?, "Entry should be protected from sync" + end end diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index f737ef820..2e3cf5e51 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -283,4 +283,49 @@ end assert_redirected_to transactions_path assert_equal "An unexpected error occurred while creating the recurring transaction", flash[:alert] end + + test "unlock clears protection flags on user-modified entry" do + family = families(:empty) + sign_in users(:empty) + account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new + entry = create_transaction(account: account, amount: 100) + transaction = entry.entryable + + # Mark as protected with locked_attributes on both entry and entryable + entry.update!(user_modified: true, locked_attributes: { "date" => Time.current.iso8601 }) + transaction.update!(locked_attributes: { "category_id" => Time.current.iso8601 }) + + assert entry.reload.protected_from_sync? + + post unlock_transaction_path(transaction) + + assert_redirected_to transactions_path + assert_equal "Entry unlocked. It may be updated on next sync.", flash[:notice] + + entry.reload + assert_not entry.user_modified? + assert_empty entry.locked_attributes, "Entry locked_attributes should be cleared" + assert_empty entry.entryable.locked_attributes, "Transaction locked_attributes should be cleared" + assert_not entry.protected_from_sync? + end + + test "unlock clears import_locked flag" do + family = families(:empty) + sign_in users(:empty) + account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new + entry = create_transaction(account: account, amount: 100) + transaction = entry.entryable + + # Mark as import locked + entry.update!(import_locked: true) + + assert entry.reload.protected_from_sync? + + post unlock_transaction_path(transaction) + + assert_redirected_to transactions_path + entry.reload + assert_not entry.import_locked? + assert_not entry.protected_from_sync? + end end