Feat: Add QIF (Quicken Interchange Format) import functionality (#1074)

* Feat: Add QIF (Quicken Interchange Format) import functionality
- Add the ability to import QIF files for users coming from Quicken
- Includes categories and tags
- Comprehensive tests for QifImport, including parsing, row generation, and import functionality.
- Ensure handling of hierarchical categories (ex "Home:Home Improvement" is imported as Parent:Child)

* Fix QIF import issues raised in code review

- Fix two-digit year windowing in QIF date parser (e.g. '99 → 1999, not 2099)
- Fix ArgumentError from invalid `undef: :raise` encoding option
- Nil-safe `leaf_category_name` with blank guard and `.to_s` coercion
- Memoize `qif_account_type` to avoid re-parsing the full QIF file
- Add strong parameters (`selection_params`) to QifCategorySelectionsController
- Wrap all mutations in DB transactions in uploads and category-selections controllers
- Skip unchanged tag rows (only write rows where tags actually differ)
- Replace hardcoded strings with i18n keys across QIF views and nav
- Fix potentially colliding checkbox/label IDs in category selection view
- Improve keyboard accessibility: use semantic `<label>` for file picker area

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix QIF import test count and Brakeman mass assignment warning

- Update ImportsControllerTest to expect 4 disabled import options (was 3),
  accounting for the new QIF import type added in this branch
- Remove :account_id from upload_params permit list; it was never accessed
  through strong params (always via params.dig with Current.family scope),
  so this resolves the Brakeman high-confidence mass assignment warning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: QIF import security, safety, and i18n issues raised in code review
- Added french, spanish and german translations for newly added i18n keys
- Replace params.dig(:import, :account_id) with a proper strong-params
  accessor (import_account_id) in UploadsController to satisfy Rails
  parameter filtering requirements
- Guard ImportsController#show against QIF imports reaching the publish
  screen before a file has been uploaded, preventing an unrescued error
  on publish
- Gate the QIF "Clean" nav step link on import.uploaded? to prevent
  routing to CleansController with an unconfigured import (which would
  raise "Unknown import type: QifImport" via ImportsHelper)
- Replace hard-coded "txn" pluralize calls in the category/tag selection
  view with t(".txn_count") and add pluralization keys to the locale file
- Localize all hard-coded strings in the QIF upload section of
  uploads/show.html.erb and add corresponding en.yml keys
- Convert the CSV upload drop zone from a clickable <div> (JS-only) to
  a semantic <label> element, making it keyboard-accessible without
  JavaScript

* Fix: missing translations keys

* Add icon mapping and random color assignment to new categories

* fix a lint issue

* Add a warning about splits and some plumbing for future support.
Updated locales.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Serge L
2026-03-14 15:22:39 -04:00
committed by GitHub
parent 5b0ddd06a4
commit 57199d6eb9
20 changed files with 2344 additions and 71 deletions

View File

@@ -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? }

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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:<name>" 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

View File

@@ -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

View File

@@ -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

382
app/models/qif_import.rb Normal file
View File

@@ -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

View File

@@ -0,0 +1,128 @@
<%= content_for :header_nav do %>
<%= render "imports/nav", import: @import %>
<% end %>
<%= content_for :previous_path, import_upload_path(@import) %>
<div class="space-y-8 mx-auto max-w-2xl mb-6">
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium"><%= t(".title") %></h1>
<p class="text-secondary text-sm"><%= t(".description") %></p>
</div>
<%= form_with url: import_qif_category_selection_path(@import), method: :put, class: "space-y-8" do |form| %>
<%# ── Split transaction warning ────────────────────────────── %>
<% if @has_split_transactions %>
<div class="flex gap-3 items-start rounded-xl border border-warning bg-warning/5 px-4 py-3">
<%= icon("triangle-alert", size: "md", class: "text-warning shrink-0 mt-0.5") %>
<div class="text-sm text-primary space-y-1">
<p class="font-medium"><%= t(".split_warning_title") %></p>
<p class="text-secondary"><%= t(".split_warning_description") %></p>
</div>
</div>
<% end %>
<%# ── Categories ─────────────────────────────────────────────── %>
<% if @categories.any? %>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium text-primary"><%= t(".categories_heading") %></h2>
<span class="text-sm text-secondary"><%= t(".categories_found", count: @categories.count) %></span>
</div>
<div class="bg-container-inset rounded-xl p-1">
<div class="bg-container shadow-border-xs rounded-lg overflow-hidden">
<div class="grid grid-cols-12 gap-2 px-5 py-3 text-xs font-medium text-secondary uppercase border-b border-primary">
<div class="col-span-1"></div>
<div class="col-span-8"><%= t(".category_name_col") %></div>
<div class="col-span-3 text-right"><%= t(".transactions_col") %></div>
</div>
<div class="divide-y divide-alpha-black-100">
<% @categories.each_with_index do |category, index| %>
<% is_split = @split_categories.include?(category) %>
<label for="cat_<%= index %>_<%= category.parameterize(separator: "_") %>" class="grid grid-cols-12 gap-2 items-center px-5 py-3 bg-container cursor-pointer hover:bg-surface-inset transition-colors">
<div class="col-span-1">
<input
type="checkbox"
name="categories[]"
value="<%= category %>"
id="cat_<%= index %>_<%= category.parameterize(separator: "_") %>"
<%= "checked" unless is_split %>
class="rounded border-primary">
</div>
<div class="col-span-8 flex items-center gap-2">
<span class="text-sm text-primary"><%= category %></span>
<% if is_split %>
<span class="inline-flex items-center rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning"><%= t(".split_badge") %></span>
<% end %>
</div>
<span class="col-span-3 text-sm text-secondary text-right">
<%= t(".txn_count", count: @category_counts[category] || 0) %>
</span>
</label>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
<%# ── Tags ───────────────────────────────────────────────────── %>
<% if @tags.any? %>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium text-primary"><%= t(".tags_heading") %></h2>
<span class="text-sm text-secondary"><%= t(".tags_found", count: @tags.count) %></span>
</div>
<div class="bg-container-inset rounded-xl p-1">
<div class="bg-container shadow-border-xs rounded-lg overflow-hidden">
<div class="grid grid-cols-12 gap-2 px-5 py-3 text-xs font-medium text-secondary uppercase border-b border-primary">
<div class="col-span-1"></div>
<div class="col-span-8"><%= t(".tag_name_col") %></div>
<div class="col-span-3 text-right"><%= t(".transactions_col") %></div>
</div>
<div class="divide-y divide-alpha-black-100">
<% @tags.each_with_index do |tag, index| %>
<label for="tag_<%= index %>_<%= tag.parameterize(separator: "_") %>" class="grid grid-cols-12 gap-2 items-center px-5 py-3 bg-container cursor-pointer hover:bg-surface-inset transition-colors">
<div class="col-span-1">
<input
type="checkbox"
name="tags[]"
value="<%= tag %>"
id="tag_<%= index %>_<%= tag.parameterize(separator: "_") %>"
checked
class="rounded border-primary">
</div>
<span class="col-span-8 text-sm text-primary"><%= tag %></span>
<span class="col-span-3 text-sm text-secondary text-right">
<%= t(".txn_count", count: @tag_counts[tag] || 0) %>
</span>
</label>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
<%# ── Empty state ─────────────────────────────────────────────── %>
<% if @categories.empty? && @tags.empty? %>
<div class="text-center py-12 text-secondary space-y-2">
<%= icon("tag", size: "lg", class: "mx-auto mb-2") %>
<p class="text-sm"><%= t(".empty_state_primary") %></p>
<p class="text-xs"><%= t(".empty_state_secondary") %></p>
</div>
<% end %>
<%# ── Submit ──────────────────────────────────────────────────── %>
<div class="flex justify-center">
<%= form.submit t(".submit"),
class: "btn btn-primary w-full md:w-auto" %>
</div>
<% end %>
</div>

View File

@@ -4,77 +4,123 @@
<%= content_for :previous_path, imports_path %>
<div class="space-y-4" data-controller="drag-and-drop-import">
<!-- Overlay -->
<%= 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 ── %>
<div class="space-y-4 mx-auto max-w-md">
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium"><%= t(".title") %></h1>
<p class="text-secondary text-sm"><%= t(".description") %></p>
<h1 class="text-3xl text-primary font-medium"><%= t(".qif_title") %></h1>
<p class="text-secondary text-sm"><%= t(".qif_description") %></p>
</div>
<%= 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 %>
<div class="flex flex-col items-center justify-center w-full h-64 border border-secondary border-dashed rounded-xl cursor-pointer" data-controller="file-upload" data-action="click->file-upload#triggerFileInput" data-file-upload-target="uploadArea">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<div data-file-upload-target="uploadText" class="flex flex-col items-center">
<%= icon("plus", size: "lg", class: "mb-4 mx-auto") %>
<p class="mb-2 text-md text-gray text-center">
<span class="font-medium text-primary">Browse</span> to add your CSV file here
</p>
</div>
<div class="flex flex-col gap-4 items-center hidden mb-2" data-file-upload-target="fileName">
<span class="text-primary">
<%= icon("file-text", size: "lg", color: "current") %>
</span>
<p class="text-md font-medium text-primary"></p>
</div>
<%= 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" %>
</div>
<label for="import_import_file" class="flex flex-col items-center justify-center w-full h-64 border border-secondary border-dashed rounded-xl cursor-pointer" data-controller="file-upload" data-file-upload-target="uploadArea">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<div data-file-upload-target="uploadText" class="flex flex-col items-center">
<%= icon("plus", size: "lg", class: "mb-4 mx-auto") %>
<p class="mb-2 text-md text-gray text-center">
<span class="font-medium text-primary"><%= t(".browse") %></span> <%= t(".qif_file_prompt") %>
</p>
<p class="text-xs text-secondary"><%= t(".qif_file_hint") %></p>
</div>
<%= form.submit "Upload CSV", disabled: @import.complete? %>
<% end %>
<% end %>
<div class="flex flex-col gap-4 items-center hidden mb-2" data-file-upload-target="fileName">
<span class="text-primary">
<%= icon("file-text", size: "lg", color: "current") %>
</span>
<p class="text-md font-medium text-primary"></p>
</div>
<% 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 %>
<%= form.file_field :import_file,
accept: ".qif",
id: "import_import_file",
class: "hidden",
"data-file-upload-target": "input" %>
</div>
</label>
<% 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 %>
</div>
<div class="flex justify-center">
<span class="text-secondary text-sm">
<%= 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
</span>
<% else %>
<%# ── Standard CSV upload ── %>
<div class="space-y-4" data-controller="drag-and-drop-import">
<!-- Overlay -->
<%= render "imports/drag_drop_overlay", title: "Drop CSV to upload", subtitle: "Your file will be uploaded automatically" %>
<div class="space-y-4 mx-auto max-w-md">
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium"><%= t(".title") %></h1>
<p class="text-secondary text-sm"><%= t(".description") %></p>
</div>
<%= 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 %>
<label for="import_import_file_csv" class="flex flex-col items-center justify-center w-full h-64 border border-secondary border-dashed rounded-xl cursor-pointer" data-controller="file-upload" data-file-upload-target="uploadArea">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<div data-file-upload-target="uploadText" class="flex flex-col items-center">
<%= icon("plus", size: "lg", class: "mb-4 mx-auto") %>
<p class="mb-2 text-md text-gray text-center">
<span class="font-medium text-primary"><%= t(".browse") %></span> <%= t(".csv_file_prompt") %>
</p>
</div>
<div class="flex flex-col gap-4 items-center hidden mb-2" data-file-upload-target="fileName">
<span class="text-primary">
<%= icon("file-text", size: "lg", color: "current") %>
</span>
<p class="text-md font-medium text-primary"></p>
</div>
<%= form.file_field :import_file, id: "import_import_file_csv", class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input", "data-drag-and-drop-import-target": "input" %>
</div>
</label>
<%= 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 %>
</div>
<div class="flex justify-center">
<span class="text-secondary text-sm">
<%= 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
</span>
</div>
</div>
</div>
<% end %>

View File

@@ -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 },

View File

@@ -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? %>
<li>
<%= styled_form_with url: imports_path, scope: :import, multipart: true, class: "w-full" do |form| %>

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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?("<OFX><STMTTRN></STMTTRN></OFX>")
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