From 45ae4a973777265626a7cb9ec23a8b27caee87b0 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 17 May 2024 09:09:32 -0400 Subject: [PATCH] CSV Transaction Imports (#708) Introduces a basic CSV import module for bulk-importing account transactions. Changes include: - User can load a CSV - User can configure the column mappings for a CSV - Imported CSV shows invalid cells - User can clean up their data directly in the UI - User can see a preview of the import rows and confirm import - Layout refactor + Import nav stepper - System test stability improvements --- Gemfile | 1 + Gemfile.lock | 2 + app/assets/images/apple-logo.png | Bin 0 -> 2923 bytes app/assets/images/empower-logo.jpeg | Bin 0 -> 13383 bytes app/assets/images/mint-logo.jpeg | Bin 0 -> 8391 bytes app/controllers/accounts_controller.rb | 16 +- app/controllers/imports_controller.rb | 102 +++++++++++ app/controllers/pages_controller.rb | 2 + .../settings/billings_controller.rb | 2 +- .../settings/hostings_controller.rb | 2 +- .../settings/notifications_controller.rb | 2 +- .../settings/preferences_controller.rb | 5 +- .../settings/profiles_controller.rb | 2 +- .../settings/securities_controller.rb | 2 +- app/controllers/settings_controller.rb | 3 + .../categories/deletions_controller.rb | 2 + .../transactions/categories_controller.rb | 2 + .../transactions/merchants_controller.rb | 2 + .../transactions/rules_controller.rb | 2 + app/controllers/transactions_controller.rb | 2 + app/helpers/application_helper.rb | 5 + app/helpers/imports_helper.rb | 19 +++ app/jobs/import_job.rb | 7 + app/models/account.rb | 1 + app/models/family.rb | 1 + app/models/import.rb | 161 ++++++++++++++++++ app/models/import/csv.rb | 74 ++++++++ app/models/import/field.rb | 32 ++++ app/views/imports/_empty.html.erb | 9 + app/views/imports/_form.html.erb | 7 + app/views/imports/_import.html.erb | 61 +++++++ app/views/imports/_nav_step.html.erb | 18 ++ app/views/imports/_sample_table.html.erb | 22 +++ app/views/imports/_type_selector.html.erb | 90 ++++++++++ app/views/imports/clean.html.erb | 49 ++++++ app/views/imports/configure.html.erb | 24 +++ app/views/imports/confirm.html.erb | 16 ++ app/views/imports/edit.html.erb | 10 ++ app/views/imports/index.html.erb | 30 ++++ app/views/imports/load.html.erb | 39 +++++ app/views/imports/new.html.erb | 16 ++ app/views/imports/show.html.erb | 15 ++ .../transactions/_transaction.html.erb | 12 ++ .../transactions/_transaction_group.html.erb | 13 ++ app/views/layouts/application.html.erb | 51 +++--- app/views/layouts/auth.html.erb | 74 +++----- app/views/layouts/imports.html.erb | 34 ++++ app/views/layouts/with_sidebar.html.erb | 18 ++ app/views/pages/changelog.html.erb | 2 +- app/views/settings/_nav.html.erb | 3 + app/views/shared/_app_version.html.erb | 6 + app/views/transactions/index.html.erb | 16 +- app/views/transactions/rules/index.html.erb | 2 +- config/environments/test.rb | 2 + config/locales/views/imports/en.yml | 95 +++++++++++ config/locales/views/settings/en.yml | 1 + config/locales/views/transaction/en.yml | 2 + config/routes.rb | 18 +- db/migrate/20240502205006_create_imports.rb | 15 ++ db/schema.rb | 15 +- test/application_system_test_case.rb | 32 ++-- test/controllers/imports_controller_test.rb | 141 +++++++++++++++ test/fixtures/imports.yml | 32 ++++ test/integration/.keep | 0 test/jobs/import_job_test.rb | 18 ++ test/models/import/csv_test.rb | 87 ++++++++++ test/models/import/field_test.rb | 28 +++ test/models/import_test.rb | 61 +++++++ test/support/import_test_helper.rb | 24 +++ test/system/imports_test.rb | 113 ++++++++++++ test/system/settings_test.rb | 2 + 71 files changed, 1657 insertions(+), 117 deletions(-) create mode 100644 app/assets/images/apple-logo.png create mode 100644 app/assets/images/empower-logo.jpeg create mode 100644 app/assets/images/mint-logo.jpeg create mode 100644 app/controllers/imports_controller.rb create mode 100644 app/controllers/settings_controller.rb create mode 100644 app/helpers/imports_helper.rb create mode 100644 app/jobs/import_job.rb create mode 100644 app/models/import.rb create mode 100644 app/models/import/csv.rb create mode 100644 app/models/import/field.rb create mode 100644 app/views/imports/_empty.html.erb create mode 100644 app/views/imports/_form.html.erb create mode 100644 app/views/imports/_import.html.erb create mode 100644 app/views/imports/_nav_step.html.erb create mode 100644 app/views/imports/_sample_table.html.erb create mode 100644 app/views/imports/_type_selector.html.erb create mode 100644 app/views/imports/clean.html.erb create mode 100644 app/views/imports/configure.html.erb create mode 100644 app/views/imports/confirm.html.erb create mode 100644 app/views/imports/edit.html.erb create mode 100644 app/views/imports/index.html.erb create mode 100644 app/views/imports/load.html.erb create mode 100644 app/views/imports/new.html.erb create mode 100644 app/views/imports/show.html.erb create mode 100644 app/views/imports/transactions/_transaction.html.erb create mode 100644 app/views/imports/transactions/_transaction_group.html.erb create mode 100644 app/views/layouts/imports.html.erb create mode 100644 app/views/layouts/with_sidebar.html.erb create mode 100644 app/views/shared/_app_version.html.erb create mode 100644 config/locales/views/imports/en.yml create mode 100644 db/migrate/20240502205006_create_imports.rb create mode 100644 test/controllers/imports_controller_test.rb create mode 100644 test/fixtures/imports.yml delete mode 100644 test/integration/.keep create mode 100644 test/jobs/import_job_test.rb create mode 100644 test/models/import/csv_test.rb create mode 100644 test/models/import/field_test.rb create mode 100644 test/models/import_test.rb create mode 100644 test/support/import_test_helper.rb create mode 100644 test/system/imports_test.rb diff --git a/Gemfile b/Gemfile index 194186db..41c7d699 100644 --- a/Gemfile +++ b/Gemfile @@ -46,6 +46,7 @@ gem "octokit" gem "pagy" gem "rails-settings-cached" gem "tzinfo-data", platforms: %i[ windows jruby ] +gem "csv" group :development, :test do gem "debug", platforms: %i[ mri windows ] diff --git a/Gemfile.lock b/Gemfile.lock index 6f604816..4f60365a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -156,6 +156,7 @@ GEM bigdecimal rexml crass (1.0.6) + csv (3.2.8) date (3.3.4) debug (1.9.2) irb (~> 1.10) @@ -456,6 +457,7 @@ DEPENDENCIES brakeman capybara climate_control + csv debug dotenv-rails erb_lint diff --git a/app/assets/images/apple-logo.png b/app/assets/images/apple-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..107a7858e8d431e3e953cd4a424277fb48ba96ec GIT binary patch literal 2923 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 ArT_o{ literal 0 HcmV?d00001 diff --git a/app/assets/images/empower-logo.jpeg b/app/assets/images/empower-logo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..7193dcc29d305f6156bdd42d773f6961c230d295 GIT binary patch literal 13383 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 { order(created_at: :desc) } + + def publish_later + ImportJob.perform_later(self) + end + + def loaded? + raw_csv_str.present? + end + + def configured? + csv.present? + end + + def cleaned? + loaded? && configured? && csv.valid? + end + + def csv + get_normalized_csv_with_validation + end + + def available_headers + get_raw_csv.table.headers + end + + def get_selected_header_for_field(field) + column_mappings&.dig(field) || field.key + end + + def update_csv!(row_idx:, col_idx:, value:) + updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value) + update! normalized_csv_str: updated_csv.to_s + end + + # Type-specific methods (potential STI inheritance in future when more import types added) + def publish + update!(status: "importing") + + transaction do + generate_transactions.each do |txn| + txn.save! + end + end + + update!(status: "complete") + rescue => e + update!(status: "failed") + Rails.logger.error("Import with id #{id} failed: #{e}") + end + + def dry_run + generate_transactions + end + + def expected_fields + @expected_fields ||= create_expected_fields + end + + private + + def get_normalized_csv_with_validation + return nil if normalized_csv_str.nil? + + csv = Import::Csv.new(normalized_csv_str) + + expected_fields.each do |field| + csv.define_validator(field.key, field.validator) if field.validator + end + + csv + end + + def get_raw_csv + return nil if raw_csv_str.nil? + Import::Csv.new(raw_csv_str) + end + + def should_initialize_csv? + raw_csv_str_changed? || column_mappings_changed? + end + + def initialize_csv + generated_csv = generate_normalized_csv(raw_csv_str) + self.normalized_csv_str = generated_csv.table.to_s + end + + # Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import + def generate_normalized_csv(csv_str) + Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings) + end + + def update_csv(row_idx, col_idx, value) + updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value) + update! normalized_csv_str: updated_csv.to_s + end + + def generate_transactions + transactions = [] + + csv.table.each do |row| + category = account.family.transaction_categories.find_or_initialize_by(name: row["category"]) + txn = account.transactions.build \ + name: row["name"] || "Imported transaction", + date: Date.iso8601(row["date"]), + category: category, + amount: BigDecimal(row["amount"]) * -1 # User inputs amounts with opposite signage of our internal representation + + transactions << txn + end + + transactions + end + + def create_expected_fields + date_field = Import::Field.new \ + key: "date", + label: "Date", + validator: ->(value) { Import::Field.iso_date_validator(value) } + + name_field = Import::Field.new \ + key: "name", + label: "Name" + + category_field = Import::Field.new \ + key: "category", + label: "Category" + + amount_field = Import::Field.new \ + key: "amount", + label: "Amount", + validator: ->(value) { Import::Field.bigdecimal_validator(value) } + + [ date_field, name_field, category_field, amount_field ] + end + + def define_column_mapping_keys + expected_fields.each do |field| + field.key.to_sym + end + end + + def raw_csv_must_be_parsable + begin + CSV.parse(raw_csv_str || "") + rescue CSV::MalformedCSVError + errors.add(:raw_csv_str, "is not a valid CSV format") + end + end +end diff --git a/app/models/import/csv.rb b/app/models/import/csv.rb new file mode 100644 index 00000000..5018af9c --- /dev/null +++ b/app/models/import/csv.rb @@ -0,0 +1,74 @@ +class Import::Csv + def self.parse_csv(csv_str) + CSV.parse((csv_str || "").strip, headers: true, converters: [ ->(str) { str.strip } ]) + end + + def self.create_with_field_mappings(raw_csv_str, fields, field_mappings) + raw_csv = self.parse_csv(raw_csv_str) + + generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true do |csv| + raw_csv.each do |row| + row_values = [] + + fields.each do |field| + # Finds the column header name the user has designated for the expected field + mapped_field_key = field_mappings[field.key] if field_mappings + mapped_header = mapped_field_key || field.key + + row_values << row.fetch(mapped_header, "") + end + + csv << row_values + end + end + + new(generated_csv_str) + end + + attr_reader :csv_str + + def initialize(csv_str, column_validators: nil) + @csv_str = csv_str + @column_validators = column_validators || {} + end + + def table + @table ||= self.class.parse_csv(csv_str) + end + + def update_cell(row_idx, col_idx, value) + copy = table.by_col_or_row + copy[row_idx][col_idx] = value + copy + end + + def valid? + table.each_with_index.all? do |row, row_idx| + row.each_with_index.all? do |cell, col_idx| + cell_valid?(row_idx, col_idx) + end + end + end + + def cell_valid?(row_idx, col_idx) + value = table.dig(row_idx, col_idx) + header = table.headers[col_idx] + validator = get_validator_by_header(header) + validator.call(value) + end + + def define_validator(header_key, validator = nil, &block) + header = table.headers.find { |h| h.strip == header_key } + raise "Cannot define validator for header #{header_key}: header does not exist in CSV" if header.nil? + + column_validators[header] = validator || block + end + + private + + attr_accessor :column_validators + + def get_validator_by_header(header) + column_validators&.dig(header) || ->(_v) { true } + end +end diff --git a/app/models/import/field.rb b/app/models/import/field.rb new file mode 100644 index 00000000..aff9f186 --- /dev/null +++ b/app/models/import/field.rb @@ -0,0 +1,32 @@ +class Import::Field + def self.iso_date_validator(value) + Date.iso8601(value) + true + rescue + false + end + + def self.bigdecimal_validator(value) + BigDecimal(value) + true + rescue + false + end + + attr_reader :key, :label, :validator + + def initialize(key:, label:, validator: nil) + @key = key.to_s + @label = label + @validator = validator + end + + def define_validator(validator = nil, &block) + @validator = validator || block + end + + def validate(value) + return true if validator.nil? + validator.call(value) + end +end diff --git a/app/views/imports/_empty.html.erb b/app/views/imports/_empty.html.erb new file mode 100644 index 00000000..ed7a3b32 --- /dev/null +++ b/app/views/imports/_empty.html.erb @@ -0,0 +1,9 @@ +
+
+

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

+ <%= link_to new_import_path(enable_type_selector: true), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> + <%= lucide_icon("plus", class: "w-5 h-5") %> + <%= t(".new") %> + <% end %> +
+
diff --git a/app/views/imports/_form.html.erb b/app/views/imports/_form.html.erb new file mode 100644 index 00000000..783a1350 --- /dev/null +++ b/app/views/imports/_form.html.erb @@ -0,0 +1,7 @@ +<%= form_with model: @import do |form| %> +
+ <%= form.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".account"), required: true } %> +
+ + <%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium" %> +<% end %> diff --git a/app/views/imports/_import.html.erb b/app/views/imports/_import.html.erb new file mode 100644 index 00000000..0983e01b --- /dev/null +++ b/app/views/imports/_import.html.erb @@ -0,0 +1,61 @@ +
+
+ +
+

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

