diff --git a/app/controllers/import/cleans_controller.rb b/app/controllers/import/cleans_controller.rb index 2c502f0a1..7d91f2134 100644 --- a/app/controllers/import/cleans_controller.rb +++ b/app/controllers/import/cleans_controller.rb @@ -9,7 +9,7 @@ class Import::CleansController < ApplicationController return redirect_to redirect_path, alert: "Please configure your import before proceeding." end - rows = @import.rows.ordered + rows = @import.rows_ordered if params[:view] == "errors" rows = rows.reject { |row| row.valid? } diff --git a/app/controllers/import/qif_category_selections_controller.rb b/app/controllers/import/qif_category_selections_controller.rb new file mode 100644 index 000000000..28669c950 --- /dev/null +++ b/app/controllers/import/qif_category_selections_controller.rb @@ -0,0 +1,68 @@ +class Import::QifCategorySelectionsController < ApplicationController + layout "imports" + + before_action :set_import + + def show + @categories = @import.row_categories + @tags = @import.row_tags + @category_counts = @import.rows.group(:category).count.reject { |k, _| k.blank? } + @tag_counts = compute_tag_counts + @split_categories = @import.split_categories + @has_split_transactions = @import.has_split_transactions? + end + + def update + all_categories = @import.row_categories + all_tags = @import.row_tags + + selected_categories = Array(selection_params[:categories]).reject(&:blank?) + selected_tags = Array(selection_params[:tags]).reject(&:blank?) + + deselected_categories = all_categories - selected_categories + deselected_tags = all_tags - selected_tags + + ActiveRecord::Base.transaction do + # Clear category on rows whose category was deselected + if deselected_categories.any? + @import.rows.where(category: deselected_categories).update_all(category: "") + end + + # Strip deselected tags from any row that carries them + if deselected_tags.any? + @import.rows.where.not(tags: [ nil, "" ]).find_each do |row| + remaining = row.tags_list - deselected_tags + remaining.reject!(&:blank?) + updated_tags = remaining.join("|") + row.update_column(:tags, updated_tags) if updated_tags != row.tags.to_s + end + end + + @import.sync_mappings + end + + redirect_to import_clean_path(@import), notice: "Categories and tags saved." + end + + private + + def set_import + @import = Current.family.imports.find(params[:import_id]) + + unless @import.is_a?(QifImport) + redirect_to imports_path + end + end + + def compute_tag_counts + counts = Hash.new(0) + @import.rows.each do |row| + row.tags_list.each { |tag| counts[tag] += 1 unless tag.blank? } + end + counts + end + + def selection_params + params.permit(categories: [], tags: []) + end +end diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index a9a185d51..fec74b5bc 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -14,8 +14,10 @@ class Import::UploadsController < ApplicationController end def update - if csv_valid?(csv_str) - @import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id)) + if @import.is_a?(QifImport) + handle_qif_upload + elsif csv_valid?(csv_str) + @import.account = Current.family.accounts.find_by(id: import_account_id) @import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep]) @import.save!(validate: false) @@ -32,6 +34,28 @@ class Import::UploadsController < ApplicationController @import = Current.family.imports.find(params[:import_id]) end + def handle_qif_upload + unless QifParser.valid?(csv_str) + flash.now[:alert] = "Must be a valid QIF file" + render :show, status: :unprocessable_entity and return + end + + unless import_account_id.present? + flash.now[:alert] = "Please select an account for the QIF import" + render :show, status: :unprocessable_entity and return + end + + ActiveRecord::Base.transaction do + @import.account = Current.family.accounts.find(import_account_id) + @import.raw_file_str = QifParser.normalize_encoding(csv_str) + @import.save!(validate: false) + @import.generate_rows_from_csv + @import.sync_mappings + end + + redirect_to import_qif_category_selection_path(@import), notice: "QIF file uploaded successfully." + end + def csv_str @csv_str ||= upload_params[:import_file]&.read || upload_params[:raw_file_str] end @@ -50,4 +74,8 @@ class Import::UploadsController < ApplicationController def upload_params params.require(:import).permit(:raw_file_str, :import_file, :col_sep) end + + def import_account_id + params.require(:import).permit(:account_id)[:account_id] + end end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index ef5f4b067..046e43632 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -92,7 +92,10 @@ class ImportsController < ApplicationController end def show - return unless @import.requires_csv_workflow? + unless @import.requires_csv_workflow? + redirect_to import_upload_path(@import), alert: t("imports.show.finalize_upload") unless @import.uploaded? + return + end if !@import.uploaded? redirect_to import_upload_path(@import), alert: t("imports.show.finalize_upload") diff --git a/app/models/category.rb b/app/models/category.rb index 743397456..94d5097e1 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -35,6 +35,55 @@ class Category < ApplicationRecord PAYMENT_COLOR = "#db5a54" TRADE_COLOR = "#e99537" + ICON_KEYWORDS = { + /income|salary|paycheck|wage|earning/ => "circle-dollar-sign", + /groceries|grocery|supermarket/ => "shopping-bag", + /food|dining|restaurant|meal|lunch|dinner|breakfast/ => "utensils", + /coffee|cafe|café/ => "coffee", + /shopping|retail/ => "shopping-cart", + /transport|transit|commute|subway|metro/ => "bus", + /parking/ => "circle-parking", + /car|auto|vehicle/ => "car", + /gas|fuel|petrol/ => "fuel", + /flight|airline/ => "plane", + /travel|trip|vacation|holiday/ => "plane", + /hotel|lodging|accommodation/ => "hotel", + /movie|cinema|film|theater|theatre/ => "film", + /music|concert/ => "music", + /game|gaming/ => "gamepad-2", + /entertainment|leisure/ => "drama", + /sport|fitness|gym|workout|exercise/ => "dumbbell", + /pharmacy|drug|medicine|pill|medication|dental|dentist/ => "pill", + /health|medical|clinic|doctor|physician/ => "stethoscope", + /personal care|beauty|salon|spa|hair/ => "scissors", + /mortgage|rent/ => "home", + /home|house|apartment|housing/ => "home", + /improvement|renovation|remodel/ => "hammer", + /repair|maintenance/ => "wrench", + /electric|power|energy/ => "zap", + /water|sewage/ => "waves", + /internet|cable|broadband|subscription|streaming/ => "wifi", + /utilities|utility/ => "lightbulb", + /phone|telephone/ => "phone", + /mobile|cell/ => "smartphone", + /insurance/ => "shield", + /gift|present/ => "gift", + /donat|charity|nonprofit/ => "hand-helping", + /tax|irs|revenue/ => "landmark", + /loan|debt|credit card/ => "credit-card", + /service|professional/ => "briefcase", + /fee|charge/ => "receipt", + /bank|banking/ => "landmark", + /saving/ => "piggy-bank", + /invest|stock|fund|portfolio/ => "trending-up", + /pet|dog|cat|animal|vet/ => "paw-print", + /education|school|university|college|tuition/ => "graduation-cap", + /book|reading|library/ => "book", + /child|kid|baby|infant|daycare/ => "baby", + /cloth|apparel|fashion|wear/ => "shirt", + /ticket/ => "ticket" + }.freeze + # Category name keys for i18n UNCATEGORIZED_NAME_KEY = "models.category.uncategorized" OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments" @@ -58,6 +107,16 @@ class Category < ApplicationRecord end class << self + def suggested_icon(name) + name_down = name.to_s.downcase + + ICON_KEYWORDS.each do |pattern, icon| + return icon if name_down.match?(pattern) + end + + "shapes" + end + def icon_codes %w[ ambulance apple award baby badge-dollar-sign banknote barcode bar-chart-3 bath diff --git a/app/models/concerns/qif_parser.rb b/app/models/concerns/qif_parser.rb new file mode 100644 index 000000000..38f1c1abf --- /dev/null +++ b/app/models/concerns/qif_parser.rb @@ -0,0 +1,428 @@ +# Parses QIF (Quicken Interchange Format) files. +# +# A QIF file is a plain-text format exported by Quicken. It is divided into +# sections, each introduced by a "!Type:" header line. Records within +# a section are terminated by a "^" line. Each data line starts with a single +# letter field code followed immediately by the value. +# +# Sections handled: +# !Type:Tag – tag definitions (N=name, D=description) +# !Type:Cat – category definitions (N=name, D=description, I=income, E=expense) +# !Type:Security – security definitions (N=name, S=ticker, T=type) +# !Type:CCard / !Type:Bank / !Type:Cash / !Type:Oth L – transactions +# !Type:Invst – investment transactions +# +# Transaction field codes: +# D date M/ D'YY or MM/DD'YYYY +# T amount may include commas, e.g. "-1,234.56" +# U amount same as T (alternate field) +# P payee +# M memo +# L category plain name or [TransferAccount]; /Tag suffix is supported +# N check/ref (not a tag – the check number or reference) +# C cleared X = cleared, * = reconciled +# ^ end of record +# +# Investment-specific field codes (in !Type:Invst records): +# N action Buy, Sell, Div, XIn, XOut, IntInc, CGLong, CGShort, etc. +# Y security security name (matches N field in !Type:Security) +# I price price per share +# Q quantity number of shares +# T total total cash amount of transaction +module QifParser + TRANSACTION_TYPES = %w[CCard Bank Cash Invst Oth\ L Oth\ A].freeze + + # Investment action types that create Trade records (buy or sell shares). + BUY_LIKE_ACTIONS = %w[Buy ReinvDiv Cover].freeze + SELL_LIKE_ACTIONS = %w[Sell ShtSell].freeze + TRADE_ACTIONS = (BUY_LIKE_ACTIONS + SELL_LIKE_ACTIONS).freeze + + # Investment action types that create Transaction records. + INFLOW_TRANSACTION_ACTIONS = %w[Div IntInc XIn CGLong CGShort MiscInc].freeze + OUTFLOW_TRANSACTION_ACTIONS = %w[XOut MiscExp].freeze + + ParsedTransaction = Struct.new( + :date, :amount, :payee, :memo, :category, :tags, :check_num, :cleared, :split, + keyword_init: true + ) + + ParsedCategory = Struct.new(:name, :description, :income, keyword_init: true) + ParsedTag = Struct.new(:name, :description, keyword_init: true) + + ParsedSecurity = Struct.new(:name, :ticker, :security_type, keyword_init: true) + + ParsedInvestmentTransaction = Struct.new( + :date, :action, :security_name, :security_ticker, + :price, :qty, :amount, :memo, :payee, :category, :tags, + keyword_init: true + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + # Transcodes raw file bytes to UTF-8. + # Quicken on Windows writes QIF files in a Windows code page that varies by region: + # Windows-1252 – North America, Western Europe + # Windows-1250 – Central/Eastern Europe (Poland, Czech Republic, Hungary, …) + # + # We try each encoding with undef: :raise so we only accept an encoding when + # every byte in the file is defined in that code page. Windows-1252 has five + # undefined byte values (0x81, 0x8D, 0x8F, 0x90, 0x9D); if any are present we + # fall through to Windows-1250 which covers those slots differently. + FALLBACK_ENCODINGS = %w[Windows-1252 Windows-1250].freeze + + def self.normalize_encoding(content) + return content if content.nil? + + binary = content.b # Force ASCII-8BIT; never raises on invalid bytes + + utf8_attempt = binary.dup.force_encoding("UTF-8") + return utf8_attempt if utf8_attempt.valid_encoding? + + FALLBACK_ENCODINGS.each do |encoding| + begin + return binary.encode("UTF-8", encoding) + rescue Encoding::UndefinedConversionError + next + end + end + + # Last resort: replace any remaining undefined bytes rather than raise + binary.encode("UTF-8", "Windows-1252", invalid: :replace, undef: :replace, replace: "") + end + + # Returns true if the content looks like a valid QIF file. + def self.valid?(content) + return false if content.blank? + + binary = content.b + binary.include?("!Type:") + end + + # Returns the transaction account type string (e.g. "CCard", "Bank", "Invst"). + # Skips metadata sections (Tag, Cat, Security, Prices) which are not account data. + def self.account_type(content) + return nil if content.blank? + + content.scan(/^!Type:(.+)/i).flatten + .map(&:strip) + .reject { |t| %w[Tag Cat Security Prices].include?(t) } + .first + end + + # Parses all transactions from the file, excluding the Opening Balance entry. + # Returns an array of ParsedTransaction structs. + def self.parse(content) + return [] unless valid?(content) + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + type = account_type(content) + return [] unless type + + section = extract_section(content, type) + return [] unless section + + parse_records(section).filter_map { |record| build_transaction(record) } + end + + # Returns the opening balance entry from the QIF file, if present. + # In Quicken's QIF format, the first transaction of a bank/cash account is often + # an "Opening Balance" record with payee "Opening Balance". This entry is NOT a + # real transaction – it is the account's starting balance. + # + # Returns a hash { date: Date, amount: BigDecimal } or nil. + def self.parse_opening_balance(content) + return nil unless valid?(content) + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + type = account_type(content) + return nil unless type + + section = extract_section(content, type) + return nil unless section + + record = parse_records(section).find { |r| r["P"]&.strip == "Opening Balance" } + return nil unless record + + date = parse_qif_date(record["D"]) + amount = parse_qif_amount(record["T"] || record["U"]) + return nil unless date && amount + + { date: Date.parse(date), amount: amount.to_d } + end + + # Parses categories from the !Type:Cat section. + # Returns an array of ParsedCategory structs. + def self.parse_categories(content) + return [] if content.blank? + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + section = extract_section(content, "Cat") + return [] unless section + + parse_records(section).filter_map do |record| + next unless record["N"].present? + + ParsedCategory.new( + name: record["N"], + description: record["D"], + income: record.key?("I") && !record.key?("E") + ) + end + end + + # Parses tags from the !Type:Tag section. + # Returns an array of ParsedTag structs. + def self.parse_tags(content) + return [] if content.blank? + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + section = extract_section(content, "Tag") + return [] unless section + + parse_records(section).filter_map do |record| + next unless record["N"].present? + + ParsedTag.new( + name: record["N"], + description: record["D"] + ) + end + end + + # Parses all !Type:Security sections and returns an array of ParsedSecurity structs. + # Each security in a QIF file gets its own !Type:Security header, so we scan + # for all occurrences rather than just the first. + def self.parse_securities(content) + return [] if content.blank? + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + securities = [] + + content.scan(/^!Type:Security[^\n]*\n(.*?)(?=^!Type:|\z)/mi) do |captures| + parse_records(captures[0]).each do |record| + next unless record["N"].present? && record["S"].present? + + securities << ParsedSecurity.new( + name: record["N"].strip, + ticker: record["S"].strip, + security_type: record["T"]&.strip + ) + end + end + + securities + end + + # Parses investment transactions from the !Type:Invst section. + # Uses the !Type:Security sections to resolve security names to tickers. + # Returns an array of ParsedInvestmentTransaction structs. + def self.parse_investment_transactions(content) + return [] unless valid?(content) + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + ticker_by_name = parse_securities(content).each_with_object({}) { |s, h| h[s.name] = s.ticker } + + section = extract_section(content, "Invst") + return [] unless section + + parse_records(section).filter_map { |record| build_investment_transaction(record, ticker_by_name) } + end + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def self.normalize_line_endings(content) + content.gsub(/\r\n/, "\n").gsub(/\r/, "\n") + end + private_class_method :normalize_line_endings + + # Extracts the raw text of a named section (everything after its !Type: header + # up to the next !Type: header or end-of-file). + def self.extract_section(content, type_name) + escaped = Regexp.escape(type_name) + pattern = /^!Type:#{escaped}[^\n]*\n(.*?)(?=^!Type:|\z)/mi + content.match(pattern)&.captures&.first + end + private_class_method :extract_section + + # Splits a section into an array of field-code => value hashes. + # Single-letter codes with no value (e.g. "I", "E", "T") are stored with nil. + # Split transactions (multiple S/$/E lines) are flagged with "_split" => true. + def self.parse_records(section_content) + records = [] + current = {} + + section_content.each_line do |line| + line = line.chomp + next if line.blank? + + if line == "^" + records << current unless current.empty? + current = {} + else + code = line[0] + value = line[1..]&.strip + next unless code + + # Mark records that contain split fields (S = split category, $ = split amount) + current["_split"] = true if code == "S" + + # Flag fields like "I" (income) and "E" (expense) have no meaningful value + current[code] = value.presence + end + end + + records << current unless current.empty? + records + end + private_class_method :parse_records + + def self.build_transaction(record) + # "Opening Balance" is a Quicken convention for the account's starting balance – + # it is not a real transaction and must not be imported as one. + return nil if record["P"]&.strip == "Opening Balance" + + raw_date = record["D"] + raw_amount = record["T"] || record["U"] + + return nil unless raw_date.present? && raw_amount.present? + + date = parse_qif_date(raw_date) + amount = parse_qif_amount(raw_amount) + + return nil unless date && amount + + category, tags = parse_category_and_tags(record["L"]) + + ParsedTransaction.new( + date: date, + amount: amount, + payee: record["P"], + memo: record["M"], + category: category, + tags: tags, + check_num: record["N"], + cleared: record["C"], + split: record["_split"] == true + ) + end + private_class_method :build_transaction + + # Separates the category name from any tag(s) appended with a "/" delimiter. + # Transfer accounts are wrapped in brackets – treated as no category. + # + # Examples: + # "Food & Dining" → ["Food & Dining", []] + # "Food & Dining/EUROPE2025" → ["Food & Dining", ["EUROPE2025"]] + # "[TD - Chequing]" → ["", []] + def self.parse_category_and_tags(l_field) + return [ "", [] ] if l_field.blank? + + # Transfer account reference + return [ "", [] ] if l_field.start_with?("[") + + # Quicken uses "--Split--" as a placeholder category for split transactions + return [ "", [] ] if l_field.strip.match?(/\A--Split--\z/i) + + parts = l_field.split("/", 2) + category = parts[0].strip + tags = parts[1].present? ? parts[1].split(":").map(&:strip).reject(&:blank?) : [] + + [ category, tags ] + end + private_class_method :parse_category_and_tags + + # Parses a QIF date string into an ISO 8601 date string. + # + # Quicken uses several variants: + # M/D'YY → 6/ 4'20 → 2020-06-04 + # M/ D'YY → 6/ 4'20 → 2020-06-04 + # MM/DD/YYYY → 06/04/2020 (less common) + def self.parse_qif_date(date_str) + return nil if date_str.blank? + + # Primary format: M/D'YY or M/ D'YY (spaces around day are optional) + if (m = date_str.match(%r{\A(\d{1,2})/\s*(\d{1,2})'(\d{2,4})\z})) + month = m[1].to_i + day = m[2].to_i + if m[3].length == 2 + year = 2000 + m[3].to_i + year -= 100 if year > Date.today.year + else + year = m[3].to_i + end + return Date.new(year, month, day).iso8601 + end + + # Fallback: MM/DD/YYYY + if (m = date_str.match(%r{\A(\d{1,2})/(\d{1,2})/(\d{4})\z})) + return Date.new(m[3].to_i, m[1].to_i, m[2].to_i).iso8601 + end + + nil + rescue Date::Error, ArgumentError + nil + end + private_class_method :parse_qif_date + + # Strips thousands-separator commas and returns a clean decimal string. + def self.parse_qif_amount(amount_str) + return nil if amount_str.blank? + + cleaned = amount_str.gsub(",", "").strip + cleaned =~ /\A-?\d+\.?\d*\z/ ? cleaned : nil + end + private_class_method :parse_qif_amount + + # Builds a ParsedInvestmentTransaction from a raw record hash. + # ticker_by_name maps security names (N field in !Type:Security) to tickers (S field). + def self.build_investment_transaction(record, ticker_by_name) + action = record["N"]&.strip + return nil unless action.present? + + raw_date = record["D"] + return nil unless raw_date.present? + + date = parse_qif_date(raw_date) + return nil unless date + + security_name = record["Y"]&.strip + security_ticker = ticker_by_name[security_name] || security_name + + price = parse_qif_amount(record["I"]) + qty = parse_qif_amount(record["Q"]) + amount = parse_qif_amount(record["T"] || record["U"]) + + category, tags = parse_category_and_tags(record["L"]) + + ParsedInvestmentTransaction.new( + date: date, + action: action, + security_name: security_name, + security_ticker: security_ticker, + price: price, + qty: qty, + amount: amount, + memo: record["M"]&.strip, + payee: record["P"]&.strip, + category: category, + tags: tags + ) + end + private_class_method :build_investment_transaction +end diff --git a/app/models/import.rb b/app/models/import.rb index 203ed3a1a..2f2a0043e 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -9,7 +9,7 @@ class Import < ApplicationRecord DOCUMENT_TYPES = %w[bank_statement credit_card_statement investment_statement financial_document contract other].freeze - TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport].freeze + TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport QifImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze @@ -197,6 +197,10 @@ class Import < ApplicationRecord [] end + def rows_ordered + rows.ordered + end + def uploaded? raw_file_str.present? end diff --git a/app/models/import/category_mapping.rb b/app/models/import/category_mapping.rb index 4b633ea47..56e7fbdd4 100644 --- a/app/models/import/category_mapping.rb +++ b/app/models/import/category_mapping.rb @@ -2,10 +2,26 @@ class Import::CategoryMapping < Import::Mapping class << self def mappables_by_key(import) unique_values = import.rows.map(&:category).uniq - categories = import.family.categories.where(name: unique_values).index_by(&:name) - unique_values.index_with { |value| categories[value] } + # For hierarchical QIF keys like "Home:Home Improvement", look up the child + # name ("Home Improvement") since category names are unique per family. + lookup_names = unique_values.map { |v| leaf_category_name(v) } + categories = import.family.categories.where(name: lookup_names).index_by(&:name) + + unique_values.index_with { |value| categories[leaf_category_name(value)] } end + + private + + # Returns the leaf (child) name for a potentially hierarchical key. + # "Home:Home Improvement" → "Home Improvement" + # "Fees & Charges" → "Fees & Charges" + def leaf_category_name(key) + return "" if key.blank? + + parts = key.to_s.split(":", 2) + parts.length == 2 ? parts[1].strip : key + end end def selectable_values @@ -33,7 +49,30 @@ class Import::CategoryMapping < Import::Mapping def create_mappable! return unless creatable? - self.mappable = import.family.categories.find_or_create_by!(name: key) + parts = key.split(":", 2) + + if parts.length == 2 + parent_name = parts[0].strip + child_name = parts[1].strip + + # Ensure the parent category exists before creating the child. + parent = import.family.categories.find_or_create_by!(name: parent_name) do |cat| + cat.color = Category::COLORS.sample + cat.lucide_icon = Category.suggested_icon(parent_name) + end + + self.mappable = import.family.categories.find_or_create_by!(name: child_name) do |cat| + cat.parent = parent + cat.color = parent.color + cat.lucide_icon = Category.suggested_icon(child_name) + end + else + self.mappable = import.family.categories.find_or_create_by!(name: key) do |cat| + cat.color = Category::COLORS.sample + cat.lucide_icon = Category.suggested_icon(key) + end + end + save! end end diff --git a/app/models/qif_import.rb b/app/models/qif_import.rb new file mode 100644 index 000000000..7fcb70c96 --- /dev/null +++ b/app/models/qif_import.rb @@ -0,0 +1,382 @@ +class QifImport < Import + after_create :set_default_config + + # Parses the stored QIF content and creates Import::Row records. + # Overrides the base CSV-based method with QIF-specific parsing. + def generate_rows_from_csv + rows.destroy_all + + if investment_account? + generate_investment_rows + else + generate_transaction_rows + end + + update_column(:rows_count, rows.count) + end + + def import! + transaction do + mappings.each(&:create_mappable!) + + if investment_account? + import_investment_rows! + else + import_transaction_rows! + + if (ob = QifParser.parse_opening_balance(raw_file_str)) + Account::OpeningBalanceManager.new(account).set_opening_balance( + balance: ob[:amount], + date: ob[:date] + ) + else + adjust_opening_anchor_if_needed! + end + end + end + end + + # QIF has a fixed format – no CSV column mapping step needed. + def requires_csv_workflow? + false + end + + def rows_ordered + rows.order(date: :desc, id: :desc) + end + + def column_keys + if qif_account_type == "Invst" + %i[date ticker qty price amount currency name] + else + %i[date amount name currency category tags notes] + end + end + + def publishable? + account.present? && super + end + + # Returns true if import! will move the opening anchor back to cover transactions + # that predate the current anchor date. Used to show a notice in the confirm step. + def will_adjust_opening_anchor? + return false if investment_account? + return false if QifParser.parse_opening_balance(raw_file_str).present? + return false unless account.present? + + manager = Account::OpeningBalanceManager.new(account) + return false unless manager.has_opening_anchor? + + earliest = earliest_row_date + earliest.present? && earliest < manager.opening_date + end + + # The date the opening anchor will be moved to when will_adjust_opening_anchor? is true. + def adjusted_opening_anchor_date + earliest = earliest_row_date + (earliest - 1.day) if earliest.present? + end + + # The account type declared in the QIF file (e.g. "CCard", "Bank", "Invst"). + def qif_account_type + return @qif_account_type if instance_variable_defined?(:@qif_account_type) + @qif_account_type = raw_file_str.present? ? QifParser.account_type(raw_file_str) : nil + end + + # Unique categories used across all rows (blank entries excluded). + def row_categories + rows.distinct.pluck(:category).reject(&:blank?).sort + end + + # Returns true if the QIF file contains any split transactions. + def has_split_transactions? + return @has_split_transactions if defined?(@has_split_transactions) + @has_split_transactions = parsed_transactions_with_splits.any?(&:split) + end + + # Categories that appear on split transactions in the QIF file. + # Split transactions use S/$ fields to break a total into sub-amounts; + # the app does not yet support splits, so these categories are flagged. + def split_categories + return @split_categories if defined?(@split_categories) + + split_cats = parsed_transactions_with_splits.select(&:split).map(&:category).reject(&:blank?).uniq.sort + @split_categories = split_cats & row_categories + end + + # Unique tags used across all rows (blank entries excluded). + def row_tags + rows.flat_map(&:tags_list).uniq.reject(&:blank?).sort + end + + # True once the category/tag selection step has been completed + # (sync_mappings has been called, which always produces at least one mapping). + def categories_selected? + mappings.any? + end + + def mapping_steps + [ Import::CategoryMapping, Import::TagMapping ] + end + + private + + def parsed_transactions_with_splits + @parsed_transactions_with_splits ||= QifParser.parse(raw_file_str) + end + + def investment_account? + qif_account_type == "Invst" + end + + # ------------------------------------------------------------------ + # Row generation + # ------------------------------------------------------------------ + + def generate_transaction_rows + transactions = QifParser.parse(raw_file_str) + + mapped_rows = transactions.map do |trn| + { + date: trn.date.to_s, + amount: trn.amount.to_s, + currency: default_currency.to_s, + name: (trn.payee.presence || default_row_name).to_s, + notes: trn.memo.to_s, + category: trn.category.to_s, + tags: trn.tags.join("|"), + account: "", + qty: "", + ticker: "", + price: "", + exchange_operating_mic: "", + entity_type: "" + } + end + + if mapped_rows.any? + rows.insert_all!(mapped_rows) + rows.reset + end + end + + def generate_investment_rows + inv_transactions = QifParser.parse_investment_transactions(raw_file_str) + + mapped_rows = inv_transactions.map do |trn| + if QifParser::TRADE_ACTIONS.include?(trn.action) + qty = trade_qty_for(trn.action, trn.qty) + + { + date: trn.date.to_s, + ticker: trn.security_ticker.to_s, + qty: qty.to_s, + price: trn.price.to_s, + amount: trn.amount.to_s, + currency: default_currency.to_s, + name: trade_row_name(trn), + notes: trn.memo.to_s, + category: "", + tags: "", + account: "", + exchange_operating_mic: "", + entity_type: trn.action + } + else + { + date: trn.date.to_s, + amount: trn.amount.to_s, + currency: default_currency.to_s, + name: transaction_row_name(trn), + notes: trn.memo.to_s, + category: trn.category.to_s, + tags: trn.tags.join("|"), + account: "", + qty: "", + ticker: "", + price: "", + exchange_operating_mic: "", + entity_type: trn.action + } + end + end + + if mapped_rows.any? + rows.insert_all!(mapped_rows) + rows.reset + end + end + + # ------------------------------------------------------------------ + # Import execution + # ------------------------------------------------------------------ + + def import_transaction_rows! + transactions = rows.map do |row| + category = mappings.categories.mappable_for(row.category) + tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact + + Transaction.new( + category: category, + tags: tags, + entry: Entry.new( + account: account, + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: row.currency, + notes: row.notes, + import: self, + import_locked: true + ) + ) + end + + Transaction.import!(transactions, recursive: true) + end + + def import_investment_rows! + trade_rows = rows.select { |r| QifParser::TRADE_ACTIONS.include?(r.entity_type) } + transaction_rows = rows.reject { |r| QifParser::TRADE_ACTIONS.include?(r.entity_type) } + + if trade_rows.any? + trades = trade_rows.map do |row| + security = find_or_create_security(ticker: row.ticker) + + # Use the stored T-field amount for accuracy (includes any fees/commissions). + # Buy-like actions are cash outflows (positive); sell-like are inflows (negative). + entry_amount = QifParser::BUY_LIKE_ACTIONS.include?(row.entity_type) ? row.amount.to_d : -row.amount.to_d + + Trade.new( + security: security, + qty: row.qty.to_d, + price: row.price.to_d, + currency: row.currency, + investment_activity_label: investment_activity_label_for(row.entity_type), + entry: Entry.new( + account: account, + date: row.date_iso, + amount: entry_amount, + name: row.name, + currency: row.currency, + import: self, + import_locked: true + ) + ) + end + + Trade.import!(trades, recursive: true) + end + + if transaction_rows.any? + transactions = transaction_rows.map do |row| + # Inflow actions: money entering account → negative Entry.amount + # Outflow actions: money leaving account → positive Entry.amount + entry_amount = QifParser::INFLOW_TRANSACTION_ACTIONS.include?(row.entity_type) ? -row.amount.to_d : row.amount.to_d + + category = mappings.categories.mappable_for(row.category) + tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact + + Transaction.new( + category: category, + tags: tags, + entry: Entry.new( + account: account, + date: row.date_iso, + amount: entry_amount, + name: row.name, + currency: row.currency, + notes: row.notes, + import: self, + import_locked: true + ) + ) + end + + Transaction.import!(transactions, recursive: true) + end + end + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def adjust_opening_anchor_if_needed! + manager = Account::OpeningBalanceManager.new(account) + return unless manager.has_opening_anchor? + + earliest = earliest_row_date + return unless earliest.present? && earliest < manager.opening_date + + Account::OpeningBalanceManager.new(account).set_opening_balance( + balance: manager.opening_balance, + date: earliest - 1.day + ) + end + + def earliest_row_date + str = rows.minimum(:date) + Date.parse(str) if str.present? + end + + def set_default_config + update!( + signage_convention: "inflows_positive", + date_format: "%Y-%m-%d", + number_format: "1,234.56" + ) + end + + # Returns the signed qty for a trade row: + # buy-like actions keep qty positive; sell-like negate it. + def trade_qty_for(action, raw_qty) + qty = raw_qty.to_d + QifParser::SELL_LIKE_ACTIONS.include?(action) ? -qty : qty + end + + def investment_activity_label_for(action) + return nil if action.blank? + QifParser::BUY_LIKE_ACTIONS.include?(action) ? "Buy" : "Sell" + end + + def trade_row_name(trn) + type = QifParser::BUY_LIKE_ACTIONS.include?(trn.action) ? "buy" : "sell" + ticker = trn.security_ticker.presence || trn.security_name || "Unknown" + Trade.build_name(type, trn.qty.to_d.abs, ticker) + end + + def transaction_row_name(trn) + security = trn.security_name.presence + payee = trn.payee.presence + + case trn.action + when "Div" then payee || (security ? "Dividend: #{security}" : "Dividend") + when "IntInc" then payee || (security ? "Interest: #{security}" : "Interest") + when "XIn" then payee || "Cash Transfer In" + when "XOut" then payee || "Cash Transfer Out" + when "CGLong" then payee || (security ? "Capital Gain (Long): #{security}" : "Capital Gain (Long)") + when "CGShort" then payee || (security ? "Capital Gain (Short): #{security}" : "Capital Gain (Short)") + when "MiscInc" then payee || trn.memo.presence || "Miscellaneous Income" + when "MiscExp" then payee || trn.memo.presence || "Miscellaneous Expense" + else payee || trn.action + end + end + + def find_or_create_security(ticker: nil, exchange_operating_mic: nil) + return nil unless ticker.present? + + @security_cache ||= {} + + cache_key = [ ticker, exchange_operating_mic ].compact.join(":") + security = @security_cache[cache_key] + return security if security.present? + + security = Security::Resolver.new( + ticker, + exchange_operating_mic: exchange_operating_mic.presence + ).resolve + + @security_cache[cache_key] = security + security + end +end diff --git a/app/views/import/qif_category_selections/show.html.erb b/app/views/import/qif_category_selections/show.html.erb new file mode 100644 index 000000000..fdf8cbbba --- /dev/null +++ b/app/views/import/qif_category_selections/show.html.erb @@ -0,0 +1,128 @@ +<%= content_for :header_nav do %> + <%= render "imports/nav", import: @import %> +<% end %> + +<%= content_for :previous_path, import_upload_path(@import) %> + +
+
+

