Files
sure/app/models/import/category_mapping.rb
Serge L 57199d6eb9 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>
2026-03-14 20:22:39 +01:00

79 lines
2.2 KiB
Ruby

class Import::CategoryMapping < Import::Mapping
class << self
def mappables_by_key(import)
unique_values = import.rows.map(&:category).uniq
# 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
family_categories = import.family.categories.alphabetically.map { |category| [ category.name, category.id ] }
unless key.blank?
family_categories.unshift [ "Add as new category", CREATE_NEW_KEY ]
end
family_categories
end
def requires_selection?
false
end
def values_count
import.rows.where(category: key).count
end
def mappable_class
Category
end
def create_mappable!
return unless creatable?
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