diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 110f31d0..33711536 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -35,7 +35,7 @@ } .form-field__submit { - @apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700; + @apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700; } input:checked+label+.toggle-switch-dot { @@ -100,11 +100,15 @@ } .btn { - @apply px-3 py-2 rounded-lg text-sm font-medium; + @apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer; } .btn--primary { - @apply bg-gray-900 text-white hover:bg-gray-700; + @apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400; + } + + .btn--secondary { + @apply bg-gray-50 hover:bg-gray-100 text-gray-900; } .btn--outline { diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 11b55ac6..4ecc9b27 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -23,7 +23,10 @@ class AccountsController < ApplicationController end def new - @account = Account.new(accountable: Accountable.from_type(params[:type])&.new) + @account = Account.new( + accountable: Accountable.from_type(params[:type])&.new, + currency: Current.family.currency + ) @account.accountable.address = Address.new if @account.accountable.is_a?(Property) diff --git a/app/controllers/import/cleans_controller.rb b/app/controllers/import/cleans_controller.rb new file mode 100644 index 00000000..a8bda133 --- /dev/null +++ b/app/controllers/import/cleans_controller.rb @@ -0,0 +1,22 @@ +class Import::CleansController < ApplicationController + layout "imports" + + before_action :set_import + + def show + redirect_to import_configuration_path(@import), alert: "Please configure your import before proceeding." unless @import.configured? + + rows = @import.rows.ordered + + if params[:view] == "errors" + rows = rows.reject { |row| row.valid? } + end + + @pagy, @rows = pagy_array(rows, limit: params[:per_page] || "10") + end + + private + def set_import + @import = Current.family.imports.find(params[:import_id]) + end +end diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb new file mode 100644 index 00000000..b399f683 --- /dev/null +++ b/app/controllers/import/configurations_controller.rb @@ -0,0 +1,25 @@ +class Import::ConfigurationsController < ApplicationController + layout "imports" + + before_action :set_import + + def show + end + + def update + @import.update!(import_params) + @import.generate_rows_from_csv + @import.reload.sync_mappings + + redirect_to import_clean_path(@import), notice: "Import configured successfully." + end + + private + def set_import + @import = Current.family.imports.find(params[:import_id]) + end + + def import_params + params.require(:import).permit(:date_col_label, :date_format, :name_col_label, :category_col_label, :tags_col_label, :amount_col_label, :signage_convention, :account_col_label, :notes_col_label, :entity_type_col_label) + end +end diff --git a/app/controllers/import/confirms_controller.rb b/app/controllers/import/confirms_controller.rb new file mode 100644 index 00000000..0c1a8872 --- /dev/null +++ b/app/controllers/import/confirms_controller.rb @@ -0,0 +1,14 @@ +class Import::ConfirmsController < ApplicationController + layout "imports" + + before_action :set_import + + def show + redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned? + end + + private + def set_import + @import = Current.family.imports.find(params[:import_id]) + end +end diff --git a/app/controllers/import/mappings_controller.rb b/app/controllers/import/mappings_controller.rb new file mode 100644 index 00000000..098c4010 --- /dev/null +++ b/app/controllers/import/mappings_controller.rb @@ -0,0 +1,43 @@ +class Import::MappingsController < ApplicationController + before_action :set_import + + def update + mapping = @import.mappings.find(params[:id]) + + mapping.update! \ + create_when_empty: create_when_empty, + mappable: mappable, + value: mapping_params[:value] + + redirect_back_or_to import_confirm_path(@import) + end + + private + def mapping_params + params.require(:import_mapping).permit(:type, :key, :mappable_id, :mappable_type, :value) + end + + def set_import + @import = Current.family.imports.find(params[:import_id]) + end + + def mappable + return nil unless mappable_class.present? + + @mappable ||= mappable_class.find_by(id: mapping_params[:mappable_id], family: Current.family) + end + + def create_when_empty + return false unless mapping_class.present? + + mapping_params[:mappable_id] == mapping_class::CREATE_NEW_KEY + end + + def mappable_class + mapping_params[:mappable_type]&.constantize + end + + def mapping_class + mapping_params[:type]&.constantize + end +end diff --git a/app/controllers/import/rows_controller.rb b/app/controllers/import/rows_controller.rb new file mode 100644 index 00000000..b5b9092c --- /dev/null +++ b/app/controllers/import/rows_controller.rb @@ -0,0 +1,24 @@ +class Import::RowsController < ApplicationController + before_action :set_import_row + + def update + @row.assign_attributes(row_params) + @row.save!(validate: false) + @row.sync_mappings + + redirect_to import_row_path(@row.import, @row) + end + + def show + end + + private + def row_params + params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes) + end + + def set_import_row + @import = Current.family.imports.find(params[:import_id]) + @row = @import.rows.find(params[:id]) + end +end diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb new file mode 100644 index 00000000..e89988ac --- /dev/null +++ b/app/controllers/import/uploads_controller.rb @@ -0,0 +1,47 @@ +class Import::UploadsController < ApplicationController + layout "imports" + + before_action :set_import + + def show + end + + def update + if csv_valid?(csv_str) + @import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep]) + @import.save!(validate: false) + + redirect_to import_configuration_path(@import), 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 + end + end + + private + def set_import + @import = Current.family.imports.find(params[:import_id]) + end + + def csv_str + @csv_str ||= upload_params[:csv_file]&.read || upload_params[:raw_file_str] + end + + def csv_valid?(str) + require "csv" + + begin + csv = CSV.parse(str || "", headers: true) + return false if csv.headers.empty? + return false if csv.count == 0 + true + rescue CSV::MalformedCSVError + false + end + end + + def upload_params + params.require(:import).permit(:raw_file_str, :csv_file, :col_sep) + end +end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index c2268eec..ef374f6c 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -1,118 +1,44 @@ -require "ostruct" - class ImportsController < ApplicationController - before_action :set_import, except: %i[index new create] + before_action :set_import, only: %i[show publish destroy] + + def publish + @import.publish_later + + redirect_to import_path(@import), notice: "Your import has started in the background." + end def index @imports = Current.family.imports - render layout: "with_sidebar" + + render layout: with_sidebar end def new - account = Current.family.accounts.find_by(id: params[:account_id]) - @import = Import.new account: account - end - - def edit - end - - def update - account = Current.family.accounts.find(params[:import][:account_id]) - @import.update! account: account, col_sep: params[:import][:col_sep] - - redirect_to load_import_path(@import), notice: t(".import_updated") + @pending_import = Current.family.imports.ordered.pending.first end def create - account = Current.family.accounts.find(params[:import][:account_id]) - @import = Import.create! account: account, col_sep: params[:import][:col_sep] + import = Current.family.imports.create! import_params - redirect_to load_import_path(@import), notice: t(".import_created") + redirect_to import_upload_path(import) + end + + def show + redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." unless @import.publishable? end def destroy - @import.destroy! - redirect_to imports_url, notice: t(".import_destroyed"), status: :see_other - end + @import.destroy - def load - end - - def upload_csv - begin - @import.raw_file_str = import_params[:raw_file_str].read - rescue NoMethodError - flash.now[:alert] = "Please select a file to upload" - render :load, status: :unprocessable_entity and return - end - if @import.save - redirect_to configure_import_path(@import), notice: t(".import_loaded") - else - flash.now[:alert] = @import.errors.full_messages.to_sentence - render :load, status: :unprocessable_entity - end - end - - def load_csv - if @import.update(import_params) - redirect_to configure_import_path(@import), notice: t(".import_loaded") - else - flash.now[:alert] = @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 + redirect_to imports_path, notice: "Your import has been deleted." end private - def set_import @import = Current.family.imports.find(params[:id]) end - def import_params(permitted_mappings = nil) - params.require(:import).permit(:raw_file_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ]) + def import_params + params.require(:import).permit(:type) end end diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index 0b924d36..a202a697 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -1,19 +1,63 @@ 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 - "" + def mapping_label(mapping_class) + { + "Import::AccountTypeMapping" => "Account Type", + "Import::AccountMapping" => "Account", + "Import::CategoryMapping" => "Category", + "Import::TagMapping" => "Tag" + }.fetch(mapping_class.name) 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 } - ] + def import_col_label(key) + { + date: "Date", + amount: "Amount", + name: "Name", + currency: "Currency", + category: "Category", + tags: "Tags", + account: "Account", + notes: "Notes", + qty: "Quantity", + ticker: "Ticker", + price: "Price", + entity_type: "Type" + }[key] end + + def dry_run_resource(key) + map = { + transactions: DryRunResource.new(label: "Transactions", icon: "credit-card", text_class: "text-cyan-500", bg_class: "bg-cyan-500/5"), + 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") + } + + map[key] + end + + def permitted_import_configuration_path(import) + if permitted_import_types.include?(import.type.underscore) + "import/configurations/#{import.type.underscore}" + else + raise "Unknown import type: #{import.type}" + end + end + + def cell_class(row, field) + base = "text-sm focus:ring-gray-900 focus:border-gray-900 w-full max-w-full disabled:text-gray-400" + + row.valid? # populate errors + + border = row.errors.key?(field) ? "border-red-500" : "border-transparent" + + [ base, border ].join(" ") + end + + private + def permitted_import_types + %w[transaction_import trade_import account_import mint_import] + end + + DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true) end diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb index 6da18c39..e9e27538 100644 --- a/app/helpers/styled_form_builder.rb +++ b/app/helpers/styled_form_builder.rb @@ -49,7 +49,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder end def submit(value = nil, options = {}) - merged_options = { class: "form-field__submit" }.merge(options) + merged_options = { class: "btn btn--primary w-full" }.merge(options) value, options = nil, value if value.is_a?(Hash) super(value, merged_options) end diff --git a/app/javascript/controllers/import_upload_controller.js b/app/javascript/controllers/import_upload_controller.js deleted file mode 100644 index 224b7cdb..00000000 --- a/app/javascript/controllers/import_upload_controller.js +++ /dev/null @@ -1,98 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["input", "preview", "submit", "filename", "filesize"] - static values = { - acceptedTypes: Array, // ["text/csv", "application/csv", ".csv"] - acceptedExtension: String, // "csv" - unacceptableTypeLabel: String, // "Only CSV files are allowed." - }; - - connect() { - this.submitTarget.disabled = true - } - - addFile(event) { - const file = event.target.files[0] - this._fileAdded(file) - } - - dragover(event) { - event.preventDefault() - event.stopPropagation() - event.currentTarget.classList.add("bg-gray-100") - } - - dragleave(event) { - event.preventDefault() - event.stopPropagation() - event.currentTarget.classList.remove("bg-gray-100") - } - - drop(event) { - event.preventDefault() - event.stopPropagation() - event.currentTarget.classList.remove("bg-gray-100") - - const file = event.dataTransfer.files[0] - if (file && this._formatAcceptable(file)) { - this._setFileInput(file); - this._fileAdded(file) - } else { - this.previewTarget.classList.add("text-red-500") - this.previewTarget.textContent = this.unacceptableTypeLabelValue - } - } - - // Private - - _fetchFileSize(size) { - let fileSize = ''; - if (size < 1024 * 1024) { - fileSize = (size / 1024).toFixed(2) + ' KB'; // Convert bytes to KB - } else { - fileSize = (size / (1024 * 1024)).toFixed(2) + ' MB'; // Convert bytes to MB - } - return fileSize; - } - - _fileAdded(file) { - const fileSizeLimit = 5 * 1024 * 1024 // 5MB - - if (file) { - if (file.size > fileSizeLimit) { - this.previewTarget.classList.add("text-red-500") - this.previewTarget.textContent = this.unacceptableTypeLabelValue - return - } - - this.submitTarget.classList.remove([ - "bg-alpha-black-25", - "text-gray", - "cursor-not-allowed", - ]); - this.submitTarget.classList.add( - "bg-gray-900", - "text-white", - "cursor-pointer", - ); - this.submitTarget.disabled = false; - this.previewTarget.innerHTML = document.querySelector("#template-preview").innerHTML; - this.previewTarget.classList.remove("text-red-500") - this.previewTarget.classList.add("text-gray-900") - this.filenameTarget.textContent = file.name; - this.filesizeTarget.textContent = this._fetchFileSize(file.size); - } - } - - _formatAcceptable(file) { - const extension = file.name.split('.').pop().toLowerCase() - return this.acceptedTypesValue.includes(file.type) || extension === this.acceptedExtensionValue - } - - _setFileInput(file) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(file); - this.inputTarget.files = dataTransfer.files; - } -} diff --git a/app/models/account.rb b/app/models/account.rb index d29f5479..cf6ff98b 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -5,14 +5,15 @@ class Account < ApplicationRecord belongs_to :family belongs_to :institution, optional: true + belongs_to :import, optional: true + has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" has_many :entries, dependent: :destroy, class_name: "Account::Entry" has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction" has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation" has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade" has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy - has_many :imports, dependent: :destroy has_many :syncs, dependent: :destroy has_many :issues, as: :issuable, dependent: :destroy diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 9bedbab0..4c38ffbd 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -5,6 +5,7 @@ class Account::Entry < ApplicationRecord belongs_to :account belongs_to :transfer, optional: true + belongs_to :import, optional: true delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy accepts_nested_attributes_for :entryable @@ -12,7 +13,6 @@ class Account::Entry < ApplicationRecord validates :date, :amount, :currency, presence: true validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? } validates :date, comparison: { greater_than: -> { min_supported_date } } - validate :trade_valid?, if: -> { account_trade? } scope :chronological, -> { order(:date, :created_at) } scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) } @@ -219,20 +219,4 @@ class Account::Entry < ApplicationRecord previous: previous_entry&.amount_money, favorable_direction: account.favorable_direction end - - def trade_valid? - if account_trade.sell? - current_qty = account.holding_qty(account_trade.security) - - if current_qty < account_trade.qty.abs - errors.add( - :base, - :invalid_sell_quantity, - sell_qty: account_trade.qty.abs, - ticker: account_trade.security.ticker, - current_qty: current_qty - ) - end - end - end end diff --git a/app/models/account_import.rb b/app/models/account_import.rb new file mode 100644 index 00000000..3987a1ff --- /dev/null +++ b/app/models/account_import.rb @@ -0,0 +1,49 @@ +class AccountImport < Import + def import! + transaction do + rows.each do |row| + mapping = mappings.account_types.find_by(key: row.entity_type) + accountable_class = mapping.value.constantize + + account = family.accounts.build( + name: row.name, + balance: row.amount.to_d, + currency: row.currency, + accountable: accountable_class.new, + import: self + ) + + account.save! + end + end + end + + def mapping_steps + [ Import::AccountTypeMapping ] + end + + def required_column_keys + %i[name amount] + end + + def column_keys + %i[entity_type name amount currency] + end + + def dry_run + { + accounts: rows.count + } + end + + def csv_template + template = <<-CSV + Account type*,Name*,Balance*,Currency + Checking,Main Checking Account,1000.00,USD + Savings,Emergency Fund,5000.00,USD + Credit Card,Rewards Card,-500.00,USD + CSV + + CSV.parse(template, headers: true) + end +end diff --git a/app/models/category.rb b/app/models/category.rb index 516b7631..3744295a 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,5 +1,6 @@ class Category < ApplicationRecord has_many :transactions, dependent: :nullify, class_name: "Account::Transaction" + has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" belongs_to :family validates :name, :color, :family, presence: true diff --git a/app/models/family.rb b/app/models/family.rb index ce875c64..b7ffdbc9 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -3,9 +3,9 @@ class Family < ApplicationRecord has_many :tags, dependent: :destroy has_many :accounts, dependent: :destroy has_many :institutions, dependent: :destroy + has_many :imports, dependent: :destroy has_many :transactions, through: :accounts has_many :entries, through: :accounts - has_many :imports, through: :accounts has_many :categories, dependent: :destroy has_many :merchants, dependent: :destroy has_many :issues, through: :accounts diff --git a/app/models/import.rb b/app/models/import.rb index 2cb1e634..c77ed6c7 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -1,185 +1,137 @@ class Import < ApplicationRecord - belongs_to :account + TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze + SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] - validate :raw_file_must_be_parsable - validates :col_sep, inclusion: { in: Csv::COL_SEP_LIST } - - 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 + belongs_to :family scope :ordered, -> { order(created_at: :desc) } - FALLBACK_TRANSACTION_NAME = "Imported transaction" + enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true + + validates :type, inclusion: { in: TYPES } + validates :col_sep, inclusion: { in: [ ",", ";" ] } + validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS } + + has_many :rows, dependent: :destroy + has_many :mappings, dependent: :destroy + has_many :accounts, dependent: :destroy + has_many :entries, dependent: :destroy, class_name: "Account::Entry" def publish_later + raise "Import is not publishable" unless publishable? + + update! status: :importing + ImportJob.perform_later(self) end - def loaded? + def publish + import! + + family.sync + + update! status: :complete + rescue => error + update! status: :failed, error: error.message + end + + def csv_rows + @csv_rows ||= parsed_csv + end + + def csv_headers + parsed_csv.headers + end + + def csv_sample + @csv_sample ||= parsed_csv.first(2) + end + + def dry_run + { + transactions: rows.count, + accounts: Import::AccountMapping.for_import(self).creational.count, + categories: Import::CategoryMapping.for_import(self).creational.count, + tags: Import::TagMapping.for_import(self).creational.count + } + end + + def required_column_keys + [] + end + + def column_keys + raise NotImplementedError, "Subclass must implement column_keys" + end + + def generate_rows_from_csv + rows.destroy_all + + mapped_rows = csv_rows.map do |row| + { + account: row[account_col_label].to_s, + date: row[date_col_label].to_s, + qty: row[qty_col_label].to_s, + ticker: row[ticker_col_label].to_s, + price: row[price_col_label].to_s, + amount: row[amount_col_label].to_s, + currency: (row[currency_col_label] || default_currency).to_s, + name: (row[name_col_label] || default_row_name).to_s, + category: row[category_col_label].to_s, + tags: row[tags_col_label].to_s, + entity_type: row[entity_type_col_label].to_s, + notes: row[notes_col_label].to_s + } + end + + rows.insert_all!(mapped_rows) + end + + def sync_mappings + mapping_steps.each do |mapping| + mapping.sync(self) + end + end + + def mapping_steps + [] + end + + def uploaded? raw_file_str.present? end def configured? - csv.present? + uploaded? && rows.any? end def cleaned? - loaded? && configured? && csv.valid? + configured? && rows.all?(&: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.key) || 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 - - self.account.sync - - 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 + def publishable? + cleaned? && mappings.all?(&:valid?) 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 + def import! + # no-op, subclasses can implement for customization of algorithm end - def get_raw_csv - return nil if raw_file_str.nil? - Import::Csv.new(raw_file_str, col_sep:) + def default_row_name + "Imported item" end - def should_initialize_csv? - raw_file_str_changed? || column_mappings_changed? + def default_currency + family.currency end - def initialize_csv - generated_csv = generate_normalized_csv(raw_file_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, col_sep) - 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 - transaction_entries = [] - category_cache = {} - tag_cache = {} - - csv.table.each do |row| - category_name = row["category"].presence - tag_strings = row["tags"].presence&.split("|") || [] - tags = [] - - tag_strings.each do |tag_string| - tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string) - end - - category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present? - - entry = account.entries.build \ - name: row["name"].presence || FALLBACK_TRANSACTION_NAME, - date: Date.iso8601(row["date"]), - currency: account.currency, - amount: BigDecimal(row["amount"]) * -1, - entryable: Account::Transaction.new(category: category, tags: tags) - - transaction_entries << entry - end - - transaction_entries - 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", - is_optional: true - - category_field = Import::Field.new \ - key: "category", - label: "Category", - is_optional: true - - tags_field = Import::Field.new \ - key: "tags", - label: "Tags", - is_optional: true - - amount_field = Import::Field.new \ - key: "amount", - label: "Amount", - validator: ->(value) { Import::Field.bigdecimal_validator(value) } - - [ date_field, name_field, category_field, tags_field, amount_field ] - end - - def define_column_mapping_keys - expected_fields.each do |field| - field.key.to_sym - end - end - - def raw_file_must_be_parsable - begin - CSV.parse(raw_file_str || "", col_sep:) - rescue CSV::MalformedCSVError - errors.add(:raw_file_str, :invalid_csv_format) - end + def parsed_csv + @parsed_csv ||= CSV.parse( + (raw_file_str || "").strip, + headers: true, + col_sep: col_sep, + converters: [ ->(str) { str&.strip } ] + ) end end diff --git a/app/models/import/account_mapping.rb b/app/models/import/account_mapping.rb new file mode 100644 index 00000000..67b11ba5 --- /dev/null +++ b/app/models/import/account_mapping.rb @@ -0,0 +1,45 @@ +class Import::AccountMapping < Import::Mapping + validates :mappable, presence: true, if: -> { key.blank? || !create_when_empty } + + class << self + def mapping_values(import) + import.rows.map(&:account).uniq + end + end + + def selectable_values + family_accounts = import.family.accounts.alphabetically.map { |account| [ account.name, account.id ] } + + unless key.blank? + family_accounts.unshift [ "Add as new account", CREATE_NEW_KEY ] + end + + family_accounts + end + + def requires_selection? + true + end + + def values_count + import.rows.where(account: key).count + end + + def mappable_class + Account + end + + def create_mappable! + return unless creatable? + + account = import.family.accounts.create_or_find_by!(name: key) do |new_account| + new_account.balance = 0 + new_account.import = import + new_account.currency = import.family.currency + new_account.accountable = Depository.new + end + + self.mappable = account + save! + end +end diff --git a/app/models/import/account_type_mapping.rb b/app/models/import/account_type_mapping.rb new file mode 100644 index 00000000..8b60edcd --- /dev/null +++ b/app/models/import/account_type_mapping.rb @@ -0,0 +1,25 @@ +class Import::AccountTypeMapping < Import::Mapping + validates :value, presence: true + + class << self + def mapping_values(import) + import.rows.map(&:entity_type).uniq + end + end + + def selectable_values + Accountable::TYPES.map { |type| [ type.titleize, type ] } + end + + def requires_selection? + true + end + + def values_count + import.rows.where(entity_type: key).count + end + + def create_mappable! + # no-op + end +end diff --git a/app/models/import/category_mapping.rb b/app/models/import/category_mapping.rb new file mode 100644 index 00000000..12302603 --- /dev/null +++ b/app/models/import/category_mapping.rb @@ -0,0 +1,36 @@ +class Import::CategoryMapping < Import::Mapping + class << self + def mapping_values(import) + import.rows.map(&:category).uniq + end + end + + def selectable_values + family_categories = import.family.categories.alphabetically.map { |category| [ category.name, category.id ] } + + unless key.blank? + family_categories.unshift [ "Add as new category", CREATE_NEW_KEY ] + end + + family_categories + end + + def requires_selection? + false + end + + def values_count + import.rows.where(category: key).count + end + + def mappable_class + Category + end + + def create_mappable! + return unless creatable? + + self.mappable = import.family.categories.find_or_create_by!(name: key) + save! + end +end diff --git a/app/models/import/csv.rb b/app/models/import/csv.rb deleted file mode 100644 index 8fe593e4..00000000 --- a/app/models/import/csv.rb +++ /dev/null @@ -1,83 +0,0 @@ -class Import::Csv - DEFAULT_COL_SEP = ",".freeze - COL_SEP_LIST = [ DEFAULT_COL_SEP, ";" ].freeze - - def self.parse_csv(csv_str, col_sep: DEFAULT_COL_SEP) - CSV.parse( - csv_str&.strip || "", - headers: true, - col_sep:, - converters: [ ->(str) { str&.strip } ] - ) - end - - def self.create_with_field_mappings(raw_file_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP) - raw_csv = self.parse_csv(raw_file_str, col_sep:) - - generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true, col_sep: 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, col_sep:) - end - - attr_reader :csv_str, :col_sep - - def initialize(csv_str, column_validators: nil, col_sep: DEFAULT_COL_SEP) - @csv_str = csv_str - @col_sep = col_sep - @column_validators = column_validators || {} - end - - def table - @table ||= self.class.parse_csv(csv_str, col_sep:) - 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 deleted file mode 100644 index 45f7ee45..00000000 --- a/app/models/import/field.rb +++ /dev/null @@ -1,37 +0,0 @@ -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:, is_optional: false, validator: nil) - @key = key.to_s - @label = label - @is_optional = is_optional - @validator = validator - end - - def optional? - @is_optional - 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/models/import/mapping.rb b/app/models/import/mapping.rb new file mode 100644 index 00000000..a0a4bc8b --- /dev/null +++ b/app/models/import/mapping.rb @@ -0,0 +1,56 @@ +class Import::Mapping < ApplicationRecord + CREATE_NEW_KEY = "internal_new_resource" + + belongs_to :import + belongs_to :mappable, polymorphic: true, optional: true + + validates :key, presence: true, uniqueness: { scope: [ :import_id, :type ] }, allow_blank: true + + scope :for_import, ->(import) { where(import: import) } + scope :creational, -> { where(create_when_empty: true, mappable: nil) } + scope :categories, -> { where(type: "Import::CategoryMapping") } + scope :tags, -> { where(type: "Import::TagMapping") } + scope :accounts, -> { where(type: "Import::AccountMapping") } + scope :account_types, -> { where(type: "Import::AccountTypeMapping") } + + class << self + def mappable_for(key) + find_by(key: key)&.mappable + end + + def sync(import) + unique_values = mapping_values(import).uniq + + unique_values.each do |value| + mapping = find_or_initialize_by(key: value, import: import, create_when_empty: value.present?) + mapping.save(validate: false) if mapping.new_record? + end + + where(import: import).where.not(key: unique_values).destroy_all + end + + def mapping_values(import) + raise NotImplementedError, "Subclass must implement mapping_values" + end + end + + def selectable_values + raise NotImplementedError, "Subclass must implement selectable_values" + end + + def values_count + raise NotImplementedError, "Subclass must implement values_count" + end + + def mappable_class + nil + end + + def creatable? + mappable.nil? && key.present? && create_when_empty + end + + def create_mappable! + raise NotImplementedError, "Subclass must implement create_mappable!" + end +end diff --git a/app/models/import/row.rb b/app/models/import/row.rb new file mode 100644 index 00000000..a4e7a473 --- /dev/null +++ b/app/models/import/row.rb @@ -0,0 +1,70 @@ +class Import::Row < ApplicationRecord + belongs_to :import + + validates :amount, numericality: true, allow_blank: true + validates :currency, presence: true + + validate :date_matches_user_format + validate :required_columns + validate :currency_is_valid + + scope :ordered, -> { order(:id) } + + def tags_list + if tags.blank? + [ "" ] + else + tags.split("|").map(&:strip) + end + end + + def date_iso + Date.strptime(date, import.date_format).iso8601 + end + + def signed_amount + if import.type == "TradeImport" + price.to_d * apply_signage_convention(qty.to_d) + else + apply_signage_convention(amount.to_d) + end + end + + def sync_mappings + Import::CategoryMapping.sync(import) if import.column_keys.include?(:category) + Import::TagMapping.sync(import) if import.column_keys.include?(:tags) + Import::AccountMapping.sync(import) if import.column_keys.include?(:account) + Import::AccountTypeMapping.sync(import) if import.column_keys.include?(:entity_type) + end + + private + def apply_signage_convention(value) + value * (import.signage_convention == "inflows_positive" ? 1 : -1) + end + + def required_columns + import.required_column_keys.each do |required_key| + errors.add(required_key, "is required") if self[required_key].blank? + end + end + + def date_matches_user_format + return if date.blank? + + parsed_date = Date.strptime(date, import.date_format) rescue nil + + if parsed_date.nil? + errors.add(:date, "must exactly match the format: #{import.date_format}") + end + end + + def currency_is_valid + return true if currency.blank? + + begin + Money::Currency.new(currency) + rescue Money::Currency::UnknownCurrencyError + errors.add(:currency, "is not a valid currency code") + end + end +end diff --git a/app/models/import/tag_mapping.rb b/app/models/import/tag_mapping.rb new file mode 100644 index 00000000..899b4dc5 --- /dev/null +++ b/app/models/import/tag_mapping.rb @@ -0,0 +1,36 @@ +class Import::TagMapping < Import::Mapping + class << self + def mapping_values(import) + import.rows.map(&:tags_list).flatten.uniq + end + end + + def selectable_values + family_tags = import.family.tags.alphabetically.map { |tag| [ tag.name, tag.id ] } + + unless key.blank? + family_tags.unshift [ "Add as new tag", CREATE_NEW_KEY ] + end + + family_tags + end + + def requires_selection? + false + end + + def values_count + import.rows.map(&:tags_list).flatten.count { |tag| tag == key } + end + + def mappable_class + Tag + end + + def create_mappable! + return unless creatable? + + self.mappable = import.family.tags.find_or_create_by!(name: key) + save! + end +end diff --git a/app/models/mint_import.rb b/app/models/mint_import.rb new file mode 100644 index 00000000..1d5c1ee6 --- /dev/null +++ b/app/models/mint_import.rb @@ -0,0 +1,94 @@ +class MintImport < Import + after_create :set_mappings + + def generate_rows_from_csv + rows.destroy_all + + mapped_rows = csv_rows.map do |row| + { + account: row[account_col_label].to_s, + date: row[date_col_label].to_s, + amount: signed_csv_amount(row).to_s, + currency: (row[currency_col_label] || default_currency).to_s, + name: (row[name_col_label] || default_row_name).to_s, + category: row[category_col_label].to_s, + tags: row[tags_col_label].to_s, + notes: row[notes_col_label].to_s + } + end + + rows.insert_all!(mapped_rows) + end + + def import! + transaction do + mappings.each(&:create_mappable!) + + rows.each do |row| + account = mappings.accounts.mappable_for(row.account) + category = mappings.categories.mappable_for(row.category) + tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact + + entry = account.entries.build \ + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: row.currency, + entryable: Account::Transaction.new(category: category, tags: tags, notes: row.notes), + import: self + + entry.save! + end + end + end + + def mapping_steps + [ Import::CategoryMapping, Import::TagMapping, Import::AccountMapping ] + end + + def required_column_keys + %i[date amount] + end + + def column_keys + %i[date amount name currency category tags account notes] + end + + def csv_template + template = <<-CSV + Date,Amount,Account Name,Description,Category,Labels,Currency,Notes,Transaction Type + 01/01/2024,-8.55,Checking,Starbucks,Food & Drink,Coffee|Breakfast,USD,Morning coffee,debit + 04/15/2024,2000,Savings,Paycheck,Income,,USD,Bi-weekly salary,credit + CSV + + CSV.parse(template, headers: true) + end + + def signed_csv_amount(csv_row) + amount = csv_row[amount_col_label] + type = csv_row["Transaction Type"] + + if type == "credit" + amount.to_d + else + amount.to_d * -1 + end + end + + private + def set_mappings + self.signage_convention = "inflows_positive" + self.date_col_label = "Date" + self.date_format = "%m/%d/%Y" + self.name_col_label = "Description" + self.amount_col_label = "Amount" + self.currency_col_label = "Currency" + self.account_col_label = "Account Name" + self.category_col_label = "Category" + self.tags_col_label = "Labels" + self.notes_col_label = "Notes" + self.entity_type_col_label = "Transaction Type" + + save! + end +end diff --git a/app/models/tag.rb b/app/models/tag.rb index eab4d866..6b2fb67b 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -2,6 +2,7 @@ class Tag < ApplicationRecord belongs_to :family has_many :taggings, dependent: :destroy has_many :transactions, through: :taggings, source: :taggable, source_type: "Account::Transaction" + has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" validates :name, presence: true, uniqueness: { scope: :family } diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb new file mode 100644 index 00000000..4ca57ea1 --- /dev/null +++ b/app/models/trade_import.rb @@ -0,0 +1,52 @@ +class TradeImport < Import + def import! + transaction do + mappings.each(&:create_mappable!) + + rows.each do |row| + account = mappings.accounts.mappable_for(row.account) + security = Security.find_or_create_by(ticker: row.ticker) + + entry = account.entries.build \ + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: row.currency, + entryable: Account::Trade.new(security: security, qty: row.qty, currency: row.currency, price: row.price), + import: self + + entry.save! + end + end + end + + def mapping_steps + [ Import::AccountMapping ] + end + + def required_column_keys + %i[date ticker qty price] + end + + def column_keys + %i[date ticker qty price currency account name] + end + + def dry_run + { + transactions: rows.count, + accounts: Import::AccountMapping.for_import(self).creational.count + } + end + + def csv_template + template = <<-CSV + date*,ticker*,qty*,price*,currency,account,name + 05/15/2024,AAPL,10,150.00,USD,Trading Account,Apple Inc. Purchase + 05/16/2024,GOOGL,-5,2500.00,USD,Investment Account,Alphabet Inc. Sale + 05/17/2024,TSLA,2,700.50,USD,Retirement Account,Tesla Inc. Purchase + CSV + + CSV.parse(template, headers: true) + end +end diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb new file mode 100644 index 00000000..7e8cc4fb --- /dev/null +++ b/app/models/transaction_import.rb @@ -0,0 +1,46 @@ +class TransactionImport < Import + def import! + transaction do + mappings.each(&:create_mappable!) + + rows.each do |row| + account = mappings.accounts.mappable_for(row.account) + category = mappings.categories.mappable_for(row.category) + tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact + + entry = account.entries.build \ + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: row.currency, + entryable: Account::Transaction.new(category: category, tags: tags, notes: row.notes), + import: self + + entry.save! + end + end + end + + def required_column_keys + %i[date amount] + end + + def column_keys + %i[date amount name currency category tags account notes] + end + + def mapping_steps + [ Import::CategoryMapping, Import::TagMapping, Import::AccountMapping ] + end + + def csv_template + template = <<-CSV + date*,amount*,name,currency,category,tags,account,notes + 05/15/2024,-45.99,Grocery Store,USD,Food,groceries|essentials,Checking Account,Monthly grocery run + 05/16/2024,1500.00,Salary,,Income,,Main Account, + 05/17/2024,-12.50,Coffee Shop,,,coffee,, + CSV + + CSV.parse(template, headers: true) + end +end diff --git a/app/views/import/cleans/show.html.erb b/app/views/import/cleans/show.html.erb new file mode 100644 index 00000000..ef0c230f --- /dev/null +++ b/app/views/import/cleans/show.html.erb @@ -0,0 +1,59 @@ +<%= content_for :header_nav do %> + <%= render "imports/nav", import: @import %> +<% end %> + +<%= content_for :previous_path, import_configuration_path(@import) %> + +
+
+