<%= t(".title") %>

+

<%= t(".description") %>

+
+ + <%= form_with url: import_qif_category_selection_path(@import), method: :put, class: "space-y-8" do |form| %> + + <%# ── Split transaction warning ────────────────────────────── %> + <% if @has_split_transactions %> +
+ <%= icon("triangle-alert", size: "md", class: "text-warning shrink-0 mt-0.5") %> +
+

<%= t(".split_warning_title") %>

+

<%= t(".split_warning_description") %>

+
+
+ <% end %> + + <%# ── Categories ─────────────────────────────────────────────── %> + <% if @categories.any? %> +
+
+

<%= t(".categories_heading") %>

+ <%= t(".categories_found", count: @categories.count) %> +
+ +
+
+
+
+
<%= t(".category_name_col") %>
+
<%= t(".transactions_col") %>
+
+ +
+ <% @categories.each_with_index do |category, index| %> + <% is_split = @split_categories.include?(category) %> + + <% end %> +
+
+
+
+ <% end %> + + <%# ── Tags ───────────────────────────────────────────────────── %> + <% if @tags.any? %> +
+
+

<%= t(".tags_heading") %>

+ <%= t(".tags_found", count: @tags.count) %> +
+ +
+
+
+
+
<%= t(".tag_name_col") %>
+
<%= t(".transactions_col") %>
+
+ +
+ <% @tags.each_with_index do |tag, index| %> + + <% end %> +
+
+
+
+ <% end %> + + <%# ── Empty state ─────────────────────────────────────────────── %> + <% if @categories.empty? && @tags.empty? %> +
+ <%= icon("tag", size: "lg", class: "mx-auto mb-2") %> +

