From c88fe2e3b25693432864445002324798329698ea Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 18 Apr 2025 10:48:10 -0400 Subject: [PATCH] Feature: Add "amount type" configuration column for CSV imports (#1947) * Rough draft * Schema conflict update * Implement signage * Update system tests * Lint fixes --- .../import/configurations_controller.rb | 4 +- .../controllers/import_controller.js | 118 +++++++++++++++++ app/models/import.rb | 5 +- app/models/import/row.rb | 14 +- app/models/trade_import.rb | 1 + app/models/transaction_import.rb | 6 + .../configurations/_account_import.html.erb | 8 +- .../configurations/_mint_import.html.erb | 8 +- .../configurations/_trade_import.html.erb | 26 ++-- .../_transaction_import.html.erb | 120 +++++++++++++++--- app/views/import/configurations/show.html.erb | 9 +- app/views/layouts/shared/_htmldoc.html.erb | 2 +- app/views/loans/_form.html.erb | 2 +- app/views/transactions/_form.html.erb | 2 +- .../transactions/_selection_bar.html.erb | 2 +- ...50304200956_add_signage_type_to_imports.rb | 20 +++ db/schema.rb | 26 ++-- test/interfaces/import_interface_test.rb | 1 + test/models/trade_import_test.rb | 16 +-- test/models/transaction_import_test.rb | 32 +++++ test/system/imports_test.rb | 27 ++-- 21 files changed, 366 insertions(+), 83 deletions(-) create mode 100644 app/javascript/controllers/import_controller.js create mode 100644 db/migrate/20250304200956_add_signage_type_to_imports.rb diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index 3b9ca5b5..f723c63e 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -36,7 +36,9 @@ class Import::ConfigurationsController < ApplicationController :currency_col_label, :date_format, :number_format, - :signage_convention + :signage_convention, + :amount_type_strategy, + :amount_type_inflow_value, ) end end diff --git a/app/javascript/controllers/import_controller.js b/app/javascript/controllers/import_controller.js new file mode 100644 index 00000000..7c555d3f --- /dev/null +++ b/app/javascript/controllers/import_controller.js @@ -0,0 +1,118 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="import" +export default class extends Controller { + static values = { + csv: { type: Array, default: [] }, + amountTypeColumnKey: { type: String, default: "" }, + }; + + static targets = [ + "signedAmountFieldset", + "customColumnFieldset", + "amountTypeValue", + "amountTypeStrategySelect", + ]; + + connect() { + if ( + this.amountTypeStrategySelectTarget.value === "custom_column" && + this.amountTypeColumnKeyValue + ) { + this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue); + } + } + + handleAmountTypeStrategyChange(event) { + const amountTypeStrategy = event.target.value; + + if (amountTypeStrategy === "custom_column") { + this.#enableCustomColumnFieldset(); + + if (this.amountTypeColumnKeyValue) { + this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue); + } + } + + if (amountTypeStrategy === "signed_amount") { + this.#enableSignedAmountFieldset(); + } + } + + handleAmountTypeChange(event) { + const amountTypeColumnKey = event.target.value; + + this.#showAmountTypeValueTargets(amountTypeColumnKey); + } + + #showAmountTypeValueTargets(amountTypeColumnKey) { + const selectableValues = this.#uniqueValuesForColumn(amountTypeColumnKey); + + this.amountTypeValueTarget.classList.remove("hidden"); + this.amountTypeValueTarget.classList.add("flex"); + + const select = this.amountTypeValueTarget.querySelector("select"); + const currentValue = select.value; + select.options.length = 0; + const fragment = document.createDocumentFragment(); + + // Only add the prompt if there's no current value + if (!currentValue) { + fragment.appendChild(new Option("Select value", "")); + } + + selectableValues.forEach((value) => { + const option = new Option(value, value); + if (value === currentValue) { + option.selected = true; + } + fragment.appendChild(option); + }); + + select.appendChild(fragment); + } + + #uniqueValuesForColumn(column) { + const colIdx = this.csvValue[0].indexOf(column); + const values = this.csvValue.slice(1).map((row) => row[colIdx]); + return [...new Set(values)]; + } + + #enableCustomColumnFieldset() { + this.customColumnFieldsetTarget.classList.remove("hidden"); + this.signedAmountFieldsetTarget.classList.add("hidden"); + + // Set required on custom column fields + this.customColumnFieldsetTarget + .querySelectorAll("select, input") + .forEach((field) => { + field.setAttribute("required", ""); + }); + + // Remove required from signed amount fields + this.signedAmountFieldsetTarget + .querySelectorAll("select, input") + .forEach((field) => { + field.removeAttribute("required"); + }); + } + + #enableSignedAmountFieldset() { + this.customColumnFieldsetTarget.classList.add("hidden"); + this.signedAmountFieldsetTarget.classList.remove("hidden"); + + // Remove required from custom column fields + this.customColumnFieldsetTarget + .querySelectorAll("select, input") + .forEach((field) => { + field.removeAttribute("required"); + }); + + // Set required on signed amount fields + this.signedAmountFieldsetTarget + .querySelectorAll("select, input") + .forEach((field) => { + field.setAttribute("required", ""); + }); + } +} diff --git a/app/models/import.rb b/app/models/import.rb index bb367cf9..3ea68015 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -10,6 +10,8 @@ class Import < ApplicationRecord "1,234" => { separator: "", delimiter: "," } # Zero-decimal currencies like JPY }.freeze + AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze + belongs_to :family belongs_to :account, optional: true @@ -27,8 +29,9 @@ class Import < ApplicationRecord }, validate: true, default: "pending" validates :type, inclusion: { in: TYPES } + validates :amount_type_strategy, inclusion: { in: AMOUNT_TYPE_STRATEGIES } validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) } - validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS } + validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }, allow_nil: true validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys } has_many :rows, dependent: :destroy diff --git a/app/models/import/row.rb b/app/models/import/row.rb index 350d8084..0d8d9aba 100644 --- a/app/models/import/row.rb +++ b/app/models/import/row.rb @@ -44,7 +44,19 @@ class Import::Row < ApplicationRecord # In the Maybe system, positive amounts == "outflows", so we must reverse signage def apply_transaction_signage_convention(value) - value * (import.signage_convention == "inflows_positive" ? -1 : 1) + if import.amount_type_strategy == "signed_amount" + value * (import.signage_convention == "inflows_positive" ? -1 : 1) + elsif import.amount_type_strategy == "custom_column" + inflow_value = import.amount_type_inflow_value + + if entity_type == inflow_value + value * -1 + else + value + end + else + raise "Unknown amount type strategy for import: #{import.amount_type_strategy}" + end end def required_columns diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index d126fec0..04a6a4cf 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -31,6 +31,7 @@ class TradeImport < Import ), ) end + Trade.import!(trades, recursive: true) end end diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index dd44cca4..d233c2e4 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -48,6 +48,12 @@ class TransactionImport < Import base end + def selectable_amount_type_values + return [] if entity_type_col_label.nil? + + csv_rows.map { |row| row[entity_type_col_label] }.uniq + end + def csv_template template = <<-CSV date*,amount*,name,currency,category,tags,account,notes diff --git a/app/views/import/configurations/_account_import.html.erb b/app/views/import/configurations/_account_import.html.erb index afcd3e8b..7f6ff6f2 100644 --- a/app/views/import/configurations/_account_import.html.erb +++ b/app/views/import/configurations/_account_import.html.erb @@ -1,10 +1,10 @@ <%# locals: (import:) %> -<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %> +<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" 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.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name" }, required: true %> + <%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" }, required: true %> + <%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %> <%= 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 index 987f2fed..e1271c4a 100644 --- a/app/views/import/configurations/_mint_import.html.erb +++ b/app/views/import/configurations/_mint_import.html.erb @@ -5,18 +5,18 @@

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| %> -
+<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" 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, Family::DATE_FORMATS, { label: t(".date_format_label")}, 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 :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" }, disabled: import.complete? %> <%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
diff --git a/app/views/import/configurations/_trade_import.html.erb b/app/views/import/configurations/_trade_import.html.erb index 7db04ba9..8231e9cf 100644 --- a/app/views/import/configurations/_trade_import.html.erb +++ b/app/views/import/configurations/_trade_import.html.erb @@ -1,31 +1,31 @@ <%# locals: (import:) %> -<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %> +<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" do |form| %>
-
- <%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %> +
+ <%= form.select :date_col_label, import.csv_headers, { include_blank: "Select column", label: "Date" }, required: true %> <%= form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format_label")}, 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 :qty_col_label, import.csv_headers, { include_blank: "Select column", label: "Quantity" }, required: true %> + <%= form.select :signage_convention, [["Buys are positive qty", "inflows_positive"], ["Buys are negative qty", "inflows_negative"]], label: true, required: true %>
-
- <%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %> +
+ <%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %> <%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
- <%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %> - <%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Exchange Operating MIC" } %> - <%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %> + <%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Select column", label: "Ticker" }, required: true %> + <%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Stock exchange code" } %> + <%= form.select :price_col_label, import.csv_headers, { include_blank: "Select column", label: "Price" }, required: true %> <% unless import.account.present? %> - <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %> + <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account" } %> <% end %> - <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name" } %> <% unless Security.provider %>
diff --git a/app/views/import/configurations/_transaction_import.html.erb b/app/views/import/configurations/_transaction_import.html.erb index a75b1681..9181f97f 100644 --- a/app/views/import/configurations/_transaction_import.html.erb +++ b/app/views/import/configurations/_transaction_import.html.erb @@ -1,29 +1,113 @@ <%# 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, Family::DATE_FORMATS, { label: t(".date_format_label")}, label: true, required: true %> +<%= styled_form_with model: @import, + url: import_configuration_path(@import), + scope: :import, + method: :patch, + class: "space-y-4", + data: { + controller: "import", + import_csv_value: import.csv_rows.to_json, + import_amount_type_column_key_value: @import.entity_type_col_label + } do |form| %> + + <%# Date Configuration %> +
+ <%= form.select :date_col_label, + import.csv_headers, + { label: "Date", prompt: "Select column" }, + required: true %> + <%= form.select :date_format, + Family::DATE_FORMATS, + { label: t(".date_format_label"), prompt: "Select format" }, + 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 %> + <%# Amount Configuration %> +
+ <%= form.select :amount_col_label, + import.csv_headers, + { label: "Amount", container_class: "w-2/5", prompt: "Select column" }, + required: true %> + <%= form.select :currency_col_label, + import.csv_headers, + { include_blank: "Default", label: "Currency", container_class: "w-1/5" } %> + <%= form.select :number_format, + Import::NUMBER_FORMATS.keys, + { label: "Format", prompt: "Select format", container_class: "w-2/5" }, + required: true %>
-
- <%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %> - <%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %> -
+ <%# Amount Type Strategy %> + <%= form.select :amount_type_strategy, + Import::AMOUNT_TYPE_STRATEGIES.map { |strategy| [strategy.humanize, strategy] }, + { label: "Amount type strategy", prompt: "Select strategy" }, + required: true, + data: { + action: "import#handleAmountTypeStrategyChange", + import_target: "amountTypeStrategySelect" + } %> - <% unless import.account.present? %> - <%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %> + <%# Signed Amount Configuration %> + <%= tag.fieldset data: { import_target: "signedAmountFieldset" }, + class: @import.amount_type_strategy == "signed_amount" ? "block" : "hidden" do %> +
+ + <%= form.select :signage_convention, + [["Incomes are positive", "inflows_positive"], ["Incomes are negative", "inflows_negative"]], + { label: "Amount type", prompt: "Select convention" }, + required: @import.amount_type_strategy == "signed_amount" %> +
<% end %> - <%= 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)" } %> + <%# Custom Column Configuration %> + <%= tag.fieldset data: { import_target: "customColumnFieldset" }, + class: @import.amount_type_strategy == "custom_column" ? "block" : "hidden" do %> +
+
+ + Set + <%= form.select :entity_type_col_label, + import.csv_headers, + { prompt: "Select column", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, + required: @import.amount_type_strategy == "custom_column", + data: { action: "import#handleAmountTypeChange" } %> + as amount type column +
- <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %> +
" data-import-target="amountTypeValue"> + + Set + <%= form.select :amount_type_inflow_value, + @import.selectable_amount_type_values, + { prompt: "Select column", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, + required: @import.amount_type_strategy == "custom_column" %> + as "income" (inflow) value +
+
+ <% end %> + + <%# Optional Fields %> + <% unless import.account.present? %> + <%= form.select :account_col_label, + import.csv_headers, + { include_blank: "Leave empty", label: "Account" } %> + <% end %> + + <%= form.select :name_col_label, + import.csv_headers, + { include_blank: "Leave empty", label: "Name" } %> + <%= form.select :category_col_label, + import.csv_headers, + { include_blank: "Leave empty", label: "Category" } %> + <%= form.select :tags_col_label, + import.csv_headers, + { include_blank: "Leave empty", label: "Tags" } %> + <%= form.select :notes_col_label, + import.csv_headers, + { include_blank: "Leave empty", label: "Notes" } %> + + <%= 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 index aaefd4e6..d061b692 100644 --- a/app/views/import/configurations/show.html.erb +++ b/app/views/import/configurations/show.html.erb @@ -24,18 +24,15 @@
<% else %> -
+

<%= 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" %>
- -
- <%= render "imports/table", headers: @import.csv_headers, rows: @import.csv_sample, caption: "Sample data from your uploaded CSV" %> -
<% end %> diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index 8a5595e4..6e213a25 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -1,5 +1,5 @@ - +"> <%= render "layouts/shared/head" %> <%= yield :head %> diff --git a/app/views/loans/_form.html.erb b/app/views/loans/_form.html.erb index 73dd7bb7..6af5715f 100644 --- a/app/views/loans/_form.html.erb +++ b/app/views/loans/_form.html.erb @@ -11,7 +11,7 @@ default_currency: Current.family.currency, required: true %>
- +
<%= loan_form.number_field :interest_rate, label: t("loans.form.interest_rate"), diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index d671b664..16046a58 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -42,7 +42,7 @@ multiple: true, label: t(".tags_label"), container_class: "h-40" - }%> + } %> <% end %> <%= f.text_area :notes, label: t(".note_label"), diff --git a/app/views/transactions/_selection_bar.html.erb b/app/views/transactions/_selection_bar.html.erb index 1ab10771..fada06aa 100644 --- a/app/views/transactions/_selection_bar.html.erb +++ b/app/views/transactions/_selection_bar.html.erb @@ -7,7 +7,7 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %> - <%= link_to new_transactions_bulk_update_path, + <%= link_to new_transactions_bulk_update_path, class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md", title: "Edit", data: { turbo_frame: "bulk_transaction_edit_drawer" } do %> diff --git a/db/migrate/20250304200956_add_signage_type_to_imports.rb b/db/migrate/20250304200956_add_signage_type_to_imports.rb new file mode 100644 index 00000000..b9651850 --- /dev/null +++ b/db/migrate/20250304200956_add_signage_type_to_imports.rb @@ -0,0 +1,20 @@ +class AddSignageTypeToImports < ActiveRecord::Migration[7.2] + def change + change_column_default :imports, :date_col_label, from: "date", to: nil + change_column_default :imports, :amount_col_label, from: "amount", to: nil + change_column_default :imports, :name_col_label, from: "name", to: nil + change_column_default :imports, :category_col_label, from: "category", to: nil + change_column_default :imports, :tags_col_label, from: "tags", to: nil + change_column_default :imports, :account_col_label, from: "account", to: nil + change_column_default :imports, :qty_col_label, from: "qty", to: nil + change_column_default :imports, :ticker_col_label, from: "ticker", to: nil + change_column_default :imports, :price_col_label, from: "price", to: nil + change_column_default :imports, :entity_type_col_label, from: "entity_type", to: nil + change_column_default :imports, :notes_col_label, from: "notes", to: nil + change_column_default :imports, :currency_col_label, from: "currency", to: nil + + # User can select "custom", then assign "debit" or "credit" (or custom value) to determine inflow/outflow of txn + add_column :imports, :amount_type_strategy, :string, default: "signed_amount" + add_column :imports, :amount_type_inflow_value, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index a5a5b2b5..280aae75 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -299,23 +299,25 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do t.uuid "family_id", null: false t.uuid "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_col_label" + t.string "amount_col_label" + t.string "name_col_label" + t.string "category_col_label" + t.string "tags_col_label" + t.string "account_col_label" + t.string "qty_col_label" + t.string "ticker_col_label" + t.string "price_col_label" + t.string "entity_type_col_label" + t.string "notes_col_label" + t.string "currency_col_label" t.string "date_format", default: "%m/%d/%Y" t.string "signage_convention", default: "inflows_positive" t.string "error" t.string "number_format" t.string "exchange_operating_mic_col_label" + t.string "amount_type_strategy", default: "signed_amount" + t.string "amount_type_inflow_value" t.index ["family_id"], name: "index_imports_on_family_id" end diff --git a/test/interfaces/import_interface_test.rb b/test/interfaces/import_interface_test.rb index 2dfa1bb4..2b475927 100644 --- a/test/interfaces/import_interface_test.rb +++ b/test/interfaces/import_interface_test.rb @@ -277,6 +277,7 @@ module ImportInterfaceTest csv_data = "date,amount,name\n01/01/2024,1234.56,Test" import.update!(raw_file_str: csv_data) import.update!( + date_col_label: "date", date_format: "%Y-%m-%d" # Does not match the raw CSV date, so rows will be invalid, but still generated ) diff --git a/test/models/trade_import_test.rb b/test/models/trade_import_test.rb index 66637fc5..f5fab129 100644 --- a/test/models/trade_import_test.rb +++ b/test/models/trade_import_test.rb @@ -36,6 +36,7 @@ class TradeImportTest < ActiveSupport::TestCase CSV @import.update!( + account: accounts(:depository), raw_file_str: import, date_col_label: "date", ticker_col_label: "ticker", @@ -52,16 +53,11 @@ class TradeImportTest < ActiveSupport::TestCase @import.reload - assert_difference [ - -> { Entry.count }, - -> { Trade.count } - ], 2 do - assert_difference [ - -> { Security.count }, - -> { Account.count } - ], 1 do - @import.publish - end + assert_difference -> { Entry.count } => 2, + -> { Trade.count } => 2, + -> { Security.count } => 1, + -> { Account.count } => 1 do + @import.publish end assert_equal "complete", @import.status diff --git a/test/models/transaction_import_test.rb b/test/models/transaction_import_test.rb index c9afc553..dc94b20c 100644 --- a/test/models/transaction_import_test.rb +++ b/test/models/transaction_import_test.rb @@ -68,4 +68,36 @@ class TransactionImportTest < ActiveSupport::TestCase assert_equal "complete", @import.status end + + test "imports transactions with separate type column for signage convention" do + import = <<~CSV + date,amount,amount_type + 01/01/2024,100,debit + 01/02/2024,200,credit + 01/03/2024,300,debit + CSV + + @import.update!( + account: accounts(:depository), + raw_file_str: import, + date_col_label: "date", + date_format: "%m/%d/%Y", + amount_col_label: "amount", + entity_type_col_label: "amount_type", + amount_type_inflow_value: "debit", + amount_type_strategy: "custom_column", + signage_convention: nil # Explicitly set to nil to prove this is not needed + ) + + @import.generate_rows_from_csv + + @import.reload + + assert_difference -> { Entry.count } => 3, + -> { Transaction.count } => 3 do + @import.publish + end + + assert_equal [ -100, 200, -300 ], @import.entries.map(&:amount) + end end diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb index bf7b7907..9cedd2f6 100644 --- a/test/system/imports_test.rb +++ b/test/system/imports_test.rb @@ -21,14 +21,14 @@ class ImportsTest < ApplicationSystemTestCase 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)" + select "Date", from: "import[date_col_label]" + select "YYYY-MM-DD", from: "import[date_format]" + select "Amount", from: "import[amount_col_label]" + select "Account", from: "import[account_col_label]" + select "Name", from: "import[name_col_label]" + select "Category", from: "import[category_col_label]" + select "Tags", from: "import[tags_col_label]" + select "Notes", from: "import[notes_col_label]" click_on "Apply configuration" @@ -67,7 +67,12 @@ class ImportsTest < ApplicationSystemTestCase find('input[type="submit"][value="Upload CSV"]').click - select "YYYY-MM-DD", from: "Date format" + select "date", from: "import[date_col_label]" + select "YYYY-MM-DD", from: "import[date_format]" + select "qty", from: "import[qty_col_label]" + select "ticker", from: "import[ticker_col_label]" + select "price", from: "import[price_col_label]" + select "account", from: "import[account_col_label]" click_on "Apply configuration" @@ -100,6 +105,10 @@ class ImportsTest < ApplicationSystemTestCase find('input[type="submit"][value="Upload CSV"]').click + select "type", from: "import[entity_type_col_label]" + select "name", from: "import[name_col_label]" + select "amount", from: "import[amount_col_label]" + click_on "Apply configuration" click_on "Next step"