Skip to content

Commit 7a81bf5

Browse files
luckyPipewrenchalessiocappa
authored andcommitted
Simplefin sync improvements (we-promise#240)
* Fix syncing issues with new connections and accounts.. - Keep SimpleFin institution metadata strictly per account (`simplefin_accounts.org_data`). - Relax `simplefin_items` institution constraints to allow creating items before org data exists. - Remove code that copied the first account’s `org` onto `simplefin_items`. * Improve Simplefin Sync • SimpleFin: family “Sync” includes SimpleFin items; importer does unbounded discovery (with pending=1 fallback) before windowed fetch, for both regular and first syncs. • Stop populating item‑level institution fields; keep institution metadata per account. • Relax NOT NULL on item institution fields. • Post‑sync dashboard broadcasts are now guarded (UI cannot fail the job). • Show a friendly “daily refresh limit” banner on the SimpleFin card when the latest sync is rate‑limited. • Add bin/rails sure:simplefin:debug[ITEM_ID] to print latest sync, snapshot account count, simplefin_accounts count, and unlinked list. * Fixed double‑quoted strings, spacing around array brackets and commas * chore: ignore local .junie files * - Broadcast error logs now include full backtraces - SimpleFin discovery logic deduplicated fixed - app/models/simplefin_item/importer.rb --Added a concise docstring for perform_account_discovery describing purpose, steps, and side‑effects. --Added a docstring for fetch_accounts_data describing params and return value.
1 parent fddd438 commit 7a81bf5

File tree

9 files changed

+181
-26
lines changed

9 files changed

+181
-26
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,6 @@ scripts/
106106
.windsurfrules
107107
.cursor/rules/dev_workflow.mdc
108108
.cursor/rules/taskmaster.mdc
109+
110+
# Local Junie helpers
111+
.junie/

app/models/family/sync_complete_event.rb

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,27 @@ def initialize(family)
66
end
77

88
def broadcast
9-
family.broadcast_replace(
10-
target: "balance-sheet",
11-
partial: "pages/dashboard/balance_sheet",
12-
locals: { balance_sheet: family.balance_sheet }
13-
)
9+
# Dashboard partials can occasionally raise when rendered from background jobs
10+
# (e.g., if intermediate series values are nil during a sync). Make broadcasts
11+
# resilient so a post-sync UI refresh never causes the overall sync to report an error.
12+
begin
13+
family.broadcast_replace(
14+
target: "balance-sheet",
15+
partial: "pages/dashboard/balance_sheet",
16+
locals: { balance_sheet: family.balance_sheet }
17+
)
18+
rescue => e
19+
Rails.logger.error("Family::SyncCompleteEvent balance_sheet broadcast failed: #{e.message}\n#{e.backtrace&.join("\n")}")
20+
end
1421

15-
family.broadcast_replace(
16-
target: "net-worth-chart",
17-
partial: "pages/dashboard/net_worth_chart",
18-
locals: { balance_sheet: family.balance_sheet, period: Period.last_30_days }
19-
)
22+
begin
23+
family.broadcast_replace(
24+
target: "net-worth-chart",
25+
partial: "pages/dashboard/net_worth_chart",
26+
locals: { balance_sheet: family.balance_sheet, period: Period.last_30_days }
27+
)
28+
rescue => e
29+
Rails.logger.error("Family::SyncCompleteEvent net_worth_chart broadcast failed: #{e.message}\n#{e.backtrace&.join("\n")}")
30+
end
2031
end
2132
end

app/models/family/syncer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,6 @@ def perform_post_sync
2626

2727
private
2828
def child_syncables
29-
family.plaid_items + family.enable_banking_items + family.accounts.manual
29+
family.plaid_items + family.simplefin_items.active + family.enable_banking_items + family.accounts.manual
3030
end
3131
end

app/models/simplefin_item.rb

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,8 @@ def upsert_simplefin_snapshot!(accounts_snapshot)
5454
raw_payload: accounts_snapshot,
5555
)
5656

57-
# Extract institution data from the first account if available
58-
snapshot = accounts_snapshot.to_h.with_indifferent_access
59-
if snapshot[:accounts].present?
60-
first_account = snapshot[:accounts].first
61-
org = first_account[:org]
62-
upsert_institution_data!(org) if org.present?
63-
end
57+
# Do not populate item-level institution fields from account data.
58+
# Institution metadata belongs to each simplefin_account (in org_data).
6459

6560
save!
6661
end
@@ -153,6 +148,26 @@ def institution_summary
153148
end
154149
end
155150