<%= t(".empty_state_primary") %>

+

<%= t(".empty_state_secondary") %>

+
+ <% end %> + + <%# ── Submit ──────────────────────────────────────────────────── %> +
+ <%= form.submit t(".submit"), + class: "btn btn-primary w-full md:w-auto" %> +
+ + <% end %> +
diff --git a/app/views/import/uploads/show.html.erb b/app/views/import/uploads/show.html.erb index 338654578..2915703cf 100644 --- a/app/views/import/uploads/show.html.erb +++ b/app/views/import/uploads/show.html.erb @@ -4,77 +4,123 @@ <%= content_for :previous_path, imports_path %> -
- - <%= render "imports/drag_drop_overlay", title: "Drop CSV to upload", subtitle: "Your file will be uploaded automatically" %> - +<% if @import.is_a?(QifImport) %> + <%# ── QIF upload – fixed format, account required ── %>
-

<%= t(".title") %>

-

<%= t(".description") %>

+

<%= t(".qif_title") %>

+

<%= t(".qif_description") %>

- <%= render DS::Tabs.new(active_tab: params[:tab] || "csv-upload", url_param_key: "tab", testid: "import-tabs") do |tabs| %> - <% tabs.with_nav do |nav| %> - <% nav.with_btn(id: "csv-upload", label: "Upload CSV") %> - <% nav.with_btn(id: "csv-paste", label: "Copy & Paste") %> - <% end %> + <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-4" do |form| %> + <%= form.select :account_id, + @import.family.accounts.visible.pluck(:name, :id), + { label: t(".qif_account_label"), include_blank: t(".qif_account_placeholder"), selected: @import.account_id }, + required: true %> - <% tabs.with_panel(tab_id: "csv-upload") do %> - <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2", data: { drag_and_drop_import_target: "form" } do |form| %> - <%= form.select :col_sep, Import::SEPARATORS, label: true %> - - <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> - <%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> - <% end %> - -
-
-
- <%= icon("plus", size: "lg", class: "mb-4 mx-auto") %> -

