Files
sure/app/models/lunchflow_account/processor.rb
Stephen Jolly ba0e169f6b Don't persist zero balances when a provider balance fetch fails (#2617)
* fix(sync): don't persist zero balances when a provider balance fetch fails

A nil current_balance means the sync's balance fetch did not succeed
(the snapshot upsert clears it; only a successful fetch repopulates it).
The Lunchflow and Enable Banking processors coerced nil to 0 and write
it (plus a currency fallback) onto the linked account, so any transient
provider failure persists wrong data with no user-visible signal.
Instead, treat nil as no-data: skip the account update. Also stop the
Lunchflow snapshot upsert resetting an established account's currency to
USD when the accounts endpoint omits currency.

* fix(lunchflow): normalize the preserved currency in snapshot upsert

Addresses the review comment on #2617: the preserved fallback reused the
record's raw in-memory currency, so a blank value would fail the presence
validation and break import, and an invalid code would persist instead of
falling back to USD. Run it through parse_currency like the payload value.
Regression test added.

* fix(lunchflow,eb): review round 2 — failure visibility and currency parity

Addresses the three review findings on #2617:
1. Capture the Lunch Flow balance-fetch failure via DebugLogEntry (the sync
   otherwise reports success with no mention of the skip).
   This is Lunch Flow only: Enable Banking's importer already surfaces the
   failure through transactions_failed/@sync_error.
2. Apply the currency-preservation fix to Enable Banking's snapshot upsert
   (same shape as the Lunch Flow fix: parity/safety). Regression test added.
3. Label the Enable Banking processor currency assertion as a parity check —
   it also passes on main, since the reset defect was Lunch Flow-specific.
2026-07-08 08:08:10 +02:00

101 lines
3.6 KiB
Ruby

class LunchflowAccount::Processor
include CurrencyNormalizable
attr_reader :lunchflow_account
def initialize(lunchflow_account)
@lunchflow_account = lunchflow_account
end
def process
unless lunchflow_account.current_account.present?
Rails.logger.info "LunchflowAccount::Processor - No linked account for lunchflow_account #{lunchflow_account.id}, skipping processing"
return
end
Rails.logger.info "LunchflowAccount::Processor - Processing lunchflow_account #{lunchflow_account.id} (account #{lunchflow_account.account_id})"
begin
process_account!
rescue StandardError => e
Rails.logger.error "LunchflowAccount::Processor - Failed to process account #{lunchflow_account.id}: #{e.message}"
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
report_exception(e, "account")
raise
end
process_transactions
process_investments
end
private
def process_account!
if lunchflow_account.current_account.blank?
Rails.logger.error("Lunchflow account #{lunchflow_account.id} has no associated Account")
return
end
# Update account balance from latest Lunchflow data
account = lunchflow_account.current_account
balance = lunchflow_account.current_balance
# A nil current_balance means this sync's balance fetch did not succeed:
# upsert_lunchflow_snapshot! clears it (the accounts endpoint carries no
# balance) and only a successful balance fetch repopulates it. Coercing
# nil to 0 here would persist a zero balance (and the USD currency
# fallback) onto a healthy account whenever the provider has a
# transient failure, so leave the account untouched instead.
if balance.nil?
Rails.logger.warn("LunchflowAccount::Processor - No balance available for lunchflow_account #{lunchflow_account.id} (balance fetch failed or not yet run), skipping account update")
return
end
# LunchFlow balance convention matches our app convention:
# - Positive balance = debt (you owe money)
# - Negative balance = credit balance (bank owes you, e.g., overpayment)
# No sign conversion needed - pass through as-is (same as Plaid)
#
# Exception: CreditCard and Loan accounts return inverted signs
# Provider returns negative for positive balance, so we negate it
if account.accountable_type == "CreditCard" || account.accountable_type == "Loan"
balance = -balance
end
# Normalize currency with fallback chain: parsed lunchflow currency -> existing account currency -> USD
currency = parse_currency(lunchflow_account.currency) || account.currency || "USD"
# Update account balance
account.update!(
balance: balance,
cash_balance: balance,
currency: currency
)
end
def process_transactions
LunchflowAccount::Transactions::Processor.new(lunchflow_account).process
rescue => e
report_exception(e, "transactions")
end
def process_investments
# Only process holdings for investment/crypto accounts with holdings support
return unless lunchflow_account.holdings_supported?
return unless [ "Investment", "Crypto" ].include?(lunchflow_account.current_account&.accountable_type)
LunchflowAccount::Investments::HoldingsProcessor.new(lunchflow_account).process
rescue => e
report_exception(e, "holdings")
end
def report_exception(error, context)
Sentry.capture_exception(error) do |scope|
scope.set_tags(
lunchflow_account_id: lunchflow_account.id,
context: context
)
end
end
end