mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 20:14: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")
|
||||
|
||||
Reference in New Issue
Block a user