+ + <% if import.pending? %> + + <%= t(".in_progress") %> + + <% elsif import.importing? %> + + <%= t(".uploading") %> + + <% elsif import.failed? %> + + <%= t(".failed") %> + + <% elsif import.complete? %> + + <%= t(".complete") %> + + <% end %> +
+ + <% if import.complete? %> +

<%= t(".completed_on", datetime: import.updated_at.strftime("%Y-%m-%d")) %>

+ <% else %> +

<%= t(".started_on", datetime: import.created_at.strftime("%Y-%m-%d")) %>

+ <% end %> +
+ + <% if import.complete? %> +
+ <%= lucide_icon("check", class: "text-green-500 w-4 h-4") %> +
+ <% else %> + <%= contextual_menu do %> +
+ <%= link_to edit_import_path(import), + class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> + <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> + + <%= t(".edit") %> + <% end %> + + <%= button_to import_path(import), + method: :delete, + class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg", + data: { turbo_confirm: true } do %> + <%= lucide_icon "trash-2", class: "w-5 h-5" %> + + <%= t(".delete") %> + <% end %> +
+ <% end %> + <% end %> + +
diff --git a/app/views/imports/_nav_step.html.erb b/app/views/imports/_nav_step.html.erb new file mode 100644 index 00000000..375f1e92 --- /dev/null +++ b/app/views/imports/_nav_step.html.erb @@ -0,0 +1,18 @@ +<% is_current = request.path == step[:path] %> +<% text_class = if is_current + "text-gray-900" + else + step[:complete] ? "text-green-600" : "text-gray-500" + end %> +<% step_class = if is_current + "bg-gray-900 text-white" + else + step[:complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50" + end %> + +
+ + <%= step[:complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : step_idx + 1 %> + + <%= step[:name] %> +
diff --git a/app/views/imports/_sample_table.html.erb b/app/views/imports/_sample_table.html.erb new file mode 100644 index 00000000..83a16f5c --- /dev/null +++ b/app/views/imports/_sample_table.html.erb @@ -0,0 +1,22 @@ + +
+
Date
+
Name
+
Category
+
Amount
+ +
2024-01-01
+
Amazon
+
Shopping
+
-24.99
+ +
2024-03-01
+
Spotify
+
+
-16.32
+ +
2023-01-06
+
Acme
+
Income
+
151.22
+
diff --git a/app/views/imports/_type_selector.html.erb b/app/views/imports/_type_selector.html.erb new file mode 100644 index 00000000..9f68c343 --- /dev/null +++ b/app/views/imports/_type_selector.html.erb @@ -0,0 +1,90 @@ +
+ +
+
+

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

+ +
+ +

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

+
+ +
+

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

+
    +
  • + <% if Current.family.imports.pending.present? %> + <%= link_to edit_import_path(Current.family.imports.pending.ordered.first), class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %> +
    + <%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %> +
    + + <%= t(".resume_latest_import") %> + + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %> + <% end %> + +
    +
    +
    +
  • + <% end %> +
  • + <%= link_to new_import_path, class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %> +
    + <%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %> +
    + + <%= t(".import_from_csv") %> + + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %> + <% end %> + +
    +
    +
    +
  • +
  • +
    + <%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %> + + <%= t(".import_from_mint") %> + + <%= t(".soon") %> + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %> +
    + +
    +
    +
    +
  • +
  • +
    + <%= image_tag("empower-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 border border-alpha-black-100 rounded-md") %> + + <%= t(".import_from_empower") %> + + <%= t(".soon") %> + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %> +
    + +
    +
    +
    +
  • +
  • +
    + <%= image_tag("apple-logo.png", alt: "Mint logo", class: "w-8 h-8 rounded-md") %> + + <%= t(".import_from_apple") %> + + <%= t(".soon") %> + <%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %> +
    +
  • +
+
+ +
diff --git a/app/views/imports/clean.html.erb b/app/views/imports/clean.html.erb new file mode 100644 index 00000000..05f94db9 --- /dev/null +++ b/app/views/imports/clean.html.erb @@ -0,0 +1,49 @@ +<%= content_for :return_to_path, return_to_path(params, imports_path) %> + +
+

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

+ +
+

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

+

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

+
+ +
+
+ <% @import.expected_fields.each do |field| %> +
<%= field.label %>
+ <% end %> +
+ +
+ <% @import.csv.table.each_with_index do |row, row_index| %> +
+ <% row.fields.each_with_index do |value, col_index| %> + <%= form_with model: @import, + builder: ActionView::Helpers::FormBuilder, + url: clean_import_url(@import), + method: :patch, + data: { turbo: false, controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %> + <%= form.fields_for :csv_update do |ff| %> + <%= ff.hidden_field :row_idx, value: row_index %> + <%= ff.hidden_field :col_idx, value: col_index %> + <%= ff.text_field :value, value: value, + id: "cell-#{row_index}-#{col_index}", + class: "#{@import.csv.cell_valid?(row_index, col_index) ? "focus:border-transparent border-transparent" : "border-red-500"} border px-4 py-3.5 text-sm w-full bg-transparent focus:ring-gray-900 focus:ring-2 focus-visible:outline-none #{table_corner_class(row_index, col_index, @import.csv.table, row.fields)}", + data: { "auto-submit-form-target" => "auto" } %> + <% end %> + <% end %> + <% end %> +
+ <% end %> +
+
+ + <% if @import.csv.valid? %> + <%= link_to "Next", confirm_import_path(@import), class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %> + <% end %> +
diff --git a/app/views/imports/configure.html.erb b/app/views/imports/configure.html.erb new file mode 100644 index 00000000..78a186f0 --- /dev/null +++ b/app/views/imports/configure.html.erb @@ -0,0 +1,24 @@ +<%= content_for :return_to_path, return_to_path(params, imports_path) %> + +
+

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

+ +
+

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

+

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

+
+ + <%= form_with model: @import, url: configure_import_path(@import) do |form| %> +
+ <%= form.fields_for :column_mappings do |mappings| %> + <% @import.expected_fields.each do |field| %> + <%= mappings.select field.key, + options_for_select(@import.available_headers, @import.get_selected_header_for_field(field)), + label: field.label %> + <% end %> + <% end %> +
+ + <%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.column_mappings? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %> + <% end %> +
diff --git a/app/views/imports/confirm.html.erb b/app/views/imports/confirm.html.erb new file mode 100644 index 00000000..56ac1975 --- /dev/null +++ b/app/views/imports/confirm.html.erb @@ -0,0 +1,16 @@ +<%= content_for :return_to_path, return_to_path(params, imports_path) %> + +
+

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

+ +
+

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

+

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

+
+ +
+ <%= render partial: "imports/transactions/transaction_group", collection: @import.dry_run.group_by(&:date) %> +
+ + <%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %> +
diff --git a/app/views/imports/edit.html.erb b/app/views/imports/edit.html.erb new file mode 100644 index 00000000..c873e237 --- /dev/null +++ b/app/views/imports/edit.html.erb @@ -0,0 +1,10 @@ +<%= content_for :return_to_path, return_to_path(params, imports_path) %> + +
+

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

+
+

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

+

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

+
+ <%= render "form", import: @import %> +
diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb new file mode 100644 index 00000000..97d1ee7e --- /dev/null +++ b/app/views/imports/index.html.erb @@ -0,0 +1,30 @@ +<% content_for :sidebar do %> + <%= render "settings/nav" %> +<% end %> + +
+
+

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

+ <%= link_to new_import_path(enable_type_selector: true), class: "flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> + <%= lucide_icon("plus", class: "w-5 h-5") %> + <%= t(".new") %> + <% end %> +
+
+ <% if @imports.empty? %> + <%= render partial: "imports/empty" %> + <% else %> +
+

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

+ +
+ <%= render @imports.ordered %> +
+
+ <% end %> +
+
+ <%= previous_setting("Rules", transaction_rules_path) %> + <%= next_setting("What's new", changelog_path) %> +
+
diff --git a/app/views/imports/load.html.erb b/app/views/imports/load.html.erb new file mode 100644 index 00000000..68fab9b9 --- /dev/null +++ b/app/views/imports/load.html.erb @@ -0,0 +1,39 @@ +<%= content_for :return_to_path, return_to_path(params, imports_path) %> + +
+

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

+ +
+

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

+

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

+
+ + <%= form_with model: @import, url: load_import_path(@import) do |form| %> +
+ <%= form.text_area :raw_csv_str, + rows: 10, + required: true, + placeholder: "Paste your CSV file contents here", + class: "rounded-md w-full border text-sm border-alpha-black-100 bg-white placeholder:text-gray-400 p-4" %> +
+ + <%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %> + <% end %> + +
+
+
+ <%= lucide_icon("info", class: "w-5 h-5 shrink-0") %> +

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

+
+ +
    +
  • <%= t(".requirement1") %>
  • +
  • <%= t(".requirement2") %>
  • +
+
+ + <%= render partial: "imports/sample_table" %> + +
+
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb new file mode 100644 index 00000000..afecb846 --- /dev/null +++ b/app/views/imports/new.html.erb @@ -0,0 +1,16 @@ +<%= content_for :return_to_path, return_to_path(params, imports_path) %> + +<% if params[:enable_type_selector].present? %> + <%= modal do %> + <%= render "type_selector" %> + <% end %> +<% end %> + +
+

New import

+
+

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

+

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

+
+ <%= render "form", import: @import %> +
diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb new file mode 100644 index 00000000..83dc2e69 --- /dev/null +++ b/app/views/imports/show.html.erb @@ -0,0 +1,15 @@ +
+
+ <% if notice.present? %> +

<%= notice %>

+ <% end %> + + <%= render @import %> + + <%= link_to "Edit this import", edit_import_path(@import), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> +
+ <%= button_to "Destroy this import", import_path(@import), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %> +
+ <%= link_to "Back to imports", imports_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> +
+
diff --git a/app/views/imports/transactions/_transaction.html.erb b/app/views/imports/transactions/_transaction.html.erb new file mode 100644 index 00000000..202e0ca7 --- /dev/null +++ b/app/views/imports/transactions/_transaction.html.erb @@ -0,0 +1,12 @@ +<%# locals: (transaction:) %> +
+ <%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %> + +
+ <%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %> +
+ +
+ <%= content_tag :p, format_money(-transaction.amount), class: ["whitespace-nowrap", BigDecimal(transaction.amount).negative? ? "text-green-600" : "text-red-600"] %> +
+
diff --git a/app/views/imports/transactions/_transaction_group.html.erb b/app/views/imports/transactions/_transaction_group.html.erb new file mode 100644 index 00000000..19c1ed4c --- /dev/null +++ b/app/views/imports/transactions/_transaction_group.html.erb @@ -0,0 +1,13 @@ +<%# locals: (transaction_group:) %> +<% date = transaction_group[0] %> +<% transactions = transaction_group[1] %> + +
+
+

<%= date.strftime("%b %d, %Y") %> · <%= transactions.size %>

+ <%= format_money -transactions.sum { |t| t.amount } %> +
+
+ <%= render partial: "imports/transactions/transaction", collection: transactions %> +
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e279c9e9..ddef53e2 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,52 +1,39 @@ - + <%= content_for(:title) || "Maybe" %> + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + + <%= javascript_importmap_tags %> + <%= hotwire_livereload_tags if Rails.env.development? %> + <%= turbo_refreshes_with method: :morph, scroll: :preserve %> + - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - - - - - - <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> - <%= javascript_importmap_tags %> - <%= hotwire_livereload_tags if Rails.env.development? %> - <%= turbo_refreshes_with method: :morph, scroll: :preserve %> + <%= yield :head %> +
<%= safe_join(flash.map { |type, message| notification(message, type: type) }) %> -
-
- <% if content_for?(:sidebar) %> - <%= yield :sidebar %> - <% else %> - <%= render "layouts/sidebar" %> - <% end %> -
-
- <%= yield %> -
-
+ + <%= content_for?(:content) ? yield(:content) : yield %> + <%= turbo_frame_tag "modal" %> <%= render "shared/confirm_modal" %> - <%= render "shared/upgrade_notification" %> + <% if self_hosted? %> -
-

Self-hosted Maybe: <%= Maybe.version.to_release_tag %>

- <%= link_to settings_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %> - <%= lucide_icon("settings", class: "w-4 h-4 text-gray-500 shrink-0") %> - <% end %> -
+ <%= render "shared/app_version" %> <% end %> diff --git a/app/views/layouts/auth.html.erb b/app/views/layouts/auth.html.erb index 2e009d33..05c81e68 100644 --- a/app/views/layouts/auth.html.erb +++ b/app/views/layouts/auth.html.erb @@ -1,61 +1,31 @@ - - - - Maybe - - - - +<%= content_for :content do %> +
+
+ <%= render "shared/logo" %> - - - - - - - +

+ <%= content_for?(:header_title) ? yield(:header_title).html_safe : t(".your_account") %> +

- <%= csrf_meta_tags %> - <%= csp_meta_tag %> - <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> - - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> - <%= javascript_importmap_tags %> - - <%= hotwire_livereload_tags if Rails.env.development? %> - <%= turbo_refreshes_with method: :morph, scroll: :preserve %> - - - -
- -
- <%= render "shared/logo" %> - -

- <%= content_for?(:header_title) ? yield(:header_title).html_safe : t(".your_account") %> -

- - <% if controller_name == "sessions" %> + <% if controller_name == "sessions" %>

<%= t(".or") %> <%= link_to t(".sign_up"), new_registration_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %>

- <% elsif controller_name == "registrations" %> + <% elsif controller_name == "registrations" %>

<%= t(".or") %> <%= link_to t(".sign_in"), new_session_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %>

- <% end %> - -
- -
- <%= yield %> -
- -
-

<%= link_to t(".privacy_policy"), "/privacy", class: "font-medium text-gray-600 hover:text-gray-400 transition" %> • <%= link_to t(".terms_of_service"), "/terms", class: "font-medium text-gray-600 hover:text-gray-400 transition" %>

-
- + <% end %>
- - + +
+ <%= yield %> +
+ +
+

<%= link_to t(".privacy_policy"), "/privacy", class: "font-medium text-gray-600 hover:text-gray-400 transition" %> • <%= link_to t(".terms_of_service"), "/terms", class: "font-medium text-gray-600 hover:text-gray-400 transition" %>

+
+
+<% end %> + +<%= render template: "layouts/application" %> diff --git a/app/views/layouts/imports.html.erb b/app/views/layouts/imports.html.erb new file mode 100644 index 00000000..e44ffb25 --- /dev/null +++ b/app/views/layouts/imports.html.erb @@ -0,0 +1,34 @@ +<%= content_for :content do %> +
+ <%= link_to root_path do %> + <%= image_tag "logo.svg", alt: "Maybe", class: "h-[22px]" %> + <% end %> + + <%= link_to content_for(:return_to_path) do %> + <%= lucide_icon("x", class: "text-gray-500 w-5 h-5") %> + <% end %> +
+ + <%= yield %> +<% end %> + +<%= render template: "layouts/application" %> diff --git a/app/views/layouts/with_sidebar.html.erb b/app/views/layouts/with_sidebar.html.erb new file mode 100644 index 00000000..7950d53a --- /dev/null +++ b/app/views/layouts/with_sidebar.html.erb @@ -0,0 +1,18 @@ +<%= content_for :content do %> +
+
+ <% if content_for?(:sidebar) %> + <%= yield :sidebar %> + <% else %> + <%= render "layouts/sidebar" %> + <% end %> +
+
+ <%= yield %> +
+
+ + <%= render "shared/upgrade_notification" %> +<% end %> + +<%= render template: "layouts/application" %> diff --git a/app/views/pages/changelog.html.erb b/app/views/pages/changelog.html.erb index 700b358e..f44eef8c 100644 --- a/app/views/pages/changelog.html.erb +++ b/app/views/pages/changelog.html.erb @@ -9,7 +9,7 @@
- <%= previous_setting("Rules", transaction_rules_path) %> + <%= previous_setting("Imports", imports_path) %> <%= next_setting("Feedback", feedback_path) %>
diff --git a/app/views/settings/_nav.html.erb b/app/views/settings/_nav.html.erb index 838684f7..2741bc87 100644 --- a/app/views/settings/_nav.html.erb +++ b/app/views/settings/_nav.html.erb @@ -55,6 +55,9 @@
  • <%= sidebar_link_to t(".rules_label"), transaction_rules_path, icon: "list-checks" %>
  • +
  • + <%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %> +
  • diff --git a/app/views/shared/_app_version.html.erb b/app/views/shared/_app_version.html.erb new file mode 100644 index 00000000..0632359e --- /dev/null +++ b/app/views/shared/_app_version.html.erb @@ -0,0 +1,6 @@ +
    +

    Version: <%= Maybe.version.to_release_tag %>

    + <%= link_to settings_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %> + <%= lucide_icon("settings", class: "w-4 h-4 text-gray-500 shrink-0") %> + <% end %> +
    diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 5184296e..d7151e73 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -6,16 +6,28 @@ <%= contextual_menu do %>
    <%= link_to transaction_categories_path, - class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %> + class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %> <%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %> <%= t(".edit_categories") %> <% end %> + + <%= link_to imports_path, + class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %> + <%= lucide_icon "hard-drive-upload", class: "w-5 h-5 text-gray-500" %> + <%= t(".edit_imports") %> + <% end %>
    + + <% end %> + + <%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-50 border border-gray-200 flex items-center gap-1 justify-center px-3 py-2", data: { turbo_frame: "modal" } do %> + <%= lucide_icon("download", class: "text-gray-500 w-4 h-4") %> +

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

    <% end %> <%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %> <%= lucide_icon("plus", class: "w-5 h-5") %> -

    New transaction

    +

    New transaction

    <% end %> diff --git a/app/views/transactions/rules/index.html.erb b/app/views/transactions/rules/index.html.erb index 7fce52ab..bb0bf5f6 100644 --- a/app/views/transactions/rules/index.html.erb +++ b/app/views/transactions/rules/index.html.erb @@ -10,6 +10,6 @@
    <%= previous_setting("Merchants", transaction_merchants_path) %> - <%= next_setting("What's New", changelog_path) %> + <%= next_setting("Imports", imports_path) %>
    diff --git a/config/environments/test.rb b/config/environments/test.rb index e7e507b7..501daafe 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -62,4 +62,6 @@ Rails.application.configure do # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + + config.autoload_paths += %w[ test/support ] end diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml new file mode 100644 index 00000000..8a089ca5 --- /dev/null +++ b/config/locales/views/imports/en.yml @@ -0,0 +1,95 @@ +--- +en: + imports: + clean: + clean_and_edit: Clean and edit your data + clean_description: Edit your transactions in the table below. Click on any cell + to change the date, name, category, or amount. + clean_import: Clean import + invalid_csv: Please load a CSV first + configure: + configure_description: Select the columns that match the necessary data fields, + so that the columns in your CSV can be correctly mapped with our format. + configure_subtitle: Setup your CSV file + configure_title: Configure import + confirm_accept: Change mappings + confirm_body: Changing your mappings may erase any edits you have made to the + CSV so far. + confirm_title: Are you sure? + invalid_csv: Please load a CSV first + next: Next + confirm: + confirm_description: Preview your transactions below and check to see if there + are any changes that are required. + confirm_subtitle: Confirm your transactions + confirm_title: Confirm import + invalid_data: You have invalid data, please fix before continuing + create: + import_created: Import created + destroy: + import_destroyed: Import destroyed + edit: + description_text: Importing transactions can only be done for one account at + a time. You will need to go through this process again for other accounts. + edit_title: Edit import + header_text: Select the account your transactions will belong to + empty: + message: No imports to show + new: New Import + form: + account: Account + next: Next + select_account: Select account + import: + complete: Complete + completed_on: Completed on %{datetime} + delete: Delete + edit: Edit + failed: Failed + in_progress: In progress + label: 'Import for: %{account}' + started_on: Started on %{datetime} + uploading: Processing rows + index: + imports: Imports + new: New import + title: Imports + load: + confirm_accept: Yep, start over! + confirm_body: This will reset your import. Any changes you have made to the + CSV will be erased. + confirm_title: Are you sure? + description: Create a spreadsheet or upload an exported CSV from your financial + institution. + instructions: Your CSV should have the following columns and formats for the + best import experience. + load_title: Load import + next: Next + requirement1: Dates must be in ISO 8601 format (YYYY-MM-DD) + requirement2: Negative transaction is an "outflow" (expense), positive is an + "inflow" (income) + subtitle: Import your transactions + load_csv: + import_loaded: Import CSV loaded + new: + description_text: Importing transactions can only be done for one account at + a time. You will need to go through this process again for other accounts. + header_text: Select the account your transactions will belong to + publish: + import_published: Import has started in the background + invalid_data: Your import is invalid + type_selector: + description: You can manually import transactions from CSVs or from other financial + apps like Mint, Empower (formerly Personal Capital) or Apple Card. + import_from_apple: Import from Apple Card + import_from_csv: New import from CSV + import_from_empower: Import from Empower + import_from_mint: Import from Mint + import_transactions: Import transactions + resume_latest_import: Resume latest import + soon: Soon + sources: Sources + update: + import_updated: Import updated + update_mappings: + column_mappings_saved: Column mappings saved diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index a39160fa..21071ce8 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -55,6 +55,7 @@ en: categories_label: Categories feedback_label: Feedback general_section_title: General + imports_label: Imports invite_label: Invite friends merchants_label: Merchants notifications_label: Notifications diff --git a/config/locales/views/transaction/en.yml b/config/locales/views/transaction/en.yml index 12e2e841..ab928126 100644 --- a/config/locales/views/transaction/en.yml +++ b/config/locales/views/transaction/en.yml @@ -57,6 +57,8 @@ en: transfer: Transfer index: edit_categories: Edit categories + edit_imports: Edit imports + import: Import merchants: create: success: New merchant created successfully diff --git a/config/routes.rb b/config/routes.rb index 3f63169c..909870af 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,8 +21,24 @@ Rails.application.routes.draw do resource :security, only: %i[show update] end + resources :imports, except: :show do + member do + get "load" + patch "load" => "imports#load_csv" + + get "configure" + patch "configure" => "imports#update_mappings" + + get "clean" + patch "clean" => "imports#update_csv" + + get "confirm" + patch "confirm" => "imports#publish" + end + end + resources :transactions do - match "search" => "transactions#search", on: :collection, via: [ :get, :post ], as: :search + match "search" => "transactions#search", on: :collection, via: %i[ get post ], as: :search collection do scope module: :transactions do diff --git a/db/migrate/20240502205006_create_imports.rb b/db/migrate/20240502205006_create_imports.rb new file mode 100644 index 00000000..79c67284 --- /dev/null +++ b/db/migrate/20240502205006_create_imports.rb @@ -0,0 +1,15 @@ +class CreateImports < ActiveRecord::Migration[7.2] + def change + create_enum :import_status, %w[pending importing complete failed] + + create_table :imports, id: :uuid do |t| + t.references :account, null: false, foreign_key: true, type: :uuid + t.jsonb :column_mappings + t.enum :status, enum_type: :import_status, default: "pending" + t.string :raw_csv_str + t.string :normalized_csv_str + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c3e2f2da..75a4c530 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do +ActiveRecord::Schema[7.2].define(version: 2024_05_02_205006) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -18,6 +18,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do # Custom types defined in this database. # Note that some types may not work with other database engines. Be careful if changing database. create_enum "account_status", ["ok", "syncing", "error"] + create_enum "import_status", ["pending", "importing", "complete", "failed"] create_enum "user_role", ["admin", "member"] create_table "account_balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -222,6 +223,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" end + create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "account_id", null: false + t.jsonb "column_mappings" + t.enum "status", default: "pending", enum_type: "import_status" + t.string "raw_csv_str" + t.string "normalized_csv_str" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_imports_on_account_id" + end + create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "token", null: false t.datetime "created_at", null: false @@ -305,6 +317,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do add_foreign_key "accounts", "families" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "imports", "accounts" add_foreign_key "transaction_categories", "families" add_foreign_key "transaction_merchants", "families" add_foreign_key "transactions", "accounts", on_delete: :cascade diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 145d7302..4713e4b6 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -1,21 +1,27 @@ require "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] + driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ] private - def sign_in(user) - visit new_session_path - within "form" do - fill_in "Email", with: user.email - fill_in "Password", with: "password" - click_button "Log in" - end - end + def sign_in(user) + visit new_session_path + within "form" do + fill_in "Email", with: user.email + fill_in "Password", with: "password" + click_on "Log in" + end - def sign_out - find("#user-menu").click - click_button "Logout" - end + # Trigger Capybara's wait mechanism to avoid timing issues with logins + find("h1", text: "Dashboard") + end + + def sign_out + find("#user-menu").click + click_button "Logout" + + # Trigger Capybara's wait mechanism to avoid timing issues with logout + find("h2", text: "Sign in to your account") + end end diff --git a/test/controllers/imports_controller_test.rb b/test/controllers/imports_controller_test.rb new file mode 100644 index 00000000..3a71aa07 --- /dev/null +++ b/test/controllers/imports_controller_test.rb @@ -0,0 +1,141 @@ +require "test_helper" + +class ImportsControllerTest < ActionDispatch::IntegrationTest + include ImportTestHelper + + setup do + sign_in @user = users(:family_admin) + @empty_import = imports(:empty_import) + @loaded_import = imports(:loaded_import) + @completed_import = imports(:completed_import) + end + + test "should get index" do + get imports_url + assert_response :success + + @user.family.imports.ordered.each do |import| + assert_select "#" + dom_id(import), count: 1 + end + end + + test "should get new" do + get new_import_url + assert_response :success + end + + test "should create import" do + assert_difference("Import.count") do + post imports_url, params: { import: { account_id: @user.family.accounts.first.id } } + end + + assert_redirected_to load_import_path(Import.ordered.first) + end + + test "should get edit" do + get edit_import_url(@empty_import) + assert_response :success + end + + test "should update import" do + patch import_url(@empty_import), params: { import: { account_id: @empty_import.account_id } } + assert_redirected_to load_import_path(@empty_import) + end + + test "should destroy import" do + assert_difference("Import.count", -1) do + delete import_url(@empty_import) + end + + assert_redirected_to imports_url + end + + test "should get load" do + get load_import_url(@empty_import) + assert_response :success + end + + test "should save raw CSV if valid" do + patch load_import_url(@empty_import), params: { import: { raw_csv_str: valid_csv_str } } + + assert_redirected_to configure_import_path(@empty_import) + assert_equal "Import CSV loaded", flash[:notice] + end + + test "should flash error message if invalid CSV input" do + patch load_import_url(@empty_import), params: { import: { raw_csv_str: malformed_csv_str } } + + assert_response :unprocessable_entity + assert_equal "Raw csv str is not a valid CSV format", flash[:error] + end + + test "should get configure" do + get configure_import_url(@loaded_import) + assert_response :success + end + + test "should redirect back to load step with an alert message if not loaded" do + get configure_import_url(@empty_import) + assert_equal "Please load a CSV first", flash[:alert] + assert_redirected_to load_import_path(@empty_import) + end + + test "should update mappings" do + patch configure_import_url(@loaded_import), params: { + import: { + column_mappings: { + date: "date", + name: "name", + category: "category", + amount: "amount" + } + } + } + + assert_redirected_to clean_import_path(@loaded_import) + assert_equal "Column mappings saved", flash[:notice] + end + + test "can update a cell" do + assert_equal @loaded_import.csv.table[0][1], "Starbucks drink" + + patch clean_import_url(@loaded_import), params: { + import: { + csv_update: { + row_idx: 0, + col_idx: 1, + value: "new_merchant" + } + } + } + + assert_response :success + + @loaded_import.reload + assert_equal "new_merchant", @loaded_import.csv.table[0][1] + end + + test "should get clean" do + get clean_import_url(@loaded_import) + assert_response :success + end + + test "should get confirm if all values are valid" do + get confirm_import_url(@loaded_import) + assert_response :success + end + + test "should redirect back to clean if data is invalid" do + @empty_import.update! raw_csv_str: valid_csv_with_invalid_values + + get confirm_import_url(@empty_import) + assert_equal "You have invalid data, please fix before continuing", flash[:alert] + assert_redirected_to clean_import_path(@empty_import) + end + + test "should confirm import" do + patch confirm_import_url(@loaded_import) + assert_redirected_to imports_path + assert_equal "Import has started in the background", flash[:notice] + end +end diff --git a/test/fixtures/imports.yml b/test/fixtures/imports.yml new file mode 100644 index 00000000..b0c30b4a --- /dev/null +++ b/test/fixtures/imports.yml @@ -0,0 +1,32 @@ +empty_import: + account: checking + created_at: <%= 1.minute.ago %> + +loaded_import: + account: checking + raw_csv_str: | + date,name,category,amount + 2024-01-01,Starbucks drink,Food,20 + 2024-01-02,Amazon stuff,Shopping,200 + normalized_csv_str: | + date,name,category,amount + 2024-01-01,Starbucks drink,Food,20 + 2024-01-02,Amazon stuff,Shopping,200 + created_at: <%= 2.days.ago %> + +completed_import: + account: checking + column_mappings: + date: date + name: name + category: category + amount: amount + raw_csv_str: | + date,name,category,amount + 2024-01-01,Starbucks drink,Food,20 + normalized_csv_str: | + date,name,category,amount + 2024-01-01,Starbucks drink,Food,20 + created_at: <%= 2.days.ago %> + + diff --git a/test/integration/.keep b/test/integration/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/test/jobs/import_job_test.rb b/test/jobs/import_job_test.rb new file mode 100644 index 00000000..14c21339 --- /dev/null +++ b/test/jobs/import_job_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class ImportJobTest < ActiveJob::TestCase + include ImportTestHelper + + test "import is published" do + import = imports(:empty_import) + import.update! raw_csv_str: valid_csv_str + + assert import.pending? + + perform_enqueued_jobs do + ImportJob.perform_later(import) + end + + assert import.reload.complete? + end +end diff --git a/test/models/import/csv_test.rb b/test/models/import/csv_test.rb new file mode 100644 index 00000000..1bef280d --- /dev/null +++ b/test/models/import/csv_test.rb @@ -0,0 +1,87 @@ +require "test_helper" + +class Import::CsvTest < ActiveSupport::TestCase + include ImportTestHelper + + setup do + @csv = Import::Csv.new(valid_csv_str) + end + + test "cannot define validator for non-existent header" do + assert_raises do + @csv.define_validator "invalid", method(:validate_iso_date) + end + end + + test "csv with no validators is valid" do + assert @csv.cell_valid?(0, 0) + assert @csv.valid? + end + + test "valid csv values" do + @csv.define_validator "date", method(:validate_iso_date) + + assert_equal "2024-01-01", @csv.table[0][0] + assert @csv.cell_valid?(0, 0) + assert @csv.valid? + end + + test "invalid csv values" do + invalid_csv = Import::Csv.new valid_csv_with_invalid_values + + invalid_csv.define_validator "date", method(:validate_iso_date) + + assert_equal "invalid_date", invalid_csv.table[0][0] + assert_not invalid_csv.cell_valid?(0, 0) + assert_not invalid_csv.valid? + end + + test "updating a cell returns a copy of the original csv" do + original_date = "2024-01-01" + new_date = "2024-01-01" + + assert_equal original_date, @csv.table[0][0] + updated = @csv.update_cell(0, 0, new_date) + + assert_equal original_date, @csv.table[0][0] + assert_equal new_date, updated[0][0] + end + + test "can create CSV with expected columns and field mappings with validators" do + date_field = Import::Field.new \ + key: "date", + label: "Date", + validator: method(:validate_iso_date) + + name_field = Import::Field.new \ + key: "name", + label: "Name" + + fields = [ date_field, name_field ] + + raw_csv_str = <<-ROWS + date,Custom Field Header,extra_field + invalid_date_value,Starbucks drink,Food + 2024-01-02,Amazon stuff,Shopping + ROWS + + mappings = { + "name" => "Custom Field Header" + } + + csv = Import::Csv.create_with_field_mappings(raw_csv_str, fields, mappings) + + assert_equal %w[ date name ], csv.table.headers + assert_equal 2, csv.table.size + assert_equal "Amazon stuff", csv.table[1][1] + end + + private + + def validate_iso_date(value) + Date.iso8601(value) + true + rescue + false + end +end diff --git a/test/models/import/field_test.rb b/test/models/import/field_test.rb new file mode 100644 index 00000000..1550a449 --- /dev/null +++ b/test/models/import/field_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class Import::FieldTest < ActiveSupport::TestCase + test "key is always a string" do + field1 = Import::Field.new label: "Test", key: "test" + field2 = Import::Field.new label: "Test2", key: :test2 + + assert_equal "test", field1.key + assert_equal "test2", field2.key + end + + test "can set and override a validator for a field" do + field = Import::Field.new \ + label: "Test", + key: "Test", + validator: ->(val) { val == 42 } + + assert field.validate(42) + assert_not field.validate(41) + + field.define_validator do |value| + value == 100 + end + + assert field.validate(100) + assert_not field.validate(42) + end +end diff --git a/test/models/import_test.rb b/test/models/import_test.rb new file mode 100644 index 00000000..4114cf38 --- /dev/null +++ b/test/models/import_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class ImportTest < ActiveSupport::TestCase + include ImportTestHelper, ActiveJob::TestHelper + + setup do + @empty_import = imports(:empty_import) + @loaded_import = imports(:loaded_import) + end + + test "raw csv input must conform to csv spec" do + @empty_import.raw_csv_str = malformed_csv_str + assert_not @empty_import.valid? + + @empty_import.raw_csv_str = valid_csv_str + assert @empty_import.valid? + end + + test "can update csv value without affecting raw input" do + assert_equal "Starbucks drink", @loaded_import.csv.table[0][1] + + prior_raw_csv_str_value = @loaded_import.raw_csv_str + prior_normalized_csv_str_value = @loaded_import.normalized_csv_str + + @loaded_import.update_csv! \ + row_idx: 0, + col_idx: 1, + value: "new_category" + + assert_equal "new_category", @loaded_import.csv.table[0][1] + assert_equal prior_raw_csv_str_value, @loaded_import.raw_csv_str + assert_not_equal prior_normalized_csv_str_value, @loaded_import.normalized_csv_str + end + + test "publishes later" do + assert_enqueued_with(job: ImportJob) do + @loaded_import.publish_later + end + end + + test "publishes a valid import" do + assert_difference "Transaction.count", 2 do + @loaded_import.publish + end + + @loaded_import.reload + + assert @loaded_import.complete? + end + + test "failed publish results in error status" do + @empty_import.update! raw_csv_str: valid_csv_with_invalid_values + + assert_difference "Transaction.count", 0 do + @empty_import.publish + end + + @empty_import.reload + assert @empty_import.failed? + end +end diff --git a/test/support/import_test_helper.rb b/test/support/import_test_helper.rb new file mode 100644 index 00000000..ee8eb3c2 --- /dev/null +++ b/test/support/import_test_helper.rb @@ -0,0 +1,24 @@ +module ImportTestHelper + def valid_csv_str + <<-ROWS + date,name,category,amount + 2024-01-01,Starbucks drink,Food,20 + 2024-01-02,Amazon stuff,Shopping,200 + ROWS + end + + def valid_csv_with_invalid_values + <<-ROWS + date,name,category,amount + invalid_date,Starbucks drink,Food,invalid_amount + ROWS + end + + def malformed_csv_str + <<-ROWS + name,age + "John Doe,23 + "Jane Doe",25 + ROWS + end +end diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb new file mode 100644 index 00000000..b9ee4f8c --- /dev/null +++ b/test/system/imports_test.rb @@ -0,0 +1,113 @@ +require "application_system_test_case" + +class ImportsTest < ApplicationSystemTestCase + include ImportTestHelper + + setup do + sign_in @user = users(:family_admin) + + @imports = @user.family.imports.ordered.to_a + end + + test "can trigger new import from settings" do + trigger_import_from_settings + verify_import_modal + end + + test "can resume existing import from settings" do + visit imports_url + + within "#" + dom_id(@imports.first) do + click_button + click_link "Edit" + end + + assert_current_path edit_import_path(@imports.first) + end + + test "can resume latest import" do + trigger_import_from_transactions + verify_import_modal + + click_link "Resume latest import" + + assert_current_path edit_import_path(@imports.first) + end + + test "can perform basic CSV import" do + trigger_import_from_settings + verify_import_modal + + within "#modal" do + click_link "New import from CSV" + end + + # 1) Create import step + assert_selector "h1", text: "New import" + + within "form" do + select "Checking Account", from: "import_account_id" + end + + click_button "Next" + + # 2) Load Step + assert_selector "h1", text: "Load import" + + within "form" do + fill_in "import_raw_csv_str", with: <<-ROWS + date,Custom Name Column,category,amount + invalid_date,Starbucks drink,Food,-20.50 + 2024-01-01,Amazon purchase,Shopping,-89.50 + ROWS + end + + click_button "Next" + + # 3) Configure step + assert_selector "h1", text: "Configure import" + + within "form" do + select "Custom Name Column", from: "import_column_mappings_name" + end + + click_button "Next" + + # 4) Clean step + assert_selector "h1", text: "Clean import" + + # We have an invalid value, so user cannot click next yet + assert_no_text "Next" + + # Replace invalid date with valid date + fill_in "cell-0-0", with: "2024-01-02" + + # Trigger blur event so value saves + find("body").click + + click_link "Next" + + # 5) Confirm step + assert_selector "h1", text: "Confirm import" + click_button "Import 2 transactions" + assert_selector "h1", text: "Imports" + end + + private + + def trigger_import_from_settings + visit imports_url + click_link "New import" + end + + def trigger_import_from_transactions + visit transactions_url + click_link "Import" + end + + def verify_import_modal + within "#modal" do + assert_text "Import transactions" + end + end +end diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index add35d39..29517588 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -14,6 +14,7 @@ class SettingsTest < ApplicationSystemTestCase [ "Categories", "Categories", transaction_categories_path ], [ "Merchants", "Merchants", transaction_merchants_path ], [ "Rules", "Rules", transaction_rules_path ], + [ "Imports", "Imports", imports_path ], [ "What's New", "What's New", changelog_path ], [ "Feedback", "Feedback", feedback_path ], [ "Invite friends", "Invite friends", invites_path ] @@ -27,6 +28,7 @@ class SettingsTest < ApplicationSystemTestCase end private + def open_settings_from_sidebar find("#user-menu").click click_link "Settings"