Harden SimpleFIN sync: protect user data, fix stuck syncs, optimize API calls (#671)

* Implement entry protection flags for sync overwrites

- Added `user_modified` and `import_locked` flags to `entries` table to prevent provider sync from overwriting user-edited and imported data.
- Introduced backfill migration to mark existing entries based on conditions.
- Enhanced sync and processing logic to respect protection flags, track skipped entries, and log detailed stats.
- Updated UI to display skipped/protected entries and reasons in sync summaries.

* Localize error details summary text and adjust `sync_account_later` method placement

* Restored schema.rb

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
LPW
2026-01-16 06:34:06 -05:00
committed by GitHub
parent 9b1188eab4
commit c391ba2b23
20 changed files with 344 additions and 37 deletions

View File

@@ -37,6 +37,37 @@
<span><%= t("provider_sync_summary.transactions.updated", count: tx_updated) %></span>
<span><%= t("provider_sync_summary.transactions.skipped", count: tx_skipped) %></span>
</div>
<%# Protected entries detail - shown when entries were skipped due to protection %>
<% if has_skipped_entries? %>
<div class="mt-2">
<div class="flex items-center gap-1">
<%= helpers.icon "shield-check", size: "sm" %>
<span class="text-secondary"><%= t("provider_sync_summary.transactions.protected", count: tx_skipped) %></span>
</div>
<% if skip_summary.any? %>
<div class="text-xs text-secondary mt-1">
<% skip_summary.each do |reason, count| %>
<span class="mr-2"><%= t("provider_sync_summary.skip_reasons.#{reason}", default: reason.humanize) %>: <%= count %></span>
<% end %>
</div>
<% end %>
<% if skip_details.any? %>
<details class="mt-1">
<summary class="text-xs cursor-pointer text-secondary hover:text-primary">
<%= t("provider_sync_summary.transactions.view_protected") %>
</summary>
<div class="mt-1 pl-2 border-l-2 border-surface-inset space-y-1">
<% skip_details.each do |detail| %>
<p class="text-xs text-secondary">
<%= detail["name"] %> (<%= t("provider_sync_summary.skip_reasons.#{detail["reason"]}", default: detail["reason"].humanize) %>)
</p>
<% end %>
</div>
</details>
<% end %>
</div>
<% end %>
</div>
<% end %>
@@ -62,6 +93,18 @@
<% end %>
<% if has_errors? %>
<span class="text-destructive"><%= t("provider_sync_summary.health.errors", count: total_errors) %></span>
<% if error_details.any? %>
<details class="mt-1">
<summary class="text-xs cursor-pointer text-secondary hover:text-primary"><%= t("provider_sync_summary.health.view_error_details") %></summary>
<div class="mt-1 pl-2 border-l-2 border-destructive/30 space-y-1">
<% error_details.each do |detail| %>
<p class="text-xs text-destructive">
<% if detail["name"].present? %><strong><%= detail["name"] %>:</strong> <% end %><%= detail["message"] %>
</p>
<% end %>
</div>
</details>
<% end %>
<% elsif import_started? %>
<span class="text-success"><%= t("provider_sync_summary.health.errors", count: 0) %></span>
<% else %>

View File

@@ -68,6 +68,19 @@ class ProviderSyncSummary < ViewComponent::Base
stats.key?("tx_seen") || stats.key?("tx_imported") || stats.key?("tx_updated")
end
# Skip statistics (protected entries not overwritten)
def has_skipped_entries?
tx_skipped > 0
end
def skip_summary
stats["skip_summary"] || {}
end
def skip_details
stats["skip_details"] || []
end
# Holdings statistics
def holdings_found
stats["holdings_found"].to_i
@@ -127,6 +140,14 @@ class ProviderSyncSummary < ViewComponent::Base
total_errors > 0
end
def error_details
stats["errors"] || []
end
def error_buckets
stats["error_buckets"] || {}
end
# Stale pending transactions (auto-excluded)
def stale_pending_excluded
stats["stale_pending_excluded"].to_i