<%= t(".title") %>

+

<%= t(".description") %>

+
+ + <% if @import.cleaned? %> +
+
+ <%= lucide_icon "check-circle", class: "w-4 h-4 text-green-500" %> +

Your data has been cleaned

+
+ + <%= link_to "Next step", import_confirm_path(@import), class: "btn btn--primary" %> +
+ <% else %> +
+
+ <%= lucide_icon "alert-triangle", class: "w-4 h-4 text-red-500" %> +

You have errors in your data

+
+ +
+
+ <%= link_to "All rows", import_clean_path(@import, per_page: params[:per_page], view: "all"), class: "p-2 rounded-lg #{params[:view] != 'errors' ? 'bg-white' : ''}" %> + <%= link_to "Error rows", import_clean_path(@import, per_page: params[:per_page], view: "errors"), class: "p-2 rounded-lg #{params[:view] == 'errors' ? 'bg-white' : ''}" %> +
+
+
+ <% end %> + +
+
+
+ <% @import.column_keys.each do |key| %> +
<%= import_col_label(key) %>
+ <% end %> +
+ +
+ <% @rows.each do |row| %> + <%= render "import/rows/form", row: row %> + <% end %> +
+
+
+ +
+
+ <%= render "application/pagination", pagy: @pagy %> +
+
+
diff --git a/app/views/import/configurations/_account_import.html.erb b/app/views/import/configurations/_account_import.html.erb new file mode 100644 index 00000000..2e2a5cd3 --- /dev/null +++ b/app/views/import/configurations/_account_import.html.erb @@ -0,0 +1,9 @@ +<%# locals: (import:) %> + +<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %> + <%= form.select :entity_type_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Entity Type" } %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %> + <%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" } %> + + <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %> +<% end %> diff --git a/app/views/import/configurations/_mint_import.html.erb b/app/views/import/configurations/_mint_import.html.erb new file mode 100644 index 00000000..afb13156 --- /dev/null +++ b/app/views/import/configurations/_mint_import.html.erb @@ -0,0 +1,25 @@ +<%# locals: (import:) %> + +
+ <%= lucide_icon("check-circle", class: "w-5 h-5 shrink-0 text-green-500") %> +

