diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index 6602e3fbe..65c47fc68 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -5,7 +5,8 @@ class Import::ConfigurationsController < ApplicationController def show # PDF imports are auto-configured from AI extraction, skip to clean step - redirect_to import_clean_path(@import) if @import.is_a?(PdfImport) + redirect_to import_clean_path(@import) and return if @import.is_a?(PdfImport) + redirect_to import_qif_category_selection_path(@import) and return if @import.is_a?(QifImport) end def update diff --git a/app/controllers/import/qif_category_selections_controller.rb b/app/controllers/import/qif_category_selections_controller.rb index 28669c950..2ed7b195a 100644 --- a/app/controllers/import/qif_category_selections_controller.rb +++ b/app/controllers/import/qif_category_selections_controller.rb @@ -4,15 +4,28 @@ class Import::QifCategorySelectionsController < ApplicationController 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 + valid_formats = @import.valid_date_formats_with_preview + @date_formats = valid_formats.map { |f| [ f[:label], f[:format] ] } + @date_previews = valid_formats.each_with_object({}) { |f, h| h[f[:format]] = f[:preview] } + @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 + # If the user changed the date format, re-generate rows with the new format. + format_changed = false + if selection_params[:date_format].present? && selection_params[:date_format] != @import.qif_date_format + format_changed = true + @import.qif_date_format = selection_params[:date_format] + @import.update_column(:column_mappings, @import.column_mappings) + @import.generate_rows_from_csv + @import.sync_mappings + end + all_categories = @import.row_categories all_tags = @import.row_tags @@ -38,7 +51,7 @@ class Import::QifCategorySelectionsController < ApplicationController end end - @import.sync_mappings + @import.sync_mappings unless format_changed end redirect_to import_clean_path(@import), notice: "Categories and tags saved." @@ -50,7 +63,7 @@ class Import::QifCategorySelectionsController < ApplicationController @import = Current.family.imports.find(params[:import_id]) unless @import.is_a?(QifImport) - redirect_to imports_path + redirect_to imports_path and return end end @@ -63,6 +76,6 @@ class Import::QifCategorySelectionsController < ApplicationController end def selection_params - params.permit(categories: [], tags: []) + params.permit(:date_format, categories: [], tags: []) end end diff --git a/app/javascript/controllers/qif_date_format_controller.js b/app/javascript/controllers/qif_date_format_controller.js new file mode 100644 index 000000000..6c1044f58 --- /dev/null +++ b/app/javascript/controllers/qif_date_format_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus"; + +// Updates the date preview text when the QIF date format dropdown changes. +// Previews are precomputed server-side and passed as a JSON value. +export default class extends Controller { + static targets = ["preview"]; + static values = { previews: Object }; + + change(event) { + const format = event.target.value; + const date = this.previewsValue[format]; + + this.previewTarget.textContent = date || ""; + } +} diff --git a/app/models/concerns/qif_parser.rb b/app/models/concerns/qif_parser.rb index 38f1c1abf..65e8a9ba8 100644 --- a/app/models/concerns/qif_parser.rb +++ b/app/models/concerns/qif_parser.rb @@ -113,7 +113,7 @@ module QifParser # Parses all transactions from the file, excluding the Opening Balance entry. # Returns an array of ParsedTransaction structs. - def self.parse(content) + def self.parse(content, date_format: "%m/%d/%Y") return [] unless valid?(content) content = normalize_encoding(content) @@ -125,7 +125,7 @@ module QifParser section = extract_section(content, type) return [] unless section - parse_records(section).filter_map { |record| build_transaction(record) } + parse_records(section).filter_map { |record| build_transaction(record, date_format: date_format) } end # Returns the opening balance entry from the QIF file, if present. @@ -134,7 +134,7 @@ module QifParser # real transaction – it is the account's starting balance. # # Returns a hash { date: Date, amount: BigDecimal } or nil. - def self.parse_opening_balance(content) + def self.parse_opening_balance(content, date_format: "%m/%d/%Y") return nil unless valid?(content) content = normalize_encoding(content) @@ -149,7 +149,7 @@ module QifParser record = parse_records(section).find { |r| r["P"]&.strip == "Opening Balance" } return nil unless record - date = parse_qif_date(record["D"]) + date = parse_qif_date(record["D"], date_format: date_format) amount = parse_qif_amount(record["T"] || record["U"]) return nil unless date && amount @@ -228,7 +228,7 @@ module QifParser # 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) + def self.parse_investment_transactions(content, date_format: "%m/%d/%Y") return [] unless valid?(content) content = normalize_encoding(content) @@ -239,7 +239,7 @@ module QifParser section = extract_section(content, "Invst") return [] unless section - parse_records(section).filter_map { |record| build_investment_transaction(record, ticker_by_name) } + parse_records(section).filter_map { |record| build_investment_transaction(record, ticker_by_name, date_format: date_format) } end # ------------------------------------------------------------------ @@ -292,7 +292,7 @@ module QifParser end private_class_method :parse_records - def self.build_transaction(record) + def self.build_transaction(record, date_format: "%m/%d/%Y") # "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" @@ -302,7 +302,7 @@ module QifParser return nil unless raw_date.present? && raw_amount.present? - date = parse_qif_date(raw_date) + date = parse_qif_date(raw_date, date_format: date_format) amount = parse_qif_amount(raw_amount) return nil unless date && amount @@ -347,38 +347,82 @@ module QifParser end private_class_method :parse_category_and_tags - # Parses a QIF date string into an ISO 8601 date string. + # Normalizes a QIF date string into a standard format that Date.strptime can + # handle. QIF files use Quicken-specific conventions: # - # 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) + # - Apostrophe as year separator: 6/ 4'20 or 6/ 4'2020 + # - Optional spaces around components: 6/ 4'20 → 6/4/20 + # - Dot separators: 04.06.2020 + # - Dash separators: 04-06-2020 + # + # This method: + # 1. Strips whitespace + # 2. Replaces the Quicken apostrophe with the file's date separator + # 3. Expands 2-digit years to 4-digit (00-99 → 2000-2099, capped at current year) + # 4. Returns a cleaned date string suitable for Date.strptime + def self.normalize_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 + s = date_str.strip + + # Replace Quicken apostrophe year separator with the preceding separator + if s.include?("'") + sep = s.match(%r{[/.\-]})&.to_s || "/" + s = s.gsub("'", sep) 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 + # Remove internal spaces (e.g. "6/ 4/20" → "6/4/20") + s = s.gsub(/\s+/, "") + + # Expand 2-digit year at end to 4-digit, but only when the string doesn't + # already contain a 4-digit number (which would be a full year). + if !s.match?(/\d{4}/) && (m = s.match(%r{\A(.+[/.\-])(\d{2})\z})) + short_year = m[2].to_i + full_year = 2000 + short_year + full_year -= 100 if full_year > Date.today.year + s = "#{m[1]}#{full_year}" end - nil + s + end + private_class_method :normalize_qif_date + + # Parses a QIF date string into an ISO 8601 date string using the given + # strptime format. The date is first normalized (apostrophe → separator, + # 2-digit year expansion, whitespace removal) before parsing. + # + # +date_format+ should be a strptime format string such as "%m/%d/%Y" or + # "%d.%m.%Y". Defaults to "%m/%d/%Y" (US convention) for backwards + # compatibility. + # Attempts to parse a raw QIF date string with the given format. + # Returns the parsed ISO 8601 date string, or nil if parsing fails. + def self.try_parse_date(date_str, date_format: "%m/%d/%Y") + normalized = normalize_qif_date(date_str) + return nil unless normalized + + Date.strptime(normalized, date_format).iso8601 rescue Date::Error, ArgumentError nil end - private_class_method :parse_qif_date + + private_class_method def self.parse_qif_date(date_str, date_format: "%m/%d/%Y") + try_parse_date(date_str, date_format: date_format) + end + + # Extracts all raw date strings from D-fields in transaction sections only. + # Skips metadata sections (Cat, Tag, Security) where D means "description". + # Used by Import.detect_date_format to sample dates before parsing. + def self.extract_raw_dates(content) + return [] if content.blank? + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + transaction_sections = TRANSACTION_TYPES.filter_map { |type| extract_section(content, type) } + transaction_sections.flat_map { |section| section.scan(/^D(.+)$/i).flatten } + .map { |d| normalize_qif_date(d) } + .compact + end # Strips thousands-separator commas and returns a clean decimal string. def self.parse_qif_amount(amount_str) @@ -391,14 +435,14 @@ module QifParser # 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) + def self.build_investment_transaction(record, ticker_by_name, date_format: "%m/%d/%Y") 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) + date = parse_qif_date(raw_date, date_format: date_format) return nil unless date security_name = record["Y"]&.strip diff --git a/app/models/import.rb b/app/models/import.rb index bc279b009..941a6ead6 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -20,6 +20,10 @@ class Import < ApplicationRecord "1,234" => { separator: "", delimiter: "," } # Zero-decimal currencies like JPY }.freeze + def self.reasonable_date_range + Date.new(1970, 1, 1)..Date.today.next_year(5) + end + AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze belongs_to :family @@ -64,6 +68,51 @@ class Import < ApplicationRecord liberal_parsing: true ) end + + # Attempts to identify the best-matching date format from a list of candidates + # by trying to parse sample date strings with each format. + # + # Returns the strptime format string (e.g. "%m-%d-%Y") that best matches the + # samples, or the +fallback+ when no candidate can parse any sample. + # + # Scoring: + # 1. Formats that parse ALL samples beat those that only parse some. + # 2. Among equal parse counts, formats whose parsed dates fall within a + # reasonable range (1970..today+5y) score higher. + def detect_date_format(samples, candidates: Family::DATE_FORMATS.map(&:last), fallback: "%Y-%m-%d") + return fallback if samples.blank? + + cleaned = samples.map(&:to_s).reject(&:blank?).uniq.first(50) + return fallback if cleaned.empty? + + reasonable_range = reasonable_date_range + + scored = candidates.map do |fmt| + parsed_count = 0 + reasonable_count = 0 + + cleaned.each do |s| + begin + date = Date.strptime(s, fmt) + rescue Date::Error, ArgumentError + next + end + next unless date + + parsed_count += 1 + reasonable_count += 1 if reasonable_range.cover?(date) + end + + { format: fmt, parsed: parsed_count, reasonable: reasonable_count } + end + + # Filter to candidates that parsed at least one sample + viable = scored.select { |s| s[:parsed] > 0 } + return fallback if viable.empty? + + best = viable.max_by { |s| [ s[:parsed], s[:reasonable] ] } + best[:format] + end end def publish_later @@ -252,6 +301,38 @@ class Import < ApplicationRecord ) end + # Returns date formats that can successfully parse the file's date samples, + # filtered to dates within reasonable_date_range. + # Result: array of { label:, format:, preview: } hashes. + # Subclasses should override #raw_date_samples to provide date strings. + def valid_date_formats_with_preview + first_sample = raw_date_samples.find(&:present?) + return [] if first_sample.blank? + + Family::DATE_FORMATS.filter_map do |label, fmt| + parsed = try_parse_date_sample(first_sample, format: fmt) + next unless parsed + next unless self.class.reasonable_date_range.cover?(Date.parse(parsed)) + + { label: label, format: fmt, preview: parsed } + end + end + + # Returns raw date strings from the import file for format detection/preview. + # Subclasses should override to extract dates from their specific format. + def raw_date_samples + [] + end + + # Attempts to parse a raw date sample with the given strptime format. + # Returns ISO 8601 date string or nil. Subclasses can override for + # format-specific normalization (e.g. QIF apostrophe dates). + def try_parse_date_sample(sample, format:) + Date.strptime(sample, format).iso8601 + rescue Date::Error, ArgumentError + nil + end + def max_row_count 10000 end diff --git a/app/models/qif_import.rb b/app/models/qif_import.rb index 7fcb70c96..90a867d28 100644 --- a/app/models/qif_import.rb +++ b/app/models/qif_import.rb @@ -1,9 +1,25 @@ class QifImport < Import after_create :set_default_config + # The date format used to parse the raw QIF file's D-fields (e.g. "%m/%d/%Y"). + # Stored in column_mappings so it doesn't conflict with date_format, which is + # always "%Y-%m-%d" because QIF rows store dates in ISO 8601 after parsing. + def qif_date_format + column_mappings&.dig("qif_date_format") || "%m/%d/%Y" + end + + def qif_date_format=(fmt) + self.column_mappings = (column_mappings || {}).merge("qif_date_format" => fmt) + end + # Parses the stored QIF content and creates Import::Row records. # Overrides the base CSV-based method with QIF-specific parsing. + # + # On first run (qif_date_format not yet set), auto-detects the date format + # from the QIF file's D-field samples. def generate_rows_from_csv + detect_and_set_qif_date_format! unless column_mappings&.key?("qif_date_format") + rows.destroy_all if investment_account? @@ -24,7 +40,7 @@ class QifImport < Import else import_transaction_rows! - if (ob = QifParser.parse_opening_balance(raw_file_str)) + if (ob = QifParser.parse_opening_balance(raw_file_str, date_format: qif_date_format)) Account::OpeningBalanceManager.new(account).set_opening_balance( balance: ob[:amount], date: ob[:date] @@ -61,7 +77,7 @@ class QifImport < Import # 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 if QifParser.parse_opening_balance(raw_file_str, date_format: qif_date_format).present? return false unless account.present? manager = Account::OpeningBalanceManager.new(account) @@ -119,6 +135,16 @@ class QifImport < Import [ Import::CategoryMapping, Import::TagMapping ] end + # QIF dates need normalization (apostrophe → separator, 2-digit year expansion) + # before strptime can parse them, so we delegate to QifParser. + def raw_date_samples + QifParser.extract_raw_dates(raw_file_str) + end + + def try_parse_date_sample(sample, format:) + QifParser.try_parse_date(sample, date_format: format) + end + private def parsed_transactions_with_splits @@ -134,7 +160,7 @@ class QifImport < Import # ------------------------------------------------------------------ def generate_transaction_rows - transactions = QifParser.parse(raw_file_str) + transactions = QifParser.parse(raw_file_str, date_format: qif_date_format) mapped_rows = transactions.map do |trn| { @@ -161,7 +187,7 @@ class QifImport < Import end def generate_investment_rows - inv_transactions = QifParser.parse_investment_transactions(raw_file_str) + inv_transactions = QifParser.parse_investment_transactions(raw_file_str, date_format: qif_date_format) mapped_rows = inv_transactions.map do |trn| if QifParser::TRADE_ACTIONS.include?(trn.action) @@ -327,6 +353,15 @@ class QifImport < Import ) end + # Auto-detects the QIF file's date format from D-field samples and persists it. + # Falls back to "%m/%d/%Y" (US convention) if detection is inconclusive. + def detect_and_set_qif_date_format! + samples = QifParser.extract_raw_dates(raw_file_str) + detected = Import.detect_date_format(samples, fallback: "%m/%d/%Y") + self.qif_date_format = detected + update_column(:column_mappings, column_mappings) + 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) diff --git a/app/views/import/qif_category_selections/show.html.erb b/app/views/import/qif_category_selections/show.html.erb index fdf8cbbba..4976d9961 100644 --- a/app/views/import/qif_category_selections/show.html.erb +++ b/app/views/import/qif_category_selections/show.html.erb @@ -7,11 +7,39 @@
<%= t(".description") %>
+<%= t(".description", product_name: product_name) %>
<%= t("imports.date_format.description") %>
+ + <% if @date_formats.any? %> + <%= form.label :date_format, t("imports.date_format.heading"), class: "sr-only" %> + <%= form.select :date_format, + options_for_select(@date_formats, @import.qif_date_format), + {}, + { class: "w-full rounded-lg border border-secondary bg-container px-3 py-2 text-sm text-primary", + data: { action: "change->qif-date-format#change" } } %> + +<%= t("imports.date_format.error_title") %>
+<%= t("imports.date_format.error_description") %>
+