- Browse to add your CSV file here -

-
- - - - <%= form.file_field :import_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input", "data-drag-and-drop-import-target": "input" %> -
+ - <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> - <%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> - <% end %> - - <%= form.text_area :raw_file_str, - rows: 10, - required: true, - placeholder: "Paste your CSV file contents here", - "data-auto-submit-form-target": "auto" %> - - <%= form.submit "Upload CSV", disabled: @import.complete? %> - <% end %> - <% end %> + <%= form.submit t(".qif_submit"), disabled: @import.complete? %> <% end %>
-
- - <%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format - +<% else %> + <%# ── Standard CSV upload ── %> +
+ + <%= render "imports/drag_drop_overlay", title: "Drop CSV to upload", subtitle: "Your file will be uploaded automatically" %> + +
+
+

<%= t(".title") %>

+

<%= t(".description") %>

+
+ + <%= render DS::Tabs.new(active_tab: params[:tab] || "csv-upload", url_param_key: "tab", testid: "import-tabs") do |tabs| %> + <% tabs.with_nav do |nav| %> + <% nav.with_btn(id: "csv-upload", label: "Upload CSV") %> + <% nav.with_btn(id: "csv-paste", label: "Copy & Paste") %> + <% end %> + + <% tabs.with_panel(tab_id: "csv-upload") do %> + <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2", data: { drag_and_drop_import_target: "form" } do |form| %> + <%= form.select :col_sep, Import::SEPARATORS, label: true %> + + <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> + <%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> + <% end %> + + + + <%= form.submit "Upload CSV", disabled: @import.complete? %> + <% end %> + <% end %> + + <% tabs.with_panel(tab_id: "csv-paste") do %> + <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %> + <%= form.select :col_sep, Import::SEPARATORS, label: true %> + + <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> + <%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> + <% end %> + + <%= form.text_area :raw_file_str, + rows: 10, + required: true, + placeholder: "Paste your CSV file contents here", + "data-auto-submit-form-target": "auto" %> + + <%= form.submit "Upload CSV", disabled: @import.complete? %> + <% end %> + <% end %> + <% end %> +
+ +
+ + <%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format + +
-
+<% end %> diff --git a/app/views/imports/_nav.html.erb b/app/views/imports/_nav.html.erb index 43282c5d3..5d56bce7b 100644 --- a/app/views/imports/_nav.html.erb +++ b/app/views/imports/_nav.html.erb @@ -9,6 +9,15 @@ { name: t("imports.steps.clean", default: "Clean"), path: import.configured? ? import_clean_path(import) : nil, is_complete: import.cleaned?, step_number: 3 }, { name: t("imports.steps.confirm", default: "Confirm"), path: import_path(import), is_complete: import.complete?, step_number: 4 } ] +elsif import.is_a?(QifImport) + # QIF imports skip Configure (fixed format) and add a category/tag selection step. + [ + { name: t("imports.steps.upload", default: "Upload"), path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 }, + { name: t("imports.steps.select", default: "Select"), path: import.uploaded? ? import_qif_category_selection_path(import) : nil, is_complete: import.categories_selected?, step_number: 2 }, + { name: t("imports.steps.clean", default: "Clean"), path: import.uploaded? ? import_clean_path(import) : nil, is_complete: import.cleaned?, step_number: 3 }, + { name: t("imports.steps.map", default: "Map"), key: "Map", path: import_confirm_path(import), is_complete: import.publishable?, step_number: 4 }, + { name: t("imports.steps.confirm", default: "Confirm"), path: import_path(import), is_complete: import.complete?, step_number: 5 } + ].reject { |step| step[:key] == "Map" && import.mapping_steps.empty? } else [ { name: t("imports.steps.upload", default: "Upload"), path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 }, diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index 0b8c1a490..5fd42d76b 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -89,6 +89,17 @@ disabled_message: requires_account_message %> <% end %> + <% if params[:type].nil? || params[:type] == "QifImport" %> + <%= render "imports/import_option", + type: "QifImport", + icon_name: "file-clock", + icon_bg_class: "bg-teal-500/5", + icon_text_class: "text-teal-500", + label: t(".import_qif"), + enabled: has_accounts, + disabled_message: requires_account_message %> + <% end %> + <% if (params[:type].nil? || params[:type].in?(%w[DocumentImport PdfImport])) && @document_upload_extensions.any? %>
  • <%= styled_form_with url: imports_path, scope: :import, multipart: true, class: "w-full" do |form| %> diff --git a/config/locales/views/imports/de.yml b/config/locales/views/imports/de.yml index d9f5d992c..0af711a79 100644 --- a/config/locales/views/imports/de.yml +++ b/config/locales/views/imports/de.yml @@ -1,6 +1,30 @@ --- de: import: + qif_category_selections: + show: + title: "Kategorien und Tags auswählen" + description: "Wähle aus, welche Kategorien und Tags aus deiner QIF-Datei in Sure übernommen werden sollen. Abgewählte Einträge werden aus den betreffenden Transaktionen entfernt." + categories_heading: Kategorien + categories_found: + one: "1 Kategorie gefunden" + other: "%{count} Kategorien gefunden" + category_name_col: Kategoriename + transactions_col: Buchungen + tags_heading: Tags + tags_found: + one: "1 Tag gefunden" + other: "%{count} Tags gefunden" + tag_name_col: Tag-Name + txn_count: + one: "1 Buchung" + other: "%{count} Buchungen" + empty_state_primary: In dieser QIF-Datei wurden keine Kategorien oder Tags gefunden. + empty_state_secondary: Alle Transaktionen werden ohne Kategorien und Tags importiert. + submit: Weiter zur Überprüfung + split_warning_title: Aufgeteilte Buchungen erkannt + split_warning_description: "Diese QIF-Datei enthält aufgeteilte Buchungen. Aufgeteilte Buchungen werden noch nicht unterstützt – jede aufgeteilte Buchung wird als einzelne Buchung mit ihrem Gesamtbetrag und ohne Kategorie importiert. Die einzelnen Aufteilungsdetails werden nicht übernommen." + split_badge: aufgeteilt cleans: show: description: Bearbeite deine Daten in der Tabelle unten. Rote Zellen sind ungültig. @@ -47,6 +71,15 @@ de: tag_mapping_title: Tags zuweisen uploads: show: + qif_title: QIF-Datei hochladen + qif_description: Wähle das Konto, zu dem diese QIF-Datei gehört, und lade deinen .qif-Export aus Quicken hoch. + qif_account_label: Konto + qif_account_placeholder: Konto auswählen… + qif_file_prompt: um deine QIF-Datei hier hinzuzufügen + qif_file_hint: Nur .qif-Dateien + qif_submit: QIF hochladen + browse: Durchsuchen + csv_file_prompt: um deine CSV-Datei hier hinzuzufügen description: Füge unten deine CSV-Datei ein oder lade sie hoch. Bitte lies die Anweisungen in der Tabelle unten, bevor du beginnst. instructions_1: Unten siehst du ein Beispiel einer CSV-Datei mit verfügbaren Spalten für den Import. instructions_2: Deine CSV muss eine Kopfzeile enthalten. @@ -55,6 +88,13 @@ de: instructions_5: Keine Kommas, Währungssymbole oder Klammern in Zahlen verwenden. title: Daten importieren imports: + steps: + upload: Hochladen + configure: Konfigurieren + clean: Bereinigen + map: Zuordnen + confirm: Bestätigen + select: Auswählen index: title: Importe new: Neuer Import @@ -89,6 +129,7 @@ de: import_portfolio: Investitionen importieren import_rules: Regeln importieren import_transactions: Transaktionen importieren + import_qif: Von Quicken importieren (QIF) requires_account: Importiere zuerst Konten, um diese Option zu nutzen. resume: "%{type} fortsetzen" sources: Quellen diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index 6be06ff26..3a80af608 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -1,6 +1,30 @@ --- en: import: + qif_category_selections: + show: + title: "Select categories & tags" + description: "Choose which categories and tags from your QIF file to bring into Sure. Deselected items will be removed from those transactions." + categories_heading: Categories + categories_found: + one: "1 category found" + other: "%{count} categories found" + category_name_col: Category name + transactions_col: Transactions + tags_heading: Tags + tags_found: + one: "1 tag found" + other: "%{count} tags found" + tag_name_col: Tag name + txn_count: + one: "1 txn" + other: "%{count} txns" + split_warning_title: Split transactions detected + split_warning_description: "This QIF file contains split transactions. Splits are not yet supported, so each split transaction will be imported as a single transaction with its full amount and no category. The individual split breakdowns will not be preserved." + split_badge: split + empty_state_primary: No categories or tags were found in this QIF file. + empty_state_secondary: All transactions will be imported without categories or tags. + submit: Continue to review cleans: show: description: Edit your data in the table below. Red cells are invalid. @@ -59,6 +83,15 @@ en: tag_mapping_title: Assign your tags uploads: show: + qif_title: Upload QIF file + qif_description: Select the account this QIF file belongs to, then upload your .qif export from Quicken. + qif_account_label: Account + qif_account_placeholder: Select an account… + qif_file_prompt: to add your QIF file here + qif_file_hint: .qif files only + qif_submit: Upload QIF + browse: Browse + csv_file_prompt: to add your CSV file here description: Paste or upload your CSV file below. Please review the instructions in the table below before beginning. instructions_1: Below is an example CSV with columns available for import. @@ -69,6 +102,13 @@ en: instructions_5: No commas, no currency symbols, and no parentheses in numbers. title: Import your data imports: + steps: + upload: Upload + configure: Configure + clean: Clean + map: Map + confirm: Confirm + select: Select index: title: Imports new: New Import @@ -102,6 +142,7 @@ en: import_portfolio: Import investments import_rules: Import rules import_transactions: Import transactions + import_qif: Import from Quicken (QIF) import_file: Import document import_file_description: AI-powered analysis for PDFs and searchable upload for other supported files requires_account: Import accounts first to unlock this option. diff --git a/config/locales/views/imports/es.yml b/config/locales/views/imports/es.yml index 56c71e50e..7f67f6e0b 100644 --- a/config/locales/views/imports/es.yml +++ b/config/locales/views/imports/es.yml @@ -1,6 +1,30 @@ --- es: import: + qif_category_selections: + show: + title: "Seleccionar categorías y etiquetas" + description: "Elige qué categorías y etiquetas de tu archivo QIF importar en Sure. Los elementos deseleccionados se eliminarán de esas transacciones." + categories_heading: Categorías + categories_found: + one: "1 categoría encontrada" + other: "%{count} categorías encontradas" + category_name_col: Nombre de categoría + transactions_col: Transacciones + tags_heading: Etiquetas + tags_found: + one: "1 etiqueta encontrada" + other: "%{count} etiquetas encontradas" + tag_name_col: Nombre de etiqueta + txn_count: + one: "1 transacción" + other: "%{count} transacciones" + empty_state_primary: No se encontraron categorías ni etiquetas en este archivo QIF. + empty_state_secondary: Todas las transacciones se importarán sin categorías ni etiquetas. + submit: Continuar a la revisión + split_warning_title: Transacciones divididas detectadas + split_warning_description: "Este archivo QIF contiene transacciones divididas. Las divisiones aún no son compatibles, por lo que cada transacción dividida se importará como una única transacción con su importe total y sin categoría. Los desgloses individuales de las divisiones no se conservarán." + split_badge: dividida cleans: show: description: Edita tus datos en la tabla de abajo. Las celdas rojas son inválidas. @@ -47,6 +71,15 @@ es: tag_mapping_title: Asigna tus etiquetas uploads: show: + qif_title: Subir archivo QIF + qif_description: Selecciona la cuenta a la que pertenece este archivo QIF y sube tu exportación .qif desde Quicken. + qif_account_label: Cuenta + qif_account_placeholder: Seleccionar una cuenta… + qif_file_prompt: para añadir tu archivo QIF aquí + qif_file_hint: Solo archivos .qif + qif_submit: Subir QIF + browse: Examinar + csv_file_prompt: para añadir tu archivo CSV aquí description: Pega o sube tu archivo CSV abajo. Por favor, revisa las instrucciones en la tabla de abajo antes de comenzar. instructions_1: Abajo hay un ejemplo de CSV con columnas disponibles para importar. instructions_2: Tu CSV debe tener una fila de encabezado. @@ -55,6 +88,13 @@ es: instructions_5: Sin comas, sin símbolos de moneda y sin paréntesis en los números. title: Importa tus datos imports: + steps: + upload: Subir + configure: Configurar + clean: Limpiar + map: Mapear + confirm: Confirmar + select: Seleccionar index: title: Importaciones new: Nueva importación @@ -87,6 +127,7 @@ es: import_portfolio: Importar inversiones import_rules: Importar reglas import_transactions: Importar transacciones + import_qif: Importar desde Quicken (QIF) import_file: Importar documento import_file_description: Análisis potenciado por IA para PDFs y subida con búsqueda para otros archivos compatibles requires_account: Importa cuentas primero para desbloquear esta opción. diff --git a/config/locales/views/imports/fr.yml b/config/locales/views/imports/fr.yml index 386ea94d6..39e0567e3 100644 --- a/config/locales/views/imports/fr.yml +++ b/config/locales/views/imports/fr.yml @@ -1,6 +1,30 @@ --- fr: import: + qif_category_selections: + show: + title: "Sélectionner les catégories et étiquettes" + description: "Choisissez les catégories et étiquettes de votre fichier QIF à importer dans Sure. Les éléments désélectionnés seront retirés des transactions correspondantes." + categories_heading: Catégories + categories_found: + one: "1 catégorie trouvée" + other: "%{count} catégories trouvées" + category_name_col: Nom de la catégorie + transactions_col: Transactions + tags_heading: Étiquettes + tags_found: + one: "1 étiquette trouvée" + other: "%{count} étiquettes trouvées" + tag_name_col: Nom de l'étiquette + txn_count: + one: "1 opération" + other: "%{count} opérations" + empty_state_primary: Aucune catégorie ou étiquette trouvée dans ce fichier QIF. + empty_state_secondary: Toutes les transactions seront importées sans catégories ni étiquettes. + submit: Continuer vers la revue + split_warning_title: Transactions scindées détectées + split_warning_description: "Ce fichier QIF contient des transactions scindées. Les transactions scindées ne sont pas encore prises en charge : chaque transaction scindée sera importée comme une transaction unique avec son montant total et sans catégorie. Les ventilations individuelles ne seront pas conservées." + split_badge: scindée cleans: show: description: Modifiez vos données dans le tableau ci-dessous. Les cellules rouges sont invalides. @@ -47,6 +71,15 @@ fr: tag_mapping_title: Attribuez vos étiquettes uploads: show: + qif_title: Téléverser le fichier QIF + qif_description: Sélectionnez le compte auquel appartient ce fichier QIF, puis téléversez votre export .qif depuis Quicken. + qif_account_label: Compte + qif_account_placeholder: Sélectionner un compte… + qif_file_prompt: pour ajouter votre fichier QIF ici + qif_file_hint: Fichiers .qif uniquement + qif_submit: Téléverser le QIF + browse: Parcourir + csv_file_prompt: pour ajouter votre fichier CSV ici description: Collez ou téléversez votre fichier CSV ci-dessous. Veuillez examiner les instructions dans le tableau ci-dessous avant de commencer. instructions_1: Voici un exemple de CSV avec des colonnes disponibles pour l'importation. instructions_2: Votre CSV doit avoir une ligne d'en-tête @@ -55,7 +88,15 @@ fr: instructions_5: Pas de virgules, pas de symboles monétaires et pas de parenthèses dans les nombres. title: Importez vos données imports: + steps: + upload: Téléverser + configure: Configurer + clean: Nettoyer + map: Mapper + confirm: Confirmer + select: Sélectionner index: + title: Importations imports: Imports new: Nouvelle importation table: @@ -87,9 +128,57 @@ fr: import_portfolio: Importer les investissements import_rules: Importer les règles import_transactions: Importer les transactions + import_qif: Importer depuis Quicken (QIF) + import_file: Importer un document + import_file_description: Analyse par IA pour les PDFs et téléversement avec recherche pour les autres fichiers pris en charge + requires_account: Importez d'abord des comptes pour débloquer cette option. resume: Reprendre %{type} sources: Sources title: Nouvelle importation CSV + create: + file_too_large: Le fichier est trop volumineux. La taille maximale est de %{max_size} Mo. + invalid_file_type: Type de fichier invalide. Veuillez téléverser un fichier CSV. + csv_uploaded: CSV téléversé avec succès. + pdf_too_large: Le fichier PDF est trop volumineux. La taille maximale est de %{max_size} Mo. + pdf_processing: Votre PDF est en cours de traitement. Vous recevrez un e-mail lorsque l'analyse sera terminée. + invalid_pdf: Le fichier téléversé n'est pas un PDF valide. + document_too_large: Le document est trop volumineux. La taille maximale est de %{max_size} Mo. + invalid_document_file_type: Type de fichier de document invalide pour le magasin de vecteurs actif. + document_uploaded: Document téléversé avec succès. + document_upload_failed: Nous n'avons pas pu téléverser le document dans le magasin de vecteurs. Veuillez réessayer. + document_provider_not_configured: Aucun magasin de vecteurs n'est configuré pour les téléversements de documents. + show: + finalize_upload: Veuillez finaliser le téléversement de votre fichier. + finalize_mappings: Veuillez finaliser vos correspondances avant de continuer. + errors: + custom_column_requires_inflow: "Les importations de colonnes personnalisées nécessitent la sélection d'une colonne d'entrée" + document_types: + bank_statement: Relevé bancaire + credit_card_statement: Relevé de carte de crédit + investment_statement: Relevé d'investissement + financial_document: Document financier + contract: Contrat + other: Autre document + unknown: Document inconnu + pdf_import: + processing_title: Traitement de votre PDF + processing_description: Nous analysons votre document à l'aide de l'IA. Cela peut prendre un moment. Vous recevrez un e-mail lorsque l'analyse sera terminée. + check_status: Vérifier le statut + back_to_dashboard: Retour au tableau de bord + failed_title: Traitement échoué + failed_description: Nous n'avons pas pu traiter votre document PDF. Veuillez réessayer ou contacter le support. + try_again: Réessayer + delete_import: Supprimer l'importation + complete_title: Document analysé + complete_description: Nous avons analysé votre PDF et voici ce que nous avons trouvé. + document_type_label: Type de document + summary_label: Résumé + email_sent_notice: Un e-mail vous a été envoyé avec les prochaines étapes. + back_to_imports: Retour aux importations + unknown_state_title: État inconnu + unknown_state_description: Cette importation est dans un état inattendu. Veuillez retourner aux importations. + processing_failed_with_message: "%{message}" + processing_failed_generic: "Traitement échoué : %{error}" ready: description: Voici un résumé des nouveaux éléments qui seront ajoutés à votre compte une fois que vous aurez publié cette importation. title: Confirmez vos données d'importation diff --git a/config/routes.rb b/config/routes.rb index 8fede6930..7cc994f59 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -239,6 +239,7 @@ Rails.application.routes.draw do resource :configuration, only: %i[show update], module: :import resource :clean, only: :show, module: :import resource :confirm, only: :show, module: :import + resource :qif_category_selection, only: %i[show update], module: :import resources :rows, only: %i[show update], module: :import resources :mappings, only: :update, module: :import diff --git a/test/controllers/imports_controller_test.rb b/test/controllers/imports_controller_test.rb index 72f04c1cf..280ec2d41 100644 --- a/test/controllers/imports_controller_test.rb +++ b/test/controllers/imports_controller_test.rb @@ -33,8 +33,9 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest assert_select "button", text: "Import transactions", count: 0 assert_select "button", text: "Import investments", count: 0 assert_select "button", text: "Import from Mint", count: 0 - assert_select "span", text: "Import accounts first to unlock this option.", count: 3 - assert_select "div[aria-disabled=true]", count: 3 + assert_select "button", text: "Import from Quicken (QIF)", count: 0 + assert_select "span", text: "Import accounts first to unlock this option.", count: 4 + assert_select "div[aria-disabled=true]", count: 4 end test "creates import" do diff --git a/test/models/qif_import_test.rb b/test/models/qif_import_test.rb new file mode 100644 index 000000000..2aebe7021 --- /dev/null +++ b/test/models/qif_import_test.rb @@ -0,0 +1,854 @@ +require "test_helper" + +class QifImportTest < ActiveSupport::TestCase + # ── QifParser unit tests ──────────────────────────────────────────────────── + + SAMPLE_QIF = <<~QIF + !Type:Tag + NTRIP2025 + ^ + NVACATION2023 + DSummer Vacation 2023 + ^ + !Type:Cat + NFood & Dining + DFood and dining expenses + E + ^ + NFood & Dining:Restaurants + DRestaurants + E + ^ + NSalary + DSalary Income + I + ^ + !Type:CCard + D6/ 4'20 + U-99.00 + T-99.00 + C* + NTXFR + PMerchant A + LFees & Charges + ^ + D3/29'21 + U-28,500.00 + T-28,500.00 + PTransfer Out + L[Savings Account] + ^ + D10/ 1'20 + U500.00 + T500.00 + PPayment Received + LFood & Dining/TRIP2025 + ^ + QIF + + QIF_WITH_HIERARCHICAL_CATEGORIES = <<~QIF + !Type:Bank + D1/ 1'24 + U-150.00 + T-150.00 + PHardware Store + LHome:Home Improvement + ^ + D2/ 1'24 + U-50.00 + T-50.00 + PGrocery Store + LFood:Groceries + ^ + QIF + + # A QIF file that includes an Opening Balance entry as the first transaction. + # This mirrors how Quicken exports bank accounts. + QIF_WITH_OPENING_BALANCE = <<~QIF + !Type:Bank + D1/ 1'20 + U500.00 + T500.00 + POpening Balance + L[Checking Account] + ^ + D3/ 1'20 + U100.00 + T100.00 + PFirst Deposit + ^ + D4/ 1'20 + U-25.00 + T-25.00 + PCoffee Shop + ^ + QIF + + # A minimal investment QIF with two securities, trades, a dividend, and a cash transfer. + SAMPLE_INVST_QIF = <<~QIF + !Type:Security + NACME + SACME + TStock + ^ + !Type:Security + NCORP + SCORP + TStock + ^ + !Type:Invst + D1/17'22 + NDiv + YACME + U190.75 + T190.75 + ^ + D1/17'22 + NBuy + YACME + I66.10 + Q2 + U132.20 + T132.20 + ^ + D1/ 7'22 + NXIn + PMonthly Deposit + U8000.00 + T8000.00 + ^ + D2/ 1'22 + NSell + YCORP + I45.00 + Q3 + U135.00 + T135.00 + ^ + QIF + + # A QIF file that includes split transactions (S/$ fields) with an L field category. + QIF_WITH_SPLITS = <<~QIF + !Type:Cat + NFood & Dining + E + ^ + NHousehold + E + ^ + NUtilities + E + ^ + !Type:Bank + D1/ 1'24 + U-150.00 + T-150.00 + PGrocery & Hardware Store + LFood & Dining + SFood & Dining + $-100.00 + EGroceries + SHousehold + $-50.00 + ESupplies + ^ + D1/ 2'24 + U-75.00 + T-75.00 + PElectric Company + LUtilities + ^ + QIF + + # A QIF file where Quicken uses --Split-- as the L field for split transactions. + QIF_WITH_SPLIT_PLACEHOLDER = <<~QIF + !Type:Bank + D1/ 1'24 + U-100.00 + T-100.00 + PWalmart + L--Split-- + SClothing + $-25.00 + SFood + $-25.00 + SHome Improvement + $-50.00 + ^ + D1/ 2'24 + U-30.00 + T-30.00 + PCoffee Shop + LFood & Dining + ^ + QIF + + # ── QifParser: valid? ─────────────────────────────────────────────────────── + + test "valid? returns true for QIF content" do + assert QifParser.valid?(SAMPLE_QIF) + end + + test "valid? returns false for non-QIF content" do + refute QifParser.valid?("") + refute QifParser.valid?("date,amount,name\n2024-01-01,100,Coffee") + refute QifParser.valid?(nil) + refute QifParser.valid?("") + end + + # ── QifParser: account_type ───────────────────────────────────────────────── + + test "account_type extracts transaction section type" do + assert_equal "CCard", QifParser.account_type(SAMPLE_QIF) + end + + test "account_type ignores Tag and Cat sections" do + qif = "!Type:Tag\nNMyTag\n^\n!Type:Cat\nNMyCat\n^\n!Type:Bank\nD1/1'24\nT100.00\nPTest\n^\n" + assert_equal "Bank", QifParser.account_type(qif) + end + + # ── QifParser: parse (transactions) ───────────────────────────────────────── + + test "parse returns correct number of transactions" do + assert_equal 3, QifParser.parse(SAMPLE_QIF).length + end + + test "parse extracts dates correctly" do + transactions = QifParser.parse(SAMPLE_QIF) + assert_equal "2020-06-04", transactions[0].date + assert_equal "2021-03-29", transactions[1].date + assert_equal "2020-10-01", transactions[2].date + end + + test "parse extracts negative amount with commas" do + assert_equal "-28500.00", QifParser.parse(SAMPLE_QIF)[1].amount + end + + test "parse extracts simple negative amount" do + assert_equal "-99.00", QifParser.parse(SAMPLE_QIF)[0].amount + end + + test "parse extracts payee" do + transactions = QifParser.parse(SAMPLE_QIF) + assert_equal "Merchant A", transactions[0].payee + assert_equal "Transfer Out", transactions[1].payee + end + + test "parse extracts category and ignores transfer accounts" do + transactions = QifParser.parse(SAMPLE_QIF) + assert_equal "Fees & Charges", transactions[0].category + assert_equal "", transactions[1].category # [Savings Account] = transfer + assert_equal "Food & Dining", transactions[2].category + end + + test "parse extracts tags from L field slash suffix" do + transactions = QifParser.parse(SAMPLE_QIF) + assert_equal [], transactions[0].tags + assert_equal [], transactions[1].tags + assert_equal [ "TRIP2025" ], transactions[2].tags + end + + # ── QifParser: parse_categories ───────────────────────────────────────────── + + test "parse_categories returns all categories" do + names = QifParser.parse_categories(SAMPLE_QIF).map(&:name) + assert_includes names, "Food & Dining" + assert_includes names, "Food & Dining:Restaurants" + assert_includes names, "Salary" + end + + test "parse_categories marks income vs expense correctly" do + categories = QifParser.parse_categories(SAMPLE_QIF) + salary = categories.find { |c| c.name == "Salary" } + food = categories.find { |c| c.name == "Food & Dining" } + assert salary.income + refute food.income + end + + # ── QifParser: parse_tags ─────────────────────────────────────────────────── + + test "parse_tags returns all tags" do + names = QifParser.parse_tags(SAMPLE_QIF).map(&:name) + assert_includes names, "TRIP2025" + assert_includes names, "VACATION2023" + end + + test "parse_tags captures description" do + vacation = QifParser.parse_tags(SAMPLE_QIF).find { |t| t.name == "VACATION2023" } + assert_equal "Summer Vacation 2023", vacation.description + end + + # ── QifParser: encoding ────────────────────────────────────────────────────── + + test "normalize_encoding returns content unchanged when already valid UTF-8" do + result = QifParser.normalize_encoding("!Type:CCard\n") + assert_equal "!Type:CCard\n", result + end + + # ── QifParser: opening balance ─────────────────────────────────────────────── + + test "parse skips Opening Balance transaction" do + transactions = QifParser.parse(QIF_WITH_OPENING_BALANCE) + assert_equal 2, transactions.length + refute transactions.any? { |t| t.payee == "Opening Balance" } + end + + test "parse_opening_balance returns date and amount" do + ob = QifParser.parse_opening_balance(QIF_WITH_OPENING_BALANCE) + assert_not_nil ob + assert_equal Date.new(2020, 1, 1), ob[:date] + assert_equal BigDecimal("500"), ob[:amount] + end + + test "parse_opening_balance returns nil when no Opening Balance entry" do + assert_nil QifParser.parse_opening_balance(SAMPLE_QIF) + end + + test "parse_opening_balance returns nil for blank content" do + assert_nil QifParser.parse_opening_balance(nil) + assert_nil QifParser.parse_opening_balance("") + end + + # ── QifParser: split transactions ────────────────────────────────────────── + + test "parse flags split transactions" do + transactions = QifParser.parse(QIF_WITH_SPLITS) + split_txn = transactions.find { |t| t.payee == "Grocery & Hardware Store" } + normal_txn = transactions.find { |t| t.payee == "Electric Company" } + + assert split_txn.split, "Expected split transaction to be flagged" + refute normal_txn.split, "Expected normal transaction not to be flagged" + end + + test "parse returns correct count including split transactions" do + transactions = QifParser.parse(QIF_WITH_SPLITS) + assert_equal 2, transactions.length + end + + test "parse strips --Split-- placeholder from category" do + transactions = QifParser.parse(QIF_WITH_SPLIT_PLACEHOLDER) + walmart = transactions.find { |t| t.payee == "Walmart" } + + assert walmart.split, "Expected split transaction to be flagged" + assert_equal "", walmart.category, "Expected --Split-- to be stripped from category" + end + + test "parse preserves normal category alongside --Split-- placeholder" do + transactions = QifParser.parse(QIF_WITH_SPLIT_PLACEHOLDER) + coffee = transactions.find { |t| t.payee == "Coffee Shop" } + + refute coffee.split + assert_equal "Food & Dining", coffee.category + end + + # ── QifImport model ───────────────────────────────────────────────────────── + + setup do + @family = families(:dylan_family) + @account = accounts(:depository) + @import = QifImport.create!(family: @family, account: @account) + end + + test "generates rows from QIF content" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert_equal 3, @import.rows.count + end + + test "rows_count is updated after generate_rows_from_csv" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert_equal 3, @import.reload.rows_count + end + + test "generates row with correct date and amount" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + row = @import.rows.find_by(name: "Merchant A") + assert_equal "2020-06-04", row.date + assert_equal "-99.00", row.amount + end + + test "generates row with category" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + row = @import.rows.find_by(name: "Merchant A") + assert_equal "Fees & Charges", row.category + end + + test "generates row with tags stored as pipe-separated string" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + row = @import.rows.find_by(name: "Payment Received") + assert_equal "TRIP2025", row.tags + end + + test "transfer rows have blank category" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + row = @import.rows.find_by(name: "Transfer Out") + assert row.category.blank? + end + + test "requires_csv_workflow? is false" do + refute @import.requires_csv_workflow? + end + + test "qif_account_type returns CCard for credit card QIF" do + @import.update!(raw_file_str: SAMPLE_QIF) + assert_equal "CCard", @import.qif_account_type + end + + test "row_categories excludes blank categories" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + cats = @import.row_categories + assert_includes cats, "Fees & Charges" + assert_includes cats, "Food & Dining" + refute_includes cats, "" + end + + test "row_tags excludes blank tags" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + tags = @import.row_tags + assert_includes tags, "TRIP2025" + refute_includes tags, "" + end + + test "split_categories returns categories from split transactions" do + @import.update!(raw_file_str: QIF_WITH_SPLITS) + @import.generate_rows_from_csv + + split_cats = @import.split_categories + assert_includes split_cats, "Food & Dining" + refute_includes split_cats, "Utilities" + end + + test "split_categories returns empty when no splits" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert_empty @import.split_categories + end + + test "has_split_transactions? returns true when splits exist" do + @import.update!(raw_file_str: QIF_WITH_SPLITS) + assert @import.has_split_transactions? + end + + test "has_split_transactions? returns true for --Split-- placeholder" do + @import.update!(raw_file_str: QIF_WITH_SPLIT_PLACEHOLDER) + assert @import.has_split_transactions? + end + + test "has_split_transactions? returns false when no splits" do + @import.update!(raw_file_str: SAMPLE_QIF) + refute @import.has_split_transactions? + end + + test "split_categories is empty when splits use --Split-- placeholder" do + @import.update!(raw_file_str: QIF_WITH_SPLIT_PLACEHOLDER) + @import.generate_rows_from_csv + + assert_empty @import.split_categories + refute_includes @import.row_categories, "--Split--" + end + + test "categories_selected? is false before sync_mappings" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + refute @import.categories_selected? + end + + test "categories_selected? is true after sync_mappings" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + @import.sync_mappings + + assert @import.categories_selected? + end + + test "publishable? requires account to be present" do + import_without_account = QifImport.create!(family: @family) + import_without_account.update_columns(raw_file_str: SAMPLE_QIF, rows_count: 1) + + refute import_without_account.publishable? + end + + # ── Opening balance handling ───────────────────────────────────────────────── + + test "Opening Balance row is not generated as a transaction row" do + @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE) + @import.generate_rows_from_csv + + assert_equal 2, @import.rows.count + refute @import.rows.exists?(name: "Opening Balance") + end + + test "import! sets opening anchor from QIF Opening Balance entry" do + @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE) + @import.generate_rows_from_csv + @import.sync_mappings + @import.import! + + manager = Account::OpeningBalanceManager.new(@account) + assert manager.has_opening_anchor? + assert_equal Date.new(2020, 1, 1), manager.opening_date + assert_equal BigDecimal("500"), manager.opening_balance + end + + test "import! moves opening anchor back when transactions predate it" do + # Anchor set 2 years ago; SAMPLE_QIF has transactions from 2020 which predate it + @account.entries.create!( + date: 2.years.ago.to_date, + name: "Opening balance", + amount: 0, + currency: @account.currency, + entryable: Valuation.new(kind: "opening_anchor") + ) + + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + @import.sync_mappings + @import.import! + + manager = Account::OpeningBalanceManager.new(@account.reload) + # Day before the earliest SAMPLE_QIF transaction (2020-06-04) + assert_equal Date.new(2020, 6, 3), manager.opening_date + assert_equal 0, manager.opening_balance + end + + test "import! does not move opening anchor when transactions do not predate it" do + anchor_date = Date.new(2020, 1, 1) # before the earliest SAMPLE_QIF transaction (2020-06-04) + @account.entries.create!( + date: anchor_date, + name: "Opening balance", + amount: 0, + currency: @account.currency, + entryable: Valuation.new(kind: "opening_anchor") + ) + + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + @import.sync_mappings + @import.import! + + assert_equal anchor_date, Account::OpeningBalanceManager.new(@account.reload).opening_date + end + + test "import! updates a pre-existing opening anchor from QIF Opening Balance entry" do + @account.entries.create!( + date: 2.years.ago.to_date, + name: "Opening balance", + amount: 0, + currency: @account.currency, + entryable: Valuation.new(kind: "opening_anchor") + ) + + @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE) + @import.generate_rows_from_csv + @import.sync_mappings + @import.import! + + manager = Account::OpeningBalanceManager.new(@account.reload) + assert_equal Date.new(2020, 1, 1), manager.opening_date + assert_equal BigDecimal("500"), manager.opening_balance + end + + test "will_adjust_opening_anchor? returns true when transactions predate anchor" do + @account.entries.create!( + date: 2.years.ago.to_date, + name: "Opening balance", + amount: 0, + currency: @account.currency, + entryable: Valuation.new(kind: "opening_anchor") + ) + + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert @import.will_adjust_opening_anchor? + end + + test "will_adjust_opening_anchor? returns false when QIF has Opening Balance entry" do + @account.entries.create!( + date: 2.years.ago.to_date, + name: "Opening balance", + amount: 0, + currency: @account.currency, + entryable: Valuation.new(kind: "opening_anchor") + ) + + @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE) + @import.generate_rows_from_csv + + refute @import.will_adjust_opening_anchor? + end + + test "adjusted_opening_anchor_date is one day before earliest transaction" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert_equal Date.new(2020, 6, 3), @import.adjusted_opening_anchor_date + end + + # ── Hierarchical category (Parent:Child) ───────────────────────────────────── + + test "generates rows with hierarchical category stored as-is" do + @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES) + @import.generate_rows_from_csv + + row = @import.rows.find_by(name: "Hardware Store") + assert_equal "Home:Home Improvement", row.category + end + + test "create_mappable! creates parent and child categories for hierarchical key" do + @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES) + @import.generate_rows_from_csv + @import.sync_mappings + + mapping = @import.mappings.categories.find_by(key: "Home:Home Improvement") + mapping.update!(create_when_empty: true) + mapping.create_mappable! + + child = @family.categories.find_by(name: "Home Improvement") + assert_not_nil child + assert_not_nil child.parent + assert_equal "Home", child.parent.name + end + + test "create_mappable! reuses existing parent category for hierarchical key" do + existing_parent = @family.categories.create!( + name: "Home", color: "#aabbcc", lucide_icon: "house", classification: "expense" + ) + + @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES) + @import.generate_rows_from_csv + @import.sync_mappings + + mapping = @import.mappings.categories.find_by(key: "Home:Home Improvement") + mapping.update!(create_when_empty: true) + + assert_no_difference "@family.categories.where(name: 'Home').count" do + mapping.create_mappable! + end + + child = @family.categories.find_by(name: "Home Improvement") + assert_equal existing_parent.id, child.parent_id + end + + test "mappables_by_key pre-matches hierarchical key to existing child category" do + parent = @family.categories.create!( + name: "Home", color: "#aabbcc", lucide_icon: "house", classification: "expense" + ) + child = @family.categories.create!( + name: "Home Improvement", color: "#aabbcc", lucide_icon: "house", + classification: "expense", parent: parent + ) + + @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES) + @import.generate_rows_from_csv + + mappables = Import::CategoryMapping.mappables_by_key(@import) + assert_equal child, mappables["Home:Home Improvement"] + end + + # ── Investment (Invst) QIF: parser ────────────────────────────────────────── + + test "parse_securities returns all securities from investment QIF" do + securities = QifParser.parse_securities(SAMPLE_INVST_QIF) + assert_equal 2, securities.length + tickers = securities.map(&:ticker) + assert_includes tickers, "ACME" + assert_includes tickers, "CORP" + end + + test "parse_securities maps name to ticker and type correctly" do + acme = QifParser.parse_securities(SAMPLE_INVST_QIF).find { |s| s.ticker == "ACME" } + assert_equal "ACME", acme.name + assert_equal "Stock", acme.security_type + end + + test "parse_securities returns empty array for non-investment QIF" do + assert_empty QifParser.parse_securities(SAMPLE_QIF) + end + + test "parse_investment_transactions returns all investment records" do + assert_equal 4, QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).length + end + + test "parse_investment_transactions resolves security name to ticker" do + buy = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Buy" } + assert_equal "ACME", buy.security_ticker + assert_equal "ACME", buy.security_name + end + + test "parse_investment_transactions extracts price, qty, and amount for trade actions" do + buy = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Buy" } + assert_equal "66.10", buy.price + assert_equal "2", buy.qty + assert_equal "132.20", buy.amount + end + + test "parse_investment_transactions extracts amount and ticker for dividend" do + div = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Div" } + assert_equal "190.75", div.amount + assert_equal "ACME", div.security_ticker + end + + test "parse_investment_transactions extracts payee for cash actions" do + xin = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "XIn" } + assert_equal "Monthly Deposit", xin.payee + assert_equal "8000.00", xin.amount + end + + # ── Investment (Invst) QIF: row generation ────────────────────────────────── + + test "qif_account_type returns Invst for investment QIF" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + assert_equal "Invst", @import.qif_account_type + end + + test "generates correct number of rows from investment QIF" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + @import.generate_rows_from_csv + + assert_equal 4, @import.rows.count + end + + test "generates trade rows with correct entity_type, ticker, qty, and price" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + @import.generate_rows_from_csv + + buy_row = @import.rows.find_by(entity_type: "Buy") + assert_not_nil buy_row + assert_equal "ACME", buy_row.ticker + assert_equal "2.0", buy_row.qty + assert_equal "66.10", buy_row.price + assert_equal "132.20", buy_row.amount + end + + test "generates sell row with negative qty" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + @import.generate_rows_from_csv + + sell_row = @import.rows.find_by(entity_type: "Sell") + assert_not_nil sell_row + assert_equal "CORP", sell_row.ticker + assert_equal "-3.0", sell_row.qty + end + + test "generates transaction row for Div with security name in row name" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + @import.generate_rows_from_csv + + div_row = @import.rows.find_by(entity_type: "Div") + assert_not_nil div_row + assert_equal "Dividend: ACME", div_row.name + assert_equal "190.75", div_row.amount + end + + test "generates transaction row for XIn using payee as name" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + @import.generate_rows_from_csv + + xin_row = @import.rows.find_by(entity_type: "XIn") + assert_not_nil xin_row + assert_equal "Monthly Deposit", xin_row.name + end + + # ── Investment (Invst) QIF: import! ───────────────────────────────────────── + + test "import! creates Trade records for buy and sell rows" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + import.sync_mappings + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + + assert_difference "Trade.count", 2 do + import.import! + end + end + + test "import! creates Transaction records for dividend and cash rows" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + import.sync_mappings + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + + assert_difference "Transaction.count", 2 do + import.import! + end + end + + test "import! creates inflow Entry for Div (negative amount)" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + import.sync_mappings + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + import.import! + + div_entry = accounts(:investment).entries.find_by(name: "Dividend: ACME") + assert_not_nil div_entry + assert div_entry.amount.negative?, "Dividend should be an inflow (negative amount)" + assert_in_delta(-190.75, div_entry.amount, 0.01) + end + + test "import! creates outflow Entry for Buy (positive amount)" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + import.sync_mappings + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + import.import! + + buy_entry = accounts(:investment) + .entries + .joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'") + .find_by("trades.qty > 0") + assert_not_nil buy_entry + assert buy_entry.amount.positive?, "Buy trade should be an outflow (positive amount)" + end + + test "import! creates inflow Entry for Sell (negative amount)" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + import.sync_mappings + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + import.import! + + sell_entry = accounts(:investment) + .entries + .joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'") + .find_by("trades.qty < 0") + assert_not_nil sell_entry + assert sell_entry.amount.negative?, "Sell trade should be an inflow (negative amount)" + end + + test "will_adjust_opening_anchor? returns false for investment accounts" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + + refute import.will_adjust_opening_anchor? + end +end