diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index 3f4628720..61948a7b3 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -71,7 +71,7 @@ module ImportsHelper private def permitted_import_types - %w[transaction_import trade_import account_import mint_import category_import rule_import] + %w[transaction_import trade_import account_import mint_import actual_import category_import rule_import] end DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true) diff --git a/app/models/actual_import.rb b/app/models/actual_import.rb new file mode 100644 index 000000000..32628f4aa --- /dev/null +++ b/app/models/actual_import.rb @@ -0,0 +1,104 @@ +class ActualImport < Import + after_create :set_mappings + + DEFAULT_COLUMN_MAPPINGS = { + signage_convention: "inflows_positive", + date_col_label: "Date", + date_format: "%Y-%m-%d", + name_col_label: "Payee", + amount_col_label: "Amount", + account_col_label: "Account", + category_col_label: "Category", + notes_col_label: "Notes" + }.freeze + + CATEGORY_GROUP_COLUMN = "Category_Group".freeze + + def self.default_column_mappings + DEFAULT_COLUMN_MAPPINGS + end + + def generate_rows_from_csv + rows.destroy_all + + mapped_rows = csv_rows.map.with_index(1) do |row, index| + { + source_row_number: index, + account: row[account_col_label].to_s, + date: row[date_col_label].to_s, + amount: signed_csv_amount(row).to_s, + currency: default_currency.to_s, + name: row[name_col_label].to_s, + category: combined_category(row), + notes: row[notes_col_label].to_s + } + end + + rows.insert_all!(mapped_rows) + update_column(:rows_count, rows.count) + 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) + + entry = account.entries.build \ + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: account.currency.presence || family.currency, + notes: row.notes, + entryable: Transaction.new(category: category), + import: self + + entry.save! + end + end + end + + def mapping_steps + [ Import::CategoryMapping, Import::AccountMapping ] + end + + def required_column_keys + %i[date amount] + end + + def column_keys + %i[date amount name category account notes] + end + + def csv_template + template = <<~CSV + Account,Date,Payee,Notes,Category_Group,Category,Amount,Split_Amount,Cleared + Checking Account,2024-01-01,Employer,Monthly salary,Income,Paycheck,2500.00,0,Reconciled + Credit Card,2024-01-03,Coffee Shop,Morning coffee,Food,Coffee,-4.25,0,Cleared + CSV + + CSV.parse(template, headers: true) + end + + def signed_csv_amount(csv_row) + csv_row[amount_col_label].to_d + end + + private + def set_mappings + assign_attributes(self.class.default_column_mappings) + save! + end + + def combined_category(row) + category = row[category_col_label].to_s.strip + category_group = row[CATEGORY_GROUP_COLUMN].to_s.strip + + return category if category_group.blank? + return category_group if category.blank? + + "#{category_group}: #{category}" + end +end diff --git a/app/models/import.rb b/app/models/import.rb index ac3c5d5df..c5e26327e 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -10,7 +10,7 @@ class Import < ApplicationRecord DOCUMENT_TYPES = %w[bank_statement credit_card_statement investment_statement financial_document contract other].freeze - TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport QifImport SureImport].freeze + TYPES = %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport PdfImport QifImport SureImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze diff --git a/app/models/import/preflight.rb b/app/models/import/preflight.rb index ef9429246..69051eac3 100644 --- a/app/models/import/preflight.rb +++ b/app/models/import/preflight.rb @@ -313,9 +313,9 @@ class Import::Preflight end def apply_import_defaults(import) - return unless import.is_a?(MintImport) + return unless import.class.respond_to?(:default_column_mappings) - MintImport.default_column_mappings.each do |attribute, value| + import.class.default_column_mappings.each do |attribute, value| import.public_send("#{attribute}=", value) if import.public_send(attribute).blank? end end diff --git a/app/views/import/configurations/_actual_import.html.erb b/app/views/import/configurations/_actual_import.html.erb new file mode 100644 index 000000000..6b10d8a11 --- /dev/null +++ b/app/views/import/configurations/_actual_import.html.erb @@ -0,0 +1,27 @@ +<%# locals: (import:) %> + +
+ + <%= icon("check-circle", color: "current") %> + +

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

+
+ +<%= 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: t(".leave_empty"), label: t(".date_label") }, 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: t(".leave_empty"), label: t(".amount_label") }, required: true, disabled: import.complete? %> + <%= form.select :signage_convention, [[t(".incomes_are_negative"), "inflows_negative"], [t(".incomes_are_positive"), "inflows_positive"]], { label: t(".signage_convention_label") }, disabled: import.complete? %> +
+ + <%= form.select :account_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".account_label") }, disabled: import.complete? %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".name_label") }, disabled: import.complete? %> + <%= form.select :category_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".category_label") }, disabled: import.complete? %> + <%= form.select :notes_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".notes_label") }, disabled: import.complete? %> + + <%= form.submit t(".apply_configuration"), disabled: import.complete? %> +<% end %> diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index 84d235a15..e71d84b6f 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -31,7 +31,7 @@ <% import_type = params[:type].presence || @pending_import&.type - active_tab = import_type.present? && !import_type.in?(%w[MintImport QifImport SureImport DocumentImport PdfImport]) ? "raw_data" : "financial_tools" + active_tab = import_type.present? && !import_type.in?(%w[MintImport ActualImport QifImport SureImport DocumentImport PdfImport]) ? "raw_data" : "financial_tools" %> <%= render DS::Tabs.new(active_tab: active_tab) do |tabs| %> <% tabs.with_nav do |nav| %> @@ -42,7 +42,7 @@ <% tabs.with_panel(tab_id: "financial_tools") do %>