We have pre-configured your Mint import for you. Please proceed to the next step.

+
+ +<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %> +
+ <%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true, disabled: import.complete? %> + <%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], { label: true }, required: true, disabled: import.complete? %> +
+ +
+ <%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Amount" }, required: true, disabled: import.complete? %> + <%= form.select :signage_convention, [["Incomes are negative", "inflows_negative"], ["Incomes are positive", "inflows_positive"]], { label: true }, disabled: import.complete? %> +
+ + <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" }, disabled: import.complete? %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" }, disabled: import.complete? %> + <%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" }, disabled: import.complete? %> + <%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" }, disabled: import.complete? %> + + <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %> +<% end %> diff --git a/app/views/import/configurations/_trade_import.html.erb b/app/views/import/configurations/_trade_import.html.erb new file mode 100644 index 00000000..a953dc31 --- /dev/null +++ b/app/views/import/configurations/_trade_import.html.erb @@ -0,0 +1,20 @@ +<%# locals: (import:) %> + +<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %> +
+ <%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %> + <%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], label: true, required: true %> +
+ +
+ <%= form.select :qty_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Quantity" } %> + <%= form.select :signage_convention, [["Buys are positive qty", "inflows_positive"], ["Buys are negative qty", "inflows_negative"]], label: true %> +
+ + <%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %> + <%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %> + <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %> + + <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %> +<% end %> diff --git a/app/views/import/configurations/_transaction_import.html.erb b/app/views/import/configurations/_transaction_import.html.erb new file mode 100644 index 00000000..5bc48f75 --- /dev/null +++ b/app/views/import/configurations/_transaction_import.html.erb @@ -0,0 +1,21 @@ +<%# locals: (import:) %> + +<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %> +
+ <%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %> + <%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], label: true, required: true %> +
+ +
+ <%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Amount" }, required: true %> + <%= form.select :signage_convention, [["Incomes are positive", "inflows_positive"], ["Incomes are negative", "inflows_negative"]], label: true %> +
+ + <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %> + <%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" } %> + <%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" } %> + <%= form.select :notes_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Notes (optional)" } %> + + <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %> +<% end %> diff --git a/app/views/import/configurations/show.html.erb b/app/views/import/configurations/show.html.erb new file mode 100644 index 00000000..7cde287a --- /dev/null +++ b/app/views/import/configurations/show.html.erb @@ -0,0 +1,22 @@ +<%= content_for :header_nav do %> + <%= render "imports/nav", import: @import %> +<% end %> + +<%= content_for :previous_path, import_upload_path(@import) %> + +
+
+
+

<%= t(".title") %>

+

<%= t(".description") %>

+
+ +
+ <%= render partial: permitted_import_configuration_path(@import), locals: { import: @import } %> +
+
+ +
+ <%= render "imports/table", headers: @import.csv_headers, rows: @import.csv_sample, caption: "Sample data from your uploaded CSV" %> +
+
diff --git a/app/views/import/confirms/_mappings.html.erb b/app/views/import/confirms/_mappings.html.erb new file mode 100644 index 00000000..9e86a65d --- /dev/null +++ b/app/views/import/confirms/_mappings.html.erb @@ -0,0 +1,29 @@ +<%# locals: (import:, mapping_class:, step_idx:) %> + +<% mappings = mapping_class.for_import(import) %> +<% is_last_step = step_idx == import.mapping_steps.count - 1 %> + +
+
+
+

CSV <%= mapping_label(mapping_class) %>

+

Maybe <%= mapping_label(mapping_class) %>

+

Rows

