Full .ndjson import / reorganize UI with Financial Tools / Raw Data tabs (#1208)

* Reorganize import UI with Financial Tools / Raw Data tabs

Split the flat list of import sources into two tabbed sections using
DS::Tabs: "Financial Tools" (Mint, Quicken/QIF, YNAB coming soon) and
"Raw Data" (transactions, investments, accounts, categories, rules,
documents). This prepares for adding more tool-specific importers
without cluttering the list.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* Fix import controller test to account for YNAB coming soon entry

The new YNAB "coming soon" disabled entry adds a 5th aria-disabled
element to the import dialog.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* Fix system tests to click Raw Data tab before selecting import type

Transaction, trade, and account imports are now under the Raw Data tab
and need an explicit tab click before the buttons are visible.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* feat: Add bulk import for NDJSON export files

Implements an import flow that accepts the full all.ndjson file from data exports,
allowing users to restore their complete data including:
- Accounts with accountable types
- Categories with parent relationships
- Tags and merchants
- Transactions with category, merchant, and tag references
- Trades with securities
- Valuations
- Budgets and budget categories
- Rules with conditions and actions (including compound conditions)

Key changes:
- Add BulkImport model extending Import base class
- Add Family::DataImporter to handle NDJSON parsing and import logic
- Update imports controller and views to support NDJSON workflow
- Skip configuration/mapping steps for structured NDJSON imports
- Add i18n translations for bulk import UI
- Add tests for BulkImport and DataImporter

* fix: Fix category import and test query issues

- Add default lucide_icon ("shapes") for categories when not provided
- Fix valuation test to use proper ActiveRecord joins syntax

* Linter errors

* fix: Add default color for tags when not provided in import

* fix: Add default kind for transactions when not provided in import

* Fix test

* Fix tests

* Fix remaining merge conflicts from PR 766 cherry-pick

Resolve conflict markers in test fixtures and clean up BulkImport
entry in new.html.erb to use the _import_option partial consistently.

https://claude.ai/code/session_01BM4SBWNhATqoKTEvy3qTS3

* Import Sure `.ndjson`

* Remove `.ndjson` import from raw data

* Fix support for Sure "bulk" import from old branch

* Linter

* Fix CI test

* Fix more CI tests

* Fix tests

* Fix tests / move PDF import to first tab

* Remove redundant title

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Juan José Mata
2026-03-23 14:27:41 +01:00
committed by GitHub
parent 998ea6f7c5
commit 2595885eb7
32 changed files with 1960 additions and 163 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

132
app/models/sure_import.rb Normal file
View File

@@ -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

View File

@@ -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) %>
<div class="space-y-6 mx-auto max-w-md mb-6">
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium"><%= t("import.confirms.sure_import.title") %></h1>
<p class="text-secondary text-sm"><%= t("import.confirms.sure_import.description") %></p>
</div>
<div class="space-y-12 mx-auto max-w-md mb-6">
<div class="flex justify-center items-center gap-2">
<% @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 %>
<span class="sr-only">Step <%= idx + 1 %></span>
<div class="bg-container shadow-border-xs rounded-xl p-6 space-y-4">
<h3 class="text-sm font-medium text-secondary uppercase"><%= t("import.confirms.sure_import.summary") %></h3>
<% dry_run = @import.dry_run %>
<% sure_summary_empty = dry_run.values.none?(&:positive?) %>
<% if sure_summary_empty %>
<p class="text-secondary text-sm text-center py-4"><%= t("import.confirms.sure_import.empty_summary") %></p>
<% else %>
<ul class="space-y-3">
<% dry_run.each do |key, count| %>
<% next if count.zero? %>
<li class="flex items-center justify-between">
<span class="text-primary"><%= key.to_s.humanize %></span>
<span class="text-secondary font-medium"><%= count %></span>
</li>
<% end %>
</ul>
<% end %>
<% end %>
</div>
<div class="flex flex-col gap-2">
<%= 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" %>
</div>
</div>
<% else %>
<% step_idx = (params[:step] || "1").to_i - 1 %>
<% step_mapping_class = @import.mapping_steps[step_idx] %>
<div class="space-y-12 mx-auto max-w-md mb-6">
<div class="flex justify-center items-center gap-2">
<% @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 %>
<span class="sr-only">Step <%= idx + 1 %></span>
<% end %>
<% end %>
</div>
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium">
<%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %>
</h1>
<p class="text-secondary text-sm">
<%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize, product_name: product_name) %>
</p>
</div>
</div>
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium">
<%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %>
</h1>
<p class="text-secondary text-sm">
<%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize, product_name: product_name) %>
</p>
<div class="max-w-3xl mx-auto flex flex-col items-center">
<%= render partial: "import/confirms/mappings", locals: { import: @import, mapping_class: step_mapping_class, step_idx: step_idx } %>
</div>
</div>
<div class="max-w-3xl mx-auto flex flex-col items-center">
<%= render partial: "import/confirms/mappings", locals: { import: @import, mapping_class: step_mapping_class, step_idx: step_idx } %>
</div>
<% end %>

View File