151+
# Detect a recent rate-limited sync and return a friendly message, else nil
152+
def rate_limited_message
153+
latest = latest_sync
154+
return nil unless latest
155+
156+
# Some Sync records may not have a status_text column; guard with respond_to?
157+
parts = []
158+
parts << latest.error if latest.respond_to?(:error)
159+
parts << latest.status_text if latest.respond_to?(:status_text)
160+
msg = parts.compact.join(" — ")
161+
return nil if msg.blank?
162+
163+
down = msg.downcase
164+
if down.include?("make fewer requests") || down.include?("only refreshed once every 24 hours") || down.include?("rate limit")
165+
"You've hit SimpleFin's daily refresh limit. Please try again after the bridge refreshes (up to 24 hours)."
166+
else
167+
nil
168+
end
169+
end
170+
156171
private
157172
def remove_simplefin_item
158173
# SimpleFin doesn't require server-side cleanup like Plaid

app/models/simplefin_item/importer.rb

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
class SimplefinItem::Importer
2+
class RateLimitedError < StandardError; end
23
attr_reader :simplefin_item, :simplefin_provider
34

45
def initialize(simplefin_item, simplefin_provider:)
@@ -44,6 +45,10 @@ def import_with_chunked_history
4445
target_start_date = max_lookback_date
4546
end
4647

48+
# Pre-step: Unbounded discovery to ensure we see all accounts even if the
49+
# chunked window would otherwise filter out newly added, inactive accounts.
50+
perform_account_discovery
51+
4752
total_accounts_imported = 0
4853
chunk_count = 0
4954

@@ -100,30 +105,75 @@ def import_with_chunked_history
100105
end
101106

102107
def import_regular_sync
103-
start_date = determine_sync_start_date
108+
perform_account_discovery
104109

110+
# Step 2: Fetch transactions/holdings using the regular window.
111+
start_date = determine_sync_start_date
105112
accounts_data = fetch_accounts_data(start_date: start_date)
106113
return if accounts_data.nil? # Error already handled
107114

108115
# Store raw payload
109116
simplefin_item.upsert_simplefin_snapshot!(accounts_data)
110117

111-
# Import accounts
118+
# Import accounts (merges transactions/holdings into existing rows)
112119
accounts_data[:accounts]&.each do |account_data|
113120
import_account(account_data)
114121
end
115122
end
116123

117-
def fetch_accounts_data(start_date:, end_date: nil)
124+
#
125+
# Performs discovery of accounts in an unbounded way so providers that
126+
# filter by date windows cannot hide newly created upstream accounts.
127+
#
128+
# Steps:
129+
# - Request `/accounts` without dates; count results
130+
# - If zero, retry with `pending: true` (some bridges only reveal new/pending)
131+
# - If any accounts are returned, upsert a snapshot and import each account
132+
#
133+
# Returns nothing; side-effects are snapshot + account upserts.
134+
def perform_account_discovery
135+
discovery_data = fetch_accounts_data(start_date: nil)
136+
discovered_count = discovery_data&.dig(:accounts)&.size.to_i
137+
Rails.logger.info "SimpleFin discovery (no params) returned #{discovered_count} accounts"
138+
139+
if discovered_count.zero?
140+
discovery_data = fetch_accounts_data(start_date: nil, pending: true)
141+
discovered_count = discovery_data&.dig(:accounts)&.size.to_i
142+
Rails.logger.info "SimpleFin discovery (pending=1) returned #{discovered_count} accounts"
143+
end
144+
145+
if discovery_data && discovered_count > 0
146+
simplefin_item.upsert_simplefin_snapshot!(discovery_data)
147+
discovery_data[:accounts]&.each { |account_data| import_account(account_data) }
148+
end
149+
end
150+
151+
# Fetches accounts (and optionally transactions/holdings) from SimpleFin.
152+
#
153+
# Params:
154+
# - start_date: Date or nil — when provided, provider may filter by date window
155+
# - end_date: Date or nil — optional end of window
156+
# - pending: Boolean or nil — when true, ask provider to include pending/new
157+
#
158+
# Returns a Hash payload with keys like :accounts, or nil when an error is
159+
# handled internally via `handle_errors`.
160+
def fetch_accounts_data(start_date:, end_date: nil, pending: nil)
118161
# Debug logging to track exactly what's being sent to SimpleFin API
119-
days_requested = end_date ? (end_date.to_date - start_date.to_date).to_i : "unknown"
120-
Rails.logger.info "SimplefinItem::Importer - API Request: #{start_date.strftime('%Y-%m-%d')} to #{end_date&.strftime('%Y-%m-%d') || 'current'} (#{days_requested} days)"
162+
start_str = start_date.respond_to?(:strftime) ? start_date.strftime("%Y-%m-%d") : "none"
163+
end_str = end_date.respond_to?(:strftime) ? end_date.strftime("%Y-%m-%d") : "current"
164+
days_requested = if start_date && end_date
165+
(end_date.to_date - start_date.to_date).to_i
166+
else
167+
"unknown"
168+
end
169+
Rails.logger.info "SimplefinItem::Importer - API Request: #{start_str} to #{end_str} (#{days_requested} days)"
121170

