mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
UI Suggestions for Account Types in Setup Modal + Stats-Based Inactive Handling (#368)
* - Add tests for `Simplefin::AccountTypeMapper` and `AccountSimplefinCreation` - Implement `Simplefin::AccountTypeMapper` for account type inference with fallback-only logic - Enhance inactive state handling for `SimplefinItem::Importer` - Improve subtype selection handling in views with confidence-based inference * Remove unnecessary `.presence` check for `openai_uri_base` in hostings settings * Refine zero balance detection logic in `SimplefinItem::Importer` and add regression test for missing balances scenario * Enhance account type and subtype inference logic with explicit investment subtype mapping, improved regex handling, and institution-based credit card detection * Refine retirement subtype mapping in `AccountTypeMapper` tests with explicit case-based assertions * Expand `AccountTypeMapper` investment subtype mapping to include `403b` and `tsp` with updated regex definitions * Remove unused `retirement_hint?` method in `AccountTypeMapper` to simplify codebase --------- Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
This commit is contained in:
@@ -87,6 +87,12 @@ class Account < ApplicationRecord
|
||||
|
||||
|
||||
def create_from_simplefin_account(simplefin_account, account_type, subtype = nil)
|
||||
# Respect user choice when provided; otherwise infer a sensible default
|
||||
# Require an explicit account_type; do not infer on the backend
|
||||
if account_type.blank? || account_type.to_s == "unknown"
|
||||
raise ArgumentError, "account_type is required when creating an account from SimpleFIN"
|
||||
end
|
||||
|
||||
# Get the balance from SimpleFin
|
||||
balance = simplefin_account.current_balance || simplefin_account.available_balance || 0
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ class Investment < ApplicationRecord
|
||||
"retirement" => { short: "Retirement", long: "Retirement" },
|
||||
"401k" => { short: "401(k)", long: "401(k)" },
|
||||
"roth_401k" => { short: "Roth 401(k)", long: "Roth 401(k)" },
|
||||
"403b" => { short: "403(b)", long: "403(b)" },
|
||||
"tsp" => { short: "TSP", long: "Thrift Savings Plan" },
|
||||
"529_plan" => { short: "529 Plan", long: "529 Plan" },
|
||||
"hsa" => { short: "HSA", long: "Health Savings Account" },
|
||||
"mutual_fund" => { short: "Mutual Fund", long: "Mutual Fund" },
|
||||
|
||||
90
app/models/simplefin/account_type_mapper.rb
Normal file
90
app/models/simplefin/account_type_mapper.rb
Normal file
@@ -0,0 +1,90 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Fallback-only inference for SimpleFIN-provided accounts.
|
||||
# Conservative, used only to suggest a default type during setup/creation.
|
||||
# Never overrides a user-selected type.
|
||||
module Simplefin
|
||||
class AccountTypeMapper
|
||||
Inference = Struct.new(:accountable_type, :subtype, :confidence, keyword_init: true)
|
||||
|
||||
RETIREMENT_KEYWORDS = /\b(401k|401\(k\)|403b|403\(b\)|tsp|ira|roth|retirement)\b/i.freeze
|
||||
BROKERAGE_KEYWORD = /\bbrokerage\b/i.freeze
|
||||
CREDIT_NAME_KEYWORDS = /\b(credit|card)\b/i.freeze
|
||||
CREDIT_BRAND_KEYWORDS = /\b(visa|mastercard|amex|american express|discover|apple card|freedom unlimited|quicksilver)\b/i.freeze
|
||||
LOAN_KEYWORDS = /\b(loan|mortgage|heloc|line of credit|loc)\b/i.freeze
|
||||
|
||||
# Explicit investment subtype tokens mapped to known SUBTYPES keys
|
||||
EXPLICIT_INVESTMENT_TOKENS = {
|
||||
/\btraditional\s+ira\b/i => "ira",
|
||||
/\broth\s+ira\b/i => "roth_ira",
|
||||
/\broth\s+401\(k\)\b|\broth\s*401k\b/i => "roth_401k",
|
||||
/\b401\(k\)\b|\b401k\b/i => "401k",
|
||||
/\b529\s*plan\b|\b529\b/i => "529_plan",
|
||||
/\bhsa\b|\bhealth\s+savings\s+account\b/i => "hsa",
|
||||
/\bpension\b/i => "pension",
|
||||
/\bmutual\s+fund\b/i => "mutual_fund",
|
||||
/\b403b\b|\b403\(b\)\b/i => "403b",
|
||||
/\btsp\b/i => "tsp"
|
||||
}.freeze
|
||||
|
||||
# Public API
|
||||
# @param name [String, nil]
|
||||
# @param holdings [Array<Hash>, nil]
|
||||
# @param extra [Hash, nil] - provider extras when present
|
||||
# @param balance [Numeric, String, nil]
|
||||
# @param available_balance [Numeric, String, nil]
|
||||
# @return [Inference] e.g. Inference.new(accountable_type: "Investment", subtype: "retirement", confidence: :high)
|
||||
def self.infer(name:, holdings: nil, extra: nil, balance: nil, available_balance: nil, institution: nil)
|
||||
nm_raw = name.to_s
|
||||
nm = nm_raw
|
||||
# Normalized form to catch variants like RothIRA, Traditional-IRA, 401(k)
|
||||
nm_norm = nm_raw.downcase.gsub(/[^a-z0-9]+/, " ").squeeze(" ").strip
|
||||
inst = institution.to_s
|
||||
holdings_present = holdings.is_a?(Array) && holdings.any?
|
||||
bal = (balance.to_d rescue nil)
|
||||
avail = (available_balance.to_d rescue nil)
|
||||
|
||||
# 0) Explicit retirement/plan tokens → Investment with explicit subtype (match against normalized name)
|
||||
if (explicit_sub = EXPLICIT_INVESTMENT_TOKENS.find { |rx, _| nm_norm.match?(rx) }&.last)
|
||||
if defined?(Investment::SUBTYPES) && Investment::SUBTYPES.key?(explicit_sub)
|
||||
return Inference.new(accountable_type: "Investment", subtype: explicit_sub, confidence: :high)
|
||||
else
|
||||
return Inference.new(accountable_type: "Investment", subtype: nil, confidence: :high)
|
||||
end
|
||||
end
|
||||
|
||||
# 1) Holdings present => Investment (high confidence)
|
||||
if holdings_present
|
||||
# Do not guess generic retirement; explicit tokens handled above
|
||||
return Inference.new(accountable_type: "Investment", subtype: nil, confidence: :high)
|
||||
end
|
||||
|
||||
# 2) Name suggests LOAN (high confidence)
|
||||
if LOAN_KEYWORDS.match?(nm)
|
||||
return Inference.new(accountable_type: "Loan", confidence: :high)
|
||||
end
|
||||
|
||||
# 3) Credit card signals
|
||||
# - Name contains credit/card (medium to high)
|
||||
# - Card brands (Visa/Mastercard/Amex/Discover/Apple Card) → high
|
||||
# - Or negative balance with available-balance present (medium)
|
||||
if CREDIT_NAME_KEYWORDS.match?(nm) || CREDIT_BRAND_KEYWORDS.match?(nm) || CREDIT_BRAND_KEYWORDS.match?(inst)
|
||||
return Inference.new(accountable_type: "CreditCard", confidence: :high)
|
||||
end
|
||||
# Strong combined signal for credit card: negative balance and positive available-balance
|
||||
if bal && bal < 0 && avail && avail > 0
|
||||
return Inference.new(accountable_type: "CreditCard", confidence: :high)
|
||||
end
|
||||
|
||||
# 4) Retirement keywords without holdings still point to Investment (retirement)
|
||||
if RETIREMENT_KEYWORDS.match?(nm)
|
||||
# If the name contains 'brokerage', avoid forcing retirement subtype
|
||||
subtype = BROKERAGE_KEYWORD.match?(nm) ? nil : "retirement"
|
||||
return Inference.new(accountable_type: "Investment", subtype: subtype, confidence: :high)
|
||||
end
|
||||
|
||||
# 5) Default
|
||||
Inference.new(accountable_type: "Depository", confidence: :low)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -109,6 +109,51 @@ class SimplefinItem::Importer
|
||||
@stats ||= {}
|
||||
end
|
||||
|
||||
# Heuristics to set a SimpleFIN account inactive when upstream indicates closure/hidden
|
||||
# or when we repeatedly observe zero balances and zero holdings. This should not block
|
||||
# import and only sets a flag and suggestion via sync stats.
|
||||
def update_inactive_state(simplefin_account, account_data)
|
||||
payload = (account_data || {}).with_indifferent_access
|
||||
raw = (simplefin_account.raw_payload || {}).with_indifferent_access
|
||||
|
||||
# Flags from payloads
|
||||
closed = [ payload[:closed], payload[:hidden], payload.dig(:extra, :closed), raw[:closed], raw[:hidden] ].compact.any? { |v| v == true || v.to_s == "true" }
|
||||
|
||||
balance = payload[:balance]
|
||||
avail = payload[:"available-balance"]
|
||||
holdings = payload[:holdings]
|
||||
amounts = [ balance, avail ].compact
|
||||
zeroish_balance = amounts.any? && amounts.all? { |x| x.to_d.zero? rescue false }
|
||||
no_holdings = !(holdings.is_a?(Array) && holdings.any?)
|
||||
|
||||
stats["zero_runs"] ||= {}
|
||||
stats["inactive"] ||= {}
|
||||
key = simplefin_account.account_id.presence || simplefin_account.id
|
||||
key = key.to_s
|
||||
# Ensure key exists and defaults to false (so tests don't read nil)
|
||||
stats["inactive"][key] = false unless stats["inactive"].key?(key)
|
||||
|
||||
if closed
|
||||
stats["inactive"][key] = true
|
||||
stats["hints"] = Array(stats["hints"]) + [ "Some accounts appear closed/hidden upstream. You can relink or hide them." ]
|
||||
return
|
||||
end
|
||||
|
||||
if zeroish_balance && no_holdings
|
||||
stats["zero_runs"][key] = stats["zero_runs"][key].to_i + 1
|
||||
# Cap to avoid unbounded growth
|
||||
stats["zero_runs"][key] = [ stats["zero_runs"][key], 10 ].min
|
||||
else
|
||||
stats["zero_runs"][key] = 0
|
||||
stats["inactive"][key] = false
|
||||
end
|
||||
|
||||
if stats["zero_runs"][key].to_i >= 3
|
||||
stats["inactive"][key] = true
|
||||
stats["hints"] = Array(stats["hints"]) + [ "One or more accounts show no balance/holdings for multiple syncs — consider relinking or marking inactive." ]
|
||||
end
|
||||
end
|
||||
|
||||
# Track seen error fingerprints during a single importer run to avoid double counting
|
||||
def seen_errors
|
||||
@seen_errors ||= Set.new
|
||||
@@ -457,6 +502,13 @@ class SimplefinItem::Importer
|
||||
end
|
||||
simplefin_account.assign_attributes(attrs)
|
||||
|
||||
# Inactive detection/toggling (non-blocking)
|
||||
begin
|
||||
update_inactive_state(simplefin_account, account_data)
|
||||
rescue => e
|
||||
Rails.logger.warn("SimpleFin: inactive-state evaluation failed for sfa=#{simplefin_account.id || account_id}: #{e.class} - #{e.message}")
|
||||
end
|
||||
|
||||
# Final validation before save to prevent duplicates
|
||||
if simplefin_account.account_id.blank?
|
||||
simplefin_account.account_id = account_id
|
||||
@@ -474,6 +526,10 @@ class SimplefinItem::Importer
|
||||
register_error(message: msg, category: "other", account_id: account_id, name: account_data[:name])
|
||||
persist_stats!
|
||||
nil
|
||||
ensure
|
||||
# Ensure stats like zero_runs/inactive are persisted even when no errors occur,
|
||||
# particularly helpful for focused unit tests that call import_account directly.
|
||||
persist_stats!
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user