@@ -4,7 +4,49 @@
<%= content_for :previous_path, imports_path %>
<% if @import.is_a?(QifImport) %>
<% if @import.is_a?(SureImport) %>
<div class="space-y-4" data-controller="drag-and-drop-import">
<!-- Overlay -->
<%= render "imports/drag_drop_overlay", title: t("import.uploads.sure_import.drop_title"), subtitle: t("import.uploads.sure_import.drop_subtitle") %>
<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("import.uploads.sure_import.title") %></h1>
<p class="text-secondary text-sm"><%= t("import.uploads.sure_import.description") %></p>
</div>
<%= 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| %>
<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("database", size: "lg", class: "mb-4 mx-auto") %>
<p class="mb-2 text-md text-gray text-center">
<span class="font-medium text-primary"><%= t("import.uploads.sure_import.browse") %></span> <%= t("import.uploads.sure_import.browse_hint") %>
</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 :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" %>
</div>
</div>
<%= form.submit t("import.uploads.sure_import.upload_button"), disabled: @import.complete? %>
<% end %>
</div>
<div class="flex justify-center">
<span class="text-secondary text-sm">
<%= t("import.uploads.sure_import.hint_html") %>
</span>
</div>
</div>
<% elsif @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">

View File

@@ -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
[

View File

@@ -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? %>
<div class="text-center space-y-2 mb-4 mx-auto max-w-md">
<h1 class="text-3xl text-primary font-medium"><%= t(".title") %></h1>
@@ -8,32 +11,40 @@
<div class="mx-auto max-w-2xl space-y-4">
<div class="bg-container-inset rounded-xl p-1 space-y-1">
<div class="flex justify-between items-center text-xs font-medium text-secondary uppercase px-5 py-3">
<p>item</p>
<p class="justify-self-end">count</p>
<p><%= t(".summary_item_label") %></p>
<p class="justify-self-end"><%= t(".summary_count_label") %></p>
</div>
<div class="bg-container shadow-border-xs rounded-lg text-sm">
<% import.dry_run.each do |key, count| %>
<% resource = dry_run_resource(key) %>
<% if import_summary_empty %>
<div class="px-5 py-8 text-center space-y-2">
<p class="text-primary text-sm"><%= t(".empty_summary") %></p>
</div>
<% else %>
<% resources_with_counts.each_with_index do |(resource, count), index| %>
<div class="flex items-center justify-between gap-2 bg-container px-5 py-3 rounded-lg">
<div class="flex items-center gap-3">
<%= 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 %>
<div class="flex items-center justify-between gap-2 bg-container px-5 py-3 rounded-lg">
<div class="flex items-center gap-3">
<%= 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 %>
<p><%= resource.label %></p>
</div>
<p><%= resource.label %></p>
<p class="justify-self-end"><%= count %></p>
</div>
<p class="justify-self-end"><%= count %></p>
</div>
<% if key != import.dry_run.keys.last %>
<%= render "shared/ruler" %>
<% unless index == resources_with_counts.length - 1 %>
<%= render "shared/ruler" %>
<% end %>
<% end %>
<% end %>
</div>
</div>
<%= 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 %>
</div>

View File

@@ -5,11 +5,10 @@
<% has_accounts = Current.family.accounts.any? %>
<% requires_account_message = t(".requires_account") %>
<div class="rounded-xl bg-container-inset p-1">
<h3 class="uppercase text-secondary text-xs font-medium px-3 py-1.5"><%= t(".sources") %></h3>
<ul class="bg-container shadow-border-xs rounded-lg">
<li>
<% if @pending_import.present? && (params[:type].nil? || params[:type] == @pending_import.type) %>
<% if @pending_import.present? && params[:type].nil? %>
<div class="rounded-xl bg-container-inset p-1 mb-4">
<ul class="bg-container shadow-border-xs rounded-lg">
<li>
<%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
<div class="flex items-center gap-2">
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
@@ -26,109 +25,208 @@
<%= render "shared/ruler" %>
</li>
<% end %>
</ul>
</div>
<% 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? %>
<li>
<%= styled_form_with url: imports_path, scope: :import, multipart: true, class: "w-full" do |form| %>
<%= form.hidden_field :type, value: "DocumentImport" %>
<label class="flex items-center justify-between p-4 group cursor-pointer w-full">
<div class="flex items-center gap-2">
<div class="bg-red-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<span class="text-red-500">
<%= icon("file-text", color: "current") %>
<% tabs.with_panel(tab_id: "financial_tools") do %>
<div class="rounded-xl bg-container-inset p-1">
<ul class="bg-container shadow-border-xs rounded-lg">
<% if @pending_import.present? && params[:type].present? && params[:type].in?(%w[MintImport QifImport SureImport DocumentImport PdfImport]) %>
<li>
<%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
<div class="flex items-center gap-2">
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<span class="text-orange-500">
<%= icon("loader", color: "current") %>
</span>
</div>
<span class="text-sm text-primary group-hover:text-secondary">
<%= t(".resume", type: @pending_import.type.titleize) %>
</span>
</div>
<div class="text-left">
<span class="text-sm text-primary group-hover:text-secondary block">
<%= t(".import_file") %>
</span>
<span class="text-xs text-secondary">
<%= t(".import_file_description") %>
</span>
</div>
</div>
<%= icon("chevron-right") %>
<%= form.file_field :import_file, accept: @document_upload_extensions.join(","), class: "hidden", onchange: "this.form.submit()" %>
</label>
<%= icon("chevron-right") %>
<% end %>
<%= render "shared/ruler" %>
</li>
<% end %>
<%= render "shared/ruler" %>
</li>
<% end %>
</ul>
</div>
<% if params[:type].nil? || params[:type] == "SureImport" %>
<li>
<%= styled_form_with url: imports_path, scope: :import, multipart: true, class: "w-full", data: { turbo: false } do |form| %>
<%= form.hidden_field :type, value: "SureImport" %>
<label class="flex items-center justify-between p-4 group cursor-pointer w-full">
<div class="flex items-center gap-2">
<div class="bg-cyan-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<span class="text-cyan-500">
<%= icon("file-braces", color: "current") %>
</span>
</div>
<div class="text-left">
<span class="text-sm text-primary group-hover:text-secondary block">
<%= t(".import_sure") %>
</span>
<span class="text-xs text-secondary">
<%= t(".import_sure_description") %>
</span>
</div>
</div>
<%= icon("chevron-right") %>
<%= form.file_field :import_file, accept: ".ndjson,.json", class: "hidden", onchange: "this.form.submit()" %>
</label>
<% end %>
<%= render "shared/ruler" %>
</li>
<% 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: true %>
<% 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: true %>
<% end %>
<%= render "imports/import_option",
type: "TransactionImport",
icon_name: "bar-chart-2",
icon_bg_class: "bg-gray-500/5",
icon_text_class: "text-gray-400",
label: t(".import_ynab"),
enabled: false,
disabled_message: t(".coming_soon") %>
<% 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", data: { turbo: false } do |form| %>
<%= form.hidden_field :type, value: "DocumentImport" %>
<label class="flex items-center justify-between p-4 group cursor-pointer w-full">
<div class="flex items-center gap-2">
<div class="bg-red-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<span class="text-red-500">
<%= icon("file-text", color: "current") %>
</span>
</div>
<div class="text-left">
<span class="text-sm text-primary group-hover:text-secondary block">
<%= t(".import_file") %>
</span>
<span class="text-xs text-secondary">
<%= t(".import_file_description") %>
</span>
</div>
</div>
<%= icon("chevron-right") %>
<%= form.file_field :import_file, accept: @document_upload_extensions.join(","), class: "hidden", onchange: "this.form.submit()" %>
</label>
<% end %>
<%= render "shared/ruler" %>
</li>
<% end %>
</ul>
</div>
<% end %>
<% tabs.with_panel(tab_id: "raw_data") do %>
<div class="rounded-xl bg-container-inset p-1">
<ul class="bg-container shadow-border-xs rounded-lg">
<% if @pending_import.present? && params[:type].present? && !params[:type].in?(%w[MintImport QifImport SureImport DocumentImport PdfImport]) %>
<li>
<%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
<div class="flex items-center gap-2">
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<span class="text-orange-500">
<%= icon("loader", color: "current") %>
</span>
</div>
<span class="text-sm text-primary group-hover:text-secondary">
<%= t(".resume", type: @pending_import.type.titleize) %>
</span>
</div>
<%= icon("chevron-right") %>
<% end %>
<%= render "shared/ruler" %>
</li>
<% 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 %>
<% 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 %>
</ul>
</div>
<% end %>
<% end %>
<% end %>
<% end %>

View File

@@ -110,3 +110,8 @@ ca:
description: Aquí tens un resum dels nous elements que s'afegiran al teu compte
un cop publiquis aquesta importació.
title: Confirma les teves dades d'importació
summary_item_label: Element
summary_count_label: Quantitat
empty_summary: No s'han trobat registres importables en aquest fitxer. Pot estar buit, o les línies no coincideixen amb el format d'exportació esperat (cada línia ha de ser un objecte JSON amb les claus «type» i «data», amb tipus admesos per aquesta importació).
publish_import: Publicar la importació
back_to_imports: Tornar a les importacions

View File

@@ -181,3 +181,8 @@ de:
ready:
description: Hier ist eine Zusammenfassung der neuen Elemente, die deinem Konto hinzugefügt werden, sobald du diesen Import veröffentlichst.
title: Importdaten bestätigen
summary_item_label: Eintrag
summary_count_label: Anzahl
empty_summary: In dieser Datei wurden keine importierbaren Datensätze gefunden. Sie ist möglicherweise leer, oder die Zeilen entsprechen nicht dem erwarteten Exportformat (jede Zeile sollte ein JSON-Objekt mit den Schlüsseln „type“ und „data“ und unterstützten Typen sein).
publish_import: Import veröffentlichen
back_to_imports: Zurück zu Importen

View File

@@ -57,6 +57,13 @@ en:
date_format_label: Date format
rows_to_skip_label: Skip first n rows
confirms:
sure_import:
title: Confirm your import
description: Review the data that will be imported from your export file.
summary: Import summary
empty_summary: We could not find any importable records in this file. It may be empty, or the lines may not match the expected export format (each line should be a JSON object with "type" and "data" keys, using types this import supports).
publish_button: Start import
cancel: Cancel
mappings:
create_account: Create account
csv_mapping_label: "%{mapping} in CSV"
@@ -101,7 +108,28 @@ en:
instructions_4: Columns marked with an asterisk (*) are required data.
instructions_5: No commas, no currency symbols, and no parentheses in numbers.
title: Import your data
sure_import:
title: Import from export
description: Upload the all.ndjson file from your data export to restore your accounts, transactions, categories, and more.
drop_title: Drop NDJSON to upload
drop_subtitle: Your file will be uploaded automatically
browse: Browse
browse_hint: to add your all.ndjson file here
upload_button: Upload NDJSON
hint_html: Upload the <strong>all.ndjson</strong> file from your data export ZIP
ndjson_invalid: Must be valid NDJSON with at least one record
imports:
type_labels:
transaction_import: "Transaction import"
trade_import: "Trade import"
account_import: "Account import"
mint_import: "Mint import"
qif_import: "QIF import"
category_import: "Category import"
rule_import: "Rule import"
pdf_import: "PDF import"
document_import: "Document import"
sure_import: "Sure import"
steps:
upload: Upload
configure: Configure
@@ -120,6 +148,17 @@ en:
status: Status
actions: Actions
row:
type_labels:
transaction_import: "Transaction"
trade_import: "Trade"
account_import: "Account"
mint_import: "Mint"
qif_import: "QIF"
category_import: "Category"
rule_import: "Rule"
pdf_import: "PDF"
document_import: "Document"
sure_import: "Sure"
status:
in_progress: In progress
uploading: Processing rows
@@ -134,8 +173,11 @@ en:
view: View
empty: No imports yet.
new:
description: You can manually import various types of data via CSV or use one
of our import templates like Mint.
description: Import from a financial tool or upload raw data files.
tab_financial_tools: Financial Tools & Files
tab_raw_data: Raw Data
coming_soon: Coming soon
import_ynab: Import from YNAB
import_accounts: Import accounts
import_categories: Import categories
import_mint: Import from Mint
@@ -143,8 +185,10 @@ en:
import_rules: Import rules
import_transactions: Import transactions
import_qif: Import from Quicken (QIF)
import_sure: Import from Sure
import_sure_description: Full-export .ndjson file
import_file: Import document
import_file_description: AI-powered analysis for PDFs and searchable upload for other supported files
import_file_description: AI-powered analysis for PDFs and searchable file upload
requires_account: Import accounts first to unlock this option.
resume: Resume %{type}
sources: Sources
@@ -153,6 +197,7 @@ en:
file_too_large: File is too large. Maximum size is %{max_size}MB.
invalid_file_type: Invalid file type. Please upload a CSV file.
csv_uploaded: CSV uploaded successfully.
ndjson_uploaded: NDJSON file uploaded successfully.
pdf_too_large: PDF file is too large. Maximum size is %{max_size}MB.
pdf_processing: Your PDF is being processed. You will receive an email when analysis is complete.
invalid_pdf: The uploaded file is not a valid PDF.
@@ -160,6 +205,8 @@ en:
invalid_document_file_type: Invalid document file type for the active vector store.
document_uploaded: Document uploaded successfully.
document_upload_failed: We couldn't upload the document to the vector store. Please try again.
invalid_ndjson_file_type: Invalid file type or format. Please upload a valid .ndjson or .json export file.
ndjson_uploaded: NDJSON file uploaded successfully.
document_provider_not_configured: No vector store is configured for document uploads.
show:
finalize_upload: Please finalize your file upload.
@@ -168,6 +215,11 @@ en:
description: Here's a summary of the new items that will be added to your account
once you publish this import.
title: Confirm your import data
summary_item_label: Item
summary_count_label: Count
empty_summary: We could not find any importable records in this file. It may be empty, or the lines may not match the expected export format (each line should be a JSON object with "type" and "data" keys, using types this import supports).
publish_import: Publish import
back_to_imports: Back to imports
errors:
custom_column_requires_inflow: "Custom column imports require an inflow column to be selected"
document_types:

View File

@@ -152,6 +152,11 @@ es:
ready:
description: Aquí tienes un resumen de los nuevos elementos que se añadirán a tu cuenta una vez publiques esta importación.
title: Confirma tus datos de importación
summary_item_label: Elemento
summary_count_label: Cantidad
empty_summary: No se han encontrado registros importables en este archivo. Puede estar vacío, o las líneas no coinciden con el formato de exportación esperado (cada línea debe ser un objeto JSON con las claves «type» y «data», usando tipos que admite esta importación).
publish_import: Publicar importación
back_to_imports: Volver a importaciones
errors:
custom_column_requires_inflow: "Las importaciones de columnas personalizadas requieren que se seleccione una columna de entrada de fondos (inflow)"
document_types:

View File

@@ -53,6 +53,13 @@ fr:
date_format_label: Format de date
rows_to_skip_label: Ignorer les n premières lignes
confirms:
sure_import:
title: Confirmer votre importation
description: Vérifiez les données qui seront importées depuis votre fichier d'export.
summary: Résumé de l'importation
empty_summary: Aucun enregistrement importable n'a été trouvé dans ce fichier. Il est peut-être vide, ou les lignes ne correspondent pas au format d'export attendu (chaque ligne doit être un objet JSON avec les clés « type » et « data », pour des types pris en charge par cet import).
publish_button: Démarrer l'importation
cancel: Annuler
mappings:
create_account: Créer un compte
csv_mapping_label: "%{mapping} dans le CSV"
@@ -87,6 +94,16 @@ fr:
instructions_4: Les colonnes marquées avec une étoile (*) sont des données requises.
instructions_5: Pas de virgules, pas de symboles monétaires et pas de parenthèses dans les nombres.
title: Importez vos données
sure_import:
title: Importer depuis l'export
description: Téléversez le fichier all.ndjson de votre export de données pour restaurer vos comptes, transactions, catégories et plus encore.
drop_title: Déposez le NDJSON pour téléverser
drop_subtitle: Votre fichier sera téléversé automatiquement
browse: Parcourir
browse_hint: pour ajouter votre fichier all.ndjson ici
upload_button: Téléverser le NDJSON
hint_html: Téléversez le fichier <strong>all.ndjson</strong> de l'archive ZIP d'export de vos données
ndjson_invalid: Le fichier doit être un NDJSON valide avec au moins un enregistrement
imports:
steps:
upload: Téléverser
@@ -182,3 +199,8 @@ fr:
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
summary_item_label: Élément
summary_count_label: Nombre
empty_summary: Aucun enregistrement importable n'a été trouvé dans ce fichier. Il est peut-être vide, ou les lignes ne correspondent pas au format d'export attendu (chaque ligne doit être un objet JSON avec les clés « type » et « data », pour des types pris en charge par cet import).
publish_import: Publier l'importation
back_to_imports: Retour aux importations

View File

@@ -94,4 +94,9 @@ nb:
ready:
description: Her er en oppsummering av de nye elementene som vil bli lagt til kontoen din
når du publiserer denne importen.
title: Bekreft importdataene dine
title: Bekreft importdataene dine
summary_item_label: Element
summary_count_label: Antall
empty_summary: Vi fant ingen poster som kan importeres i denne filen. Den kan være tom, eller linjene samsvarer ikke med det forventede eksportformatet (hver linje skal være et JSON-objekt med nøklene «type» og «data», med typer denne importen støtter).
publish_import: Publiser import
back_to_imports: Tilbake til importer

View File

@@ -93,3 +93,8 @@ nl:
ready:
description: Hier is een samenvatting van de nieuwe items die aan uw account worden toegevoegd zodra u deze import publiceert.
title: Uw importgegevens bevestigen
summary_item_label: Item
summary_count_label: Aantal
empty_summary: Er zijn geen importeerbare records in dit bestand gevonden. Het bestand is mogelijk leeg, of de regels voldoen niet aan het verwachte exportformaat (elke regel moet een JSON-object zijn met de sleutels „type“ en „data“, met typen die deze import ondersteunt).
publish_import: Import publiceren
back_to_imports: Terug naar importen

View File

@@ -101,3 +101,8 @@ pt-BR:
description: Aqui está um resumo dos novos itens que serão adicionados à sua conta
assim que você publicar esta importação.
title: Confirmar seus dados de importação
summary_item_label: Item
summary_count_label: Quantidade
empty_summary: Não foi possível encontrar registros importáveis neste arquivo. Ele pode estar vazio, ou as linhas não correspondem ao formato de exportação esperado (cada linha deve ser um objeto JSON com as chaves «type» e «data», usando tipos suportados por esta importação).
publish_import: Publicar importação
back_to_imports: Voltar às importações

View File

@@ -80,3 +80,8 @@ ro:
ready:
description: Iată un rezumat al elementelor noi care vor fi adăugate contului tău odată ce vei publica acest import.
title: Confirmă datele importate
summary_item_label: Element
summary_count_label: Număr
empty_summary: Nu s-au găsit înregistrări importabile în acest fișier. Fișierul poate fi gol sau liniile nu respectă formatul de export așteptat (fiecare linie trebuie să fie un obiect JSON cu cheile „type” și „data”, folosind tipuri acceptate de acest import).
publish_import: Publică importul
back_to_imports: Înapoi la importuri

View File

@@ -79,4 +79,9 @@ tr:
title: Yeni CSV İçe Aktarma
ready:
description: Bu içe aktarmayı yayınladığınızda hesabınıza eklenecek yeni öğelerin özeti aşağıdadır.
title: İçe aktarma verilerinizi onaylayın
title: İçe aktarma verilerinizi onaylayın
summary_item_label: Öğe
summary_count_label: Adet
empty_summary: Bu dosyada içe aktarılabilir kayıt bulunamadı. Dosya boş olabilir veya satırlar beklenen dışa aktarma biçimiyle eşleşmiyor (her satır, bu içe aktarmanın desteklediği türlerle «type» ve «data» anahtarlarına sahip bir JSON nesnesi olmalıdır).
publish_import: İçe aktarmayı yayınla
back_to_imports: İçe aktarmalara dön

View File

@@ -90,3 +90,8 @@ zh-CN:
ready:
description: 以下是发布导入后将添加到您账户的新项目摘要。
title: 确认导入数据
summary_item_label: 项目
summary_count_label: 数量
empty_summary: 在此文件中未找到可导入的记录。文件可能为空或各行不符合预期的导出格式每行应为包含「type」和「data」键的 JSON 对象,且类型须为本导入支持的类型)。
publish_import: 发布导入
back_to_imports: 返回导入列表