+
+ +
+ <% mappings.sort_by(&:key).each do |mapping| %> +
+ <%= render partial: "import/mappings/form", locals: { mapping: mapping } %> +
+ <% end %> +
+
+ +
+ <%= link_to is_last_step ? import_path(import) : url_for(step: step_idx + 2), class: "btn btn--primary w-36 flex items-center justify-between gap-2" do %> + Next + <%= lucide_icon "arrow-right", class: "w-5 h-5" %> + <% end %> +
+
diff --git a/app/views/import/confirms/show.html.erb b/app/views/import/confirms/show.html.erb new file mode 100644 index 00000000..ae87907d --- /dev/null +++ b/app/views/import/confirms/show.html.erb @@ -0,0 +1,33 @@ +<%= content_for :header_nav do %> + <%= render "imports/nav", import: @import %> +<% end %> + +<%= content_for :previous_path, import_clean_path(@import) %> + +<% step_idx = (params[:step] || "1").to_i - 1 %> +<% step_mapping_class = @import.mapping_steps[step_idx] %> + +
+
+ <% @import.mapping_steps.each_with_index do |step_mapping_class, idx| %> + <% is_active = step_idx == idx %> + + <%= link_to url_for(step: idx + 1), class: "w-5 h-[3px] #{is_active ? 'bg-gray-900' : 'bg-gray-100'} rounded-xl hover:bg-gray-300 transition-colors duration-200" do %> + Step <%= idx + 1 %> + <% end %> + <% end %> +
+ +
+

+ <%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %> +

+

+ <%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize) %> +

+
+
+ +
+ <%= render partial: "import/confirms/mappings", locals: { import: @import, mapping_class: step_mapping_class, step_idx: step_idx } %> +
diff --git a/app/views/import/mappings/_form.html.erb b/app/views/import/mappings/_form.html.erb new file mode 100644 index 00000000..6bef825f --- /dev/null +++ b/app/views/import/mappings/_form.html.erb @@ -0,0 +1,29 @@ +<%# locals: (mapping:) %> + +<%= styled_form_with model: mapping, + scope: :import_mapping, + url: import_mapping_path(mapping.import, mapping), + class: "grid grid-cols-3 gap-2 items-center", + data: { controller: "auto-submit-form" }, + html: { id: dom_id(mapping, :form) } do |form| %> + <%= mapping.key.blank? ? "(unassigned)" : mapping.key %> + + <% if mapping.mappable_class.present? %> + <%= form.hidden_field :mappable_type, value: mapping.mappable_class, id: dom_id(mapping, :mappable_type) %> + <%= form.select :mappable_id, + mapping.selectable_values, + { container_class: mapping.invalid? ? "border-red-500" : nil, include_blank: mapping.requires_selection? ? "Select an option" : "Leave unassigned", selected: mapping.create_when_empty? ? mapping.class::CREATE_NEW_KEY : mapping.mappable_id }, + "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "change", disabled: mapping.import.complete?, id: dom_id(mapping, :mappable_id) %> + <% else %> + <%= form.select :value, mapping.selectable_values, + { container_class: mapping.invalid? ? "border-red-500" : nil, include_blank: mapping.requires_selection? ? "Select an option" : "Leave unassigned" }, + "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "change", disabled: mapping.import.complete?, id: dom_id(mapping, :value) %> + <% end %> + + <%= form.hidden_field :key, value: mapping.key, id: dom_id(mapping, :key) %> + <%= form.hidden_field :type, value: mapping.type, id: dom_id(mapping, :type) %> + + + <%= mapping.values_count %> + +<% end %> diff --git a/app/views/import/rows/_form.html.erb b/app/views/import/rows/_form.html.erb new file mode 100644 index 00000000..3f6cfd24 --- /dev/null +++ b/app/views/import/rows/_form.html.erb @@ -0,0 +1,27 @@ +<%# locals: (row:) %> + +
+ <% row.import.column_keys.each_with_index do |key, idx| %> + <%= turbo_frame_tag dom_id(row, key), title: row.valid? ? nil : row.errors.full_messages.join(", ") do %> + <%= form_with( + model: [row.import, row], + scope: :import_row, + url: import_row_path(row.import, row), + method: :patch, + data: { + controller: "auto-submit-form", + auto_submit_form_trigger_event_value: "blur" + } + ) do |form| %> + <%= form.text_field key, + "data-auto-submit-form-target": "auto", + class: [ + cell_class(row, key), + idx == 0 ? "group-first:rounded-tl-lg group-last:rounded-bl-lg" : "", + idx == row.import.column_keys.count - 1 ? "group-first:rounded-tr-lg group-last:rounded-br-lg" : "", + ], + disabled: row.import.complete? %> + <% end %> + <% end %> + <% end %> +
diff --git a/app/views/import/rows/show.html.erb b/app/views/import/rows/show.html.erb new file mode 100644 index 00000000..f7735583 --- /dev/null +++ b/app/views/import/rows/show.html.erb @@ -0,0 +1 @@ +<%= render "import/rows/form", row: @row %> diff --git a/app/views/import/uploads/show.html.erb b/app/views/import/uploads/show.html.erb new file mode 100644 index 00000000..509aff07 --- /dev/null +++ b/app/views/import/uploads/show.html.erb @@ -0,0 +1,69 @@ +<%= content_for :header_nav do %> + <%= render "imports/nav", import: @import %> +<% end %> + +<%= content_for :previous_path, imports_path %> + +
+
+
+

<%= t(".title") %>

+

<%= t(".description") %>

+
+ +
+
+
+ + +
+
+ +
+ <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %> + <%= form.select :col_sep, [["Comma (,)", ","], ["Semicolon (;)", ";"]], label: true %> + <%= form.text_area :raw_file_str, + rows: 10, + required: true, + placeholder: "Paste your CSV file contents here", + "data-auto-submit-form-target": "auto" %> + + <%= form.submit "Upload CSV", disabled: @import.complete? %> + <% end %> + +
+ + +
+
+ +
+
+
+ <%= lucide_icon("info", class: "w-5 h-5 shrink-0") %> +

<%= t(".instructions_1") %>

+ +
+ +
    +
  • <%= t(".instructions_2") %>
  • +
  • <%= t(".instructions_3") %>
  • +
  • <%= t(".instructions_4") %>
  • +
+
+ + <%= render partial: "imports/table", locals: { headers: @import.csv_template.headers, rows: @import.csv_template } %> +
+
diff --git a/app/views/imports/_csv_paste.html.erb b/app/views/imports/_csv_paste.html.erb deleted file mode 100644 index 6203cc11..00000000 --- a/app/views/imports/_csv_paste.html.erb +++ /dev/null @@ -1,25 +0,0 @@ -<%= styled_form_with model: @import, url: load_import_path(@import), class: "space-y-4" do |form| %> - <%= form.text_area :raw_file_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" %> - - <%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_file_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") %>

-
- - -
- <%= render partial: "imports/sample_table" %> -
diff --git a/app/views/imports/_csv_upload.html.erb b/app/views/imports/_csv_upload.html.erb deleted file mode 100644 index 1da6c140..00000000 --- a/app/views/imports/_csv_upload.html.erb +++ /dev/null @@ -1,39 +0,0 @@ -<%= styled_form_with model: @import, url: upload_import_path(@import), class: "dropzone space-y-4", data: { controller: "import-upload", import_upload_accepted_types_value: ["text/csv", "application/csv", ".csv"], import_upload_extension_value: "csv", import_upload_unacceptable_type_label_value: t(".allowed_filetypes") }, method: :patch, multipart: true do |form| %> -
- -
- <%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-alpha-black-25 text-gray text-sm font-medium", data: { import_upload_target: "submit", turbo_confirm: (@import.raw_file_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") %> - - <%= link_to "download this template", "/transactions.csv", download: "" %> - -

-
-
- <%= render partial: "imports/sample_table" %> -
diff --git a/app/views/imports/_empty.html.erb b/app/views/imports/_empty.html.erb index ed7a3b32..41b98a06 100644 --- a/app/views/imports/_empty.html.erb +++ b/app/views/imports/_empty.html.erb @@ -1,7 +1,7 @@
-
+

<%= 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 %> + <%= link_to new_import_path(enable_type_selector: true), class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: "modal" } do %> <%= lucide_icon("plus", class: "w-5 h-5") %> <%= t(".new") %> <% end %> diff --git a/app/views/imports/_failure.html.erb b/app/views/imports/_failure.html.erb new file mode 100644 index 00000000..9dc003d8 --- /dev/null +++ b/app/views/imports/_failure.html.erb @@ -0,0 +1,18 @@ +<%# locals: (import:) %> + +
+
+
+ <%= lucide_icon "alert-octagon", class: "w-5 h-5 text-red-500" %> +
+ +
+

Import failed

+

Please check that your file format, for any errors and that all required fields are filled, then come back and try again.

+
+ +
+ <%= button_to "Try again", publish_import_path(import), class: "btn btn--primary text-center w-full" %> +
+
+
diff --git a/app/views/imports/_form.html.erb b/app/views/imports/_form.html.erb deleted file mode 100644 index e7503016..00000000 --- a/app/views/imports/_form.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<%= styled_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.collection_select :col_sep, Import::Csv::COL_SEP_LIST, :to_s, -> { t(".col_sep_char.#{_1.ord}") }, { prompt: t(".select_col_sep"), label: t(".col_sep"), 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 cursor-pointer hover:bg-gray-700" %> -<% end %> diff --git a/app/views/imports/_import.html.erb b/app/views/imports/_import.html.erb index 0983e01b..6620f3b9 100644 --- a/app/views/imports/_import.html.erb +++ b/app/views/imports/_import.html.erb @@ -1,51 +1,39 @@ -
-
+
-
-

- <%= t(".label", account: import.account.name) %> -

+
+ <%= link_to import_path(import), class: "text-sm text-gray-900 hover:underline" do %> + <%= t(".label", type: import.type.titleize, datetime: import.updated_at.strftime("%b %-d, %Y at %l:%M %p")) %> + <% end %> - <% 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")) %>

+ <% 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? %> -
- <%= lucide_icon("check", class: "text-green-500 w-4 h-4") %> -
- <% else %> - <%= contextual_menu do %> -
- <%= link_to edit_import_path(import), + <%= contextual_menu do %> +
+ <%= link_to 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" %> + <%= lucide_icon "eye", class: "w-5 h-5 text-gray-500" %> - <%= t(".edit") %> - <% end %> + <%= t(".view") %> + <% end %> + <% unless import.complete? %> <%= 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", @@ -54,8 +42,7 @@ <%= t(".delete") %> <% end %> -
- <% end %> + <% end %> +
<% end %> -
diff --git a/app/views/imports/_importing.html.erb b/app/views/imports/_importing.html.erb new file mode 100644 index 00000000..cf2f136c --- /dev/null +++ b/app/views/imports/_importing.html.erb @@ -0,0 +1,19 @@ +<%# locals: (import:) %> + +
+
+
+ <%= lucide_icon "loader", class: "animate-pulse w-5 h-5 text-gray-500" %> +
+ +
+

Import in progress

+

Your import is in progress. Check the imports menu for status updates or click 'Check Status' to refresh the page for updates. Feel free to continue using the app.

+
+ +
+ <%= link_to "Check status", import_path(import), class: "block btn btn--primary text-center w-full" %> + <%= link_to "Back to dashboard", root_path, class: "block btn btn--secondary text-center w-full" %> +
+
+
diff --git a/app/views/imports/_nav.html.erb b/app/views/imports/_nav.html.erb new file mode 100644 index 00000000..101efcd1 --- /dev/null +++ b/app/views/imports/_nav.html.erb @@ -0,0 +1,40 @@ +<%# locals: (import:) %> + +<% steps = [ + { name: "Upload", path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 }, + { name: "Configure", path: import_configuration_path(import), is_complete: import.configured?, step_number: 2 }, + { name: "Clean", path: import_clean_path(import), is_complete: import.cleaned?, step_number: 3 }, + { name: "Map", path: import_confirm_path(import), is_complete: import.publishable?, step_number: 4 }, + { name: "Confirm", path: import_path(import), is_complete: import.complete?, step_number: 5 } +] %> + +
    + <% steps.each_with_index do |step, idx| %> +
  • + <% is_current = request.path == step[:path] %> + + <% text_class = if is_current + "text-gray-900" + else + step[:is_complete] ? "text-green-600" : "text-gray-500" + end %> + <% step_class = if is_current + "bg-gray-900 text-white" + else + step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50" + end %> + + <%= link_to step[:path], class: "flex items-center gap-3" do %> +
    + + <%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %> + + + <%= step[:name] %> +
    + <% end %> + +
    +
  • + <% end %> +
diff --git a/app/views/imports/_nav_step.html.erb b/app/views/imports/_nav_step.html.erb deleted file mode 100644 index 375f1e92..00000000 --- a/app/views/imports/_nav_step.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<% 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/_ready.html.erb b/app/views/imports/_ready.html.erb new file mode 100644 index 00000000..1800d8b0 --- /dev/null +++ b/app/views/imports/_ready.html.erb @@ -0,0 +1,39 @@ +<%# locals: (import:) %> + +
+

<%= t(".title") %>

+

<%= t(".description") %>

+
+ +
+
+
+

item

+

count

+
+ +
+ <% import.dry_run.each do |key, count| %> + <% resource = dry_run_resource(key) %> + +
+
+
+ <%= lucide_icon resource.icon, class: "#{resource.text_class} w-5 h-5 shrink-0" %> +
+ +

<%= resource.label %>

+
+ +

<%= count %>

+
+ + <% if key != import.dry_run.keys.last %> +
+ <% end %> + <% end %> +
+
+ + <%= button_to "Publish import", publish_import_path(import), class: "btn btn--primary w-full" %> +
diff --git a/app/views/imports/_sample_table.html.erb b/app/views/imports/_sample_table.html.erb deleted file mode 100644 index f4b82143..00000000 --- a/app/views/imports/_sample_table.html.erb +++ /dev/null @@ -1,26 +0,0 @@ - -
-
date
-
name
-
category
-
tags
-
amount
- -
2024-01-01
-
Amazon
-
Shopping
-
Tag1|Tag2
-
-24.99
- -
2024-03-01
-
Spotify
-
-
-
-16.32
- -
2023-01-06
-
Acme
-
Income
-
Tag3
-
151.22
-
diff --git a/app/views/imports/_success.html.erb b/app/views/imports/_success.html.erb new file mode 100644 index 00000000..a7e035c4 --- /dev/null +++ b/app/views/imports/_success.html.erb @@ -0,0 +1,18 @@ +<%# locals: (import:) %> + +
+
+
+ <%= lucide_icon "check", class: "w-5 h-5 text-green-500" %> +
+ +
+

Import successful

+

Your imported data has been successfully added to the app and is now ready for use.

+
+ +
+ <%= link_to "Back to dashboard", root_path, class: "block btn btn--primary text-center w-full" %> +
+
+
diff --git a/app/views/imports/_table.html.erb b/app/views/imports/_table.html.erb new file mode 100644 index 00000000..18b963b6 --- /dev/null +++ b/app/views/imports/_table.html.erb @@ -0,0 +1,37 @@ +<%# locals: (headers: [], rows: [], caption: nil) %> +
+
+
+ <% headers.each_with_index do |header, index| %> +
+ "> + <%= header %> +
+ <% end %> +
+ + <% rows.each_with_index do |row, row_index| %> +
" style="grid-template-columns: repeat(<%= headers.length %>, minmax(0, 1fr))"> + <% row.each_with_index do |(header, value), col_index| %> +
+ <%= "rounded-bl-md" if !caption && row_index == rows.length - 1 && col_index == 0 %> + <%= "rounded-br-md" if !caption && row_index == rows.length - 1 && col_index == row.length - 1 %> + "> + <%= value %> +
+ <% end %> +
+ <% end %> + + <% if caption %> +
+ <%= caption %> +
+ <% end %> +
+
diff --git a/app/views/imports/_type_selector.html.erb b/app/views/imports/_type_selector.html.erb deleted file mode 100644 index eac0476e..00000000 --- a/app/views/imports/_type_selector.html.erb +++ /dev/null @@ -1,65 +0,0 @@ -
- -
-
-

<%= 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") %> -
    - -
    -
    -
    -
  • -
-
-
diff --git a/app/views/imports/clean.html.erb b/app/views/imports/clean.html.erb deleted file mode 100644 index 286cf1a5..00000000 --- a/app/views/imports/clean.html.erb +++ /dev/null @@ -1,48 +0,0 @@ -<%= 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, - 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 hover:bg-gray-700", data: { turbo: false } %> - <% end %> -
diff --git a/app/views/imports/configure.html.erb b/app/views/imports/configure.html.erb deleted file mode 100644 index 150e7491..00000000 --- a/app/views/imports/configure.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%= content_for :return_to_path, return_to_path(params, imports_path) %> - -
-

<%= t(".configure_title") %>

- -
-

<%= t(".configure_subtitle") %>

-

<%= t(".configure_description") %>

-
- - <%= styled_form_with model: @import, url: configure_import_path(@import), class: "space-y-4" 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, - include_blank: field.optional? ? t(".optional") : false %> - <% 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 cursor-pointer hover:bg-gray-700", 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 deleted file mode 100644 index 7b806a62..00000000 --- a/app/views/imports/confirm.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<%= content_for :return_to_path, return_to_path(params, imports_path) %> - -
-

<%= t(".confirm_title") %>

- -
-

<%= t(".confirm_subtitle") %>

-

<%= t(".confirm_description") %>

-
- -
- <%= entries_by_date(@import.dry_run, selectable: false) do |entries| %> - <%= render entries, show_tags: true, selectable: false, editable: false %> - <% end %> -
- - <%= 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 hover:bg-gray-700", data: { turbo: false } %> -
diff --git a/app/views/imports/edit.html.erb b/app/views/imports/edit.html.erb deleted file mode 100644 index c873e237..00000000 --- a/app/views/imports/edit.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -<%= 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 index c62d3b61..73aecf0d 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -6,7 +6,7 @@

<%= t(".title") %>

- <%= link_to new_import_path(enable_type_selector: true), 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 %> + <%= link_to new_import_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: :modal } do %> <%= lucide_icon("plus", class: "w-5 h-5") %> <%= t(".new") %> <% end %> @@ -19,7 +19,7 @@

