diff --git a/Gemfile b/Gemfile
index 194186db..41c7d699 100644
--- a/Gemfile
+++ b/Gemfile
@@ -46,6 +46,7 @@ gem "octokit"
gem "pagy"
gem "rails-settings-cached"
gem "tzinfo-data", platforms: %i[ windows jruby ]
+gem "csv"
group :development, :test do
gem "debug", platforms: %i[ mri windows ]
diff --git a/Gemfile.lock b/Gemfile.lock
index 6f604816..4f60365a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -156,6 +156,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
+ csv (3.2.8)
date (3.3.4)
debug (1.9.2)
irb (~> 1.10)
@@ -456,6 +457,7 @@ DEPENDENCIES
brakeman
capybara
climate_control
+ csv
debug
dotenv-rails
erb_lint
diff --git a/app/assets/images/apple-logo.png b/app/assets/images/apple-logo.png
new file mode 100644
index 00000000..107a7858
Binary files /dev/null and b/app/assets/images/apple-logo.png differ
diff --git a/app/assets/images/empower-logo.jpeg b/app/assets/images/empower-logo.jpeg
new file mode 100644
index 00000000..7193dcc2
Binary files /dev/null and b/app/assets/images/empower-logo.jpeg differ
diff --git a/app/assets/images/mint-logo.jpeg b/app/assets/images/mint-logo.jpeg
new file mode 100644
index 00000000..9e9723c6
Binary files /dev/null and b/app/assets/images/mint-logo.jpeg differ
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index ac4b5492..ca1b9cce 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -1,4 +1,6 @@
class AccountsController < ApplicationController
+ layout "with_sidebar"
+
include Filterable
before_action :set_account, only: %i[ show update destroy sync ]
@@ -48,7 +50,7 @@ class AccountsController < ApplicationController
end
end
else
- render "edit", status: :unprocessable_entity
+ render "show", status: :unprocessable_entity
end
end
@@ -84,11 +86,11 @@ class AccountsController < ApplicationController
private
- def set_account
- @account = Current.family.accounts.find(params[:id])
- end
+ def set_account
+ @account = Current.family.accounts.find(params[:id])
+ end
- def account_params
- params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :currency, :subtype, :is_active)
- end
+ def account_params
+ params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :currency, :subtype, :is_active)
+ end
end
diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
new file mode 100644
index 00000000..722a11a9
--- /dev/null
+++ b/app/controllers/imports_controller.rb
@@ -0,0 +1,102 @@
+require "ostruct"
+
+class ImportsController < ApplicationController
+ before_action :set_import, except: %i[ index new create ]
+
+ def index
+ @imports = Current.family.imports
+ render layout: "with_sidebar"
+ end
+
+ def new
+ @import = Import.new
+ end
+
+ def edit
+ end
+
+ def update
+ account = Current.family.accounts.find(params[:import][:account_id])
+
+ @import.update! account: account
+ redirect_to load_import_path(@import), notice: t(".import_updated")
+ end
+
+ def create
+ account = Current.family.accounts.find(params[:import][:account_id])
+ @import = Import.create!(account: account)
+
+ redirect_to load_import_path(@import), notice: t(".import_created")
+ end
+
+ def destroy
+ @import.destroy!
+ redirect_to imports_url, notice: t(".import_destroyed"), status: :see_other
+ end
+
+ def load
+ end
+
+ def load_csv
+ if @import.update(import_params)
+ redirect_to configure_import_path(@import), notice: t(".import_loaded")
+ else
+ flash.now[:error] = @import.errors.full_messages.to_sentence
+ render :load, status: :unprocessable_entity
+ end
+ end
+
+ def configure
+ unless @import.loaded?
+ redirect_to load_import_path(@import), alert: t(".invalid_csv")
+ end
+ end
+
+ def update_mappings
+ @import.update! import_params(@import.expected_fields.map(&:key))
+ redirect_to clean_import_path(@import), notice: t(".column_mappings_saved")
+ end
+
+ def clean
+ unless @import.loaded?
+ redirect_to load_import_path(@import), alert: t(".invalid_csv")
+ end
+ end
+
+ def update_csv
+ update_params = import_params[:csv_update]
+
+ @import.update_csv! \
+ row_idx: update_params[:row_idx],
+ col_idx: update_params[:col_idx],
+ value: update_params[:value]
+
+ render :clean
+ end
+
+ def confirm
+ unless @import.cleaned?
+ redirect_to clean_import_path(@import), alert: t(".invalid_data")
+ end
+ end
+
+ def publish
+ if @import.valid?
+ @import.publish_later
+ redirect_to imports_path, notice: t(".import_published")
+ else
+ flash.now[:error] = t(".invalid_data")
+ render :confirm, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def set_import
+ @import = Current.family.imports.find(params[:id])
+ end
+
+ def import_params(permitted_mappings = nil)
+ params.require(:import).permit(:raw_csv_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
+ end
+end
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index ba584e9f..943dcf75 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -1,4 +1,6 @@
class PagesController < ApplicationController
+ layout "with_sidebar"
+
include Filterable
def dashboard
diff --git a/app/controllers/settings/billings_controller.rb b/app/controllers/settings/billings_controller.rb
index d218beaa..c4bdd1f5 100644
--- a/app/controllers/settings/billings_controller.rb
+++ b/app/controllers/settings/billings_controller.rb
@@ -1,4 +1,4 @@
-class Settings::BillingsController < ApplicationController
+class Settings::BillingsController < SettingsController
def edit
end
diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb
index b64a926d..36b9a0ee 100644
--- a/app/controllers/settings/hostings_controller.rb
+++ b/app/controllers/settings/hostings_controller.rb
@@ -1,4 +1,4 @@
-class Settings::HostingsController < ApplicationController
+class Settings::HostingsController < SettingsController
before_action :verify_hosting_mode
def show
diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb
index 8fceff14..bb458dc7 100644
--- a/app/controllers/settings/notifications_controller.rb
+++ b/app/controllers/settings/notifications_controller.rb
@@ -1,4 +1,4 @@
-class Settings::NotificationsController < ApplicationController
+class Settings::NotificationsController < SettingsController
def edit
end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 87138e67..dbc5bcb3 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -1,4 +1,4 @@
-class Settings::PreferencesController < ApplicationController
+class Settings::PreferencesController < SettingsController
def edit
end
@@ -14,11 +14,12 @@ class Settings::PreferencesController < ApplicationController
redirect_to settings_preferences_path, notice: t(".success")
else
redirect_to settings_preferences_path, notice: t(".success")
- render :edit, status: :unprocessable_entity
+ render :show, status: :unprocessable_entity
end
end
private
+
def preference_params
params.require(:user).permit(family_attributes: [ :id, :currency ])
end
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 83aa94da..326a06f0 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -1,4 +1,4 @@
-class Settings::ProfilesController < ApplicationController
+class Settings::ProfilesController < SettingsController
def show
end
diff --git a/app/controllers/settings/securities_controller.rb b/app/controllers/settings/securities_controller.rb
index 6e49acf0..9d3bac42 100644
--- a/app/controllers/settings/securities_controller.rb
+++ b/app/controllers/settings/securities_controller.rb
@@ -1,4 +1,4 @@
-class Settings::SecuritiesController < ApplicationController
+class Settings::SecuritiesController < SettingsController
def edit
end
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
new file mode 100644
index 00000000..f908b939
--- /dev/null
+++ b/app/controllers/settings_controller.rb
@@ -0,0 +1,3 @@
+class SettingsController < ApplicationController
+ layout "with_sidebar"
+end
diff --git a/app/controllers/transactions/categories/deletions_controller.rb b/app/controllers/transactions/categories/deletions_controller.rb
index ee868abe..c5960ebd 100644
--- a/app/controllers/transactions/categories/deletions_controller.rb
+++ b/app/controllers/transactions/categories/deletions_controller.rb
@@ -1,4 +1,6 @@
class Transactions::Categories::DeletionsController < ApplicationController
+ layout "with_sidebar"
+
before_action :set_category
before_action :set_replacement_category, only: :create
diff --git a/app/controllers/transactions/categories_controller.rb b/app/controllers/transactions/categories_controller.rb
index b01e4439..99bcf6fc 100644
--- a/app/controllers/transactions/categories_controller.rb
+++ b/app/controllers/transactions/categories_controller.rb
@@ -1,4 +1,6 @@
class Transactions::CategoriesController < ApplicationController
+ layout "with_sidebar"
+
before_action :set_category, only: %i[ edit update ]
before_action :set_transaction, only: :create
diff --git a/app/controllers/transactions/merchants_controller.rb b/app/controllers/transactions/merchants_controller.rb
index 2025a6d0..809a8e91 100644
--- a/app/controllers/transactions/merchants_controller.rb
+++ b/app/controllers/transactions/merchants_controller.rb
@@ -1,4 +1,6 @@
class Transactions::MerchantsController < ApplicationController
+ layout "with_sidebar"
+
before_action :set_merchant, only: %i[ edit update destroy ]
def index
diff --git a/app/controllers/transactions/rules_controller.rb b/app/controllers/transactions/rules_controller.rb
index ff27f690..3c1db811 100644
--- a/app/controllers/transactions/rules_controller.rb
+++ b/app/controllers/transactions/rules_controller.rb
@@ -1,4 +1,6 @@
class Transactions::RulesController < ApplicationController
+ layout "with_sidebar"
+
def index
end
end
diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb
index 36dbe312..ad2cc6e1 100644
--- a/app/controllers/transactions_controller.rb
+++ b/app/controllers/transactions_controller.rb
@@ -1,4 +1,6 @@
class TransactionsController < ApplicationController
+ layout "with_sidebar"
+
before_action :set_transaction, only: %i[ show edit update destroy ]
def index
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 9180c92b..288c3a78 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -51,6 +51,11 @@ module ApplicationHelper
end
end
+ def return_to_path(params, fallback = root_path)
+ uri = URI.parse(params[:return_to] || fallback)
+ uri.relative? ? uri.path : root_path
+ end
+
def trend_styles(trend)
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
return fallback if trend.nil? || trend.direction.flat?
diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb
new file mode 100644
index 00000000..0b924d36
--- /dev/null
+++ b/app/helpers/imports_helper.rb
@@ -0,0 +1,19 @@
+module ImportsHelper
+ def table_corner_class(row_idx, col_idx, rows, cols)
+ return "rounded-tl-xl" if row_idx == 0 && col_idx == 0
+ return "rounded-tr-xl" if row_idx == 0 && col_idx == cols.size - 1
+ return "rounded-bl-xl" if row_idx == rows.size - 1 && col_idx == 0
+ return "rounded-br-xl" if row_idx == rows.size - 1 && col_idx == cols.size - 1
+ ""
+ end
+
+ def nav_steps(import = Import.new)
+ [
+ { name: "Select", complete: import.persisted?, path: import.persisted? ? edit_import_path(import) : new_import_path },
+ { name: "Import", complete: import.loaded?, path: import.persisted? ? load_import_path(import) : nil },
+ { name: "Setup", complete: import.configured?, path: import.persisted? ? configure_import_path(import) : nil },
+ { name: "Clean", complete: import.cleaned?, path: import.persisted? ? clean_import_path(import) : nil },
+ { name: "Confirm", complete: import.complete?, path: import.persisted? ? confirm_import_path(import) : nil }
+ ]
+ end
+end
diff --git a/app/jobs/import_job.rb b/app/jobs/import_job.rb
new file mode 100644
index 00000000..f7fc2c01
--- /dev/null
+++ b/app/jobs/import_job.rb
@@ -0,0 +1,7 @@
+class ImportJob < ApplicationJob
+ queue_as :default
+
+ def perform(import)
+ import.publish
+ end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index dc0cb30a..2550acef 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -9,6 +9,7 @@ class Account < ApplicationRecord
has_many :balances, dependent: :destroy
has_many :valuations, dependent: :destroy
has_many :transactions, dependent: :destroy
+ has_many :imports, dependent: :destroy
monetize :balance
diff --git a/app/models/family.rb b/app/models/family.rb
index 3f7747d9..e3e174b1 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -2,6 +2,7 @@ class Family < ApplicationRecord
has_many :users, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :transactions, through: :accounts
+ has_many :imports, through: :accounts
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
has_many :transaction_merchants, dependent: :destroy, class_name: "Transaction::Merchant"
diff --git a/app/models/import.rb b/app/models/import.rb
new file mode 100644
index 00000000..39deb9c3
--- /dev/null
+++ b/app/models/import.rb
@@ -0,0 +1,161 @@
+class Import < ApplicationRecord
+ belongs_to :account
+
+ validate :raw_csv_must_be_parsable
+
+ before_save :initialize_csv, if: :should_initialize_csv?
+
+ enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
+
+ store_accessor :column_mappings, :define_column_mapping_keys
+
+ scope :ordered, -> { order(created_at: :desc) }
+
+ def publish_later
+ ImportJob.perform_later(self)
+ end
+
+ def loaded?
+ raw_csv_str.present?
+ end
+
+ def configured?
+ csv.present?
+ end
+
+ def cleaned?
+ loaded? && configured? && csv.valid?
+ end
+
+ def csv
+ get_normalized_csv_with_validation
+ end
+
+ def available_headers
+ get_raw_csv.table.headers
+ end
+
+ def get_selected_header_for_field(field)
+ column_mappings&.dig(field) || field.key
+ end
+
+ def update_csv!(row_idx:, col_idx:, value:)
+ updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
+ update! normalized_csv_str: updated_csv.to_s
+ end
+
+ # Type-specific methods (potential STI inheritance in future when more import types added)
+ def publish
+ update!(status: "importing")
+
+ transaction do
+ generate_transactions.each do |txn|
+ txn.save!
+ end
+ end
+
+ update!(status: "complete")
+ rescue => e
+ update!(status: "failed")
+ Rails.logger.error("Import with id #{id} failed: #{e}")
+ end
+
+ def dry_run
+ generate_transactions
+ end
+
+ def expected_fields
+ @expected_fields ||= create_expected_fields
+ end
+
+ private
+
+ def get_normalized_csv_with_validation
+ return nil if normalized_csv_str.nil?
+
+ csv = Import::Csv.new(normalized_csv_str)
+
+ expected_fields.each do |field|
+ csv.define_validator(field.key, field.validator) if field.validator
+ end
+
+ csv
+ end
+
+ def get_raw_csv
+ return nil if raw_csv_str.nil?
+ Import::Csv.new(raw_csv_str)
+ end
+
+ def should_initialize_csv?
+ raw_csv_str_changed? || column_mappings_changed?
+ end
+
+ def initialize_csv
+ generated_csv = generate_normalized_csv(raw_csv_str)
+ self.normalized_csv_str = generated_csv.table.to_s
+ end
+
+ # Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import
+ def generate_normalized_csv(csv_str)
+ Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings)
+ end
+
+ def update_csv(row_idx, col_idx, value)
+ updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
+ update! normalized_csv_str: updated_csv.to_s
+ end
+
+ def generate_transactions
+ transactions = []
+
+ csv.table.each do |row|
+ category = account.family.transaction_categories.find_or_initialize_by(name: row["category"])
+ txn = account.transactions.build \
+ name: row["name"] || "Imported transaction",
+ date: Date.iso8601(row["date"]),
+ category: category,
+ amount: BigDecimal(row["amount"]) * -1 # User inputs amounts with opposite signage of our internal representation
+
+ transactions << txn
+ end
+
+ transactions
+ end
+
+ def create_expected_fields
+ date_field = Import::Field.new \
+ key: "date",
+ label: "Date",
+ validator: ->(value) { Import::Field.iso_date_validator(value) }
+
+ name_field = Import::Field.new \
+ key: "name",
+ label: "Name"
+
+ category_field = Import::Field.new \
+ key: "category",
+ label: "Category"
+
+ amount_field = Import::Field.new \
+ key: "amount",
+ label: "Amount",
+ validator: ->(value) { Import::Field.bigdecimal_validator(value) }
+
+ [ date_field, name_field, category_field, amount_field ]
+ end
+
+ def define_column_mapping_keys
+ expected_fields.each do |field|
+ field.key.to_sym
+ end
+ end
+
+ def raw_csv_must_be_parsable
+ begin
+ CSV.parse(raw_csv_str || "")
+ rescue CSV::MalformedCSVError
+ errors.add(:raw_csv_str, "is not a valid CSV format")
+ end
+ end
+end
diff --git a/app/models/import/csv.rb b/app/models/import/csv.rb
new file mode 100644
index 00000000..5018af9c
--- /dev/null
+++ b/app/models/import/csv.rb
@@ -0,0 +1,74 @@
+class Import::Csv
+ def self.parse_csv(csv_str)
+ CSV.parse((csv_str || "").strip, headers: true, converters: [ ->(str) { str.strip } ])
+ end
+
+ def self.create_with_field_mappings(raw_csv_str, fields, field_mappings)
+ raw_csv = self.parse_csv(raw_csv_str)
+
+ generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true do |csv|
+ raw_csv.each do |row|
+ row_values = []
+
+ fields.each do |field|
+ # Finds the column header name the user has designated for the expected field
+ mapped_field_key = field_mappings[field.key] if field_mappings
+ mapped_header = mapped_field_key || field.key
+
+ row_values << row.fetch(mapped_header, "")
+ end
+
+ csv << row_values
+ end
+ end
+
+ new(generated_csv_str)
+ end
+
+ attr_reader :csv_str
+
+ def initialize(csv_str, column_validators: nil)
+ @csv_str = csv_str
+ @column_validators = column_validators || {}
+ end
+
+ def table
+ @table ||= self.class.parse_csv(csv_str)
+ end
+
+ def update_cell(row_idx, col_idx, value)
+ copy = table.by_col_or_row
+ copy[row_idx][col_idx] = value
+ copy
+ end
+
+ def valid?
+ table.each_with_index.all? do |row, row_idx|
+ row.each_with_index.all? do |cell, col_idx|
+ cell_valid?(row_idx, col_idx)
+ end
+ end
+ end
+
+ def cell_valid?(row_idx, col_idx)
+ value = table.dig(row_idx, col_idx)
+ header = table.headers[col_idx]
+ validator = get_validator_by_header(header)
+ validator.call(value)
+ end
+
+ def define_validator(header_key, validator = nil, &block)
+ header = table.headers.find { |h| h.strip == header_key }
+ raise "Cannot define validator for header #{header_key}: header does not exist in CSV" if header.nil?
+
+ column_validators[header] = validator || block
+ end
+
+ private
+
+ attr_accessor :column_validators
+
+ def get_validator_by_header(header)
+ column_validators&.dig(header) || ->(_v) { true }
+ end
+end
diff --git a/app/models/import/field.rb b/app/models/import/field.rb
new file mode 100644
index 00000000..aff9f186
--- /dev/null
+++ b/app/models/import/field.rb
@@ -0,0 +1,32 @@
+class Import::Field
+ def self.iso_date_validator(value)
+ Date.iso8601(value)
+ true
+ rescue
+ false
+ end
+
+ def self.bigdecimal_validator(value)
+ BigDecimal(value)
+ true
+ rescue
+ false
+ end
+
+ attr_reader :key, :label, :validator
+
+ def initialize(key:, label:, validator: nil)
+ @key = key.to_s
+ @label = label
+ @validator = validator
+ end
+
+ def define_validator(validator = nil, &block)
+ @validator = validator || block
+ end
+
+ def validate(value)
+ return true if validator.nil?
+ validator.call(value)
+ end
+end
diff --git a/app/views/imports/_empty.html.erb b/app/views/imports/_empty.html.erb
new file mode 100644
index 00000000..ed7a3b32
--- /dev/null
+++ b/app/views/imports/_empty.html.erb
@@ -0,0 +1,9 @@
+
+
+
<%= t(".message") %>
+ <%= link_to new_import_path(enable_type_selector: true), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
+ <%= lucide_icon("plus", class: "w-5 h-5") %>
+
<%= t(".new") %>
+ <% end %>
+
+
diff --git a/app/views/imports/_form.html.erb b/app/views/imports/_form.html.erb
new file mode 100644
index 00000000..783a1350
--- /dev/null
+++ b/app/views/imports/_form.html.erb
@@ -0,0 +1,7 @@
+<%= form_with model: @import do |form| %>
+
+ <%= form.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".account"), required: true } %>
+
+
+ <%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium" %>
+<% end %>
diff --git a/app/views/imports/_import.html.erb b/app/views/imports/_import.html.erb
new file mode 100644
index 00000000..0983e01b
--- /dev/null
+++ b/app/views/imports/_import.html.erb
@@ -0,0 +1,61 @@
+
+
+
+
+
+ <%= t(".label", account: import.account.name) %>
+
+
+ <% if import.pending? %>
+
+ <%= t(".in_progress") %>
+
+ <% elsif import.importing? %>
+
+ <%= t(".uploading") %>
+
+ <% elsif import.failed? %>
+
+ <%= t(".failed") %>
+
+ <% elsif import.complete? %>
+
+ <%= t(".complete") %>
+
+ <% end %>
+
+
+ <% if import.complete? %>
+
<%= t(".completed_on", datetime: import.updated_at.strftime("%Y-%m-%d")) %>
+ <% else %>
+
<%= t(".started_on", datetime: import.created_at.strftime("%Y-%m-%d")) %>
+ <% end %>
+
+
+ <% if import.complete? %>
+
+ <%= lucide_icon("check", class: "text-green-500 w-4 h-4") %>
+
+ <% else %>
+ <%= contextual_menu do %>
+
+ <%= link_to edit_import_path(import),
+ class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
+ <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
+
+ <%= t(".edit") %>
+ <% end %>
+
+ <%= button_to import_path(import),
+ method: :delete,
+ class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
+ data: { turbo_confirm: true } do %>
+ <%= lucide_icon "trash-2", class: "w-5 h-5" %>
+
+ <%= t(".delete") %>
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+
diff --git a/app/views/imports/_nav_step.html.erb b/app/views/imports/_nav_step.html.erb
new file mode 100644
index 00000000..375f1e92
--- /dev/null
+++ b/app/views/imports/_nav_step.html.erb
@@ -0,0 +1,18 @@
+<% is_current = request.path == step[:path] %>
+<% text_class = if is_current
+ "text-gray-900"
+ else
+ step[:complete] ? "text-green-600" : "text-gray-500"
+ end %>
+<% step_class = if is_current
+ "bg-gray-900 text-white"
+ else
+ step[:complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
+ end %>
+
+
+
+ <%= step[:complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : step_idx + 1 %>
+
+ <%= step[:name] %>
+
diff --git a/app/views/imports/_sample_table.html.erb b/app/views/imports/_sample_table.html.erb
new file mode 100644
index 00000000..83a16f5c
--- /dev/null
+++ b/app/views/imports/_sample_table.html.erb
@@ -0,0 +1,22 @@
+
+
+
Date
+
Name
+
Category
+
Amount
+
+
2024-01-01
+
Amazon
+
Shopping
+
-24.99
+
+
2024-03-01
+
Spotify
+
+
-16.32
+
+
2023-01-06
+
Acme
+
Income
+
151.22
+
diff --git a/app/views/imports/_type_selector.html.erb b/app/views/imports/_type_selector.html.erb
new file mode 100644
index 00000000..9f68c343
--- /dev/null
+++ b/app/views/imports/_type_selector.html.erb
@@ -0,0 +1,90 @@
+
+
+
+
+
<%= t(".import_transactions") %>
+
+
+
+
<%= t(".description") %>
+
+
+
+
<%= t(".sources") %>
+
+ -
+ <% if Current.family.imports.pending.present? %>
+ <%= link_to edit_import_path(Current.family.imports.pending.ordered.first), class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
+
+ <%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
+
+
+ <%= t(".resume_latest_import") %>
+
+ <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
+ <% end %>
+
+
+
+ <% end %>
+ -
+ <%= link_to new_import_path, class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
+
+ <%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %>
+
+
+ <%= t(".import_from_csv") %>
+
+ <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
+ <% end %>
+
+
+
+ -
+
+ <%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
+
+ <%= t(".import_from_mint") %>
+
+ <%= t(".soon") %>
+ <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
+
+
+
+
+ -
+
+ <%= image_tag("empower-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 border border-alpha-black-100 rounded-md") %>
+
+ <%= t(".import_from_empower") %>
+
+ <%= t(".soon") %>
+ <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
+
+
+
+
+ -
+
+ <%= image_tag("apple-logo.png", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
+
+ <%= t(".import_from_apple") %>
+
+ <%= t(".soon") %>
+ <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
+
+
+
+
+
+
diff --git a/app/views/imports/clean.html.erb b/app/views/imports/clean.html.erb
new file mode 100644
index 00000000..05f94db9
--- /dev/null
+++ b/app/views/imports/clean.html.erb
@@ -0,0 +1,49 @@
+<%= content_for :return_to_path, return_to_path(params, imports_path) %>
+
+
+
<%= t(".clean_import") %>
+
+
+
<%= t(".clean_and_edit") %>
+
<%= t(".clean_description") %>
+
+
+
+
+ <% @import.expected_fields.each do |field| %>
+
<%= field.label %>
+ <% end %>
+
+
+
+ <% @import.csv.table.each_with_index do |row, row_index| %>
+
+ <% row.fields.each_with_index do |value, col_index| %>
+ <%= form_with model: @import,
+ builder: ActionView::Helpers::FormBuilder,
+ url: clean_import_url(@import),
+ method: :patch,
+ data: { turbo: false, controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
+ <%= form.fields_for :csv_update do |ff| %>
+ <%= ff.hidden_field :row_idx, value: row_index %>
+ <%= ff.hidden_field :col_idx, value: col_index %>
+ <%= ff.text_field :value, value: value,
+ id: "cell-#{row_index}-#{col_index}",
+ class: "#{@import.csv.cell_valid?(row_index, col_index) ? "focus:border-transparent border-transparent" : "border-red-500"} border px-4 py-3.5 text-sm w-full bg-transparent focus:ring-gray-900 focus:ring-2 focus-visible:outline-none #{table_corner_class(row_index, col_index, @import.csv.table, row.fields)}",
+ data: { "auto-submit-form-target" => "auto" } %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+ <% end %>
+
+
+
+ <% if @import.csv.valid? %>
+ <%= link_to "Next", confirm_import_path(@import), class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
+ <% end %>
+
diff --git a/app/views/imports/configure.html.erb b/app/views/imports/configure.html.erb
new file mode 100644
index 00000000..78a186f0
--- /dev/null
+++ b/app/views/imports/configure.html.erb
@@ -0,0 +1,24 @@
+<%= content_for :return_to_path, return_to_path(params, imports_path) %>
+
+
+
<%= t(".configure_title") %>
+
+
+
<%= t(".configure_subtitle") %>
+
<%= t(".configure_description") %>
+
+
+ <%= form_with model: @import, url: configure_import_path(@import) do |form| %>
+
+ <%= form.fields_for :column_mappings do |mappings| %>
+ <% @import.expected_fields.each do |field| %>
+ <%= mappings.select field.key,
+ options_for_select(@import.available_headers, @import.get_selected_header_for_field(field)),
+ label: field.label %>
+ <% end %>
+ <% end %>
+
+
+ <%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.column_mappings? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
+ <% end %>
+
diff --git a/app/views/imports/confirm.html.erb b/app/views/imports/confirm.html.erb
new file mode 100644
index 00000000..56ac1975
--- /dev/null
+++ b/app/views/imports/confirm.html.erb
@@ -0,0 +1,16 @@
+<%= content_for :return_to_path, return_to_path(params, imports_path) %>
+
+
+
<%= t(".confirm_title") %>
+
+
+
<%= t(".confirm_subtitle") %>
+
<%= t(".confirm_description") %>
+
+
+
+ <%= render partial: "imports/transactions/transaction_group", collection: @import.dry_run.group_by(&:date) %>
+
+
+ <%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
+
diff --git a/app/views/imports/edit.html.erb b/app/views/imports/edit.html.erb
new file mode 100644
index 00000000..c873e237
--- /dev/null
+++ b/app/views/imports/edit.html.erb
@@ -0,0 +1,10 @@
+<%= content_for :return_to_path, return_to_path(params, imports_path) %>
+
+
+
<%= t(".edit_title") %>
+
+
<%= t(".header_text") %>
+
<%= t(".description_text") %>
+
+ <%= render "form", import: @import %>
+
diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb
new file mode 100644
index 00000000..97d1ee7e
--- /dev/null
+++ b/app/views/imports/index.html.erb
@@ -0,0 +1,30 @@
+<% content_for :sidebar do %>
+ <%= render "settings/nav" %>
+<% end %>
+
+
+
+
<%= t(".title") %>
+ <%= link_to new_import_path(enable_type_selector: true), class: "flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
+ <%= lucide_icon("plus", class: "w-5 h-5") %>
+ <%= t(".new") %>
+ <% end %>
+
+
+ <% if @imports.empty? %>
+ <%= render partial: "imports/empty" %>
+ <% else %>
+
+
<%= t(".imports") %> ยท <%= @imports.size %>
+
+
+ <%= render @imports.ordered %>
+
+
+ <% end %>
+
+
+ <%= previous_setting("Rules", transaction_rules_path) %>
+ <%= next_setting("What's new", changelog_path) %>
+
+
diff --git a/app/views/imports/load.html.erb b/app/views/imports/load.html.erb
new file mode 100644
index 00000000..68fab9b9
--- /dev/null
+++ b/app/views/imports/load.html.erb
@@ -0,0 +1,39 @@
+<%= content_for :return_to_path, return_to_path(params, imports_path) %>
+
+
+
<%= t(".load_title") %>
+
+
+
<%= t(".subtitle") %>
+
<%= t(".description") %>
+
+
+ <%= form_with model: @import, url: load_import_path(@import) do |form| %>
+
+ <%= form.text_area :raw_csv_str,
+ rows: 10,
+ required: true,
+ placeholder: "Paste your CSV file contents here",
+ class: "rounded-md w-full border text-sm border-alpha-black-100 bg-white placeholder:text-gray-400 p-4" %>
+
+
+ <%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
+ <% end %>
+
+
+
+
+ <%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
+
<%= t(".instructions") %>
+
+
+
+ - <%= t(".requirement1") %>
+ - <%= t(".requirement2") %>
+
+
+
+ <%= render partial: "imports/sample_table" %>
+
+
+
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb
new file mode 100644
index 00000000..afecb846
--- /dev/null
+++ b/app/views/imports/new.html.erb
@@ -0,0 +1,16 @@
+<%= content_for :return_to_path, return_to_path(params, imports_path) %>
+
+<% if params[:enable_type_selector].present? %>
+ <%= modal do %>
+ <%= render "type_selector" %>
+ <% end %>
+<% end %>
+
+
+
New import
+
+
<%= t(".header_text") %>
+
<%= t(".description_text") %>
+
+ <%= render "form", import: @import %>
+
diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb
new file mode 100644
index 00000000..83dc2e69
--- /dev/null
+++ b/app/views/imports/show.html.erb
@@ -0,0 +1,15 @@
+
+
+ <% if notice.present? %>
+
<%= notice %>
+ <% end %>
+
+ <%= render @import %>
+
+ <%= link_to "Edit this import", edit_import_path(@import), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
+
+ <%= button_to "Destroy this import", import_path(@import), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
+
+ <%= link_to "Back to imports", imports_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
+
+
diff --git a/app/views/imports/transactions/_transaction.html.erb b/app/views/imports/transactions/_transaction.html.erb
new file mode 100644
index 00000000..202e0ca7
--- /dev/null
+++ b/app/views/imports/transactions/_transaction.html.erb
@@ -0,0 +1,12 @@
+<%# locals: (transaction:) %>
+
+ <%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
+
+
+ <%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
+
+
+
+ <%= content_tag :p, format_money(-transaction.amount), class: ["whitespace-nowrap", BigDecimal(transaction.amount).negative? ? "text-green-600" : "text-red-600"] %>
+
+
diff --git a/app/views/imports/transactions/_transaction_group.html.erb b/app/views/imports/transactions/_transaction_group.html.erb
new file mode 100644
index 00000000..19c1ed4c
--- /dev/null
+++ b/app/views/imports/transactions/_transaction_group.html.erb
@@ -0,0 +1,13 @@
+<%# locals: (transaction_group:) %>
+<% date = transaction_group[0] %>
+<% transactions = transaction_group[1] %>
+
+
+
+
<%= date.strftime("%b %d, %Y") %> · <%= transactions.size %>
+ <%= format_money -transactions.sum { |t| t.amount } %>
+
+
+ <%= render partial: "imports/transactions/transaction", collection: transactions %>
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index e279c9e9..ddef53e2 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -1,52 +1,39 @@
-
+
<%= content_for(:title) || "Maybe" %>
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
+ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
+
+ <%= javascript_importmap_tags %>
+ <%= hotwire_livereload_tags if Rails.env.development? %>
+ <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
+
- <%= csrf_meta_tags %>
- <%= csp_meta_tag %>
-
-
-
-
-
- <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
- <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
- <%= javascript_importmap_tags %>
- <%= hotwire_livereload_tags if Rails.env.development? %>
- <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
+
<%= yield :head %>
+
<%= safe_join(flash.map { |type, message| notification(message, type: type) }) %>
-
-
- <% if content_for?(:sidebar) %>
- <%= yield :sidebar %>
- <% else %>
- <%= render "layouts/sidebar" %>
- <% end %>
-
-
- <%= yield %>
-
-
+
+ <%= content_for?(:content) ? yield(:content) : yield %>
+
<%= turbo_frame_tag "modal" %>
<%= render "shared/confirm_modal" %>
- <%= render "shared/upgrade_notification" %>
+
<% if self_hosted? %>
-
-
Self-hosted Maybe: <%= Maybe.version.to_release_tag %>
- <%= link_to settings_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
- <%= lucide_icon("settings", class: "w-4 h-4 text-gray-500 shrink-0") %>
- <% end %>
-
+ <%= render "shared/app_version" %>
<% end %>
diff --git a/app/views/layouts/auth.html.erb b/app/views/layouts/auth.html.erb
index 2e009d33..05c81e68 100644
--- a/app/views/layouts/auth.html.erb
+++ b/app/views/layouts/auth.html.erb
@@ -1,61 +1,31 @@
-
-
-
- Maybe
-
-
-
-
+<%= content_for :content do %>
+
+
+ <%= render "shared/logo" %>
-
-
-
-
-
-
-
+
+ <%= content_for?(:header_title) ? yield(:header_title).html_safe : t(".your_account") %>
+
- <%= csrf_meta_tags %>
- <%= csp_meta_tag %>
- <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
-
- <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
- <%= javascript_importmap_tags %>
-
- <%= hotwire_livereload_tags if Rails.env.development? %>
- <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
-
-
-
-
-
-
- <%= render "shared/logo" %>
-
-
- <%= content_for?(:header_title) ? yield(:header_title).html_safe : t(".your_account") %>
-
-
- <% if controller_name == "sessions" %>
+ <% if controller_name == "sessions" %>
<%= t(".or") %> <%= link_to t(".sign_up"), new_registration_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %>
- <% elsif controller_name == "registrations" %>
+ <% elsif controller_name == "registrations" %>
<%= t(".or") %> <%= link_to t(".sign_in"), new_session_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %>
- <% end %>
-
-
-
-
- <%= yield %>
-
-
-
-
<%= link_to t(".privacy_policy"), "/privacy", class: "font-medium text-gray-600 hover:text-gray-400 transition" %> • <%= link_to t(".terms_of_service"), "/terms", class: "font-medium text-gray-600 hover:text-gray-400 transition" %>
-
-
+ <% end %>
-
-
+
+
+ <%= yield %>
+
+
+
+
<%= link_to t(".privacy_policy"), "/privacy", class: "font-medium text-gray-600 hover:text-gray-400 transition" %> • <%= link_to t(".terms_of_service"), "/terms", class: "font-medium text-gray-600 hover:text-gray-400 transition" %>
+
+
+<% end %>
+
+<%= render template: "layouts/application" %>
diff --git a/app/views/layouts/imports.html.erb b/app/views/layouts/imports.html.erb
new file mode 100644
index 00000000..e44ffb25
--- /dev/null
+++ b/app/views/layouts/imports.html.erb
@@ -0,0 +1,34 @@
+<%= content_for :content do %>
+
+ <%= link_to root_path do %>
+ <%= image_tag "logo.svg", alt: "Maybe", class: "h-[22px]" %>
+ <% end %>
+
+ <%= link_to content_for(:return_to_path) do %>
+ <%= lucide_icon("x", class: "text-gray-500 w-5 h-5") %>
+ <% end %>
+
+
+ <%= yield %>
+<% end %>
+
+<%= render template: "layouts/application" %>
diff --git a/app/views/layouts/with_sidebar.html.erb b/app/views/layouts/with_sidebar.html.erb
new file mode 100644
index 00000000..7950d53a
--- /dev/null
+++ b/app/views/layouts/with_sidebar.html.erb
@@ -0,0 +1,18 @@
+<%= content_for :content do %>
+
+
+ <% if content_for?(:sidebar) %>
+ <%= yield :sidebar %>
+ <% else %>
+ <%= render "layouts/sidebar" %>
+ <% end %>
+
+
+ <%= yield %>
+
+
+
+ <%= render "shared/upgrade_notification" %>
+<% end %>
+
+<%= render template: "layouts/application" %>
diff --git a/app/views/pages/changelog.html.erb b/app/views/pages/changelog.html.erb
index 700b358e..f44eef8c 100644
--- a/app/views/pages/changelog.html.erb
+++ b/app/views/pages/changelog.html.erb
@@ -9,7 +9,7 @@
- <%= previous_setting("Rules", transaction_rules_path) %>
+ <%= previous_setting("Imports", imports_path) %>
<%= next_setting("Feedback", feedback_path) %>
diff --git a/app/views/settings/_nav.html.erb b/app/views/settings/_nav.html.erb
index 838684f7..2741bc87 100644
--- a/app/views/settings/_nav.html.erb
+++ b/app/views/settings/_nav.html.erb
@@ -55,6 +55,9 @@
<%= sidebar_link_to t(".rules_label"), transaction_rules_path, icon: "list-checks" %>
+
+ <%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %>
+
diff --git a/app/views/shared/_app_version.html.erb b/app/views/shared/_app_version.html.erb
new file mode 100644
index 00000000..0632359e
--- /dev/null
+++ b/app/views/shared/_app_version.html.erb
@@ -0,0 +1,6 @@
+
+
Version: <%= Maybe.version.to_release_tag %>
+ <%= link_to settings_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
+ <%= lucide_icon("settings", class: "w-4 h-4 text-gray-500 shrink-0") %>
+ <% end %>
+
diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb
index 5184296e..d7151e73 100644
--- a/app/views/transactions/index.html.erb
+++ b/app/views/transactions/index.html.erb
@@ -6,16 +6,28 @@
<%= contextual_menu do %>
<%= link_to transaction_categories_path,
- class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
+ class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
<%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %>
<%= t(".edit_categories") %>
<% end %>
+
+ <%= link_to imports_path,
+ class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
+ <%= lucide_icon "hard-drive-upload", class: "w-5 h-5 text-gray-500" %>
+ <%= t(".edit_imports") %>
+ <% end %>
+
+ <% end %>
+
+ <%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-50 border border-gray-200 flex items-center gap-1 justify-center px-3 py-2", data: { turbo_frame: "modal" } do %>
+ <%= lucide_icon("download", class: "text-gray-500 w-4 h-4") %>
+ <%= t(".import") %>
<% end %>
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
- New transaction
+ New transaction
<% end %>
diff --git a/app/views/transactions/rules/index.html.erb b/app/views/transactions/rules/index.html.erb
index 7fce52ab..bb0bf5f6 100644
--- a/app/views/transactions/rules/index.html.erb
+++ b/app/views/transactions/rules/index.html.erb
@@ -10,6 +10,6 @@
<%= previous_setting("Merchants", transaction_merchants_path) %>
- <%= next_setting("What's New", changelog_path) %>
+ <%= next_setting("Imports", imports_path) %>
diff --git a/config/environments/test.rb b/config/environments/test.rb
index e7e507b7..501daafe 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -62,4 +62,6 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true
+
+ config.autoload_paths += %w[ test/support ]
end
diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml
new file mode 100644
index 00000000..8a089ca5
--- /dev/null
+++ b/config/locales/views/imports/en.yml
@@ -0,0 +1,95 @@
+---
+en:
+ imports:
+ clean:
+ clean_and_edit: Clean and edit your data
+ clean_description: Edit your transactions in the table below. Click on any cell
+ to change the date, name, category, or amount.
+ clean_import: Clean import
+ invalid_csv: Please load a CSV first
+ configure:
+ configure_description: Select the columns that match the necessary data fields,
+ so that the columns in your CSV can be correctly mapped with our format.
+ configure_subtitle: Setup your CSV file
+ configure_title: Configure import
+ confirm_accept: Change mappings
+ confirm_body: Changing your mappings may erase any edits you have made to the
+ CSV so far.
+ confirm_title: Are you sure?
+ invalid_csv: Please load a CSV first
+ next: Next
+ confirm:
+ confirm_description: Preview your transactions below and check to see if there
+ are any changes that are required.
+ confirm_subtitle: Confirm your transactions
+ confirm_title: Confirm import
+ invalid_data: You have invalid data, please fix before continuing
+ create:
+ import_created: Import created
+ destroy:
+ import_destroyed: Import destroyed
+ edit:
+ description_text: Importing transactions can only be done for one account at
+ a time. You will need to go through this process again for other accounts.
+ edit_title: Edit import
+ header_text: Select the account your transactions will belong to
+ empty:
+ message: No imports to show
+ new: New Import
+ form:
+ account: Account
+ next: Next
+ select_account: Select account
+ import:
+ complete: Complete
+ completed_on: Completed on %{datetime}
+ delete: Delete
+ edit: Edit
+ failed: Failed
+ in_progress: In progress
+ label: 'Import for: %{account}'
+ started_on: Started on %{datetime}
+ uploading: Processing rows
+ index:
+ imports: Imports
+ new: New import
+ title: Imports
+ load:
+ confirm_accept: Yep, start over!
+ confirm_body: This will reset your import. Any changes you have made to the
+ CSV will be erased.
+ confirm_title: Are you sure?
+ description: Create a spreadsheet or upload an exported CSV from your financial
+ institution.
+ instructions: Your CSV should have the following columns and formats for the
+ best import experience.
+ load_title: Load import
+ next: Next
+ requirement1: Dates must be in ISO 8601 format (YYYY-MM-DD)
+ requirement2: Negative transaction is an "outflow" (expense), positive is an
+ "inflow" (income)
+ subtitle: Import your transactions
+ load_csv:
+ import_loaded: Import CSV loaded
+ new:
+ description_text: Importing transactions can only be done for one account at
+ a time. You will need to go through this process again for other accounts.
+ header_text: Select the account your transactions will belong to
+ publish:
+ import_published: Import has started in the background
+ invalid_data: Your import is invalid
+ type_selector:
+ description: You can manually import transactions from CSVs or from other financial
+ apps like Mint, Empower (formerly Personal Capital) or Apple Card.
+ import_from_apple: Import from Apple Card
+ import_from_csv: New import from CSV
+ import_from_empower: Import from Empower
+ import_from_mint: Import from Mint
+ import_transactions: Import transactions
+ resume_latest_import: Resume latest import
+ soon: Soon
+ sources: Sources
+ update:
+ import_updated: Import updated
+ update_mappings:
+ column_mappings_saved: Column mappings saved
diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml
index a39160fa..21071ce8 100644
--- a/config/locales/views/settings/en.yml
+++ b/config/locales/views/settings/en.yml
@@ -55,6 +55,7 @@ en:
categories_label: Categories
feedback_label: Feedback
general_section_title: General
+ imports_label: Imports
invite_label: Invite friends
merchants_label: Merchants
notifications_label: Notifications
diff --git a/config/locales/views/transaction/en.yml b/config/locales/views/transaction/en.yml
index 12e2e841..ab928126 100644
--- a/config/locales/views/transaction/en.yml
+++ b/config/locales/views/transaction/en.yml
@@ -57,6 +57,8 @@ en:
transfer: Transfer
index:
edit_categories: Edit categories
+ edit_imports: Edit imports
+ import: Import
merchants:
create:
success: New merchant created successfully
diff --git a/config/routes.rb b/config/routes.rb
index 3f63169c..909870af 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -21,8 +21,24 @@ Rails.application.routes.draw do
resource :security, only: %i[show update]
end
+ resources :imports, except: :show do
+ member do
+ get "load"
+ patch "load" => "imports#load_csv"
+
+ get "configure"
+ patch "configure" => "imports#update_mappings"
+
+ get "clean"
+ patch "clean" => "imports#update_csv"
+
+ get "confirm"
+ patch "confirm" => "imports#publish"
+ end
+ end
+
resources :transactions do
- match "search" => "transactions#search", on: :collection, via: [ :get, :post ], as: :search
+ match "search" => "transactions#search", on: :collection, via: %i[ get post ], as: :search
collection do
scope module: :transactions do
diff --git a/db/migrate/20240502205006_create_imports.rb b/db/migrate/20240502205006_create_imports.rb
new file mode 100644
index 00000000..79c67284
--- /dev/null
+++ b/db/migrate/20240502205006_create_imports.rb
@@ -0,0 +1,15 @@
+class CreateImports < ActiveRecord::Migration[7.2]
+ def change
+ create_enum :import_status, %w[pending importing complete failed]
+
+ create_table :imports, id: :uuid do |t|
+ t.references :account, null: false, foreign_key: true, type: :uuid
+ t.jsonb :column_mappings
+ t.enum :status, enum_type: :import_status, default: "pending"
+ t.string :raw_csv_str
+ t.string :normalized_csv_str
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c3e2f2da..75a4c530 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
+ActiveRecord::Schema[7.2].define(version: 2024_05_02_205006) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -18,6 +18,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
# Custom types defined in this database.
# Note that some types may not work with other database engines. Be careful if changing database.
create_enum "account_status", ["ok", "syncing", "error"]
+ create_enum "import_status", ["pending", "importing", "complete", "failed"]
create_enum "user_role", ["admin", "member"]
create_table "account_balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -222,6 +223,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
end
+ create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "account_id", null: false
+ t.jsonb "column_mappings"
+ t.enum "status", default: "pending", enum_type: "import_status"
+ t.string "raw_csv_str"
+ t.string "normalized_csv_str"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_imports_on_account_id"
+ end
+
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "token", null: false
t.datetime "created_at", null: false
@@ -305,6 +317,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
add_foreign_key "accounts", "families"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "imports", "accounts"
add_foreign_key "transaction_categories", "families"
add_foreign_key "transaction_merchants", "families"
add_foreign_key "transactions", "accounts", on_delete: :cascade
diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb
index 145d7302..4713e4b6 100644
--- a/test/application_system_test_case.rb
+++ b/test/application_system_test_case.rb
@@ -1,21 +1,27 @@
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
- driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
+ driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ]
private
- def sign_in(user)
- visit new_session_path
- within "form" do
- fill_in "Email", with: user.email
- fill_in "Password", with: "password"
- click_button "Log in"
- end
- end
+ def sign_in(user)
+ visit new_session_path
+ within "form" do
+ fill_in "Email", with: user.email
+ fill_in "Password", with: "password"
+ click_on "Log in"
+ end
- def sign_out
- find("#user-menu").click
- click_button "Logout"
- end
+ # Trigger Capybara's wait mechanism to avoid timing issues with logins
+ find("h1", text: "Dashboard")
+ end
+
+ def sign_out
+ find("#user-menu").click
+ click_button "Logout"
+
+ # Trigger Capybara's wait mechanism to avoid timing issues with logout
+ find("h2", text: "Sign in to your account")
+ end
end
diff --git a/test/controllers/imports_controller_test.rb b/test/controllers/imports_controller_test.rb
new file mode 100644
index 00000000..3a71aa07
--- /dev/null
+++ b/test/controllers/imports_controller_test.rb
@@ -0,0 +1,141 @@
+require "test_helper"
+
+class ImportsControllerTest < ActionDispatch::IntegrationTest
+ include ImportTestHelper
+
+ setup do
+ sign_in @user = users(:family_admin)
+ @empty_import = imports(:empty_import)
+ @loaded_import = imports(:loaded_import)
+ @completed_import = imports(:completed_import)
+ end
+
+ test "should get index" do
+ get imports_url
+ assert_response :success
+
+ @user.family.imports.ordered.each do |import|
+ assert_select "#" + dom_id(import), count: 1
+ end
+ end
+
+ test "should get new" do
+ get new_import_url
+ assert_response :success
+ end
+
+ test "should create import" do
+ assert_difference("Import.count") do
+ post imports_url, params: { import: { account_id: @user.family.accounts.first.id } }
+ end
+
+ assert_redirected_to load_import_path(Import.ordered.first)
+ end
+
+ test "should get edit" do
+ get edit_import_url(@empty_import)
+ assert_response :success
+ end
+
+ test "should update import" do
+ patch import_url(@empty_import), params: { import: { account_id: @empty_import.account_id } }
+ assert_redirected_to load_import_path(@empty_import)
+ end
+
+ test "should destroy import" do
+ assert_difference("Import.count", -1) do
+ delete import_url(@empty_import)
+ end
+
+ assert_redirected_to imports_url
+ end
+
+ test "should get load" do
+ get load_import_url(@empty_import)
+ assert_response :success
+ end
+
+ test "should save raw CSV if valid" do
+ patch load_import_url(@empty_import), params: { import: { raw_csv_str: valid_csv_str } }
+
+ assert_redirected_to configure_import_path(@empty_import)
+ assert_equal "Import CSV loaded", flash[:notice]
+ end
+
+ test "should flash error message if invalid CSV input" do
+ patch load_import_url(@empty_import), params: { import: { raw_csv_str: malformed_csv_str } }
+
+ assert_response :unprocessable_entity
+ assert_equal "Raw csv str is not a valid CSV format", flash[:error]
+ end
+
+ test "should get configure" do
+ get configure_import_url(@loaded_import)
+ assert_response :success
+ end
+
+ test "should redirect back to load step with an alert message if not loaded" do
+ get configure_import_url(@empty_import)
+ assert_equal "Please load a CSV first", flash[:alert]
+ assert_redirected_to load_import_path(@empty_import)
+ end
+
+ test "should update mappings" do
+ patch configure_import_url(@loaded_import), params: {
+ import: {
+ column_mappings: {
+ date: "date",
+ name: "name",
+ category: "category",
+ amount: "amount"
+ }
+ }
+ }
+
+ assert_redirected_to clean_import_path(@loaded_import)
+ assert_equal "Column mappings saved", flash[:notice]
+ end
+
+ test "can update a cell" do
+ assert_equal @loaded_import.csv.table[0][1], "Starbucks drink"
+
+ patch clean_import_url(@loaded_import), params: {
+ import: {
+ csv_update: {
+ row_idx: 0,
+ col_idx: 1,
+ value: "new_merchant"
+ }
+ }
+ }
+
+ assert_response :success
+
+ @loaded_import.reload
+ assert_equal "new_merchant", @loaded_import.csv.table[0][1]
+ end
+
+ test "should get clean" do
+ get clean_import_url(@loaded_import)
+ assert_response :success
+ end
+
+ test "should get confirm if all values are valid" do
+ get confirm_import_url(@loaded_import)
+ assert_response :success
+ end
+
+ test "should redirect back to clean if data is invalid" do
+ @empty_import.update! raw_csv_str: valid_csv_with_invalid_values
+
+ get confirm_import_url(@empty_import)
+ assert_equal "You have invalid data, please fix before continuing", flash[:alert]
+ assert_redirected_to clean_import_path(@empty_import)
+ end
+
+ test "should confirm import" do
+ patch confirm_import_url(@loaded_import)
+ assert_redirected_to imports_path
+ assert_equal "Import has started in the background", flash[:notice]
+ end
+end
diff --git a/test/fixtures/imports.yml b/test/fixtures/imports.yml
new file mode 100644
index 00000000..b0c30b4a
--- /dev/null
+++ b/test/fixtures/imports.yml
@@ -0,0 +1,32 @@
+empty_import:
+ account: checking
+ created_at: <%= 1.minute.ago %>
+
+loaded_import:
+ account: checking
+ raw_csv_str: |
+ date,name,category,amount
+ 2024-01-01,Starbucks drink,Food,20
+ 2024-01-02,Amazon stuff,Shopping,200
+ normalized_csv_str: |
+ date,name,category,amount
+ 2024-01-01,Starbucks drink,Food,20
+ 2024-01-02,Amazon stuff,Shopping,200
+ created_at: <%= 2.days.ago %>
+
+completed_import:
+ account: checking
+ column_mappings:
+ date: date
+ name: name
+ category: category
+ amount: amount
+ raw_csv_str: |
+ date,name,category,amount
+ 2024-01-01,Starbucks drink,Food,20
+ normalized_csv_str: |
+ date,name,category,amount
+ 2024-01-01,Starbucks drink,Food,20
+ created_at: <%= 2.days.ago %>
+
+
diff --git a/test/integration/.keep b/test/integration/.keep
deleted file mode 100644
index e69de29b..00000000
diff --git a/test/jobs/import_job_test.rb b/test/jobs/import_job_test.rb
new file mode 100644
index 00000000..14c21339
--- /dev/null
+++ b/test/jobs/import_job_test.rb
@@ -0,0 +1,18 @@
+require "test_helper"
+
+class ImportJobTest < ActiveJob::TestCase
+ include ImportTestHelper
+
+ test "import is published" do
+ import = imports(:empty_import)
+ import.update! raw_csv_str: valid_csv_str
+
+ assert import.pending?
+
+ perform_enqueued_jobs do
+ ImportJob.perform_later(import)
+ end
+
+ assert import.reload.complete?
+ end
+end
diff --git a/test/models/import/csv_test.rb b/test/models/import/csv_test.rb
new file mode 100644
index 00000000..1bef280d
--- /dev/null
+++ b/test/models/import/csv_test.rb
@@ -0,0 +1,87 @@
+require "test_helper"
+
+class Import::CsvTest < ActiveSupport::TestCase
+ include ImportTestHelper
+
+ setup do
+ @csv = Import::Csv.new(valid_csv_str)
+ end
+
+ test "cannot define validator for non-existent header" do
+ assert_raises do
+ @csv.define_validator "invalid", method(:validate_iso_date)
+ end
+ end
+
+ test "csv with no validators is valid" do
+ assert @csv.cell_valid?(0, 0)
+ assert @csv.valid?
+ end
+
+ test "valid csv values" do
+ @csv.define_validator "date", method(:validate_iso_date)
+
+ assert_equal "2024-01-01", @csv.table[0][0]
+ assert @csv.cell_valid?(0, 0)
+ assert @csv.valid?
+ end
+
+ test "invalid csv values" do
+ invalid_csv = Import::Csv.new valid_csv_with_invalid_values
+
+ invalid_csv.define_validator "date", method(:validate_iso_date)
+
+ assert_equal "invalid_date", invalid_csv.table[0][0]
+ assert_not invalid_csv.cell_valid?(0, 0)
+ assert_not invalid_csv.valid?
+ end
+
+ test "updating a cell returns a copy of the original csv" do
+ original_date = "2024-01-01"
+ new_date = "2024-01-01"
+
+ assert_equal original_date, @csv.table[0][0]
+ updated = @csv.update_cell(0, 0, new_date)
+
+ assert_equal original_date, @csv.table[0][0]
+ assert_equal new_date, updated[0][0]
+ end
+
+ test "can create CSV with expected columns and field mappings with validators" do
+ date_field = Import::Field.new \
+ key: "date",
+ label: "Date",
+ validator: method(:validate_iso_date)
+
+ name_field = Import::Field.new \
+ key: "name",
+ label: "Name"
+
+ fields = [ date_field, name_field ]
+
+ raw_csv_str = <<-ROWS
+ date,Custom Field Header,extra_field
+ invalid_date_value,Starbucks drink,Food
+ 2024-01-02,Amazon stuff,Shopping
+ ROWS
+
+ mappings = {
+ "name" => "Custom Field Header"
+ }
+
+ csv = Import::Csv.create_with_field_mappings(raw_csv_str, fields, mappings)
+
+ assert_equal %w[ date name ], csv.table.headers
+ assert_equal 2, csv.table.size
+ assert_equal "Amazon stuff", csv.table[1][1]
+ end
+
+ private
+
+ def validate_iso_date(value)
+ Date.iso8601(value)
+ true
+ rescue
+ false
+ end
+end
diff --git a/test/models/import/field_test.rb b/test/models/import/field_test.rb
new file mode 100644
index 00000000..1550a449
--- /dev/null
+++ b/test/models/import/field_test.rb
@@ -0,0 +1,28 @@
+require "test_helper"
+
+class Import::FieldTest < ActiveSupport::TestCase
+ test "key is always a string" do
+ field1 = Import::Field.new label: "Test", key: "test"
+ field2 = Import::Field.new label: "Test2", key: :test2
+
+ assert_equal "test", field1.key
+ assert_equal "test2", field2.key
+ end
+
+ test "can set and override a validator for a field" do
+ field = Import::Field.new \
+ label: "Test",
+ key: "Test",
+ validator: ->(val) { val == 42 }
+
+ assert field.validate(42)
+ assert_not field.validate(41)
+
+ field.define_validator do |value|
+ value == 100
+ end
+
+ assert field.validate(100)
+ assert_not field.validate(42)
+ end
+end
diff --git a/test/models/import_test.rb b/test/models/import_test.rb
new file mode 100644
index 00000000..4114cf38
--- /dev/null
+++ b/test/models/import_test.rb
@@ -0,0 +1,61 @@
+require "test_helper"
+
+class ImportTest < ActiveSupport::TestCase
+ include ImportTestHelper, ActiveJob::TestHelper
+
+ setup do
+ @empty_import = imports(:empty_import)
+ @loaded_import = imports(:loaded_import)
+ end
+
+ test "raw csv input must conform to csv spec" do
+ @empty_import.raw_csv_str = malformed_csv_str
+ assert_not @empty_import.valid?
+
+ @empty_import.raw_csv_str = valid_csv_str
+ assert @empty_import.valid?
+ end
+
+ test "can update csv value without affecting raw input" do
+ assert_equal "Starbucks drink", @loaded_import.csv.table[0][1]
+
+ prior_raw_csv_str_value = @loaded_import.raw_csv_str
+ prior_normalized_csv_str_value = @loaded_import.normalized_csv_str
+
+ @loaded_import.update_csv! \
+ row_idx: 0,
+ col_idx: 1,
+ value: "new_category"
+
+ assert_equal "new_category", @loaded_import.csv.table[0][1]
+ assert_equal prior_raw_csv_str_value, @loaded_import.raw_csv_str
+ assert_not_equal prior_normalized_csv_str_value, @loaded_import.normalized_csv_str
+ end
+
+ test "publishes later" do
+ assert_enqueued_with(job: ImportJob) do
+ @loaded_import.publish_later
+ end
+ end
+
+ test "publishes a valid import" do
+ assert_difference "Transaction.count", 2 do
+ @loaded_import.publish
+ end
+
+ @loaded_import.reload
+
+ assert @loaded_import.complete?
+ end
+
+ test "failed publish results in error status" do
+ @empty_import.update! raw_csv_str: valid_csv_with_invalid_values
+
+ assert_difference "Transaction.count", 0 do
+ @empty_import.publish
+ end
+
+ @empty_import.reload
+ assert @empty_import.failed?
+ end
+end
diff --git a/test/support/import_test_helper.rb b/test/support/import_test_helper.rb
new file mode 100644
index 00000000..ee8eb3c2
--- /dev/null
+++ b/test/support/import_test_helper.rb
@@ -0,0 +1,24 @@
+module ImportTestHelper
+ def valid_csv_str
+ <<-ROWS
+ date,name,category,amount
+ 2024-01-01,Starbucks drink,Food,20
+ 2024-01-02,Amazon stuff,Shopping,200
+ ROWS
+ end
+
+ def valid_csv_with_invalid_values
+ <<-ROWS
+ date,name,category,amount
+ invalid_date,Starbucks drink,Food,invalid_amount
+ ROWS
+ end
+
+ def malformed_csv_str
+ <<-ROWS
+ name,age
+ "John Doe,23
+ "Jane Doe",25
+ ROWS
+ end
+end
diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb
new file mode 100644
index 00000000..b9ee4f8c
--- /dev/null
+++ b/test/system/imports_test.rb
@@ -0,0 +1,113 @@
+require "application_system_test_case"
+
+class ImportsTest < ApplicationSystemTestCase
+ include ImportTestHelper
+
+ setup do
+ sign_in @user = users(:family_admin)
+
+ @imports = @user.family.imports.ordered.to_a
+ end
+
+ test "can trigger new import from settings" do
+ trigger_import_from_settings
+ verify_import_modal
+ end
+
+ test "can resume existing import from settings" do
+ visit imports_url
+
+ within "#" + dom_id(@imports.first) do
+ click_button
+ click_link "Edit"
+ end
+
+ assert_current_path edit_import_path(@imports.first)
+ end
+
+ test "can resume latest import" do
+ trigger_import_from_transactions
+ verify_import_modal
+
+ click_link "Resume latest import"
+
+ assert_current_path edit_import_path(@imports.first)
+ end
+
+ test "can perform basic CSV import" do
+ trigger_import_from_settings
+ verify_import_modal
+
+ within "#modal" do
+ click_link "New import from CSV"
+ end
+
+ # 1) Create import step
+ assert_selector "h1", text: "New import"
+
+ within "form" do
+ select "Checking Account", from: "import_account_id"
+ end
+
+ click_button "Next"
+
+ # 2) Load Step
+ assert_selector "h1", text: "Load import"
+
+ within "form" do
+ fill_in "import_raw_csv_str", with: <<-ROWS
+ date,Custom Name Column,category,amount
+ invalid_date,Starbucks drink,Food,-20.50
+ 2024-01-01,Amazon purchase,Shopping,-89.50
+ ROWS
+ end
+
+ click_button "Next"
+
+ # 3) Configure step
+ assert_selector "h1", text: "Configure import"
+
+ within "form" do
+ select "Custom Name Column", from: "import_column_mappings_name"
+ end
+
+ click_button "Next"
+
+ # 4) Clean step
+ assert_selector "h1", text: "Clean import"
+
+ # We have an invalid value, so user cannot click next yet
+ assert_no_text "Next"
+
+ # Replace invalid date with valid date
+ fill_in "cell-0-0", with: "2024-01-02"
+
+ # Trigger blur event so value saves
+ find("body").click
+
+ click_link "Next"
+
+ # 5) Confirm step
+ assert_selector "h1", text: "Confirm import"
+ click_button "Import 2 transactions"
+ assert_selector "h1", text: "Imports"
+ end
+
+ private
+
+ def trigger_import_from_settings
+ visit imports_url
+ click_link "New import"
+ end
+
+ def trigger_import_from_transactions
+ visit transactions_url
+ click_link "Import"
+ end
+
+ def verify_import_modal
+ within "#modal" do
+ assert_text "Import transactions"
+ end
+ end
+end
diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb
index add35d39..29517588 100644
--- a/test/system/settings_test.rb
+++ b/test/system/settings_test.rb
@@ -14,6 +14,7 @@ class SettingsTest < ApplicationSystemTestCase
[ "Categories", "Categories", transaction_categories_path ],
[ "Merchants", "Merchants", transaction_merchants_path ],
[ "Rules", "Rules", transaction_rules_path ],
+ [ "Imports", "Imports", imports_path ],
[ "What's New", "What's New", changelog_path ],
[ "Feedback", "Feedback", feedback_path ],
[ "Invite friends", "Invite friends", invites_path ]
@@ -27,6 +28,7 @@ class SettingsTest < ApplicationSystemTestCase
end
private
+
def open_settings_from_sidebar
find("#user-menu").click
click_link "Settings"