|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +# Fallback-only inference for SimpleFIN-provided accounts. |
| 4 | +# Conservative, used only to suggest a default type during setup/creation. |
| 5 | +# Never overrides a user-selected type. |
| 6 | +module Simplefin |
| 7 | + class AccountTypeMapper |
| 8 | + Inference = Struct.new(:accountable_type, :subtype, :confidence, keyword_init: true) |
| 9 | + |
| 10 | + RETIREMENT_KEYWORDS = /\b(401k|401\(k\)|403b|403\(b\)|tsp|ira|roth|retirement)\b/i.freeze |
| 11 | + BROKERAGE_KEYWORD = /\bbrokerage\b/i.freeze |
| 12 | + CREDIT_NAME_KEYWORDS = /\b(credit|card)\b/i.freeze |
| 13 | + CREDIT_BRAND_KEYWORDS = /\b(visa|mastercard|amex|american express|discover|apple card|freedom unlimited|quicksilver)\b/i.freeze |
| 14 | + LOAN_KEYWORDS = /\b(loan|mortgage|heloc|line of credit|loc)\b/i.freeze |
| 15 | + |
| 16 | + # Explicit investment subtype tokens mapped to known SUBTYPES keys |
| 17 | + EXPLICIT_INVESTMENT_TOKENS = { |
| 18 | + /\btraditional\s+ira\b/i => "ira", |
| 19 | + /\broth\s+ira\b/i => "roth_ira", |
| 20 | + /\broth\s+401\(k\)\b|\broth\s*401k\b/i => "roth_401k", |
| 21 | + /\b401\(k\)\b|\b401k\b/i => "401k", |
| 22 | + /\b529\s*plan\b|\b529\b/i => "529_plan", |
| 23 | + /\bhsa\b|\bhealth\s+savings\s+account\b/i => "hsa", |
| 24 | + /\bpension\b/i => "pension", |
| 25 | + /\bmutual\s+fund\b/i => "mutual_fund", |
| 26 | + /\b403b\b|\b403\(b\)\b/i => "403b", |
| 27 | + /\btsp\b/i => "tsp" |
| 28 | + }.freeze |
| 29 | + |
| 30 | + # Public API |
| 31 | + # @param name [String, nil] |
| 32 | + # @param holdings [Array<Hash>, nil] |
| 33 | + # @param extra [Hash, nil] - provider extras when present |
| 34 | + # @param balance [Numeric, String, nil] |
| 35 | + # @param available_balance [Numeric, String, nil] |
| 36 | + # @return [Inference] e.g. Inference.new(accountable_type: "Investment", subtype: "retirement", confidence: :high) |
| 37 | + def self.infer(name:, holdings: nil, extra: nil, balance: nil, available_balance: nil, institution: nil) |
| 38 | + nm_raw = name.to_s |
| 39 | + nm = nm_raw |
| 40 | + # Normalized form to catch variants like RothIRA, Traditional-IRA, 401(k) |
| 41 | + nm_norm = nm_raw.downcase.gsub(/[^a-z0-9]+/, " ").squeeze(" ").strip |
| 42 | + inst = institution.to_s |
| 43 | + holdings_present = holdings.is_a?(Array) && holdings.any? |
| 44 | + bal = (balance.to_d rescue nil) |
| 45 | + avail = (available_balance.to_d rescue nil) |
| 46 | + |
| 47 | + # 0) Explicit retirement/plan tokens → Investment with explicit subtype (match against normalized name) |
| 48 | + if (explicit_sub = EXPLICIT_INVESTMENT_TOKENS.find { |rx, _| nm_norm.match?(rx) }&.last) |
| 49 | + if defined?(Investment::SUBTYPES) && Investment::SUBTYPES.key?(explicit_sub) |
| 50 | + return Inference.new(accountable_type: "Investment", subtype: explicit_sub, confidence: :high) |
| 51 | + else |
| 52 | + return Inference.new(accountable_type: "Investment", subtype: nil, confidence: :high) |
| 53 | + end |
| 54 | + end |
| 55 | + |
| 56 | + # 1) Holdings present => Investment (high confidence) |
| 57 | + if holdings_present |
| 58 | + # Do not guess generic retirement; explicit tokens handled above |
| 59 | + return Inference.new(accountable_type: "Investment", subtype: nil, confidence: :high) |
| 60 | + end |
| 61 | + |
| 62 | + # 2) Name suggests LOAN (high confidence) |
| 63 | + if LOAN_KEYWORDS.match?(nm) |
| 64 | + return Inference.new(accountable_type: "Loan", confidence: :high) |
| 65 | + end |
| 66 | + |
| 67 | + # 3) Credit card signals |
| 68 | + # - Name contains credit/card (medium to high) |
| 69 | + # - Card brands (Visa/Mastercard/Amex/Discover/Apple Card) → high |
| 70 | + # - Or negative balance with available-balance present (medium) |
| 71 | + if CREDIT_NAME_KEYWORDS.match?(nm) || CREDIT_BRAND_KEYWORDS.match?(nm) || CREDIT_BRAND_KEYWORDS.match?(inst) |
| 72 | + return Inference.new(accountable_type: "CreditCard", confidence: :high) |
| 73 | + end |
| 74 | + # Strong combined signal for credit card: negative balance and positive available-balance |
| 75 | + if bal && bal < 0 && avail && avail > 0 |
| 76 | + return Inference.new(accountable_type: "CreditCard", confidence: :high) |
| 77 | + end |
| 78 | + |
| 79 | + # 4) Retirement keywords without holdings still point to Investment (retirement) |
| 80 | + if RETIREMENT_KEYWORDS.match?(nm) |
| 81 | + # If the name contains 'brokerage', avoid forcing retirement subtype |
| 82 | + subtype = BROKERAGE_KEYWORD.match?(nm) ? nil : "retirement" |
| 83 | + return Inference.new(accountable_type: "Investment", subtype: subtype, confidence: :high) |
| 84 | + end |
| 85 | + |
| 86 | + # 5) Default |
| 87 | + Inference.new(accountable_type: "Depository", confidence: :low) |
| 88 | + end |
| 89 | + end |
| 90 | +end |
0 commit comments