<%= t(".imports") %> · <%= @imports.size %>

- <%= render @imports.ordered %> + <%= render partial: "imports/import", collection: @imports.ordered %>
<% end %> diff --git a/app/views/imports/load.html.erb b/app/views/imports/load.html.erb deleted file mode 100644 index 77f11559..00000000 --- a/app/views/imports/load.html.erb +++ /dev/null @@ -1,25 +0,0 @@ -<%= content_for :return_to_path, return_to_path(params, imports_path) %> - -
-

<%= t(".load_title") %>

- -
-

<%= t(".subtitle") %>

-

<%= t(".description") %>

-
- -
-
-
- - -
-
-
- <%= render partial: "imports/csv_upload", locals: { import: @import } %> -
- -
-
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index afecb846..b0005dc8 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -1,16 +1,108 @@ -<%= content_for :return_to_path, return_to_path(params, imports_path) %> +<%= modal do %> +
+
+
+

<%= t(".title") %>

+ +
-<% if params[:enable_type_selector].present? %> - <%= modal do %> - <%= render "type_selector" %> - <% end %> -<% end %> +

<%= t(".description") %>

+
-
-

New import

-
-

<%= t(".header_text") %>

-

<%= t(".description_text") %>

+
+

<%= t(".sources") %>

+
    +
  • + <% if @pending_import.present? %> + <%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %> +
    +
    + <%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %> +
    + + <%= t(".resume") %> + +
    + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> + <% end %> + +
    +
    +
    +
  • + <% end %> +
  • + <%= button_to imports_path(import: { type: "TransactionImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> +
    +
    + <%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %> +
    + + <%= t(".import_transactions") %> + +
    + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> + <% end %> + +
    +
    +
    +
  • + +
  • + <%= button_to imports_path(import: { type: "TradeImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> +
    +
    + <%= lucide_icon("square-percent", class: "w-5 h-5 text-yellow-500") %> +
    + + <%= t(".import_portfolio") %> + +
    + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> + <% end %> + +
    +
    +
    +
  • + +
  • + <%= button_to imports_path(import: { type: "AccountImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %> +
    +
    + <%= lucide_icon("building", class: "w-5 h-5 text-violet-500") %> +
    + + <%= t(".import_accounts") %> + +
    + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> + <% end %> + +
    +
    +
    +
  • + +
  • + <%= button_to imports_path(import: { type: "MintImport" }), class: "flex items-center justify-between p-4 group w-full", data: { turbo: false } do %> +
    + <%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %> + + <%= t(".import_mint") %> + +
    + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %> + <% end %> + +
    +
    +
    +
  • +
+
- <%= render "form", import: @import %> -
+<% end %> diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb index 83dc2e69..163be644 100644 --- a/app/views/imports/show.html.erb +++ b/app/views/imports/show.html.erb @@ -1,15 +1,15 @@ -
-
- <% if notice.present? %> -

<%= notice %>

- <% end %> +<%= content_for :header_nav do %> + <%= render "imports/nav", import: @import %> +<% end %> - <%= render @import %> +<%= content_for :previous_path, import_confirm_path(@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" %> -
-
+<% if @import.importing? %> + <%= render "imports/importing", import: @import %> +<% elsif @import.complete? %> + <%= render "imports/success", import: @import %> +<% elsif @import.failed? %> + <%= render "imports/failure", import: @import %> +<% else %> + <%= render "imports/ready", import: @import %> +<% end %> diff --git a/app/views/layouts/imports.html.erb b/app/views/layouts/imports.html.erb index abd8eaa7..699ab61e 100644 --- a/app/views/layouts/imports.html.erb +++ b/app/views/layouts/imports.html.erb @@ -1,34 +1,23 @@ <%= 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-8 h-8 hover:bg-gray-100 rounded-full p-2") %> - <% end %> -
+
+
+ <%= link_to content_for(:previous_path) || imports_path do %> + <%= lucide_icon "arrow-left", class: "w-5 h-5 text-gray-500" %> + <% end %> - <%= yield %> + + + <%= link_to imports_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/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 5bae643c..ccea86b3 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -8,10 +8,18 @@ <% end %>
- <%= link_to new_account_path, class: "flex items-center gap-1 btn btn--primary", data: { turbo_frame: "modal" } do %> - <%= lucide_icon("plus", class: "w-5 h-5") %> - <%= t(".new") %> - <% end %> +
+ <%= contextual_menu do %> +
+ <%= contextual_menu_modal_action_item t(".import"), new_import_path, icon: "hard-drive-upload" %> +
+ <% end %> + + <%= link_to new_account_path, class: "flex items-center gap-1 btn btn--primary", data: { turbo_frame: "modal" } do %> + <%= lucide_icon("plus", class: "w-5 h-5") %> + <%= t(".new") %> + <% end %> +
<% if @accounts.empty? %> diff --git a/app/views/settings/_nav.html.erb b/app/views/settings/_nav.html.erb index f252c573..e3f16ba0 100644 --- a/app/views/settings/_nav.html.erb +++ b/app/views/settings/_nav.html.erb @@ -29,6 +29,10 @@
  • <%= sidebar_link_to t(".accounts_label"), accounts_path, icon: "layers" %>
  • + +
  • + <%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %> +
  • @@ -47,9 +51,6 @@
  • <%= sidebar_link_to t(".merchants_label"), merchants_path, icon: "store" %>
  • -
  • - <%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %> -
  • diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 7d659c18..71a47b67 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -90,8 +90,42 @@ 22 ], "note": "" + }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "fb6f7abeabc405d6882ffd41dbe8016403ef39307a5c6b4cd7b18adfaf0c24bf", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/import/configurations/show.html.erb", + "line": 13, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(partial => permitted_import_configuration_path(Current.family.imports.find(params[:import_id])), { :locals => ({ :import => Current.family.imports.find(params[:import_id]) }) })", + "render_path": [ + { + "type": "controller", + "class": "Import::ConfigurationsController", + "method": "show", + "line": 7, + "file": "app/controllers/import/configurations_controller.rb", + "rendered": { + "name": "import/configurations/show", + "file": "app/views/import/configurations/show.html.erb" + } + } + ], + "location": { + "type": "template", + "template": "import/configurations/show" + }, + "user_input": "params[:import_id]", + "confidence": "Weak", + "cwe_id": [ + 22 + ], + "note": "" } ], - "updated": "2024-09-09 14:56:48 -0400", + "updated": "2024-09-28 13:27:09 -0400", "brakeman_version": "6.2.1" } diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb index 1a41c5c3..806fef7f 100644 --- a/config/initializers/pagy.rb +++ b/config/initializers/pagy.rb @@ -1,3 +1,4 @@ require "pagy/extras/overflow" +require "pagy/extras/array" Pagy::DEFAULT[:overflow] = :last_page diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index c459e2f5..1a411bca 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -1,111 +1,66 @@ --- en: + import: + cleans: + show: + description: Edit your data in the table below. Red cells are invalid. + title: Clean your data + configurations: + show: + description: Select the columns that correspond to each field in your CSV. + title: Configure your import + confirms: + show: + account_mapping_description: Assign all of your imported file's accounts to + Maybe's existing accounts. You can also add new accounts or leave them + uncategorized. + account_mapping_title: Assign your accounts + account_type_mapping_description: Assign all of your imported file's account + types to Maybe's + account_type_mapping_title: Assign your account types + category_mapping_description: Assign all of your imported file's categories + to Maybe's existing categories. You can also add new categories or leave + them uncategorized. + category_mapping_title: Assign your categories + tag_mapping_description: Assign all of your imported file's tags to Maybe's + existing tags. You can also add new tags or leave them uncategorized. + tag_mapping_title: Assign your tags + uploads: + show: + description: Paste or upload your CSV file below. + instructions_1: Below is an example CSV with columns available for import. + instructions_2: Your CSV must have a header row + instructions_3: You may name your columns anything you like. You will map + them at a later step. + instructions_4: Columns marked with an asterisk (*) are required data. + title: Import your data 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 - optional: "(optional) No column selected" - 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 - csv_paste: - 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? - instructions: Your CSV should have the following columns and formats for the - best import experience. - 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) - requirement3: Can have 0 or more tags separated by | - csv_upload: - allowed_filetypes: Only CSV files are allowed. - 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? - instructions: The csv file must be in the format below. You can also reuse and - next: Next - 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 - col_sep: CSV column separator - col_sep_char: - '44': Comma (,) - '59': Semicolon (;) - next: Next - select_account: Select account - select_col_sep: Select CSV column separator + message: No imports yet. + new: New import 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} + label: "%{type}: %{datetime}" uploading: Processing rows + view: View index: imports: Imports new: New import title: Imports - load: - description: Create a spreadsheet or upload an exported CSV from your financial - institution. - load_title: Load import - 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 other financial - apps like Mint. - import_from_csv: New import from CSV - import_from_mint: Import from Mint + description: You can manually import various types of data via CSV or use one + of our import templates like Mint. + import_accounts: Import accounts + import_mint: Import from Mint + import_portfolio: Import investments import_transactions: Import transactions - resume_latest_import: Resume latest import - soon: Soon + resume: Resume latest import sources: Sources - update: - import_updated: Import updated - update_mappings: - column_mappings_saved: Column mappings saved - upload_csv: - import_loaded: CSV File loaded + title: New CSV Import + ready: + 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 diff --git a/config/locales/views/pages/en.yml b/config/locales/views/pages/en.yml index 8bbccbc0..493632ff 100644 --- a/config/locales/views/pages/en.yml +++ b/config/locales/views/pages/en.yml @@ -8,6 +8,7 @@ en: assets: Assets debts: Debts greeting: Welcome back, %{name} + import: Import income: Income investing: Investing (coming soon...) net_worth: Net Worth diff --git a/config/routes.rb b/config/routes.rb index 2dfcd4da..cab9eeab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,23 +21,6 @@ Rails.application.routes.draw do end end - resources :imports, except: :show do - member do - get "load" - patch "load" => "imports#load_csv" - patch "upload" => "imports#upload_csv" - - get "configure" - patch "configure" => "imports#update_mappings" - - get "clean" - patch "clean" => "imports#update_csv" - - get "confirm" - patch "confirm" => "imports#publish" - end - end - resources :tags, except: %i[show destroy] do resources :deletions, only: %i[new create], module: :tag end @@ -56,6 +39,18 @@ Rails.application.routes.draw do resources :transfers, only: %i[new create destroy] end + resources :imports, only: %i[index new show create destroy] do + post :publish, on: :member + + resource :upload, only: %i[show update], module: :import + resource :configuration, only: %i[show update], module: :import + resource :clean, only: :show, module: :import + resource :confirm, only: :show, module: :import + + resources :rows, only: %i[show update], module: :import + resources :mappings, only: :update, module: :import + end + resources :accounts do collection do get :summary diff --git a/db/migrate/20240921170426_change_import_owner.rb b/db/migrate/20240921170426_change_import_owner.rb new file mode 100644 index 00000000..15348f7d --- /dev/null +++ b/db/migrate/20240921170426_change_import_owner.rb @@ -0,0 +1,28 @@ +class ChangeImportOwner < ActiveRecord::Migration[7.2] + def up + add_reference :imports, :family, foreign_key: true, type: :uuid + add_column :imports, :original_account_id, :uuid + + execute <<-SQL + UPDATE imports + SET family_id = (SELECT family_id FROM accounts WHERE accounts.id = imports.account_id), + original_account_id = account_id + SQL + + remove_reference :imports, :account, foreign_key: true, type: :uuid + change_column_null :imports, :family_id, false + end + + def down + add_reference :imports, :account, foreign_key: true, type: :uuid + + execute <<-SQL + UPDATE imports + SET account_id = original_account_id + SQL + + remove_reference :imports, :family, foreign_key: true, type: :uuid + remove_column :imports, :original_account_id, :uuid + change_column_null :imports, :account_id, false + end +end diff --git a/db/migrate/20240925112218_add_import_types.rb b/db/migrate/20240925112218_add_import_types.rb new file mode 100644 index 00000000..ba79ec2b --- /dev/null +++ b/db/migrate/20240925112218_add_import_types.rb @@ -0,0 +1,55 @@ +class AddImportTypes < ActiveRecord::Migration[7.2] + def change + change_table :imports do |t| + t.string :type, null: false + t.string :date_col_label, default: "date" + t.string :amount_col_label, default: "amount" + t.string :name_col_label, default: "name" + t.string :category_col_label, default: "category" + t.string :tags_col_label, default: "tags" + t.string :account_col_label, default: "account" + t.string :qty_col_label, default: "qty" + t.string :ticker_col_label, default: "ticker" + t.string :price_col_label, default: "price" + t.string :entity_type_col_label, default: "type" + t.string :notes_col_label, default: "notes" + t.string :currency_col_label, default: "currency" + t.string :date_format, default: "%m/%d/%Y" + t.string :signage_convention, default: "inflows_positive" + t.string :error + end + + # Add import references so we can associate imported resources after the import + add_reference :account_entries, :import, foreign_key: true, type: :uuid + add_reference :accounts, :import, foreign_key: true, type: :uuid + + create_table :import_rows, id: :uuid do |t| + t.references :import, null: false, foreign_key: true, type: :uuid + t.string :account + t.string :date + t.string :qty + t.string :ticker + t.string :price + t.string :amount + t.string :currency + t.string :name + t.string :category + t.string :tags + t.string :entity_type + t.text :notes + + t.timestamps + end + + create_table :import_mappings, id: :uuid do |t| + t.string :type, null: false + t.string :key + t.string :value + t.boolean :create_when_empty, default: true + t.references :import, null: false, type: :uuid + t.references :mappable, polymorphic: true, type: :uuid + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 8c5c2483..831bbb81 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_09_11_143158) do +ActiveRecord::Schema[7.2].define(version: 2024_09_25_112218) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -44,7 +44,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do t.datetime "updated_at", null: false t.uuid "transfer_id" t.boolean "marked_as_transfer", default: false, null: false + t.uuid "import_id" t.index ["account_id"], name: "index_account_entries_on_account_id" + t.index ["import_id"], name: "index_account_entries_on_import_id" t.index ["transfer_id"], name: "index_account_entries_on_transfer_id" end @@ -118,9 +120,11 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do t.boolean "is_active", default: true, null: false t.date "last_sync_date" t.uuid "institution_id" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.uuid "import_id" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id"], name: "index_accounts_on_family_id" + t.index ["import_id"], name: "index_accounts_on_import_id" t.index ["institution_id"], name: "index_accounts_on_institution_id" end @@ -300,8 +304,40 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" end + create_table "import_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "type", null: false + t.string "key" + t.string "value" + t.boolean "create_when_empty", default: true + t.uuid "import_id", null: false + t.string "mappable_type" + t.uuid "mappable_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["import_id"], name: "index_import_mappings_on_import_id" + t.index ["mappable_type", "mappable_id"], name: "index_import_mappings_on_mappable" + end + + create_table "import_rows", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "import_id", null: false + t.string "account" + t.string "date" + t.string "qty" + t.string "ticker" + t.string "price" + t.string "amount" + t.string "currency" + t.string "name" + t.string "category" + t.string "tags" + t.string "entity_type" + t.text "notes" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["import_id"], name: "index_import_rows_on_import_id" + 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_file_str" @@ -309,7 +345,25 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "col_sep", default: "," - t.index ["account_id"], name: "index_imports_on_account_id" + t.uuid "family_id", null: false + t.uuid "original_account_id" + t.string "type", null: false + t.string "date_col_label", default: "date" + t.string "amount_col_label", default: "amount" + t.string "name_col_label", default: "name" + t.string "category_col_label", default: "category" + t.string "tags_col_label", default: "tags" + t.string "account_col_label", default: "account" + t.string "qty_col_label", default: "qty" + t.string "ticker_col_label", default: "ticker" + t.string "price_col_label", default: "price" + t.string "entity_type_col_label", default: "type" + t.string "notes_col_label", default: "notes" + t.string "currency_col_label", default: "currency" + t.string "date_format", default: "%m/%d/%Y" + t.string "signage_convention", default: "inflows_positive" + t.string "error" + t.index ["family_id"], name: "index_imports_on_family_id" end create_table "institutions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -452,6 +506,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do add_foreign_key "account_balances", "accounts", on_delete: :cascade add_foreign_key "account_entries", "account_transfers", column: "transfer_id" add_foreign_key "account_entries", "accounts" + add_foreign_key "account_entries", "imports" add_foreign_key "account_holdings", "accounts" add_foreign_key "account_holdings", "securities" add_foreign_key "account_syncs", "accounts" @@ -459,11 +514,13 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do add_foreign_key "account_transactions", "categories", on_delete: :nullify add_foreign_key "account_transactions", "merchants" add_foreign_key "accounts", "families" + add_foreign_key "accounts", "imports" add_foreign_key "accounts", "institutions" 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 "categories", "families" - add_foreign_key "imports", "accounts" + add_foreign_key "import_rows", "imports" + add_foreign_key "imports", "families" add_foreign_key "institutions", "families" add_foreign_key "merchants", "families" add_foreign_key "taggings", "tags" diff --git a/public/transactions.csv b/public/transactions.csv deleted file mode 100644 index a338951b..00000000 --- a/public/transactions.csv +++ /dev/null @@ -1,4 +0,0 @@ -date,name,category,tags,amount -2024-01-01,Amazon,Shopping,Tag1|Tag2,-24.99 -2024-03-01,Spotify,,,-16.32 -2023-01-06,Acme,Income,Tag3,151.22 diff --git a/test/controllers/import/cleans_controller_test.rb b/test/controllers/import/cleans_controller_test.rb new file mode 100644 index 00000000..0a4f6561 --- /dev/null +++ b/test/controllers/import/cleans_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Import::CleansControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + end + + test "shows if configured" do + import = imports(:transaction) + + TransactionImport.any_instance.stubs(:configured?).returns(true) + + get import_clean_path(import) + assert_response :success + end + + test "redirects if not configured" do + import = imports(:transaction) + + TransactionImport.any_instance.stubs(:configured?).returns(false) + + get import_clean_path(import) + assert_redirected_to import_configuration_path(import) + end +end diff --git a/test/controllers/import/configurations_controller_test.rb b/test/controllers/import/configurations_controller_test.rb new file mode 100644 index 00000000..95edf0c3 --- /dev/null +++ b/test/controllers/import/configurations_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class Import::ConfigurationsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @import = imports(:transaction) + end + + test "show" do + get import_configuration_url(@import) + assert_response :success + end + + test "updating a valid configuration regenerates rows" do + TransactionImport.any_instance.expects(:generate_rows_from_csv).once + + patch import_configuration_url(@import), params: { + import: { + date_col_label: "Date", + date_format: "%Y-%m-%d", + name_col_label: "Name", + category_col_label: "Category", + tags_col_label: "Tags", + amount_col_label: "Amount", + signage_convention: "inflows_positive", + account_col_label: "Account" + } + } + + assert_redirected_to import_clean_url(@import) + assert_equal "Import configured successfully.", flash[:notice] + end +end diff --git a/test/controllers/import/confirms_controller_test.rb b/test/controllers/import/confirms_controller_test.rb new file mode 100644 index 00000000..360ea0c6 --- /dev/null +++ b/test/controllers/import/confirms_controller_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class Import::ConfirmsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + end + + test "shows if cleaned" do + import = imports(:transaction) + + TransactionImport.any_instance.stubs(:cleaned?).returns(true) + + get import_confirm_path(import) + assert_response :success + end + + test "redirects if not cleaned" do + import = imports(:transaction) + + TransactionImport.any_instance.stubs(:cleaned?).returns(false) + + get import_confirm_path(import) + assert_redirected_to import_clean_path(import) + assert_equal "You have invalid data, please edit until all errors are resolved", flash[:alert] + end +end diff --git a/test/controllers/import/mappings_controller_test.rb b/test/controllers/import/mappings_controller_test.rb new file mode 100644 index 00000000..91b335df --- /dev/null +++ b/test/controllers/import/mappings_controller_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class Import::MappingsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + + @import = imports(:transaction) + end + + test "updates mapping" do + mapping = import_mappings(:one) + new_category = categories(:income) + + patch import_mapping_path(@import, mapping), params: { + import_mapping: { + mappable_type: "Category", + mappable_id: new_category.id, + key: "Food" + } + } + + mapping.reload + + assert_equal new_category, mapping.mappable + assert_equal "Food", mapping.key + + assert_redirected_to import_confirm_path(@import) + end +end diff --git a/test/controllers/import/rows_controller_test.rb b/test/controllers/import/rows_controller_test.rb new file mode 100644 index 00000000..d16fd143 --- /dev/null +++ b/test/controllers/import/rows_controller_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Import::RowsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + + @import = imports(:transaction) + @row = import_rows(:one) + end + + test "show transaction row" do + get import_row_path(@import, @row) + + assert_row_fields(@row, [ :date, :name, :amount, :currency, :category, :tags, :account, :notes ]) + + assert_response :success + end + + test "show trade row" do + import = @user.family.imports.create!(type: "TradeImport") + row = import.rows.create!(date: "01/01/2024", currency: "USD", qty: 10, price: 100, ticker: "AAPL") + + get import_row_path(import, row) + + assert_row_fields(row, [ :date, :ticker, :qty, :price, :currency, :account, :name ]) + + assert_response :success + end + + test "show account row" do + import = @user.family.imports.create!(type: "AccountImport") + row = import.rows.create!(name: "Test Account", amount: 10000, currency: "USD") + + get import_row_path(import, row) + + assert_row_fields(row, [ :entity_type, :name, :amount, :currency ]) + + assert_response :success + end + + test "show mint row" do + import = @user.family.imports.create!(type: "MintImport") + row = import.rows.create!(date: "01/01/2024", amount: 100, currency: "USD") + + get import_row_path(import, row) + + assert_row_fields(row, [ :date, :name, :amount, :currency, :category, :tags, :account, :notes ]) + + assert_response :success + end + + test "update" do + patch import_row_path(@import, @row), params: { + import_row: { + account: "Checking Account", + date: "2024-01-01", + qty: nil, + ticker: nil, + price: nil, + amount: 100, + currency: "USD", + name: "Test", + category: "Food", + tags: "grocery, dinner", + entity_type: nil, + notes: "Weekly shopping" + } + } + + assert_redirected_to import_row_path(@import, @row) + end + + private + def assert_row_fields(row, fields) + fields.each do |field| + assert_select "turbo-frame##{dom_id(row, field)}" + end + end +end diff --git a/test/controllers/import/uploads_controller_test.rb b/test/controllers/import/uploads_controller_test.rb new file mode 100644 index 00000000..75db3c4a --- /dev/null +++ b/test/controllers/import/uploads_controller_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class Import::UploadsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @import = imports(:transaction) + end + + test "show" do + get import_upload_url(@import) + assert_response :success + end + + test "uploads valid csv by copy and pasting" do + patch import_upload_url(@import), params: { + import: { + raw_file_str: file_fixture("imports/valid.csv").read + } + } + + assert_redirected_to import_configuration_url(@import) + assert_equal "CSV uploaded successfully.", flash[:notice] + end + + test "uploads valid csv by file" do + patch import_upload_url(@import), params: { + import: { + csv_file: file_fixture_upload("imports/valid.csv") + } + } + + assert_redirected_to import_configuration_url(@import) + assert_equal "CSV uploaded successfully.", flash[:notice] + end + + test "invalid csv cannot be uploaded" do + patch import_upload_url(@import), params: { + import: { + csv_file: file_fixture_upload("imports/invalid.csv") + } + } + + assert_response :unprocessable_entity + assert_equal "Must be valid CSV with headers and at least one row of data", flash[:alert] + end +end diff --git a/test/controllers/imports_controller_test.rb b/test/controllers/imports_controller_test.rb index 1cbb862f..b8668491 100644 --- a/test/controllers/imports_controller_test.rb +++ b/test/controllers/imports_controller_test.rb @@ -1,20 +1,13 @@ require "test_helper" class ImportsControllerTest < ActionDispatch::IntegrationTest - include ImportTestHelper - setup do sign_in @user = users(:family_admin) - @empty_import = imports(:empty_import) - - @loaded_import = @empty_import.dup - @loaded_import.update! raw_file_str: valid_csv_str - - @completed_import = imports(:completed_import) end - test "should get index" do + test "gets index" do get imports_url + assert_response :success @user.family.imports.ordered.each do |import| @@ -22,152 +15,44 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest end end - test "should get new" do + test "gets new" do get new_import_url + assert_response :success + + assert_select "turbo-frame#modal" end - test "should create import" do - assert_difference("Import.count") do - post imports_url, params: { import: { account_id: @user.family.accounts.first.id, col_sep: "," } } - 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, col_sep: "," } } - 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_file_str: valid_csv_str } } - - assert_redirected_to configure_import_path(@empty_import) - assert_equal "Import CSV loaded", flash[:notice] - end - - test "should upload CSV file if valid" do - Tempfile.open([ "transactions.csv", ".csv" ]) do |temp| - CSV.open(temp, "wb", headers: true) do |csv| - valid_csv_str.split("\n").each { |row| csv << row.split(",") } - end - - patch upload_import_url(@empty_import), params: { import: { raw_file_str: Rack::Test::UploadedFile.new(temp, ".csv") } } - assert_redirected_to configure_import_path(@empty_import) - assert_equal "CSV File loaded", flash[:notice] - end - end - - test "should flash error message if invalid CSV input" do - patch load_import_url(@empty_import), params: { import: { raw_file_str: malformed_csv_str } } - - assert_response :unprocessable_entity - assert_equal "Raw file str is not a valid CSV format", flash[:alert] - end - - test "should flash error message if invalid CSV file upload" do - Tempfile.open([ "transactions.csv", ".csv" ]) do |temp| - temp.write(malformed_csv_str) - temp.rewind - - patch upload_import_url(@empty_import), params: { import: { raw_file_str: Rack::Test::UploadedFile.new(temp, ".csv") } } - assert_response :unprocessable_entity - assert_equal "Raw file str is not a valid CSV format", flash[:alert] - end - end - - test "should flash error message if no fileprovided for upload" do - patch upload_import_url(@empty_import), params: { import: { raw_file_str: nil } } - assert_response :unprocessable_entity - assert_equal "Please select a file to upload", flash[:alert] - 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" + test "creates import" do + assert_difference "Import.count", 1 do + post imports_url, params: { + import: { + type: "TransactionImport" } } - } + end - assert_redirected_to clean_import_path(@loaded_import) - assert_equal "Column mappings saved", flash[:notice] + assert_redirected_to import_upload_url(Import.all.ordered.first) end - test "can update a cell" do - assert_equal @loaded_import.csv.table[0][1], "Starbucks drink" + test "publishes import" do + import = imports(:transaction) - patch clean_import_url(@loaded_import), params: { - import: { - csv_update: { - row_idx: 0, - col_idx: 1, - value: "new_merchant" - } - } - } + TransactionImport.any_instance.expects(:publish_later).once - assert_response :success + post publish_import_url(import) - @loaded_import.reload - assert_equal "new_merchant", @loaded_import.csv.table[0][1] + assert_equal "Your import has started in the background.", flash[:notice] + assert_redirected_to import_path(import) end - test "should get clean" do - get clean_import_url(@loaded_import) - assert_response :success - end + test "destroys import" do + import = imports(:transaction) - test "should get confirm if all values are valid" do - get confirm_import_url(@loaded_import) - assert_response :success - end + assert_difference "Import.count", -1 do + delete import_url(import) + end - test "should redirect back to clean if data is invalid" do - @empty_import.update! raw_file_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/files/imports/accounts.csv b/test/fixtures/files/imports/accounts.csv new file mode 100644 index 00000000..fb3974f8 --- /dev/null +++ b/test/fixtures/files/imports/accounts.csv @@ -0,0 +1,5 @@ +type,name,amount,currency +Checking,Main Checking Account,5000.00,USD +Savings,Emergency Fund,10000.00,USD +Credit Card,Rewards Credit Card,-1500.00,USD +Investment,Retirement Portfolio,75000.00,USD diff --git a/test/fixtures/files/imports/invalid.csv b/test/fixtures/files/imports/invalid.csv new file mode 100644 index 00000000..cae4503c --- /dev/null +++ b/test/fixtures/files/imports/invalid.csv @@ -0,0 +1,3 @@ +name,age +"John Doe,23 +"Jane Doe",25 \ No newline at end of file diff --git a/test/fixtures/files/imports/mint.csv b/test/fixtures/files/imports/mint.csv new file mode 100644 index 00000000..970ec1ff --- /dev/null +++ b/test/fixtures/files/imports/mint.csv @@ -0,0 +1,11 @@ +Date,Description,Original Description,Amount,Transaction Type,Category,Account Name,Labels,Notes +05/01/2023,Grocery Store,SAFEWAY #1234,78.32,debit,Groceries,Checking Account,, +05/02/2023,Gas Station,SHELL OIL 57442893,-45.67,credit,Gas & Fuel,Credit Card,, +05/03/2023,Monthly Rent,AUTOPAY MORTGAGE,1500.00,debit,Mortgage & Rent,Checking Account,, +05/04/2023,Paycheck,ACME CORP PAYROLL,-2500.00,credit,Paycheck,Checking Account,Income, +05/05/2023,Restaurant,CHIPOTLE MEX GRILL,15.75,debit,Restaurants,Credit Card,, +05/06/2023,Online Shopping,AMAZON.COM,32.99,debit,Shopping,Credit Card,, +05/07/2023,Utility Bill,CITY POWER & LIGHT,89.50,debit,Utilities,Checking Account,, +05/08/2023,Coffee Shop,STARBUCKS,4.25,debit,Coffee Shops,Credit Card,, +05/09/2023,Gym Membership,FITNESS WORLD,49.99,debit,Gym,Checking Account,Health,Monthly membership +05/10/2023,Movie Theater,AMC THEATERS #123,24.50,debit,Movies & DVDs,Credit Card,Entertainment, diff --git a/test/fixtures/files/imports/trades.csv b/test/fixtures/files/imports/trades.csv new file mode 100644 index 00000000..f4d8fc30 --- /dev/null +++ b/test/fixtures/files/imports/trades.csv @@ -0,0 +1,11 @@ +date,ticker,qty,price,amount,account,name +2023-01-15,AAPL,10,150.25,1502.50,Brokerage Account,Buy Apple Inc +2023-02-03,GOOGL,5,2100.75,10503.75,Retirement Account,Buy Alphabet Inc +2023-03-10,MSFT,15,245.50,3682.50,Brokerage Account,Buy Microsoft Corp +2023-04-05,AMZN,8,3200.00,25600.00,Brokerage Account,Buy Amazon.com Inc +2023-05-20,TSLA,20,180.75,3615.00,Retirement Account,Buy Tesla Inc +2023-06-15,AAPL,-5,170.50,-852.50,Brokerage Account,Sell Apple Inc +2023-07-02,GOOGL,-2,2250.00,-4500.00,Retirement Account,Sell Alphabet Inc +2023-08-18,NVDA,12,450.25,5403.00,Brokerage Account,Buy NVIDIA Corp +2023-09-07,MSFT,-7,300.75,-2105.25,Brokerage Account,Sell Microsoft Corp +2023-10-01,META,25,310.50,7762.50,Retirement Account,Buy Meta Platforms Inc diff --git a/test/fixtures/files/imports/transactions.csv b/test/fixtures/files/imports/transactions.csv new file mode 100644 index 00000000..1e35cc80 --- /dev/null +++ b/test/fixtures/files/imports/transactions.csv @@ -0,0 +1,10 @@ +Date,Name,Amount,Category,Tags,Account,Notes +2023-05-01,Grocery Store,-89.75,Food,Groceries,Checking Account,Weekly grocery shopping +2023-05-03,Electric Company,-120.50,Utilities,Bills|Home,Credit Card,Monthly electricity bill +2023-05-05,Coffee Shop,-4.25,Food,Coffee|Work,Debit Card,Morning coffee +2023-05-07,Gas Station,-45.00,Transportation,Car|Fuel,Credit Card,Fill up car tank +2023-05-10,Online Retailer,-79.99,Shopping,Clothing,Credit Card,New shoes purchase +2023-05-12,Restaurant,-65.30,Food,Dining Out|Date Night,Checking Account,Dinner with partner +2023-05-15,Mobile Phone Provider,-55.00,Utilities,Bills|Communication,Debit Card,Monthly phone bill +2023-05-18,Movie Theater,-24.00,Entertainment,Movies,Credit Card,Weekend movie night +2023-05-20,Pharmacy,-32.50,Health,Medicine,Debit Card,Prescription refill diff --git a/test/fixtures/files/transactions.csv b/test/fixtures/files/imports/valid.csv similarity index 100% rename from test/fixtures/files/transactions.csv rename to test/fixtures/files/imports/valid.csv diff --git a/test/fixtures/import/mappings.yml b/test/fixtures/import/mappings.yml new file mode 100644 index 00000000..da8c5e1b --- /dev/null +++ b/test/fixtures/import/mappings.yml @@ -0,0 +1,6 @@ +one: + import: transaction + key: Food + type: Import::CategoryMapping + mappable: food_and_drink + mappable_type: Category diff --git a/test/fixtures/import/rows.yml b/test/fixtures/import/rows.yml new file mode 100644 index 00000000..7cc0fd7f --- /dev/null +++ b/test/fixtures/import/rows.yml @@ -0,0 +1,5 @@ +one: + import: transaction + date: 01/01/2024 + amount: 100 + currency: USD \ No newline at end of file diff --git a/test/fixtures/imports.yml b/test/fixtures/imports.yml index 474472a4..00c09eff 100644 --- a/test/fixtures/imports.yml +++ b/test/fixtures/imports.yml @@ -1,20 +1,3 @@ -empty_import: - account: depository - created_at: <%= 1.minute.ago %> - -completed_import: - account: depository - column_mappings: - date: date - name: name - category: category - amount: amount - raw_file_str: | - date,name,category,tags,amount - 2024-01-01,Starbucks drink,Food & Drink,Test Tag,-20 - normalized_csv_str: | - date,name,category,tags,amount - 2024-01-01,Starbucks drink,Food & Drink,Test Tag,-20 - created_at: <%= 2.days.ago %> - - +transaction: + family: dylan_family + type: TransactionImport diff --git a/test/interfaces/import_interface_test.rb b/test/interfaces/import_interface_test.rb new file mode 100644 index 00000000..68b09306 --- /dev/null +++ b/test/interfaces/import_interface_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +module ImportInterfaceTest + extend ActiveSupport::Testing::Declarative + + test "import interface" do + assert_respond_to @subject, :publish + assert_respond_to @subject, :publish_later + assert_respond_to @subject, :generate_rows_from_csv + assert_respond_to @subject, :csv_rows + assert_respond_to @subject, :csv_headers + assert_respond_to @subject, :csv_sample + assert_respond_to @subject, :uploaded? + assert_respond_to @subject, :configured? + assert_respond_to @subject, :cleaned? + assert_respond_to @subject, :publishable? + assert_respond_to @subject, :importing? + assert_respond_to @subject, :complete? + assert_respond_to @subject, :failed? + end + + test "publishes later" do + import = imports(:transaction) + + import.stubs(:publishable?).returns(true) + + assert_enqueued_with job: ImportJob, args: [ import ] do + import.publish_later + end + + assert_equal "importing", import.reload.status + end + + test "raises if not publishable" do + import = imports(:transaction) + + import.stubs(:publishable?).returns(false) + + assert_raises(RuntimeError, "Import is not publishable") do + import.publish_later + end + end + + test "handles publish errors" do + import = imports(:transaction) + + import.stubs(:publishable?).returns(true) + import.stubs(:import!).raises(StandardError, "Failed to publish") + + assert_nil import.error + + import.publish + + assert_equal "Failed to publish", import.error + assert_equal "failed", import.status + end +end diff --git a/test/jobs/import_job_test.rb b/test/jobs/import_job_test.rb index 1b59994c..edf26c26 100644 --- a/test/jobs/import_job_test.rb +++ b/test/jobs/import_job_test.rb @@ -1,19 +1,10 @@ require "test_helper" class ImportJobTest < ActiveJob::TestCase - include ImportTestHelper - test "import is published" do - import = imports(:empty_import) - import.update! raw_file_str: valid_csv_str + import = imports(:transaction) + import.expects(:publish).once - assert import.pending? - - perform_enqueued_jobs do - ImportJob.perform_later(import) - end - - assert import.reload.complete? - assert import.account.balances.present? + ImportJob.perform_now(import) end end diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index e1bfa6da..f187ffa1 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -99,20 +99,4 @@ class Account::EntryTest < ActiveSupport::TestCase assert create_transaction(amount: -10).inflow? assert create_transaction(amount: 10).outflow? end - - test "cannot sell more shares of stock than owned" do - account = families(:empty).accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Investment.new - security = securities(:aapl) - - error = assert_raises ActiveRecord::RecordInvalid do - account.entries.create! \ - date: Date.current, - amount: 100, - currency: "USD", - name: "Sell 10 shares of AMZN", - entryable: Account::Trade.new(qty: -10, price: 200, security: security) - end - - assert_match /cannot sell 10.0 shares of AAPL because you only own 0.0 shares/, error.message - end end diff --git a/test/models/import/csv_test.rb b/test/models/import/csv_test.rb deleted file mode 100644 index 64600102..00000000 --- a/test/models/import/csv_test.rb +++ /dev/null @@ -1,129 +0,0 @@ -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 "CSV with semicolon column separator" do - csv = Import::Csv.new(valid_csv_str_with_semicolon_separator, col_sep: ";") - - assert_equal %w[date name category tags amount], csv.table.headers - assert_equal 4, csv.table.size - assert_equal "Paycheck", csv.table[3][1] - end - - test "csv with additional columns and empty values" do - csv = Import::Csv.new valid_csv_with_missing_data - assert 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_file_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_file_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 - - test "can create CSV with expected columns, field mappings with validators and semicolon column separator" 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_file_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_file_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 deleted file mode 100644 index 1550a449..00000000 --- a/test/models/import/field_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -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/mapping_test.rb b/test/models/import/mapping_test.rb new file mode 100644 index 00000000..6286a582 --- /dev/null +++ b/test/models/import/mapping_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Import::MappingTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/import/row_test.rb b/test/models/import/row_test.rb new file mode 100644 index 00000000..afe1a4f6 --- /dev/null +++ b/test/models/import/row_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Import::RowTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/import_test.rb b/test/models/import_test.rb deleted file mode 100644 index ac754935..00000000 --- a/test/models/import_test.rb +++ /dev/null @@ -1,115 +0,0 @@ -require "test_helper" - -class ImportTest < ActiveSupport::TestCase - include ImportTestHelper, ActiveJob::TestHelper - - setup do - @empty_import = imports(:empty_import) - - @loaded_import = @empty_import.dup - @loaded_import.update! raw_file_str: valid_csv_str - end - - test "validates the correct col_sep" do - assert_equal ",", @empty_import.col_sep - - assert @empty_import.valid? - - @empty_import.col_sep = "invalid" - assert @empty_import.invalid? - - @empty_import.col_sep = "," - assert @empty_import.valid? - - @empty_import.col_sep = ";" - assert @empty_import.valid? - end - - test "raw csv input must conform to csv spec" do - @empty_import.raw_file_str = malformed_csv_str - assert_not @empty_import.valid? - - @empty_import.raw_file_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_file_str_value = @loaded_import.raw_file_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_file_str_value, @loaded_import.raw_file_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 - # Import has 3 unique categories: "Food & Drink", "Income", and "Shopping" (x2) - # Fixtures already define "Food & Drink" and "Income", so these should not be created - # "Shopping" is a new category, but should only be created 1x during import - assert_difference \ - -> { Account::Transaction.count } => 4, - -> { Account::Entry.count } => 4, - -> { Category.count } => 1, - -> { Tagging.count } => 4, - -> { Tag.count } => 2 do - @loaded_import.publish - end - - @loaded_import.reload - - assert @loaded_import.complete? - end - - test "publishes a valid import with missing data" do - @empty_import.update! raw_file_str: valid_csv_with_missing_data - assert_difference -> { Category.count } => 1, - -> { Account::Transaction.count } => 2, - -> { Account::Entry.count } => 2 do - @empty_import.publish - end - - assert_not_nil Account::Entry.find_sole_by(name: Import::FALLBACK_TRANSACTION_NAME) - - @empty_import.reload - - assert @empty_import.complete? - end - - test "failed publish results in error status" do - @empty_import.update! raw_file_str: valid_csv_with_invalid_values - - assert_difference "Account::Transaction.count", 0 do - @empty_import.publish - end - - @empty_import.reload - assert @empty_import.failed? - end - - test "can create transactions from csv with custom column separator" do - loaded_import = @empty_import.dup - - loaded_import.update! raw_file_str: valid_csv_str_with_semicolon_separator, col_sep: ";" - transactions = loaded_import.dry_run - - assert_equal 4, transactions.count - - data = transactions.first.as_json(only: [ :name, :amount, :date ]) - assert_equal data, { "amount" => "8.55", "date" => "2024-01-01", "name" => "Starbucks drink" } - - assert_equal valid_csv_str, loaded_import.normalized_csv_str - end -end diff --git a/test/models/transaction_import_test.rb b/test/models/transaction_import_test.rb new file mode 100644 index 00000000..fc3e2ab9 --- /dev/null +++ b/test/models/transaction_import_test.rb @@ -0,0 +1,66 @@ +require "test_helper" + +class TransactionImportTest < ActiveSupport::TestCase + include ActiveJob::TestHelper, ImportInterfaceTest + + setup do + @subject = @import = imports(:transaction) + end + + test "uploaded? if raw_file_str is present" do + @import.expects(:raw_file_str).returns("test").once + assert @import.uploaded? + end + + test "configured? if uploaded and rows are generated" do + @import.expects(:uploaded?).returns(true).once + assert @import.configured? + end + + test "cleaned? if rows are generated and valid" do + @import.expects(:configured?).returns(true).once + assert @import.cleaned? + end + + test "publishable? if cleaned and mappings are valid" do + @import.expects(:cleaned?).returns(true).once + assert @import.publishable? + end + + test "imports transactions, categories, tags, and accounts" do + import = <<-CSV + date,name,amount,category,tags,account,notes + 01/01/2024,Txn1,100,TestCategory1,TestTag1,TestAccount1,notes1 + 01/02/2024,Txn2,200,TestCategory2,TestTag1|TestTag2,TestAccount2,notes2 + 01/03/2024,Txn3,300,,,,notes3 + CSV + + @import.update!(raw_file_str: import) + + @import.generate_rows_from_csv + + @import.mappings.create! key: "TestCategory1", create_when_empty: true, type: "Import::CategoryMapping" + @import.mappings.create! key: "TestCategory2", mappable: categories(:food_and_drink), type: "Import::CategoryMapping" + @import.mappings.create! key: "", create_when_empty: false, mappable: nil, type: "Import::CategoryMapping" # Leaves uncategorized + + @import.mappings.create! key: "TestTag1", create_when_empty: true, type: "Import::TagMapping" + @import.mappings.create! key: "TestTag2", mappable: tags(:one), type: "Import::TagMapping" + @import.mappings.create! key: "", create_when_empty: false, mappable: nil, type: "Import::TagMapping" # Leaves untagged + + @import.mappings.create! key: "TestAccount1", create_when_empty: true, type: "Import::AccountMapping" + @import.mappings.create! key: "TestAccount2", mappable: accounts(:depository), type: "Import::AccountMapping" + @import.mappings.create! key: "", mappable: accounts(:depository), type: "Import::AccountMapping" + + @import.reload + + assert_difference -> { Account::Entry.count } => 3, + -> { Account::Transaction.count } => 3, + -> { Tag.count } => 1, + -> { Category.count } => 1, + -> { Account.count } => 1 do + @import.publish + end + + assert_equal "complete", @import.status + end +end diff --git a/test/support/import_test_helper.rb b/test/support/import_test_helper.rb deleted file mode 100644 index 95a1adc8..00000000 --- a/test/support/import_test_helper.rb +++ /dev/null @@ -1,44 +0,0 @@ -module ImportTestHelper - def valid_csv_str - <<~ROWS - date,name,category,tags,amount - 2024-01-01,Starbucks drink,Food & Drink,Tag1|Tag2,-8.55 - 2024-01-01,Etsy,Shopping,Tag1,-80.98 - 2024-01-02,Amazon stuff,Shopping,Tag2,-200 - 2024-01-03,Paycheck,Income,,1000 - ROWS - end - - def valid_csv_str_with_semicolon_separator - <<~ROWS - date;name;category;tags;amount - 2024-01-01;Starbucks drink;Food & Drink;Tag1|Tag2;-8.55 - 2024-01-01;Etsy;Shopping;Tag1;-80.98 - 2024-01-02;Amazon stuff;Shopping;Tag2;-200 - 2024-01-03;Paycheck;Income;;1000 - ROWS - end - - def valid_csv_with_invalid_values - <<~ROWS - date,name,category,tags,amount - invalid_date,Starbucks drink,Food,,invalid_amount - ROWS - end - - def valid_csv_with_missing_data - <<~ROWS - date,name,category,"optional id",amount - 2024-01-01,Drink,Food,1234,-200 - 2024-01-02,,,,-100 - 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 index e8ba2788..02270bb9 100644 --- a/test/system/imports_test.rb +++ b/test/system/imports_test.rb @@ -1,170 +1,157 @@ require "application_system_test_case" class ImportsTest < ApplicationSystemTestCase - include ImportTestHelper + include ActiveJob::TestHelper 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 + test "transaction import" do + visit new_import_path + + click_on "Import transactions" + + fill_in "import[raw_file_str]", with: file_fixture("imports/transactions.csv").read + + find('input[type="submit"][value="Upload CSV"]').click + + select "Date", from: "Date" + select "YYYY-MM-DD", from: "Date format" + select "Amount", from: "Amount" + select "Account", from: "Account (optional)" + select "Name", from: "Name (optional)" + select "Category", from: "Category (optional)" + select "Tags", from: "Tags (optional)" + select "Notes", from: "Notes (optional)" + + click_on "Apply configuration" + + click_on "Next step" + + assert_selector "h1", text: "Assign your categories" + click_on "Next" + + assert_selector "h1", text: "Assign your tags" + click_on "Next" + + assert_selector "h1", text: "Assign your accounts" + click_on "Next" + + click_on "Publish import" + + assert_text "Import in progress" + + perform_enqueued_jobs + + click_on "Check status" + + assert_text "Import successful" + + click_on "Back to dashboard" end - test "can resume existing import from settings" do - visit imports_url + test "trade import" do + visit new_import_path - within "#" + dom_id(@imports.first) do - click_button - click_link "Edit" - end + click_on "Import investments" - assert_current_path edit_import_path(@imports.first) + fill_in "import[raw_file_str]", with: file_fixture("imports/trades.csv").read + + find('input[type="submit"][value="Upload CSV"]').click + + select "YYYY-MM-DD", from: "Date format" + + click_on "Apply configuration" + + click_on "Next step" + + assert_selector "h1", text: "Assign your accounts" + click_on "Next" + + click_on "Publish import" + + assert_text "Import in progress" + + perform_enqueued_jobs + + click_on "Check status" + + assert_text "Import successful" + + click_on "Back to dashboard" end - test "can resume latest import" do - trigger_import_from_transactions - verify_import_modal + test "account import" do + visit new_import_path - click_link "Resume latest import" + click_on "Import accounts" - assert_current_path edit_import_path(@imports.first) - end + fill_in "import[raw_file_str]", with: file_fixture("imports/accounts.csv").read - test "can perform basic CSV import" do - trigger_import_from_settings - verify_import_modal + find('input[type="submit"][value="Upload CSV"]').click - within "#modal" do - click_link "New import from CSV" - end + click_on "Apply configuration" - # 1) Create import step - assert_selector "h1", text: "New import" + click_on "Next step" - within "form" do - select "Checking Account", from: "import_account_id" - end + assert_selector "h1", text: "Assign your account types" - click_button "Next" - - click_button "Copy & Paste" - - # 2) Load Step - assert_selector "h1", text: "Load import" - - within "form" do - fill_in "import_raw_file_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" - assert_selector "#new_account_entry", count: 2 - click_button "Import 2 transactions" - assert_selector "h1", text: "Imports" - end - - test "can perform import by CSV upload" 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" - - click_button "Upload CSV" - - find(".raw-file-drop-box").drop File.join(file_fixture_path, "transactions.csv") - assert_selector "div.csv-preview", text: "transactions.csv" - - 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" - assert_selector "#new_account_entry", count: 2 - 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" + all("form").each do |form| + within(form) do + select = form.find("select") + select "Depository", from: select["id"] + sleep 1 end end + + click_on "Next" + + click_on "Publish import" + + assert_text "Import in progress" + + perform_enqueued_jobs + + click_on "Check status" + + assert_text "Import successful" + + click_on "Back to dashboard" + end + + test "mint import" do + visit new_import_path + + click_on "Import from Mint" + + fill_in "import[raw_file_str]", with: file_fixture("imports/mint.csv").read + + find('input[type="submit"][value="Upload CSV"]').click + + click_on "Apply configuration" + + click_on "Next step" + + assert_selector "h1", text: "Assign your categories" + click_on "Next" + + assert_selector "h1", text: "Assign your tags" + click_on "Next" + + assert_selector "h1", text: "Assign your accounts" + click_on "Next" + + click_on "Publish import" + + assert_text "Import in progress" + + perform_enqueued_jobs + + click_on "Check status" + + assert_text "Import successful" + + click_on "Back to dashboard" + end end