View File

@@ -90,3 +90,8 @@ zh-TW:
ready:
description: 以下是發佈此匯入後,將新增至您帳戶的項目摘要。
title: 確認您的匯入資料
summary_item_label: 項目
summary_count_label: 數量
empty_summary: 在此檔案中找不到可匯入的記錄。檔案可能是空的或各行不符合預期的匯出格式每行應為包含「type」與「data」鍵的 JSON 物件,且類型須為此匯入支援的類型)。
publish_import: 發佈匯入
back_to_imports: 返回匯入列表

View File

@@ -176,7 +176,11 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "should not return category from another family" do
other_family_category = categories(:one) # belongs to :empty family
other_family_category = families(:empty).categories.create!(
name: "Other Family Category",
color: "#FF0000",
classification_unused: "expense"
)
get "/api/v1/categories/#{other_family_category.id}", params: {}, headers: {
"Authorization" => "Bearer #{@access_token.token}"

View File

@@ -32,10 +32,10 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
assert_select "button", text: "Import accounts"
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 "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
assert_select "button", text: "Import from Mint", count: 1
assert_select "button", text: "Import from Quicken (QIF)", count: 1
assert_select "span", text: "Import accounts first to unlock this option.", count: 2
assert_select "div[aria-disabled=true]", count: 3
end
test "creates import" do

View File

@@ -1,6 +1,6 @@
one:
name: Test
family: empty
family: dylan_family
income:
name: Income

View File

@@ -45,3 +45,8 @@ pdf_with_rows:
category: "Income"
notes: ""
rows_count: 2
sure:
family: dylan_family
type: SureImport
status: pending

View File

@@ -1,7 +1,7 @@
one:
type: FamilyMerchant
name: Test
family: empty
family: dylan_family
netflix:
type: FamilyMerchant

View File

@@ -8,4 +8,4 @@ two:
three:
name: Test
family: empty
family: dylan_family

View File

@@ -0,0 +1,574 @@
require "test_helper"
class Family::DataImporterTest < ActiveSupport::TestCase
setup do
@family = families(:empty)
end
test "imports accounts with accountable data" do
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "old-account-1",
name: "Test Checking",
balance: "1500.00",
currency: "USD",
accountable_type: "Depository",
accountable: { subtype: "checking" }
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
result = importer.import!
assert_equal 1, result[:accounts].count
account = result[:accounts].first
assert_equal "Test Checking", account.name
assert_equal 1500.0, account.balance.to_f
assert_equal "USD", account.currency
assert_equal "Depository", account.accountable_type
end
test "imports categories with parent relationships" do
ndjson = build_ndjson([
{
type: "Category",
data: {
id: "cat-parent",
name: "Shopping",
color: "#FF5733",
classification: "expense"
}
},
{
type: "Category",
data: {
id: "cat-child",
name: "Groceries",
color: "#33FF57",
classification: "expense",
parent_id: "cat-parent"
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
importer.import!
parent = @family.categories.find_by(name: "Shopping")
child = @family.categories.find_by(name: "Groceries")
assert_not_nil parent
assert_not_nil child
assert_equal parent.id, child.parent_id
end
test "imports tags" do
ndjson = build_ndjson([
{
type: "Tag",
data: {
id: "tag-1",
name: "Important",
color: "#FF0000"
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
importer.import!
tag = @family.tags.find_by(name: "Important")
assert_not_nil tag
assert_equal "#FF0000", tag.color
end
test "imports merchants" do
ndjson = build_ndjson([
{
type: "Merchant",
data: {
id: "merchant-1",
name: "Amazon"
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
importer.import!
merchant = @family.merchants.find_by(name: "Amazon")
assert_not_nil merchant
end
test "imports transactions with references" do
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "acct-1",
name: "Main Account",
balance: "5000",
currency: "USD",
accountable_type: "Depository"
}
},
{
type: "Category",
data: {
id: "cat-1",
name: "Food",
color: "#FF0000",
classification: "expense"
}
},
{
type: "Tag",
data: {
id: "tag-1",
name: "Essential"
}
},
{
type: "Transaction",
data: {
id: "txn-1",
account_id: "acct-1",
date: "2024-01-15",
amount: "-50.00",
name: "Grocery Store",
currency: "USD",
category_id: "cat-1",
tag_ids: [ "tag-1" ],
notes: "Weekly groceries"
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
result = importer.import!
assert_equal 1, result[:entries].count
transaction = @family.transactions.first
assert_not_nil transaction
assert_equal "Grocery Store", transaction.entry.name
assert_equal -50.0, transaction.entry.amount.to_f
assert_equal "Food", transaction.category.name
assert_equal 1, transaction.tags.count
assert_equal "Essential", transaction.tags.first.name
assert_equal "Weekly groceries", transaction.entry.notes
end
test "imports trades with securities" do
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "inv-acct-1",
name: "Investment Account",
balance: "10000",
currency: "USD",
accountable_type: "Investment"
}
},
{
type: "Trade",
data: {
id: "trade-1",
account_id: "inv-acct-1",
date: "2024-01-15",
ticker: "AAPL",
qty: "10",
price: "150.00",
amount: "-1500.00",
currency: "USD"
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
result = importer.import!
# Account + Opening balance + Trade entry
assert_equal 1, result[:entries].count
trade = @family.trades.first
assert_not_nil trade
assert_equal "AAPL", trade.security.ticker
assert_equal 10.0, trade.qty.to_f
assert_equal 150.0, trade.price.to_f
end
test "imports valuations" do
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "prop-acct-1",
name: "Property",
balance: "500000",
currency: "USD",
accountable_type: "Property"
}
},
{
type: "Valuation",
data: {
id: "val-1",
account_id: "prop-acct-1",
date: "2024-06-15",
amount: "520000",
name: "Updated valuation",
currency: "USD"
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
result = importer.import!
assert_equal 1, result[:entries].count
account = @family.accounts.find_by(name: "Property")
valuation = account.valuations.joins(:entry).find_by(entries: { name: "Updated valuation" })
assert_not_nil valuation
assert_equal 520000.0, valuation.entry.amount.to_f
end
test "imports budgets" do
ndjson = build_ndjson([
{
type: "Budget",
data: {
id: "budget-1",
start_date: "2024-01-01",
end_date: "2024-01-31",
budgeted_spending: "3000.00",
expected_income: "5000.00",
currency: "USD"
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
importer.import!
budget = @family.budgets.first
assert_not_nil budget
assert_equal Date.parse("2024-01-01"), budget.start_date
assert_equal Date.parse("2024-01-31"), budget.end_date
assert_equal 3000.0, budget.budgeted_spending.to_f
assert_equal 5000.0, budget.expected_income.to_f
end
test "imports budget_categories" do
ndjson = build_ndjson([
{
type: "Category",
data: {
id: "cat-groceries",
name: "Groceries",
color: "#00FF00",
classification: "expense"
}
},
{
type: "Budget",
data: {
id: "budget-1",
start_date: "2024-01-01",
end_date: "2024-01-31",
budgeted_spending: "3000.00",
expected_income: "5000.00",
currency: "USD"
}
},
{
type: "BudgetCategory",
data: {
id: "bc-1",
budget_id: "budget-1",
category_id: "cat-groceries",
budgeted_spending: "500.00",
currency: "USD"
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
importer.import!
budget = @family.budgets.first
budget_category = budget.budget_categories.first
assert_not_nil budget_category
assert_equal "Groceries", budget_category.category.name
assert_equal 500.0, budget_category.budgeted_spending.to_f
end
test "imports rules with conditions and actions" do
ndjson = build_ndjson([
{
type: "Rule",
version: 1,
data: {
name: "Categorize Coffee",
resource_type: "transaction",
active: true,
conditions: [
{
condition_type: "transaction_name",
operator: "like",
value: "starbucks"
}
],
actions: [
{
action_type: "set_transaction_category",
value: "Coffee"
}
]
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
importer.import!
rule = @family.rules.find_by(name: "Categorize Coffee")
assert_not_nil rule
assert rule.active
assert_equal "transaction", rule.resource_type
assert_equal 1, rule.conditions.count
condition = rule.conditions.first
assert_equal "transaction_name", condition.condition_type
assert_equal "like", condition.operator
assert_equal "starbucks", condition.value
assert_equal 1, rule.actions.count
action = rule.actions.first
assert_equal "set_transaction_category", action.action_type
# Category should be created
category = @family.categories.find_by(name: "Coffee")
assert_not_nil category
assert_equal category.id, action.value
end
test "imports rules with compound conditions" do
ndjson = build_ndjson([
{
type: "Rule",
version: 1,
data: {
name: "Compound Rule",
resource_type: "transaction",
active: true,
conditions: [
{
condition_type: "compound",
operator: "or",
sub_conditions: [
{
condition_type: "transaction_name",
operator: "like",
value: "walmart"
},
{
condition_type: "transaction_name",
operator: "like",
value: "target"
}
]
}
],
actions: [
{
action_type: "auto_categorize"
}
]
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
importer.import!
rule = @family.rules.find_by(name: "Compound Rule")
assert_not_nil rule
parent_condition = rule.conditions.first
assert_equal "compound", parent_condition.condition_type
assert_equal "or", parent_condition.operator
assert_equal 2, parent_condition.sub_conditions.count
end
test "skips invalid records gracefully" do
ndjson = "not valid json\n" + build_ndjson([
{
type: "Account",
data: {
id: "valid-acct",
name: "Valid Account",
balance: "1000",
currency: "USD",
accountable_type: "Depository"
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
result = importer.import!
assert_equal 1, result[:accounts].count
assert_equal "Valid Account", result[:accounts].first.name
end
test "skips unsupported record types" do
ndjson = build_ndjson([
{
type: "UnsupportedType",
data: { id: "unknown" }
},
{
type: "Account",
data: {
id: "valid-acct",
name: "Known Account",
balance: "1000",
currency: "USD",
accountable_type: "Depository"
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
result = importer.import!
assert_equal 1, result[:accounts].count
end
test "full import scenario with all entity types" do
ndjson = build_ndjson([
# Account
{
type: "Account",
data: {
id: "acct-main",
name: "Main Checking",
balance: "5000",
currency: "USD",
accountable_type: "Depository"
}
},
# Category
{
type: "Category",
data: {
id: "cat-food",
name: "Food",
color: "#FF5733",
classification: "expense"
}
},
# Tag
{
type: "Tag",
data: {
id: "tag-weekly",
name: "Weekly"
}
},
# Merchant
{
type: "Merchant",
data: {
id: "merchant-1",
name: "Local Grocery"
}
},
# Transaction
{
type: "Transaction",
data: {
id: "txn-1",
account_id: "acct-main",
date: "2024-01-15",
amount: "-75.50",
name: "Weekly groceries",
currency: "USD",
category_id: "cat-food",
merchant_id: "merchant-1",
tag_ids: [ "tag-weekly" ]
}
},
# Budget
{
type: "Budget",
data: {
id: "budget-jan",
start_date: "2024-01-01",
end_date: "2024-01-31",
budgeted_spending: "2000",
expected_income: "4000",
currency: "USD"
}
},
# BudgetCategory
{
type: "BudgetCategory",
data: {
id: "bc-food",
budget_id: "budget-jan",
category_id: "cat-food",
budgeted_spending: "500",
currency: "USD"
}
},
# Rule
{
type: "Rule",
version: 1,
data: {
name: "Auto-tag groceries",
resource_type: "transaction",
active: true,
conditions: [
{ condition_type: "transaction_name", operator: "like", value: "grocery" }
],
actions: [
{ action_type: "set_transaction_tags", value: "Weekly" }
]
}
}
])
importer = Family::DataImporter.new(@family, ndjson)
result = importer.import!
# Verify all entities were created
assert_equal 1, result[:accounts].count
assert_equal 1, @family.categories.count
assert_equal 1, @family.tags.count
assert_equal 1, @family.merchants.count
assert_equal 1, @family.transactions.count
assert_equal 1, @family.budgets.count
assert_equal 1, @family.budget_categories.count
assert_equal 1, @family.rules.count
# Verify relationships
transaction = @family.transactions.first
assert_equal "Food", transaction.category.name
assert_equal "Local Grocery", transaction.merchant.name
assert_equal "Weekly", transaction.tags.first.name
end
private
def build_ndjson(records)
records.map(&:to_json).join("\n")
end
end

View File

@@ -0,0 +1,187 @@
require "test_helper"
class SureImportTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
setup do
@family = families(:dylan_family)
@import = @family.imports.create!(type: "SureImport")
end
test "dry_run reflects attached ndjson content" do
ndjson = [
{ type: "Account", data: { id: "uuid-1", name: "Test", balance: "1000", currency: "USD", accountable_type: "Depository" } },
{ type: "Transaction", data: { id: "uuid-2" } }
].map(&:to_json).join("\n")
attach_ndjson(ndjson)
dry_run = @import.dry_run
assert_equal 1, dry_run[:accounts]
assert_equal 1, dry_run[:transactions]
end
test "publishable? is false when attached file has no supported records" do
ndjson = { type: "UnknownType", data: {} }.to_json
attach_ndjson(ndjson)
assert @import.uploaded?
assert_not @import.publishable?
end
test "column_keys required_column_keys and mapping_steps are empty" do
assert_equal [], @import.column_keys
assert_equal [], @import.required_column_keys
assert_equal [], @import.mapping_steps
end
test "max_row_count is higher than standard imports" do
assert_equal 100_000, @import.max_row_count
end
test "csv_template returns nil" do
assert_nil @import.csv_template
end
test "uploaded? returns false without ndjson attachment" do
assert_not @import.uploaded?
end
test "uploaded? returns true with valid ndjson attachment" do
attach_ndjson(build_ndjson([
{ type: "Account", data: { id: "uuid-1", name: "Test", balance: "1000", currency: "USD", accountable_type: "Depository" } }
]))
assert @import.uploaded?
end
test "uploaded? returns false with invalid ndjson attachment" do
attach_ndjson("not valid json")
assert_not @import.uploaded?
end
test "configured? and cleaned? follow uploaded?" do
attach_ndjson(build_ndjson([
{ type: "Account", data: { id: "uuid-1", name: "Test", balance: "1000", currency: "USD", accountable_type: "Depository" } }
]))
assert @import.configured?
assert @import.cleaned?
end
test "publishable? returns true when uploaded and valid" do
attach_ndjson(build_ndjson([
{ type: "Account", data: { id: "uuid-1", name: "Test", balance: "1000", currency: "USD", accountable_type: "Depository" } }
]))
assert @import.publishable?
end
test "dry_run returns counts by type" do
attach_ndjson(build_ndjson([
{ type: "Account", data: { id: "uuid-1" } },
{ type: "Account", data: { id: "uuid-2" } },
{ type: "Category", data: { id: "uuid-3" } },
{ type: "Transaction", data: { id: "uuid-4" } },
{ type: "Transaction", data: { id: "uuid-5" } },
{ type: "Transaction", data: { id: "uuid-6" } }
]))
dry_run = @import.dry_run
assert_equal 2, dry_run[:accounts]
assert_equal 1, dry_run[:categories]
assert_equal 3, dry_run[:transactions]
assert_equal 0, dry_run[:tags]
end
test "sync_ndjson_rows_count! sets total row count" do
attach_ndjson(build_ndjson([
{ type: "Account", data: { id: "uuid-1" } },
{ type: "Category", data: { id: "uuid-2" } },
{ type: "Transaction", data: { id: "uuid-3" } }
]))
@import.sync_ndjson_rows_count!
assert_equal 3, @import.rows_count
end
test "publishes import successfully" do
attach_ndjson(build_ndjson([
{ type: "Account", data: {
id: "uuid-1",
name: "Import Test Account",
balance: "1000.00",
currency: "USD",
accountable_type: "Depository",
accountable: { subtype: "checking" }
} }
]))
initial_account_count = @family.accounts.count
@import.publish
assert_equal "complete", @import.status
assert_equal initial_account_count + 1, @family.accounts.count
account = @family.accounts.find_by(name: "Import Test Account")
assert_not_nil account
assert_equal 1000.0, account.balance.to_f
assert_equal "USD", account.currency
assert_equal "Depository", account.accountable_type
end
test "import tracks created accounts for revert" do
attach_ndjson(build_ndjson([
{ type: "Account", data: {
id: "uuid-1",
name: "Revertable Account",
balance: "500.00",
currency: "USD",
accountable_type: "Depository"
} }
]))
@import.publish
assert_equal 1, @import.accounts.count
assert_equal "Revertable Account", @import.accounts.first.name
end
test "publishes later enqueues job" do
attach_ndjson(build_ndjson([
{ type: "Account", data: {
id: "uuid-1",
name: "Async Account",
balance: "100",
currency: "USD",
accountable_type: "Depository"
} }
]))
assert_enqueued_with job: ImportJob, args: [ @import ] do
@import.publish_later
end
assert_equal "importing", @import.status
end
private
def attach_ndjson(ndjson)
@import.ndjson_file.attach(
io: StringIO.new(ndjson),
filename: "all.ndjson",
content_type: "application/x-ndjson"
)
@import.sync_ndjson_rows_count!
end
def build_ndjson(records)
records.map(&:to_json).join("\n")
end
end

View File

@@ -13,6 +13,7 @@ class ImportsTest < ApplicationSystemTestCase
test "transaction import" do
visit new_import_path
click_on "Raw Data"
click_on "Import transactions"
within_testid("import-tabs") do
@@ -63,6 +64,7 @@ class ImportsTest < ApplicationSystemTestCase
test "trade import" do
visit new_import_path
click_on "Raw Data"
click_on "Import investments"
within_testid("import-tabs") do
@@ -105,6 +107,7 @@ class ImportsTest < ApplicationSystemTestCase
test "account import" do
visit new_import_path
click_on "Raw Data"
click_on "Import accounts"
within_testid("import-tabs") do
@@ -153,6 +156,8 @@ class ImportsTest < ApplicationSystemTestCase
test "mint import" do
visit new_import_path
# Pending CSV-style imports default the dialog to the Raw Data tab; Mint lives under Financial Tools.
click_on "Financial Tools"
click_on "Import from Mint"
within_testid("import-tabs") do