122171
begin
123172
accounts_data = simplefin_provider.get_accounts(
124173
simplefin_item.access_url,
125174
start_date: start_date,
126-
end_date: end_date
175+
end_date: end_date,
176+
pending: pending
127177
)
128178
rescue Provider::Simplefin::SimplefinError => e
129179
# Handle authentication errors by marking item as requiring update
@@ -141,6 +191,12 @@ def fetch_accounts_data(start_date:, end_date: nil)
141191
return nil
142192
end
143193

194+
# Some servers return a top-level message/string rather than an errors array
195+
if accounts_data[:error].present?
196+
handle_errors([ accounts_data[:error] ])
197+
return nil
198+
end
199+
144200
accounts_data
145201
end
146202

@@ -224,6 +280,13 @@ def handle_errors(errors)
224280
simplefin_item.update!(status: :requires_update)
225281
end
226282

283+
# Detect and surface rate-limit specifically with a friendlier exception
284+
if error_messages.downcase.include?("make fewer requests") ||
285+
error_messages.downcase.include?("only refreshed once every 24 hours") ||
286+
error_messages.downcase.include?("rate limit")
287+
raise RateLimitedError, "SimpleFin rate limit: data refreshes at most once every 24 hours. Try again later."
288+
end
289+
227290
raise Provider::Simplefin::SimplefinError.new(
228291
"SimpleFin API errors: #{error_messages}",
229292
:api_error

app/views/simplefin_items/_simplefin_item.html.erb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@
3838
<%= icon "alert-triangle", size: "sm", color: "warning" %>
3939
<%= tag.span t(".requires_update") %>
4040
</div>
41+
<% elsif simplefin_item.rate_limited_message.present? %>
42+
<div class="text-warning flex items-center gap-1">
43+
<%= icon "clock", size: "sm", color: "warning" %>
44+
<%= tag.span simplefin_item.rate_limited_message %>
45+
</div>
4146
<% elsif simplefin_item.sync_error.present? %>
4247
<div class="text-secondary flex items-center gap-1">
4348
<%= render DS::Tooltip.new(text: simplefin_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
class RelaxSimplefinItemInstitutionConstraints < ActiveRecord::Migration[7.2]
2+
def up
3+
# SimpleFin doesn't guarantee institution metadata on first fetch,
4+
# so these fields must be optional.
5+
change_column_null :simplefin_items, :institution_id, true
6+
change_column_null :simplefin_items, :institution_name, true
7+
end
8+
9+
def down
10+
# Restoring NOT NULL could break existing rows that legitimately have no institution metadata.
11+
# We keep this reversible but conservative: only set NOT NULL if no NULLs exist.
12+
if execute("SELECT COUNT(*) FROM simplefin_items WHERE institution_id IS NULL").first["count"].to_i == 0
13+
change_column_null :simplefin_items, :institution_id, false
14+
end
15+
16+
if execute("SELECT COUNT(*) FROM simplefin_items WHERE institution_name IS NULL").first["count"].to_i == 0
17+
change_column_null :simplefin_items, :institution_name, false
18+
end
19+
end
20+
end

db/schema.rb

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/tasks/simplefin.rake

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
3+
namespace :sure do
4+
namespace :simplefin do
5+
desc "Print debug info for a SimpleFin item: latest sync, snapshot accounts, simplefin_accounts, and unlinked list"
6+
task :debug, [ :item_id ] => :environment do |_, args|
7+
unless args[:item_id].present?
8+
puts({ error: "usage", example: "bin/rails sure:simplefin:debug[ITEM_ID]" }.to_json)
9+
exit 1
10+
end
11+
12+
item = SimplefinItem.find(args[:item_id])
13+
latest_sync = item.syncs.order(created_at: :desc).first
14+
# Our model stores the latest snapshot directly on the item (`raw_payload`).
15+
snapshot_accounts = item.raw_payload&.dig(:accounts)&.size
16+
unlinked = item.simplefin_accounts.left_joins(:account).where(accounts: { id: nil })
17+
18+
out = {
19+
item_id: item.id,
20+
name: item.name,
21+
last_synced_at: item.last_synced_at,
22+
latest_sync: latest_sync&.attributes&.slice("id", "status", "error", "status_text", "created_at", "completed_at"),
23+
snapshot_accounts: snapshot_accounts,
24+
simplefin_accounts_count: item.simplefin_accounts.count,
25+
unlinked_count: unlinked.count,
26+
unlinked: unlinked.limit(20).map { |sfa| { id: sfa.id, upstream_id: sfa.account_id, name: sfa.name } }
27+
}
28+
29+
puts out.to_json
30+
rescue => e
31+
puts({ error: e.class.name, message: e.message, backtrace: e.backtrace&.take(3) }.to_json)
32+
exit 1
33+
end
34+
end
35+
end

0 commit comments

Comments
 (0)