mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
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:
@@ -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? }
|
||||
|
||||
68
app/controllers/import/qif_category_selections_controller.rb
Normal file
68
app/controllers/import/qif_category_selections_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
428
app/models/concerns/qif_parser.rb
Normal file
428
app/models/concerns/qif_parser.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
382
app/models/qif_import.rb
Normal 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
|
||||
128
app/views/import/qif_category_selections/show.html.erb
Normal file
128
app/views/import/qif_category_selections/show.html.erb
Normal 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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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| %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
854
test/models/qif_import_test.rb
Normal file
854
test/models/qif_import_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user