Files
sure/app/models/lunchflow_item/syncer.rb
T
Jeffandjeffrey701 12eb9e15fb fix(lunchflow): mark sync unhealthy when importer reports fetch failures (#1796) (#1873)
* fix(lunchflow): mark sync unhealthy when importer reports fetch failures (#1796)

`LunchflowItem::Syncer#perform_sync` called
`lunchflow_item.import_latest_lunchflow_data` but threw the result away
and then ran `collect_health_stats(sync, errors: nil)`. The importer
already catches per-account 429/500 fetch errors, bumps a
`transactions_failed` counter, and returns `success: false` — but the
syncer never inspected the return value, so the parent sync was marked
completed/green even when zero transactions were imported because every
fetch had been rate-limited.

Capture the importer result and translate any
`accounts_failed` / `transactions_failed` / `error` fields into the
`{ message:, category: }` error shape `collect_health_stats` expects.
The exception-path `rescue` branch is unchanged.

Closes #1796

* test(lunchflow): i18n the new health messages + add syncer invariant tests (#1796)

Two pieces of follow-up feedback:

- @coderabbitai + @JSONbored: the three new operator-facing strings should
  go through I18n.t. Add keys under provider_warnings.lunchflow_*
  (matching the existing provider_warnings.limited_investment_data
  shape) and use Rails pluralization for the count-bearing entries.
  Other locales follow the repo's normal translation flow.

- @jjmata + @JSONbored: add tests for the invariants. New
  LunchflowItem::SyncerTest covers:
    * successful import → sync healthy
    * accounts_failed positive → sync unhealthy with localized message
    * transactions_failed positive → sync unhealthy with localized message
    * both counters positive → both error entries recorded in order
    * sync raises → sync_error category + reraise (existing rescue branch)

@jjmata also asked to confirm the importer contract: LunchflowItem::Importer#import
returns 'success: accounts_failed == 0 && transactions_failed == 0' (see
importer.rb), so the early 'return [] if import_result[:success]' guard
is safe — success is never true while either counter is positive.

---------

Co-authored-by: jeffrey701 <jeffrey701@users.noreply.github.com>
2026-08-24 23:26:06 +02:00

123 lines
4.8 KiB
Ruby

class LunchflowItem::Syncer
include SyncStats::Collector
attr_reader :lunchflow_item
def initialize(lunchflow_item)
@lunchflow_item = lunchflow_item
end
def perform_sync(sync)
# Phase 1: Import data from Lunchflow API
sync.update!(status_text: "Importing accounts from Lunchflow...") if sync.respond_to?(:status_text)
import_result = lunchflow_item.import_latest_lunchflow_data
# Phase 2: Collect setup statistics using shared concern
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
collect_setup_stats(sync, provider_accounts: lunchflow_item.lunchflow_accounts)
# Check for unlinked accounts
linked_accounts = lunchflow_item.lunchflow_accounts.joins(:account_provider)
unlinked_accounts = lunchflow_item.lunchflow_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
# Set pending_account_setup if there are unlinked accounts
if unlinked_accounts.any?
lunchflow_item.update!(pending_account_setup: true)
sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text)
else
lunchflow_item.update!(pending_account_setup: false)
end
# Phase 3: Process transactions and holdings for linked accounts only
if linked_accounts.any?
sync.update!(status_text: "Processing transactions and holdings...") if sync.respond_to?(:status_text)
mark_import_started(sync)
Rails.logger.info "LunchflowItem::Syncer - Processing #{linked_accounts.count} linked accounts"
lunchflow_item.process_accounts
Rails.logger.info "LunchflowItem::Syncer - Finished processing accounts"
# Warn about limited investment data for investment/crypto accounts
collect_investment_data_quality_warning(sync, linked_accounts)
# Phase 4: Schedule balance calculations for linked accounts
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
lunchflow_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 statistics
account_ids = linked_accounts.includes(:account_provider).filter_map { |la| la.current_account&.id }
collect_transaction_stats(sync, account_ids: account_ids, source: "lunchflow")
else
Rails.logger.info "LunchflowItem::Syncer - No linked accounts to process"
end
# Mark sync health — surface importer failures so the sync isn't reported
# as completed when the upstream Lunchflow API rejected fetches (e.g.
# 429 rate-limit responses wrapped as 500s, transient network errors).
collect_health_stats(sync, errors: import_failures_as_errors(import_result).presence)
rescue => e
collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ])
raise
end
def perform_post_sync
# no-op
end
private
# Translate the LunchflowItem::Importer result hash into the error-shape
# collect_health_stats expects. Returns [] for a successful import.
def import_failures_as_errors(import_result)
return [] unless import_result.is_a?(Hash)
return [] if import_result[:success]
errors = []
accounts_failed = import_result[:accounts_failed].to_i
transactions_failed = import_result[:transactions_failed].to_i
if accounts_failed.positive?
errors << {
message: I18n.t("provider_warnings.lunchflow_accounts_failed", count: accounts_failed),
category: "lunchflow_import"
}
end
if transactions_failed.positive?
errors << {
message: I18n.t("provider_warnings.lunchflow_transactions_failed", count: transactions_failed),
category: "lunchflow_import"
}
end
if errors.empty? && import_result[:error].present?
errors << {
message: I18n.t("provider_warnings.lunchflow_import_error", error: import_result[:error]),
category: "lunchflow_import"
}
end
errors
end
# Collects a data quality warning if any linked accounts are investment or crypto accounts.
# Lunchflow cannot provide activity labels (Buy, Sell, Dividend, etc.) for investment transactions,
# which may affect budget accuracy.
def collect_investment_data_quality_warning(sync, linked_lunchflow_accounts)
investment_accounts = linked_lunchflow_accounts.select do |la|
account = la.current_account
account&.accountable_type.in?(%w[Investment Crypto])
end
return if investment_accounts.empty?
collect_data_quality_stats(sync,
warnings: investment_accounts.size,
details: [ {
message: I18n.t("provider_warnings.limited_investment_data"),
severity: "warning"
} ]
)
end
end