diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb
index fec74b5bc..b7c1b1bbb 100644
--- a/app/controllers/import/uploads_controller.rb
+++ b/app/controllers/import/uploads_controller.rb
@@ -16,6 +16,8 @@ class Import::UploadsController < ApplicationController
def update
if @import.is_a?(QifImport)
handle_qif_upload
+ elsif @import.is_a?(SureImport)
+ update_sure_import_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])
@@ -23,13 +25,54 @@ class Import::UploadsController < ApplicationController
redirect_to import_configuration_path(@import, template_hint: true), notice: "CSV uploaded successfully."
else
- flash.now[:alert] = "Must be valid CSV with headers and at least one row of data"
-
- render :show, status: :unprocessable_entity
+ update_csv_import
end
end
private
+ def update_csv_import
+ if csv_valid?(csv_str)
+ @import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
+ @import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
+ @import.save!(validate: false)
+
+ redirect_to import_configuration_path(@import, template_hint: true), notice: t("imports.create.csv_uploaded")
+ else
+ flash.now[:alert] = t("import.uploads.show.csv_invalid", default: "Must be valid CSV with headers and at least one row of data")
+
+ render :show, status: :unprocessable_entity
+ end
+ end
+
+ def update_sure_import_upload
+ uploaded = upload_params[:ndjson_file]
+ unless uploaded.present?
+ flash.now[:alert] = t("import.uploads.sure_import.ndjson_invalid", default: "Must be valid NDJSON with at least one record")
+ render :show, status: :unprocessable_entity
+ return
+ end
+
+ if uploaded.size > SureImport::MAX_NDJSON_SIZE
+ flash.now[:alert] = t("imports.create.file_too_large", max_size: SureImport::MAX_NDJSON_SIZE / 1.megabyte)
+ render :show, status: :unprocessable_entity
+ return
+ end
+
+ content = uploaded.read
+ uploaded.rewind
+
+ if ndjson_valid?(content)
+ uploaded.rewind
+ @import.ndjson_file.attach(uploaded)
+ @import.sync_ndjson_rows_count!
+ redirect_to import_path(@import), notice: t("imports.create.ndjson_uploaded")
+ else
+ flash.now[:alert] = t("import.uploads.sure_import.ndjson_invalid", default: "Must be valid NDJSON with at least one record")
+
+ render :show, status: :unprocessable_entity
+ end
+ end
+
def set_import
@import = Current.family.imports.find(params[:import_id])
end
@@ -71,8 +114,23 @@ class Import::UploadsController < ApplicationController
end
end
+ def ndjson_valid?(str)
+ return false if str.blank?
+
+ # Check at least first line is valid NDJSON
+ first_line = str.lines.first&.strip
+ return false if first_line.blank?
+
+ begin
+ record = JSON.parse(first_line)
+ record.key?("type") && record.key?("data")
+ rescue JSON::ParserError
+ false
+ end
+ end
+
def upload_params
- params.require(:import).permit(:raw_file_str, :import_file, :col_sep)
+ params.require(:import).permit(:raw_file_str, :import_file, :ndjson_file, :col_sep)
end
def import_account_id
diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
index 046e43632..d8f581c1b 100644
--- a/app/controllers/imports_controller.rb
+++ b/app/controllers/imports_controller.rb
@@ -49,6 +49,11 @@ class ImportsController < ApplicationController
return
end
+ if file.present? && sure_import_request?
+ create_sure_import(file)
+ return
+ end
+
# Handle PDF file uploads - process with AI
if file.present? && Import::ALLOWED_PDF_MIME_TYPES.include?(file.content_type)
unless valid_pdf_file?(file)
@@ -85,6 +90,7 @@ class ImportsController < ApplicationController
# Stream reading is not fully applicable here as we store the raw string in the DB,
# but we have validated size beforehand to prevent memory exhaustion from massive files.
import.update!(raw_file_str: file.read)
+
redirect_to import_configuration_path(import), notice: t("imports.create.csv_uploaded")
else
redirect_to import_upload_path(import)
@@ -200,6 +206,40 @@ class ImportsController < ApplicationController
params.dig(:import, :type) == "DocumentImport"
end
+ def sure_import_request?
+ params.dig(:import, :type) == "SureImport"
+ end
+
+ def create_sure_import(file)
+ if file.size > SureImport::MAX_NDJSON_SIZE
+ redirect_to new_import_path, alert: t("imports.create.file_too_large", max_size: SureImport::MAX_NDJSON_SIZE / 1.megabyte)
+ return
+ end
+
+ ext = File.extname(file.original_filename.to_s).downcase
+ unless ext.in?(%w[.ndjson .json])
+ redirect_to new_import_path, alert: t("imports.create.invalid_ndjson_file_type")
+ return
+ end
+
+ content = file.read
+ file.rewind
+ unless SureImport.valid_ndjson_first_line?(content)
+ redirect_to new_import_path, alert: t("imports.create.invalid_ndjson_file_type")
+ return
+ end
+
+ import = Current.family.imports.create!(type: "SureImport")
+ import.ndjson_file.attach(
+ io: StringIO.new(content),
+ filename: file.original_filename,
+ content_type: file.content_type
+ )
+ import.sync_ndjson_rows_count!
+
+ redirect_to import_path(import), notice: t("imports.create.ndjson_uploaded")
+ end
+
def valid_pdf_file?(file)
header = file.read(5)
file.rewind
diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb
index 65ae6e43c..6cf680452 100644
--- a/app/helpers/imports_helper.rb
+++ b/app/helpers/imports_helper.rb
@@ -35,7 +35,12 @@ module ImportsHelper
accounts: DryRunResource.new(label: "Accounts", icon: "layers", text_class: "text-orange-500", bg_class: "bg-orange-500/5"),
categories: DryRunResource.new(label: "Categories", icon: "shapes", text_class: "text-blue-500", bg_class: "bg-blue-500/5"),
tags: DryRunResource.new(label: "Tags", icon: "tags", text_class: "text-violet-500", bg_class: "bg-violet-500/5"),
- rules: DryRunResource.new(label: "Rules", icon: "workflow", text_class: "text-green-500", bg_class: "bg-green-500/5")
+ rules: DryRunResource.new(label: "Rules", icon: "workflow", text_class: "text-green-500", bg_class: "bg-green-500/5"),
+ merchants: DryRunResource.new(label: "Merchants", icon: "store", text_class: "text-amber-500", bg_class: "bg-amber-500/5"),
+ trades: DryRunResource.new(label: "Trades", icon: "arrow-left-right", text_class: "text-emerald-500", bg_class: "bg-emerald-500/5"),
+ valuations: DryRunResource.new(label: "Valuations", icon: "trending-up", text_class: "text-pink-500", bg_class: "bg-pink-500/5"),
+ budgets: DryRunResource.new(label: "Budgets", icon: "wallet", text_class: "text-indigo-500", bg_class: "bg-indigo-500/5"),
+ budget_categories: DryRunResource.new(label: "Budget Categories", icon: "pie-chart", text_class: "text-teal-500", bg_class: "bg-teal-500/5")
}
map[key]
diff --git a/app/models/family/data_importer.rb b/app/models/family/data_importer.rb
new file mode 100644
index 000000000..4b6f2339b
--- /dev/null
+++ b/app/models/family/data_importer.rb
@@ -0,0 +1,474 @@
+class Family::DataImporter
+ SUPPORTED_TYPES = %w[Account Category Tag Merchant Transaction Trade Valuation Budget BudgetCategory Rule].freeze
+ ACCOUNTABLE_TYPES = Accountable::TYPES.freeze
+
+ def initialize(family, ndjson_content)
+ @family = family
+ @ndjson_content = ndjson_content
+ @id_mappings = {
+ accounts: {},
+ categories: {},
+ tags: {},
+ merchants: {},
+ budgets: {},
+ securities: {}
+ }
+ @created_accounts = []
+ @created_entries = []
+ end
+
+ def import!
+ records = parse_ndjson
+
+ Import.transaction do
+ # Import in dependency order
+ import_accounts(records["Account"] || [])
+ import_categories(records["Category"] || [])
+ import_tags(records["Tag"] || [])
+ import_merchants(records["Merchant"] || [])
+ import_transactions(records["Transaction"] || [])
+ import_trades(records["Trade"] || [])
+ import_valuations(records["Valuation"] || [])
+ import_budgets(records["Budget"] || [])
+ import_budget_categories(records["BudgetCategory"] || [])
+ import_rules(records["Rule"] || [])
+ end
+
+ { accounts: @created_accounts, entries: @created_entries }
+ end
+
+ private
+
+ def parse_ndjson
+ records = Hash.new { |h, k| h[k] = [] }
+
+ @ndjson_content.each_line do |line|
+ next if line.strip.empty?
+
+ begin
+ record = JSON.parse(line)
+ type = record["type"]
+ next unless SUPPORTED_TYPES.include?(type)
+
+ records[type] << record
+ rescue JSON::ParserError
+ # Skip invalid lines
+ end
+ end
+
+ records
+ end
+
+ def import_accounts(records)
+ records.each do |record|
+ data = record["data"]
+ old_id = data["id"]
+ accountable_data = data["accountable"] || {}
+ accountable_type = data["accountable_type"]
+
+ # Skip if accountable type is not valid
+ next unless ACCOUNTABLE_TYPES.include?(accountable_type)
+
+ # Build accountable
+ accountable_class = accountable_type.constantize
+ accountable = accountable_class.new
+ accountable.subtype = accountable_data["subtype"] if accountable.respond_to?(:subtype=) && accountable_data["subtype"]
+
+ # Copy any other accountable attributes
+ safe_accountable_attrs = %w[subtype locked_attributes]
+ safe_accountable_attrs.each do |attr|
+ if accountable.respond_to?("#{attr}=") && accountable_data[attr].present?
+ accountable.send("#{attr}=", accountable_data[attr])
+ end
+ end
+
+ account = @family.accounts.build(
+ name: data["name"],
+ balance: data["balance"].to_d,
+ cash_balance: data["cash_balance"]&.to_d || data["balance"].to_d,
+ currency: data["currency"] || @family.currency,
+ accountable: accountable,
+ subtype: data["subtype"],
+ institution_name: data["institution_name"],
+ institution_domain: data["institution_domain"],
+ notes: data["notes"],
+ status: "active"
+ )
+
+ account.save!
+
+ # Set opening balance if we have a historical balance
+ if data["balance"].present?
+ manager = Account::OpeningBalanceManager.new(account)
+ manager.set_opening_balance(balance: data["balance"].to_d)
+ end
+
+ @id_mappings[:accounts][old_id] = account.id
+ @created_accounts << account
+ end
+ end
+
+ def import_categories(records)
+ # First pass: create all categories without parent relationships
+ parent_mappings = {}
+
+ records.each do |record|
+ data = record["data"]
+ old_id = data["id"]
+ parent_id = data["parent_id"]
+
+ # Store parent relationship for second pass
+ parent_mappings[old_id] = parent_id if parent_id.present?
+
+ category = @family.categories.build(
+ name: data["name"],
+ color: data["color"] || Category::UNCATEGORIZED_COLOR,
+ classification_unused: data["classification_unused"] || data["classification"] || "expense",
+ lucide_icon: data["lucide_icon"] || "shapes"
+ )
+
+ category.save!
+ @id_mappings[:categories][old_id] = category.id
+ end
+
+ # Second pass: establish parent relationships
+ parent_mappings.each do |old_id, old_parent_id|
+ new_id = @id_mappings[:categories][old_id]
+ new_parent_id = @id_mappings[:categories][old_parent_id]
+
+ next unless new_id && new_parent_id
+
+ category = @family.categories.find(new_id)
+ category.update!(parent_id: new_parent_id)
+ end
+ end
+
+ def import_tags(records)
+ records.each do |record|
+ data = record["data"]
+ old_id = data["id"]
+
+ tag = @family.tags.build(
+ name: data["name"],
+ color: data["color"] || Tag::COLORS.sample
+ )
+
+ tag.save!
+ @id_mappings[:tags][old_id] = tag.id
+ end
+ end
+
+ def import_merchants(records)
+ records.each do |record|
+ data = record["data"]
+ old_id = data["id"]
+
+ merchant = @family.merchants.build(
+ name: data["name"],
+ color: data["color"],
+ logo_url: data["logo_url"]
+ )
+
+ merchant.save!
+ @id_mappings[:merchants][old_id] = merchant.id
+ end
+ end
+
+ def import_transactions(records)
+ records.each do |record|
+ data = record["data"]
+
+ # Map account ID
+ new_account_id = @id_mappings[:accounts][data["account_id"]]
+ next unless new_account_id
+
+ account = @family.accounts.find(new_account_id)
+
+ # Map category ID (optional)
+ new_category_id = nil
+ if data["category_id"].present?
+ new_category_id = @id_mappings[:categories][data["category_id"]]
+ end
+
+ # Map merchant ID (optional)
+ new_merchant_id = nil
+ if data["merchant_id"].present?
+ new_merchant_id = @id_mappings[:merchants][data["merchant_id"]]
+ end
+
+ # Map tag IDs (optional)
+ new_tag_ids = []
+ if data["tag_ids"].present?
+ new_tag_ids = Array(data["tag_ids"]).map { |old_tag_id| @id_mappings[:tags][old_tag_id] }.compact
+ end
+
+ transaction = Transaction.new(
+ category_id: new_category_id,
+ merchant_id: new_merchant_id,
+ kind: data["kind"] || "standard"
+ )
+
+ entry = Entry.new(
+ account: account,
+ date: Date.parse(data["date"].to_s),
+ amount: data["amount"].to_d,
+ name: data["name"] || "Imported transaction",
+ currency: data["currency"] || account.currency,
+ notes: data["notes"],
+ excluded: data["excluded"] || false,
+ entryable: transaction
+ )
+
+ entry.save!
+
+ # Add tags through the tagging association
+ new_tag_ids.each do |tag_id|
+ transaction.taggings.create!(tag_id: tag_id)
+ end
+
+ @created_entries << entry
+ end
+ end
+
+ def import_trades(records)
+ records.each do |record|
+ data = record["data"]
+
+ # Map account ID
+ new_account_id = @id_mappings[:accounts][data["account_id"]]
+ next unless new_account_id
+
+ account = @family.accounts.find(new_account_id)
+
+ # Resolve or create security
+ ticker = data["ticker"]
+ next unless ticker.present?
+
+ security = find_or_create_security(ticker, data["currency"])
+
+ trade = Trade.new(
+ security: security,
+ qty: data["qty"].to_d,
+ price: data["price"].to_d,
+ currency: data["currency"] || account.currency
+ )
+
+ entry = Entry.new(
+ account: account,
+ date: Date.parse(data["date"].to_s),
+ amount: data["amount"].to_d,
+ name: "#{data["qty"].to_d >= 0 ? 'Buy' : 'Sell'} #{ticker}",
+ currency: data["currency"] || account.currency,
+ entryable: trade
+ )
+
+ entry.save!
+ @created_entries << entry
+ end
+ end
+
+ def import_valuations(records)
+ records.each do |record|
+ data = record["data"]
+
+ # Map account ID
+ new_account_id = @id_mappings[:accounts][data["account_id"]]
+ next unless new_account_id
+
+ account = @family.accounts.find(new_account_id)
+
+ valuation = Valuation.new
+
+ entry = Entry.new(
+ account: account,
+ date: Date.parse(data["date"].to_s),
+ amount: data["amount"].to_d,
+ name: data["name"] || "Valuation",
+ currency: data["currency"] || account.currency,
+ entryable: valuation
+ )
+
+ entry.save!
+ @created_entries << entry
+ end
+ end
+
+ def import_budgets(records)
+ records.each do |record|
+ data = record["data"]
+ old_id = data["id"]
+
+ budget = @family.budgets.build(
+ start_date: Date.parse(data["start_date"].to_s),
+ end_date: Date.parse(data["end_date"].to_s),
+ budgeted_spending: data["budgeted_spending"]&.to_d,
+ expected_income: data["expected_income"]&.to_d,
+ currency: data["currency"] || @family.currency
+ )
+
+ budget.save!
+ @id_mappings[:budgets][old_id] = budget.id
+ end
+ end
+
+ def import_budget_categories(records)
+ records.each do |record|
+ data = record["data"]
+
+ # Map budget ID
+ new_budget_id = @id_mappings[:budgets][data["budget_id"]]
+ next unless new_budget_id
+
+ # Map category ID
+ new_category_id = @id_mappings[:categories][data["category_id"]]
+ next unless new_category_id
+
+ budget = @family.budgets.find(new_budget_id)
+
+ budget_category = budget.budget_categories.build(
+ category_id: new_category_id,
+ budgeted_spending: data["budgeted_spending"].to_d,
+ currency: data["currency"] || budget.currency
+ )
+
+ budget_category.save!
+ end
+ end
+
+ def import_rules(records)
+ records.each do |record|
+ data = record["data"]
+
+ rule = @family.rules.build(
+ name: data["name"],
+ resource_type: data["resource_type"] || "transaction",
+ active: data["active"] || false,
+ effective_date: data["effective_date"].present? ? Date.parse(data["effective_date"].to_s) : nil
+ )
+
+ # Build conditions
+ (data["conditions"] || []).each do |condition_data|
+ build_rule_condition(rule, condition_data)
+ end
+
+ # Build actions
+ (data["actions"] || []).each do |action_data|
+ build_rule_action(rule, action_data)
+ end
+
+ rule.save!
+ end
+ end
+
+ def build_rule_condition(rule, condition_data, parent: nil)
+ value = resolve_rule_condition_value(condition_data)
+
+ condition = if parent
+ parent.sub_conditions.build(
+ condition_type: condition_data["condition_type"],
+ operator: condition_data["operator"],
+ value: value
+ )
+ else
+ rule.conditions.build(
+ condition_type: condition_data["condition_type"],
+ operator: condition_data["operator"],
+ value: value
+ )
+ end
+
+ # Handle nested sub_conditions for compound conditions
+ (condition_data["sub_conditions"] || []).each do |sub_condition_data|
+ build_rule_condition(rule, sub_condition_data, parent: condition)
+ end
+
+ condition
+ end
+
+ def build_rule_action(rule, action_data)
+ value = resolve_rule_action_value(action_data)
+
+ rule.actions.build(
+ action_type: action_data["action_type"],
+ value: value
+ )
+ end
+
+ def resolve_rule_condition_value(condition_data)
+ condition_type = condition_data["condition_type"]
+ value = condition_data["value"]
+
+ return value unless value.present?
+
+ # Map category names to IDs
+ if condition_type == "transaction_category"
+ category = @family.categories.find_by(name: value)
+ category ||= @family.categories.create!(
+ name: value,
+ color: Category::UNCATEGORIZED_COLOR,
+ classification_unused: "expense",
+ lucide_icon: "shapes"
+ )
+ return category.id
+ end
+
+ # Map merchant names to IDs
+ if condition_type == "transaction_merchant"
+ merchant = @family.merchants.find_by(name: value)
+ merchant ||= @family.merchants.create!(name: value)
+ return merchant.id
+ end
+
+ value
+ end
+
+ def resolve_rule_action_value(action_data)
+ action_type = action_data["action_type"]
+ value = action_data["value"]
+
+ return value unless value.present?
+
+ # Map category names to IDs
+ if action_type == "set_transaction_category"
+ category = @family.categories.find_by(name: value)
+ category ||= @family.categories.create!(
+ name: value,
+ color: Category::UNCATEGORIZED_COLOR,
+ classification_unused: "expense",
+ lucide_icon: "shapes"
+ )
+ return category.id
+ end
+
+ # Map merchant names to IDs
+ if action_type == "set_transaction_merchant"
+ merchant = @family.merchants.find_by(name: value)
+ merchant ||= @family.merchants.create!(name: value)
+ return merchant.id
+ end
+
+ # Map tag names to IDs
+ if action_type == "set_transaction_tags"
+ tag = @family.tags.find_by(name: value)
+ tag ||= @family.tags.create!(name: value)
+ return tag.id
+ end
+
+ value
+ end
+
+ def find_or_create_security(ticker, currency)
+ # Check cache first
+ cache_key = "#{ticker}:#{currency}"
+ return @id_mappings[:securities][cache_key] if @id_mappings[:securities][cache_key]
+
+ security = Security.find_by(ticker: ticker.upcase)
+ security ||= Security.create!(
+ ticker: ticker.upcase,
+ name: ticker.upcase
+ )
+
+ @id_mappings[:securities][cache_key] = security
+ security
+ end
+end
diff --git a/app/models/import.rb b/app/models/import.rb
index 2f2a0043e..bc279b009 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -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 QifImport].freeze
+ TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport QifImport SureImport].freeze
SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]
SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze
diff --git a/app/models/sure_import.rb b/app/models/sure_import.rb
new file mode 100644
index 000000000..05d927c4d
--- /dev/null
+++ b/app/models/sure_import.rb
@@ -0,0 +1,132 @@
+class SureImport < Import
+ MAX_NDJSON_SIZE = 10.megabytes
+ ALLOWED_NDJSON_CONTENT_TYPES = %w[
+ application/x-ndjson
+ application/ndjson
+ application/json
+ application/octet-stream
+ text/plain
+ ].freeze
+
+ has_one_attached :ndjson_file, dependent: :purge_later
+
+ class << self
+ # Counts JSON lines by top-level "type" (used for dry-run summaries and row limits).
+ def ndjson_line_type_counts(content)
+ return {} unless content.present?
+
+ counts = Hash.new(0)
+ content.each_line do |line|
+ next if line.strip.empty?
+
+ begin
+ record = JSON.parse(line)
+ counts[record["type"]] += 1 if record["type"]
+ rescue JSON::ParserError
+ # Skip invalid lines
+ end
+ end
+ counts
+ end
+
+ def dry_run_totals_from_ndjson(content)
+ counts = ndjson_line_type_counts(content)
+ {
+ accounts: counts["Account"] || 0,
+ categories: counts["Category"] || 0,
+ tags: counts["Tag"] || 0,
+ merchants: counts["Merchant"] || 0,
+ transactions: counts["Transaction"] || 0,
+ trades: counts["Trade"] || 0,
+ valuations: counts["Valuation"] || 0,
+ budgets: counts["Budget"] || 0,
+ budget_categories: counts["BudgetCategory"] || 0,
+ rules: counts["Rule"] || 0
+ }
+ end
+
+ def valid_ndjson_first_line?(str)
+ return false if str.blank?
+
+ first_line = str.lines.first&.strip
+ return false if first_line.blank?
+
+ begin
+ record = JSON.parse(first_line)
+ record.key?("type") && record.key?("data")
+ rescue JSON::ParserError
+ false
+ end
+ end
+ end
+
+ def requires_csv_workflow?
+ false
+ end
+
+ def column_keys
+ []
+ end
+
+ def required_column_keys
+ []
+ end
+
+ def mapping_steps
+ []
+ end
+
+ def csv_template
+ nil
+ end
+
+ def dry_run
+ return {} unless uploaded?
+
+ self.class.dry_run_totals_from_ndjson(ndjson_blob_string)
+ end
+
+ def import!
+ importer = Family::DataImporter.new(family, ndjson_blob_string)
+ result = importer.import!
+
+ result[:accounts].each { |account| accounts << account }
+ result[:entries].each { |entry| entries << entry }
+ end
+
+ def uploaded?
+ return false unless ndjson_file.attached?
+
+ self.class.valid_ndjson_first_line?(ndjson_blob_string)
+ end
+
+ def configured?
+ uploaded?
+ end
+
+ def cleaned?
+ configured?
+ end
+
+ def publishable?
+ cleaned? && dry_run.values.sum.positive?
+ end
+
+ def max_row_count
+ 100_000
+ end
+
+ # Row total for max-row enforcement (counts every parsed line with a "type", including unsupported types).
+ def sync_ndjson_rows_count!
+ return unless ndjson_file.attached?
+
+ total = self.class.ndjson_line_type_counts(ndjson_blob_string).values.sum
+ update_column(:rows_count, total)
+ end
+
+ private
+
+ def ndjson_blob_string
+ ndjson_file.download.force_encoding(Encoding::UTF_8)
+ end
+end
diff --git a/app/views/import/confirms/show.html.erb b/app/views/import/confirms/show.html.erb
index 8d3122ab2..e8fe1a47c 100644
--- a/app/views/import/confirms/show.html.erb
+++ b/app/views/import/confirms/show.html.erb
@@ -2,32 +2,65 @@
<%= render "imports/nav", import: @import %>
<% end %>
-<%= content_for :previous_path, import_clean_path(@import) %>
+<%= content_for :previous_path, @import.is_a?(SureImport) ? imports_path : import_clean_path(@import) %>
-<% step_idx = (params[:step] || "1").to_i - 1 %>
-<% step_mapping_class = @import.mapping_steps[step_idx] %>
+<% if @import.is_a?(SureImport) %>
+
+
+
<%= t("import.confirms.sure_import.title") %>
+
<%= t("import.confirms.sure_import.description") %>
+
-
-
- <% @import.mapping_steps.each_with_index do |step_mapping_class, idx| %>
- <% is_active = step_idx == idx %>
-
- <%= link_to url_for(step: idx + 1), class: "w-5 h-[3px] #{is_active ? 'bg-gray-900' : 'bg-gray-100'} rounded-xl hover:bg-gray-300 transition-colors duration-200" do %>
-
Step <%= idx + 1 %>
+
+
<%= t("import.confirms.sure_import.summary") %>
+ <% dry_run = @import.dry_run %>
+ <% sure_summary_empty = dry_run.values.none?(&:positive?) %>
+ <% if sure_summary_empty %>
+
<%= t("import.confirms.sure_import.empty_summary") %>
+ <% else %>
+
+ <% dry_run.each do |key, count| %>
+ <% next if count.zero? %>
+ -
+ <%= key.to_s.humanize %>
+ <%= count %>
+
+ <% end %>
+
<% end %>
- <% end %>
+
+
+
+ <%= button_to t("import.confirms.sure_import.publish_button"), publish_import_path(@import), method: :post, class: "btn btn--primary w-full", disabled: !@import.publishable? %>
+ <%= link_to t("import.confirms.sure_import.cancel"), imports_path, class: "btn btn--ghost w-full text-center" %>
+
+
+<% else %>
+ <% step_idx = (params[:step] || "1").to_i - 1 %>
+ <% step_mapping_class = @import.mapping_steps[step_idx] %>
+
+
+
+ <% @import.mapping_steps.each_with_index do |step_mapping_class, idx| %>
+ <% is_active = step_idx == idx %>
+
+ <%= link_to url_for(step: idx + 1), class: "w-5 h-[3px] #{is_active ? 'bg-gray-900' : 'bg-gray-100'} rounded-xl hover:bg-gray-300 transition-colors duration-200" do %>
+ Step <%= idx + 1 %>
+ <% end %>
+ <% end %>
+
+
+
+
+ <%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %>
+
+
+ <%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize, product_name: product_name) %>
+
+
-
-
- <%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %>
-
-
- <%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize, product_name: product_name) %>
-
+
+ <%= render partial: "import/confirms/mappings", locals: { import: @import, mapping_class: step_mapping_class, step_idx: step_idx } %>
-
-
-
- <%= render partial: "import/confirms/mappings", locals: { import: @import, mapping_class: step_mapping_class, step_idx: step_idx } %>
-
+<% end %>
diff --git a/app/views/import/uploads/show.html.erb b/app/views/import/uploads/show.html.erb
index 2915703cf..bb2b64961 100644
--- a/app/views/import/uploads/show.html.erb
+++ b/app/views/import/uploads/show.html.erb
@@ -4,7 +4,49 @@
<%= content_for :previous_path, imports_path %>
-<% if @import.is_a?(QifImport) %>
+<% if @import.is_a?(SureImport) %>
+
+
+ <%= render "imports/drag_drop_overlay", title: t("import.uploads.sure_import.drop_title"), subtitle: t("import.uploads.sure_import.drop_subtitle") %>
+
+
+
+
<%= t("import.uploads.sure_import.title") %>
+
<%= t("import.uploads.sure_import.description") %>
+
+
+ <%= 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| %>
+
+
+
+ <%= icon("database", size: "lg", class: "mb-4 mx-auto") %>
+
+ <%= t("import.uploads.sure_import.browse") %> <%= t("import.uploads.sure_import.browse_hint") %>
+
+
+
+
+
+ <%= icon("file-text", size: "lg", color: "current") %>
+
+
+
+
+ <%= form.file_field :ndjson_file, class: "hidden", accept: ".ndjson,.json", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input", "data-drag-and-drop-import-target": "input" %>
+
+
+
+ <%= form.submit t("import.uploads.sure_import.upload_button"), disabled: @import.complete? %>
+ <% end %>
+
+
+
+
+ <%= t("import.uploads.sure_import.hint_html") %>
+
+
+
+<% elsif @import.is_a?(QifImport) %>
<%# ── QIF upload – fixed format, account required ── %>
diff --git a/app/views/imports/_nav.html.erb b/app/views/imports/_nav.html.erb
index 5d56bce7b..8bb457cd6 100644
--- a/app/views/imports/_nav.html.erb
+++ b/app/views/imports/_nav.html.erb
@@ -1,6 +1,11 @@
<%# locals: (import:) %>
-<% steps = if import.is_a?(PdfImport)
+<% steps = if import.is_a?(SureImport)
+ [
+ { name: t("imports.steps.upload", default: "Upload"), path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 },
+ { name: t("imports.steps.confirm", default: "Confirm"), path: import_path(import), is_complete: import.complete?, step_number: 2 }
+ ]
+elsif import.is_a?(PdfImport)
# PDF imports have a simplified flow: Upload -> Confirm
# Upload/Configure/Clean are always complete for processed PDF imports
[
diff --git a/app/views/imports/_ready.html.erb b/app/views/imports/_ready.html.erb
index 9ab7c0eb8..9e2e0236d 100644
--- a/app/views/imports/_ready.html.erb
+++ b/app/views/imports/_ready.html.erb
@@ -1,4 +1,7 @@
<%# locals: (import:) %>
+<% dry_run = import.dry_run %>
+<% resources_with_counts = dry_run.select { |_, count| count > 0 }.filter_map { |key, count| [dry_run_resource(key), count] if dry_run_resource(key) } %>
+<% import_summary_empty = resources_with_counts.empty? %>
<%= t(".title") %>
@@ -8,32 +11,40 @@
-
item
-
count
+
<%= t(".summary_item_label") %>
+
<%= t(".summary_count_label") %>
- <% import.dry_run.each do |key, count| %>
- <% resource = dry_run_resource(key) %>
+ <% if import_summary_empty %>
+
+
<%= t(".empty_summary") %>
+
+ <% else %>
+ <% resources_with_counts.each_with_index do |(resource, count), index| %>
+
+
+ <%= tag.div class: class_names(resource.bg_class, resource.text_class, "w-8 h-8 rounded-full flex justify-center items-center") do %>
+ <%= icon resource.icon, color: "current" %>
+ <% end %>
-
-
- <%= tag.div class: class_names(resource.bg_class, resource.text_class, "w-8 h-8 rounded-full flex justify-center items-center") do %>
- <%= icon resource.icon, color: "current" %>
- <% end %>
+
<%= resource.label %>
+
-
<%= resource.label %>
+
<%= count %>
-
<%= count %>
-
-
- <% if key != import.dry_run.keys.last %>
- <%= render "shared/ruler" %>
+ <% unless index == resources_with_counts.length - 1 %>
+ <%= render "shared/ruler" %>
+ <% end %>
<% end %>
<% end %>
- <%= render DS::Button.new(text: "Publish import", href: publish_import_path(import), full_width: true) %>
+ <% if import_summary_empty %>
+ <%= render DS::Button.new(text: t(".back_to_imports"), href: imports_path, variant: :secondary, full_width: true) %>
+ <% else %>
+ <%= render DS::Button.new(text: t(".publish_import"), href: publish_import_path(import), method: :post, full_width: true) %>
+ <% end %>
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb
index 5fd42d76b..f0a41ecc6 100644
--- a/app/views/imports/new.html.erb
+++ b/app/views/imports/new.html.erb
@@ -5,11 +5,10 @@
<% has_accounts = Current.family.accounts.any? %>
<% requires_account_message = t(".requires_account") %>
-
-
<%= t(".sources") %>
-
- -
- <% if @pending_import.present? && (params[:type].nil? || params[:type] == @pending_import.type) %>
+ <% if @pending_import.present? && params[:type].nil? %>
+
+
+ -
<%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
@@ -26,109 +25,208 @@
<%= render "shared/ruler" %>
- <% end %>
+
+
+ <% end %>
- <% if params[:type].nil? || params[:type] == "TransactionImport" %>
- <%= render "imports/import_option",
- type: "TransactionImport",
- icon_name: "file-spreadsheet",
- icon_bg_class: "bg-indigo-500/5",
- icon_text_class: "text-indigo-500",
- label: t(".import_transactions"),
- enabled: has_accounts,
- disabled_message: requires_account_message %>
- <% end %>
+ <%
+ import_type = params[:type].presence || @pending_import&.type
+ active_tab = import_type.present? && !import_type.in?(%w[MintImport QifImport SureImport DocumentImport PdfImport]) ? "raw_data" : "financial_tools"
+ %>
+ <%= render DS::Tabs.new(active_tab: active_tab) do |tabs| %>
+ <% tabs.with_nav do |nav| %>
+ <% nav.with_btn(id: "financial_tools", label: t(".tab_financial_tools")) %>
+ <% nav.with_btn(id: "raw_data", label: t(".tab_raw_data")) %>
+ <% end %>
- <% if params[:type].nil? || params[:type] == "TradeImport" %>
- <%= render "imports/import_option",
- type: "TradeImport",
- icon_name: "square-percent",
- icon_bg_class: "bg-yellow-500/5",
- icon_text_class: "text-yellow-500",
- label: t(".import_portfolio"),
- enabled: has_accounts,
- disabled_message: requires_account_message %>
- <% end %>
-
- <% if params[:type].nil? || params[:type] == "AccountImport" %>
- <%= render "imports/import_option",
- type: "AccountImport",
- icon_name: "building",
- icon_bg_class: "bg-violet-500/5",
- icon_text_class: "text-violet-500",
- label: t(".import_accounts"),
- enabled: true %>
- <% end %>
-
- <% if params[:type].nil? || params[:type] == "CategoryImport" %>
- <%= render "imports/import_option",
- type: "CategoryImport",
- icon_name: "shapes",
- icon_bg_class: "bg-blue-500/5",
- icon_text_class: "text-blue-500",
- label: t(".import_categories"),
- enabled: true %>
- <% end %>
-
- <% if params[:type].nil? || params[:type] == "RuleImport" %>
- <%= render "imports/import_option",
- type: "RuleImport",
- icon_name: "workflow",
- icon_bg_class: "bg-green-500/5",
- icon_text_class: "text-green-500",
- label: t(".import_rules"),
- enabled: true %>
- <% end %>
-
- <% if params[:type].nil? || params[:type] == "MintImport" || params[:type] == "TransactionImport" %>
- <%= render "imports/import_option",
- type: "MintImport",
- image: "mint-logo.jpeg",
- label: t(".import_mint"),
- enabled: has_accounts,
- 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? %>
-
-
- <%= styled_form_with url: imports_path, scope: :import, multipart: true, class: "w-full" do |form| %>
- <%= form.hidden_field :type, value: "DocumentImport" %>
-