mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-21 22:29:38 +02:00
CSV Imports Overhaul (Transactions, Trades, Accounts, and Mint import support) (#1209)
* Remove stale 1.0 import logic and model * Fresh start * Checkpoint before removing nav * First working prototype * Add trade, account, and mint import flows * Basic working version with tests * System tests for each import type * Clean up mappings flow * Clean up PR, refactor stale code, tests * Add back row validations * Row validations * Fix import job test * Fix import navigation * Fix mint import configuration form * Currency preset for new accounts
This commit is contained in:
parent
23786b444a
commit
398b246965
103 changed files with 2420 additions and 1689 deletions
|
@ -35,7 +35,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__submit {
|
.form-field__submit {
|
||||||
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:checked+label+.toggle-switch-dot {
|
input:checked+label+.toggle-switch-dot {
|
||||||
|
@ -100,11 +100,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@apply px-3 py-2 rounded-lg text-sm font-medium;
|
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--primary {
|
.btn--primary {
|
||||||
@apply bg-gray-900 text-white hover:bg-gray-700;
|
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--outline {
|
.btn--outline {
|
||||||
|
|
|
@ -23,7 +23,10 @@ class AccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@account = Account.new(accountable: Accountable.from_type(params[:type])&.new)
|
@account = Account.new(
|
||||||
|
accountable: Accountable.from_type(params[:type])&.new,
|
||||||
|
currency: Current.family.currency
|
||||||
|
)
|
||||||
|
|
||||||
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
|
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
|
||||||
|
|
||||||
|
|
22
app/controllers/import/cleans_controller.rb
Normal file
22
app/controllers/import/cleans_controller.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
class Import::CleansController < ApplicationController
|
||||||
|
layout "imports"
|
||||||
|
|
||||||
|
before_action :set_import
|
||||||
|
|
||||||
|
def show
|
||||||
|
redirect_to import_configuration_path(@import), alert: "Please configure your import before proceeding." unless @import.configured?
|
||||||
|
|
||||||
|
rows = @import.rows.ordered
|
||||||
|
|
||||||
|
if params[:view] == "errors"
|
||||||
|
rows = rows.reject { |row| row.valid? }
|
||||||
|
end
|
||||||
|
|
||||||
|
@pagy, @rows = pagy_array(rows, limit: params[:per_page] || "10")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_import
|
||||||
|
@import = Current.family.imports.find(params[:import_id])
|
||||||
|
end
|
||||||
|
end
|
25
app/controllers/import/configurations_controller.rb
Normal file
25
app/controllers/import/configurations_controller.rb
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
class Import::ConfigurationsController < ApplicationController
|
||||||
|
layout "imports"
|
||||||
|
|
||||||
|
before_action :set_import
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@import.update!(import_params)
|
||||||
|
@import.generate_rows_from_csv
|
||||||
|
@import.reload.sync_mappings
|
||||||
|
|
||||||
|
redirect_to import_clean_path(@import), notice: "Import configured successfully."
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_import
|
||||||
|
@import = Current.family.imports.find(params[:import_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_params
|
||||||
|
params.require(:import).permit(:date_col_label, :date_format, :name_col_label, :category_col_label, :tags_col_label, :amount_col_label, :signage_convention, :account_col_label, :notes_col_label, :entity_type_col_label)
|
||||||
|
end
|
||||||
|
end
|
14
app/controllers/import/confirms_controller.rb
Normal file
14
app/controllers/import/confirms_controller.rb
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
class Import::ConfirmsController < ApplicationController
|
||||||
|
layout "imports"
|
||||||
|
|
||||||
|
before_action :set_import
|
||||||
|
|
||||||
|
def show
|
||||||
|
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_import
|
||||||
|
@import = Current.family.imports.find(params[:import_id])
|
||||||
|
end
|
||||||
|
end
|
43
app/controllers/import/mappings_controller.rb
Normal file
43
app/controllers/import/mappings_controller.rb
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
class Import::MappingsController < ApplicationController
|
||||||
|
before_action :set_import
|
||||||
|
|
||||||
|
def update
|
||||||
|
mapping = @import.mappings.find(params[:id])
|
||||||
|
|
||||||
|
mapping.update! \
|
||||||
|
create_when_empty: create_when_empty,
|
||||||
|
mappable: mappable,
|
||||||
|
value: mapping_params[:value]
|
||||||
|
|
||||||
|
redirect_back_or_to import_confirm_path(@import)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def mapping_params
|
||||||
|
params.require(:import_mapping).permit(:type, :key, :mappable_id, :mappable_type, :value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_import
|
||||||
|
@import = Current.family.imports.find(params[:import_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def mappable
|
||||||
|
return nil unless mappable_class.present?
|
||||||
|
|
||||||
|
@mappable ||= mappable_class.find_by(id: mapping_params[:mappable_id], family: Current.family)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_when_empty
|
||||||
|
return false unless mapping_class.present?
|
||||||
|
|
||||||
|
mapping_params[:mappable_id] == mapping_class::CREATE_NEW_KEY
|
||||||
|
end
|
||||||
|
|
||||||
|
def mappable_class
|
||||||
|
mapping_params[:mappable_type]&.constantize
|
||||||
|
end
|
||||||
|
|
||||||
|
def mapping_class
|
||||||
|
mapping_params[:type]&.constantize
|
||||||
|
end
|
||||||
|
end
|
24
app/controllers/import/rows_controller.rb
Normal file
24
app/controllers/import/rows_controller.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
class Import::RowsController < ApplicationController
|
||||||
|
before_action :set_import_row
|
||||||
|
|
||||||
|
def update
|
||||||
|
@row.assign_attributes(row_params)
|
||||||
|
@row.save!(validate: false)
|
||||||
|
@row.sync_mappings
|
||||||
|
|
||||||
|
redirect_to import_row_path(@row.import, @row)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def row_params
|
||||||
|
params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_import_row
|
||||||
|
@import = Current.family.imports.find(params[:import_id])
|
||||||
|
@row = @import.rows.find(params[:id])
|
||||||
|
end
|
||||||
|
end
|
47
app/controllers/import/uploads_controller.rb
Normal file
47
app/controllers/import/uploads_controller.rb
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
class Import::UploadsController < ApplicationController
|
||||||
|
layout "imports"
|
||||||
|
|
||||||
|
before_action :set_import
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if csv_valid?(csv_str)
|
||||||
|
@import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
|
||||||
|
@import.save!(validate: false)
|
||||||
|
|
||||||
|
redirect_to import_configuration_path(@import), notice: "CSV uploaded successfully."
|
||||||
|
else
|
||||||
|
flash.now[:alert] = "Must be valid CSV with headers and at least one row of data"
|
||||||
|
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_import
|
||||||
|
@import = Current.family.imports.find(params[:import_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_str
|
||||||
|
@csv_str ||= upload_params[:csv_file]&.read || upload_params[:raw_file_str]
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_valid?(str)
|
||||||
|
require "csv"
|
||||||
|
|
||||||
|
begin
|
||||||
|
csv = CSV.parse(str || "", headers: true)
|
||||||
|
return false if csv.headers.empty?
|
||||||
|
return false if csv.count == 0
|
||||||
|
true
|
||||||
|
rescue CSV::MalformedCSVError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def upload_params
|
||||||
|
params.require(:import).permit(:raw_file_str, :csv_file, :col_sep)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,118 +1,44 @@
|
||||||
require "ostruct"
|
|
||||||
|
|
||||||
class ImportsController < ApplicationController
|
class ImportsController < ApplicationController
|
||||||
before_action :set_import, except: %i[index new create]
|
before_action :set_import, only: %i[show publish destroy]
|
||||||
|
|
||||||
|
def publish
|
||||||
|
@import.publish_later
|
||||||
|
|
||||||
|
redirect_to import_path(@import), notice: "Your import has started in the background."
|
||||||
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@imports = Current.family.imports
|
@imports = Current.family.imports
|
||||||
render layout: "with_sidebar"
|
|
||||||
|
render layout: with_sidebar
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
account = Current.family.accounts.find_by(id: params[:account_id])
|
@pending_import = Current.family.imports.ordered.pending.first
|
||||||
@import = Import.new account: account
|
|
||||||
end
|
|
||||||
|
|
||||||
def edit
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
account = Current.family.accounts.find(params[:import][:account_id])
|
|
||||||
@import.update! account: account, col_sep: params[:import][:col_sep]
|
|
||||||
|
|
||||||
redirect_to load_import_path(@import), notice: t(".import_updated")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
account = Current.family.accounts.find(params[:import][:account_id])
|
import = Current.family.imports.create! import_params
|
||||||
@import = Import.create! account: account, col_sep: params[:import][:col_sep]
|
|
||||||
|
|
||||||
redirect_to load_import_path(@import), notice: t(".import_created")
|
redirect_to import_upload_path(import)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." unless @import.publishable?
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@import.destroy!
|
@import.destroy
|
||||||
redirect_to imports_url, notice: t(".import_destroyed"), status: :see_other
|
|
||||||
end
|
|
||||||
|
|
||||||
def load
|
redirect_to imports_path, notice: "Your import has been deleted."
|
||||||
end
|
|
||||||
|
|
||||||
def upload_csv
|
|
||||||
begin
|
|
||||||
@import.raw_file_str = import_params[:raw_file_str].read
|
|
||||||
rescue NoMethodError
|
|
||||||
flash.now[:alert] = "Please select a file to upload"
|
|
||||||
render :load, status: :unprocessable_entity and return
|
|
||||||
end
|
|
||||||
if @import.save
|
|
||||||
redirect_to configure_import_path(@import), notice: t(".import_loaded")
|
|
||||||
else
|
|
||||||
flash.now[:alert] = @import.errors.full_messages.to_sentence
|
|
||||||
render :load, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_csv
|
|
||||||
if @import.update(import_params)
|
|
||||||
redirect_to configure_import_path(@import), notice: t(".import_loaded")
|
|
||||||
else
|
|
||||||
flash.now[:alert] = @import.errors.full_messages.to_sentence
|
|
||||||
render :load, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def configure
|
|
||||||
unless @import.loaded?
|
|
||||||
redirect_to load_import_path(@import), alert: t(".invalid_csv")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_mappings
|
|
||||||
@import.update! import_params(@import.expected_fields.map(&:key))
|
|
||||||
redirect_to clean_import_path(@import), notice: t(".column_mappings_saved")
|
|
||||||
end
|
|
||||||
|
|
||||||
def clean
|
|
||||||
unless @import.loaded?
|
|
||||||
redirect_to load_import_path(@import), alert: t(".invalid_csv")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_csv
|
|
||||||
update_params = import_params[:csv_update]
|
|
||||||
|
|
||||||
@import.update_csv! \
|
|
||||||
row_idx: update_params[:row_idx],
|
|
||||||
col_idx: update_params[:col_idx],
|
|
||||||
value: update_params[:value]
|
|
||||||
|
|
||||||
render :clean
|
|
||||||
end
|
|
||||||
|
|
||||||
def confirm
|
|
||||||
unless @import.cleaned?
|
|
||||||
redirect_to clean_import_path(@import), alert: t(".invalid_data")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def publish
|
|
||||||
if @import.valid?
|
|
||||||
@import.publish_later
|
|
||||||
redirect_to imports_path, notice: t(".import_published")
|
|
||||||
else
|
|
||||||
flash.now[:error] = t(".invalid_data")
|
|
||||||
render :confirm, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_import
|
def set_import
|
||||||
@import = Current.family.imports.find(params[:id])
|
@import = Current.family.imports.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def import_params(permitted_mappings = nil)
|
def import_params
|
||||||
params.require(:import).permit(:raw_file_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
|
params.require(:import).permit(:type)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,19 +1,63 @@
|
||||||
module ImportsHelper
|
module ImportsHelper
|
||||||
def table_corner_class(row_idx, col_idx, rows, cols)
|
def mapping_label(mapping_class)
|
||||||
return "rounded-tl-xl" if row_idx == 0 && col_idx == 0
|
{
|
||||||
return "rounded-tr-xl" if row_idx == 0 && col_idx == cols.size - 1
|
"Import::AccountTypeMapping" => "Account Type",
|
||||||
return "rounded-bl-xl" if row_idx == rows.size - 1 && col_idx == 0
|
"Import::AccountMapping" => "Account",
|
||||||
return "rounded-br-xl" if row_idx == rows.size - 1 && col_idx == cols.size - 1
|
"Import::CategoryMapping" => "Category",
|
||||||
""
|
"Import::TagMapping" => "Tag"
|
||||||
|
}.fetch(mapping_class.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def nav_steps(import = Import.new)
|
def import_col_label(key)
|
||||||
[
|
{
|
||||||
{ name: "Select", complete: import.persisted?, path: import.persisted? ? edit_import_path(import) : new_import_path },
|
date: "Date",
|
||||||
{ name: "Import", complete: import.loaded?, path: import.persisted? ? load_import_path(import) : nil },
|
amount: "Amount",
|
||||||
{ name: "Setup", complete: import.configured?, path: import.persisted? ? configure_import_path(import) : nil },
|
name: "Name",
|
||||||
{ name: "Clean", complete: import.cleaned?, path: import.persisted? ? clean_import_path(import) : nil },
|
currency: "Currency",
|
||||||
{ name: "Confirm", complete: import.complete?, path: import.persisted? ? confirm_import_path(import) : nil }
|
category: "Category",
|
||||||
]
|
tags: "Tags",
|
||||||
|
account: "Account",
|
||||||
|
notes: "Notes",
|
||||||
|
qty: "Quantity",
|
||||||
|
ticker: "Ticker",
|
||||||
|
price: "Price",
|
||||||
|
entity_type: "Type"
|
||||||
|
}[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
def dry_run_resource(key)
|
||||||
|
map = {
|
||||||
|
transactions: DryRunResource.new(label: "Transactions", icon: "credit-card", text_class: "text-cyan-500", bg_class: "bg-cyan-500/5"),
|
||||||
|
accounts: DryRunResource.new(label: "Accounts", icon: "layers", text_class: "text-orange-500", bg_class: "bg-orange-500/5"),
|
||||||
|
categories: DryRunResource.new(label: "Categories", icon: "shapes", text_class: "text-blue-500", bg_class: "bg-blue-500/5"),
|
||||||
|
tags: DryRunResource.new(label: "Tags", icon: "tags", text_class: "text-violet-500", bg_class: "bg-violet-500/5")
|
||||||
|
}
|
||||||
|
|
||||||
|
map[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_import_configuration_path(import)
|
||||||
|
if permitted_import_types.include?(import.type.underscore)
|
||||||
|
"import/configurations/#{import.type.underscore}"
|
||||||
|
else
|
||||||
|
raise "Unknown import type: #{import.type}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def cell_class(row, field)
|
||||||
|
base = "text-sm focus:ring-gray-900 focus:border-gray-900 w-full max-w-full disabled:text-gray-400"
|
||||||
|
|
||||||
|
row.valid? # populate errors
|
||||||
|
|
||||||
|
border = row.errors.key?(field) ? "border-red-500" : "border-transparent"
|
||||||
|
|
||||||
|
[ base, border ].join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def permitted_import_types
|
||||||
|
%w[transaction_import trade_import account_import mint_import]
|
||||||
|
end
|
||||||
|
|
||||||
|
DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true)
|
||||||
|
end
|
||||||
|
|
|
@ -49,7 +49,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||||
end
|
end
|
||||||
|
|
||||||
def submit(value = nil, options = {})
|
def submit(value = nil, options = {})
|
||||||
merged_options = { class: "form-field__submit" }.merge(options)
|
merged_options = { class: "btn btn--primary w-full" }.merge(options)
|
||||||
value, options = nil, value if value.is_a?(Hash)
|
value, options = nil, value if value.is_a?(Hash)
|
||||||
super(value, merged_options)
|
super(value, merged_options)
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,98 +0,0 @@
|
||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["input", "preview", "submit", "filename", "filesize"]
|
|
||||||
static values = {
|
|
||||||
acceptedTypes: Array, // ["text/csv", "application/csv", ".csv"]
|
|
||||||
acceptedExtension: String, // "csv"
|
|
||||||
unacceptableTypeLabel: String, // "Only CSV files are allowed."
|
|
||||||
};
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.submitTarget.disabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
addFile(event) {
|
|
||||||
const file = event.target.files[0]
|
|
||||||
this._fileAdded(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
dragover(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
event.currentTarget.classList.add("bg-gray-100")
|
|
||||||
}
|
|
||||||
|
|
||||||
dragleave(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
event.currentTarget.classList.remove("bg-gray-100")
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
event.currentTarget.classList.remove("bg-gray-100")
|
|
||||||
|
|
||||||
const file = event.dataTransfer.files[0]
|
|
||||||
if (file && this._formatAcceptable(file)) {
|
|
||||||
this._setFileInput(file);
|
|
||||||
this._fileAdded(file)
|
|
||||||
} else {
|
|
||||||
this.previewTarget.classList.add("text-red-500")
|
|
||||||
this.previewTarget.textContent = this.unacceptableTypeLabelValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private
|
|
||||||
|
|
||||||
_fetchFileSize(size) {
|
|
||||||
let fileSize = '';
|
|
||||||
if (size < 1024 * 1024) {
|
|
||||||
fileSize = (size / 1024).toFixed(2) + ' KB'; // Convert bytes to KB
|
|
||||||
} else {
|
|
||||||
fileSize = (size / (1024 * 1024)).toFixed(2) + ' MB'; // Convert bytes to MB
|
|
||||||
}
|
|
||||||
return fileSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
_fileAdded(file) {
|
|
||||||
const fileSizeLimit = 5 * 1024 * 1024 // 5MB
|
|
||||||
|
|
||||||
if (file) {
|
|
||||||
if (file.size > fileSizeLimit) {
|
|
||||||
this.previewTarget.classList.add("text-red-500")
|
|
||||||
this.previewTarget.textContent = this.unacceptableTypeLabelValue
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.submitTarget.classList.remove([
|
|
||||||
"bg-alpha-black-25",
|
|
||||||
"text-gray",
|
|
||||||
"cursor-not-allowed",
|
|
||||||
]);
|
|
||||||
this.submitTarget.classList.add(
|
|
||||||
"bg-gray-900",
|
|
||||||
"text-white",
|
|
||||||
"cursor-pointer",
|
|
||||||
);
|
|
||||||
this.submitTarget.disabled = false;
|
|
||||||
this.previewTarget.innerHTML = document.querySelector("#template-preview").innerHTML;
|
|
||||||
this.previewTarget.classList.remove("text-red-500")
|
|
||||||
this.previewTarget.classList.add("text-gray-900")
|
|
||||||
this.filenameTarget.textContent = file.name;
|
|
||||||
this.filesizeTarget.textContent = this._fetchFileSize(file.size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_formatAcceptable(file) {
|
|
||||||
const extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
return this.acceptedTypesValue.includes(file.type) || extension === this.acceptedExtensionValue
|
|
||||||
}
|
|
||||||
|
|
||||||
_setFileInput(file) {
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
dataTransfer.items.add(file);
|
|
||||||
this.inputTarget.files = dataTransfer.files;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -5,14 +5,15 @@ class Account < ApplicationRecord
|
||||||
|
|
||||||
belongs_to :family
|
belongs_to :family
|
||||||
belongs_to :institution, optional: true
|
belongs_to :institution, optional: true
|
||||||
|
belongs_to :import, optional: true
|
||||||
|
|
||||||
|
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||||
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
||||||
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
|
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
|
||||||
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
|
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
|
||||||
has_many :holdings, dependent: :destroy
|
has_many :holdings, dependent: :destroy
|
||||||
has_many :balances, dependent: :destroy
|
has_many :balances, dependent: :destroy
|
||||||
has_many :imports, dependent: :destroy
|
|
||||||
has_many :syncs, dependent: :destroy
|
has_many :syncs, dependent: :destroy
|
||||||
has_many :issues, as: :issuable, dependent: :destroy
|
has_many :issues, as: :issuable, dependent: :destroy
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ class Account::Entry < ApplicationRecord
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :transfer, optional: true
|
belongs_to :transfer, optional: true
|
||||||
|
belongs_to :import, optional: true
|
||||||
|
|
||||||
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
|
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
|
||||||
accepts_nested_attributes_for :entryable
|
accepts_nested_attributes_for :entryable
|
||||||
|
@ -12,7 +13,6 @@ class Account::Entry < ApplicationRecord
|
||||||
validates :date, :amount, :currency, presence: true
|
validates :date, :amount, :currency, presence: true
|
||||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||||
validate :trade_valid?, if: -> { account_trade? }
|
|
||||||
|
|
||||||
scope :chronological, -> { order(:date, :created_at) }
|
scope :chronological, -> { order(:date, :created_at) }
|
||||||
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
||||||
|
@ -219,20 +219,4 @@ class Account::Entry < ApplicationRecord
|
||||||
previous: previous_entry&.amount_money,
|
previous: previous_entry&.amount_money,
|
||||||
favorable_direction: account.favorable_direction
|
favorable_direction: account.favorable_direction
|
||||||
end
|
end
|
||||||
|
|
||||||
def trade_valid?
|
|
||||||
if account_trade.sell?
|
|
||||||
current_qty = account.holding_qty(account_trade.security)
|
|
||||||
|
|
||||||
if current_qty < account_trade.qty.abs
|
|
||||||
errors.add(
|
|
||||||
:base,
|
|
||||||
:invalid_sell_quantity,
|
|
||||||
sell_qty: account_trade.qty.abs,
|
|
||||||
ticker: account_trade.security.ticker,
|
|
||||||
current_qty: current_qty
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
49
app/models/account_import.rb
Normal file
49
app/models/account_import.rb
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
class AccountImport < Import
|
||||||
|
def import!
|
||||||
|
transaction do
|
||||||
|
rows.each do |row|
|
||||||
|
mapping = mappings.account_types.find_by(key: row.entity_type)
|
||||||
|
accountable_class = mapping.value.constantize
|
||||||
|
|
||||||
|
account = family.accounts.build(
|
||||||
|
name: row.name,
|
||||||
|
balance: row.amount.to_d,
|
||||||
|
currency: row.currency,
|
||||||
|
accountable: accountable_class.new,
|
||||||
|
import: self
|
||||||
|
)
|
||||||
|
|
||||||
|
account.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mapping_steps
|
||||||
|
[ Import::AccountTypeMapping ]
|
||||||
|
end
|
||||||
|
|
||||||
|
def required_column_keys
|
||||||
|
%i[name amount]
|
||||||
|
end
|
||||||
|
|
||||||
|
def column_keys
|
||||||
|
%i[entity_type name amount currency]
|
||||||
|
end
|
||||||
|
|
||||||
|
def dry_run
|
||||||
|
{
|
||||||
|
accounts: rows.count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_template
|
||||||
|
template = <<-CSV
|
||||||
|
Account type*,Name*,Balance*,Currency
|
||||||
|
Checking,Main Checking Account,1000.00,USD
|
||||||
|
Savings,Emergency Fund,5000.00,USD
|
||||||
|
Credit Card,Rewards Card,-500.00,USD
|
||||||
|
CSV
|
||||||
|
|
||||||
|
CSV.parse(template, headers: true)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,5 +1,6 @@
|
||||||
class Category < ApplicationRecord
|
class Category < ApplicationRecord
|
||||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||||
|
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||||
belongs_to :family
|
belongs_to :family
|
||||||
|
|
||||||
validates :name, :color, :family, presence: true
|
validates :name, :color, :family, presence: true
|
||||||
|
|
|
@ -3,9 +3,9 @@ class Family < ApplicationRecord
|
||||||
has_many :tags, dependent: :destroy
|
has_many :tags, dependent: :destroy
|
||||||
has_many :accounts, dependent: :destroy
|
has_many :accounts, dependent: :destroy
|
||||||
has_many :institutions, dependent: :destroy
|
has_many :institutions, dependent: :destroy
|
||||||
|
has_many :imports, dependent: :destroy
|
||||||
has_many :transactions, through: :accounts
|
has_many :transactions, through: :accounts
|
||||||
has_many :entries, through: :accounts
|
has_many :entries, through: :accounts
|
||||||
has_many :imports, through: :accounts
|
|
||||||
has_many :categories, dependent: :destroy
|
has_many :categories, dependent: :destroy
|
||||||
has_many :merchants, dependent: :destroy
|
has_many :merchants, dependent: :destroy
|
||||||
has_many :issues, through: :accounts
|
has_many :issues, through: :accounts
|
||||||
|
|
|
@ -1,185 +1,137 @@
|
||||||
class Import < ApplicationRecord
|
class Import < ApplicationRecord
|
||||||
belongs_to :account
|
TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze
|
||||||
|
SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]
|
||||||
|
|
||||||
validate :raw_file_must_be_parsable
|
belongs_to :family
|
||||||
validates :col_sep, inclusion: { in: Csv::COL_SEP_LIST }
|
|
||||||
|
|
||||||
before_save :initialize_csv, if: :should_initialize_csv?
|
|
||||||
|
|
||||||
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
|
|
||||||
|
|
||||||
store_accessor :column_mappings, :define_column_mapping_keys
|
|
||||||
|
|
||||||
scope :ordered, -> { order(created_at: :desc) }
|
scope :ordered, -> { order(created_at: :desc) }
|
||||||
|
|
||||||
FALLBACK_TRANSACTION_NAME = "Imported transaction"
|
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
|
||||||
|
|
||||||
|
validates :type, inclusion: { in: TYPES }
|
||||||
|
validates :col_sep, inclusion: { in: [ ",", ";" ] }
|
||||||
|
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }
|
||||||
|
|
||||||
|
has_many :rows, dependent: :destroy
|
||||||
|
has_many :mappings, dependent: :destroy
|
||||||
|
has_many :accounts, dependent: :destroy
|
||||||
|
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||||
|
|
||||||
def publish_later
|
def publish_later
|
||||||
|
raise "Import is not publishable" unless publishable?
|
||||||
|
|
||||||
|
update! status: :importing
|
||||||
|
|
||||||
ImportJob.perform_later(self)
|
ImportJob.perform_later(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def loaded?
|
def publish
|
||||||
|
import!
|
||||||
|
|
||||||
|
family.sync
|
||||||
|
|
||||||
|
update! status: :complete
|
||||||
|
rescue => error
|
||||||
|
update! status: :failed, error: error.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_rows
|
||||||
|
@csv_rows ||= parsed_csv
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_headers
|
||||||
|
parsed_csv.headers
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_sample
|
||||||
|
@csv_sample ||= parsed_csv.first(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dry_run
|
||||||
|
{
|
||||||
|
transactions: rows.count,
|
||||||
|
accounts: Import::AccountMapping.for_import(self).creational.count,
|
||||||
|
categories: Import::CategoryMapping.for_import(self).creational.count,
|
||||||
|
tags: Import::TagMapping.for_import(self).creational.count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def required_column_keys
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def column_keys
|
||||||
|
raise NotImplementedError, "Subclass must implement column_keys"
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_rows_from_csv
|
||||||
|
rows.destroy_all
|
||||||
|
|
||||||
|
mapped_rows = csv_rows.map do |row|
|
||||||
|
{
|
||||||
|
account: row[account_col_label].to_s,
|
||||||
|
date: row[date_col_label].to_s,
|
||||||
|
qty: row[qty_col_label].to_s,
|
||||||
|
ticker: row[ticker_col_label].to_s,
|
||||||
|
price: row[price_col_label].to_s,
|
||||||
|
amount: row[amount_col_label].to_s,
|
||||||
|
currency: (row[currency_col_label] || default_currency).to_s,
|
||||||
|
name: (row[name_col_label] || default_row_name).to_s,
|
||||||
|
category: row[category_col_label].to_s,
|
||||||
|
tags: row[tags_col_label].to_s,
|
||||||
|
entity_type: row[entity_type_col_label].to_s,
|
||||||
|
notes: row[notes_col_label].to_s
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
rows.insert_all!(mapped_rows)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_mappings
|
||||||
|
mapping_steps.each do |mapping|
|
||||||
|
mapping.sync(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mapping_steps
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def uploaded?
|
||||||
raw_file_str.present?
|
raw_file_str.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def configured?
|
def configured?
|
||||||
csv.present?
|
uploaded? && rows.any?
|
||||||
end
|
end
|
||||||
|
|
||||||
def cleaned?
|
def cleaned?
|
||||||
loaded? && configured? && csv.valid?
|
configured? && rows.all?(&:valid?)
|
||||||
end
|
end
|
||||||
|
|
||||||
def csv
|
def publishable?
|
||||||
get_normalized_csv_with_validation
|
cleaned? && mappings.all?(&:valid?)
|
||||||
end
|
|
||||||
|
|
||||||
def available_headers
|
|
||||||
get_raw_csv.table.headers
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_selected_header_for_field(field)
|
|
||||||
column_mappings&.dig(field.key) || field.key
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_csv!(row_idx:, col_idx:, value:)
|
|
||||||
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
|
|
||||||
update! normalized_csv_str: updated_csv.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
# Type-specific methods (potential STI inheritance in future when more import types added)
|
|
||||||
def publish
|
|
||||||
update!(status: "importing")
|
|
||||||
|
|
||||||
transaction do
|
|
||||||
generate_transactions.each do |txn|
|
|
||||||
txn.save!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
self.account.sync
|
|
||||||
|
|
||||||
update!(status: "complete")
|
|
||||||
rescue => e
|
|
||||||
update!(status: "failed")
|
|
||||||
Rails.logger.error("Import with id #{id} failed: #{e}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def dry_run
|
|
||||||
generate_transactions
|
|
||||||
end
|
|
||||||
|
|
||||||
def expected_fields
|
|
||||||
@expected_fields ||= create_expected_fields
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
def import!
|
||||||
def get_normalized_csv_with_validation
|
# no-op, subclasses can implement for customization of algorithm
|
||||||
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
|
end
|
||||||
|
|
||||||
csv
|
def default_row_name
|
||||||
|
"Imported item"
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_raw_csv
|
def default_currency
|
||||||
return nil if raw_file_str.nil?
|
family.currency
|
||||||
Import::Csv.new(raw_file_str, col_sep:)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_initialize_csv?
|
def parsed_csv
|
||||||
raw_file_str_changed? || column_mappings_changed?
|
@parsed_csv ||= CSV.parse(
|
||||||
end
|
(raw_file_str || "").strip,
|
||||||
|
headers: true,
|
||||||
def initialize_csv
|
col_sep: col_sep,
|
||||||
generated_csv = generate_normalized_csv(raw_file_str)
|
converters: [ ->(str) { str&.strip } ]
|
||||||
self.normalized_csv_str = generated_csv.table.to_s
|
)
|
||||||
end
|
|
||||||
|
|
||||||
# Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import
|
|
||||||
def generate_normalized_csv(csv_str)
|
|
||||||
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings, col_sep)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_csv(row_idx, col_idx, value)
|
|
||||||
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
|
|
||||||
update! normalized_csv_str: updated_csv.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_transactions
|
|
||||||
transaction_entries = []
|
|
||||||
category_cache = {}
|
|
||||||
tag_cache = {}
|
|
||||||
|
|
||||||
csv.table.each do |row|
|
|
||||||
category_name = row["category"].presence
|
|
||||||
tag_strings = row["tags"].presence&.split("|") || []
|
|
||||||
tags = []
|
|
||||||
|
|
||||||
tag_strings.each do |tag_string|
|
|
||||||
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
|
|
||||||
end
|
|
||||||
|
|
||||||
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
|
|
||||||
|
|
||||||
entry = account.entries.build \
|
|
||||||
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
|
||||||
date: Date.iso8601(row["date"]),
|
|
||||||
currency: account.currency,
|
|
||||||
amount: BigDecimal(row["amount"]) * -1,
|
|
||||||
entryable: Account::Transaction.new(category: category, tags: tags)
|
|
||||||
|
|
||||||
transaction_entries << entry
|
|
||||||
end
|
|
||||||
|
|
||||||
transaction_entries
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_expected_fields
|
|
||||||
date_field = Import::Field.new \
|
|
||||||
key: "date",
|
|
||||||
label: "Date",
|
|
||||||
validator: ->(value) { Import::Field.iso_date_validator(value) }
|
|
||||||
|
|
||||||
name_field = Import::Field.new \
|
|
||||||
key: "name",
|
|
||||||
label: "Name",
|
|
||||||
is_optional: true
|
|
||||||
|
|
||||||
category_field = Import::Field.new \
|
|
||||||
key: "category",
|
|
||||||
label: "Category",
|
|
||||||
is_optional: true
|
|
||||||
|
|
||||||
tags_field = Import::Field.new \
|
|
||||||
key: "tags",
|
|
||||||
label: "Tags",
|
|
||||||
is_optional: true
|
|
||||||
|
|
||||||
amount_field = Import::Field.new \
|
|
||||||
key: "amount",
|
|
||||||
label: "Amount",
|
|
||||||
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
|
|
||||||
|
|
||||||
[ date_field, name_field, category_field, tags_field, amount_field ]
|
|
||||||
end
|
|
||||||
|
|
||||||
def define_column_mapping_keys
|
|
||||||
expected_fields.each do |field|
|
|
||||||
field.key.to_sym
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def raw_file_must_be_parsable
|
|
||||||
begin
|
|
||||||
CSV.parse(raw_file_str || "", col_sep:)
|
|
||||||
rescue CSV::MalformedCSVError
|
|
||||||
errors.add(:raw_file_str, :invalid_csv_format)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
45
app/models/import/account_mapping.rb
Normal file
45
app/models/import/account_mapping.rb
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
class Import::AccountMapping < Import::Mapping
|
||||||
|
validates :mappable, presence: true, if: -> { key.blank? || !create_when_empty }
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def mapping_values(import)
|
||||||
|
import.rows.map(&:account).uniq
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def selectable_values
|
||||||
|
family_accounts = import.family.accounts.alphabetically.map { |account| [ account.name, account.id ] }
|
||||||
|
|
||||||
|
unless key.blank?
|
||||||
|
family_accounts.unshift [ "Add as new account", CREATE_NEW_KEY ]
|
||||||
|
end
|
||||||
|
|
||||||
|
family_accounts
|
||||||
|
end
|
||||||
|
|
||||||
|
def requires_selection?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def values_count
|
||||||
|
import.rows.where(account: key).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def mappable_class
|
||||||
|
Account
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_mappable!
|
||||||
|
return unless creatable?
|
||||||
|
|
||||||
|
account = import.family.accounts.create_or_find_by!(name: key) do |new_account|
|
||||||
|
new_account.balance = 0
|
||||||
|
new_account.import = import
|
||||||
|
new_account.currency = import.family.currency
|
||||||
|
new_account.accountable = Depository.new
|
||||||
|
end
|
||||||
|
|
||||||
|
self.mappable = account
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
end
|
25
app/models/import/account_type_mapping.rb
Normal file
25
app/models/import/account_type_mapping.rb
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
class Import::AccountTypeMapping < Import::Mapping
|
||||||
|
validates :value, presence: true
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def mapping_values(import)
|
||||||
|
import.rows.map(&:entity_type).uniq
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def selectable_values
|
||||||
|
Accountable::TYPES.map { |type| [ type.titleize, type ] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def requires_selection?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def values_count
|
||||||
|
import.rows.where(entity_type: key).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_mappable!
|
||||||
|
# no-op
|
||||||
|
end
|
||||||
|
end
|
36
app/models/import/category_mapping.rb
Normal file
36
app/models/import/category_mapping.rb
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
class Import::CategoryMapping < Import::Mapping
|
||||||
|
class << self
|
||||||
|
def mapping_values(import)
|
||||||
|
import.rows.map(&:category).uniq
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def selectable_values
|
||||||
|
family_categories = import.family.categories.alphabetically.map { |category| [ category.name, category.id ] }
|
||||||
|
|
||||||
|
unless key.blank?
|
||||||
|
family_categories.unshift [ "Add as new category", CREATE_NEW_KEY ]
|
||||||
|
end
|
||||||
|
|
||||||
|
family_categories
|
||||||
|
end
|
||||||
|
|
||||||
|
def requires_selection?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def values_count
|
||||||
|
import.rows.where(category: key).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def mappable_class
|
||||||
|
Category
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_mappable!
|
||||||
|
return unless creatable?
|
||||||
|
|
||||||
|
self.mappable = import.family.categories.find_or_create_by!(name: key)
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,83 +0,0 @@
|
||||||
class Import::Csv
|
|
||||||
DEFAULT_COL_SEP = ",".freeze
|
|
||||||
COL_SEP_LIST = [ DEFAULT_COL_SEP, ";" ].freeze
|
|
||||||
|
|
||||||
def self.parse_csv(csv_str, col_sep: DEFAULT_COL_SEP)
|
|
||||||
CSV.parse(
|
|
||||||
csv_str&.strip || "",
|
|
||||||
headers: true,
|
|
||||||
col_sep:,
|
|
||||||
converters: [ ->(str) { str&.strip } ]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.create_with_field_mappings(raw_file_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP)
|
|
||||||
raw_csv = self.parse_csv(raw_file_str, col_sep:)
|
|
||||||
|
|
||||||
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true, col_sep: do |csv|
|
|
||||||
raw_csv.each do |row|
|
|
||||||
row_values = []
|
|
||||||
|
|
||||||
fields.each do |field|
|
|
||||||
# Finds the column header name the user has designated for the expected field
|
|
||||||
mapped_field_key = field_mappings[field.key] if field_mappings
|
|
||||||
mapped_header = mapped_field_key || field.key
|
|
||||||
|
|
||||||
row_values << row.fetch(mapped_header, "")
|
|
||||||
end
|
|
||||||
|
|
||||||
csv << row_values
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
new(generated_csv_str, col_sep:)
|
|
||||||
end
|
|
||||||
|
|
||||||
attr_reader :csv_str, :col_sep
|
|
||||||
|
|
||||||
def initialize(csv_str, column_validators: nil, col_sep: DEFAULT_COL_SEP)
|
|
||||||
@csv_str = csv_str
|
|
||||||
@col_sep = col_sep
|
|
||||||
@column_validators = column_validators || {}
|
|
||||||
end
|
|
||||||
|
|
||||||
def table
|
|
||||||
@table ||= self.class.parse_csv(csv_str, col_sep:)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_cell(row_idx, col_idx, value)
|
|
||||||
copy = table.by_col_or_row
|
|
||||||
copy[row_idx][col_idx] = value
|
|
||||||
copy
|
|
||||||
end
|
|
||||||
|
|
||||||
def valid?
|
|
||||||
table.each_with_index.all? do |row, row_idx|
|
|
||||||
row.each_with_index.all? do |cell, col_idx|
|
|
||||||
cell_valid?(row_idx, col_idx)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def cell_valid?(row_idx, col_idx)
|
|
||||||
value = table.dig(row_idx, col_idx)
|
|
||||||
header = table.headers[col_idx]
|
|
||||||
validator = get_validator_by_header(header)
|
|
||||||
validator.call(value)
|
|
||||||
end
|
|
||||||
|
|
||||||
def define_validator(header_key, validator = nil, &block)
|
|
||||||
header = table.headers.find { |h| h.strip == header_key }
|
|
||||||
raise "Cannot define validator for header #{header_key}: header does not exist in CSV" if header.nil?
|
|
||||||
|
|
||||||
column_validators[header] = validator || block
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
attr_accessor :column_validators
|
|
||||||
|
|
||||||
def get_validator_by_header(header)
|
|
||||||
column_validators&.dig(header) || ->(_v) { true }
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,37 +0,0 @@
|
||||||
class Import::Field
|
|
||||||
def self.iso_date_validator(value)
|
|
||||||
Date.iso8601(value)
|
|
||||||
true
|
|
||||||
rescue
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.bigdecimal_validator(value)
|
|
||||||
BigDecimal(value)
|
|
||||||
true
|
|
||||||
rescue
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
attr_reader :key, :label, :validator
|
|
||||||
|
|
||||||
def initialize(key:, label:, is_optional: false, validator: nil)
|
|
||||||
@key = key.to_s
|
|
||||||
@label = label
|
|
||||||
@is_optional = is_optional
|
|
||||||
@validator = validator
|
|
||||||
end
|
|
||||||
|
|
||||||
def optional?
|
|
||||||
@is_optional
|
|
||||||
end
|
|
||||||
|
|
||||||
def define_validator(validator = nil, &block)
|
|
||||||
@validator = validator || block
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate(value)
|
|
||||||
return true if validator.nil?
|
|
||||||
validator.call(value)
|
|
||||||
end
|
|
||||||
end
|
|
56
app/models/import/mapping.rb
Normal file
56
app/models/import/mapping.rb
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
class Import::Mapping < ApplicationRecord
|
||||||
|
CREATE_NEW_KEY = "internal_new_resource"
|
||||||
|
|
||||||
|
belongs_to :import
|
||||||
|
belongs_to :mappable, polymorphic: true, optional: true
|
||||||
|
|
||||||
|
validates :key, presence: true, uniqueness: { scope: [ :import_id, :type ] }, allow_blank: true
|
||||||
|
|
||||||
|
scope :for_import, ->(import) { where(import: import) }
|
||||||
|
scope :creational, -> { where(create_when_empty: true, mappable: nil) }
|
||||||
|
scope :categories, -> { where(type: "Import::CategoryMapping") }
|
||||||
|
scope :tags, -> { where(type: "Import::TagMapping") }
|
||||||
|
scope :accounts, -> { where(type: "Import::AccountMapping") }
|
||||||
|
scope :account_types, -> { where(type: "Import::AccountTypeMapping") }
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def mappable_for(key)
|
||||||
|
find_by(key: key)&.mappable
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync(import)
|
||||||
|
unique_values = mapping_values(import).uniq
|
||||||
|
|
||||||
|
unique_values.each do |value|
|
||||||
|
mapping = find_or_initialize_by(key: value, import: import, create_when_empty: value.present?)
|
||||||
|
mapping.save(validate: false) if mapping.new_record?
|
||||||
|
end
|
||||||
|
|
||||||
|
where(import: import).where.not(key: unique_values).destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
def mapping_values(import)
|
||||||
|
raise NotImplementedError, "Subclass must implement mapping_values"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def selectable_values
|
||||||
|
raise NotImplementedError, "Subclass must implement selectable_values"
|
||||||
|
end
|
||||||
|
|
||||||
|
def values_count
|
||||||
|
raise NotImplementedError, "Subclass must implement values_count"
|
||||||
|
end
|
||||||
|
|
||||||
|
def mappable_class
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def creatable?
|
||||||
|
mappable.nil? && key.present? && create_when_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_mappable!
|
||||||
|
raise NotImplementedError, "Subclass must implement create_mappable!"
|
||||||
|
end
|
||||||
|
end
|
70
app/models/import/row.rb
Normal file
70
app/models/import/row.rb
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
class Import::Row < ApplicationRecord
|
||||||
|
belongs_to :import
|
||||||
|
|
||||||
|
validates :amount, numericality: true, allow_blank: true
|
||||||
|
validates :currency, presence: true
|
||||||
|
|
||||||
|
validate :date_matches_user_format
|
||||||
|
validate :required_columns
|
||||||
|
validate :currency_is_valid
|
||||||
|
|
||||||
|
scope :ordered, -> { order(:id) }
|
||||||
|
|
||||||
|
def tags_list
|
||||||
|
if tags.blank?
|
||||||
|
[ "" ]
|
||||||
|
else
|
||||||
|
tags.split("|").map(&:strip)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def date_iso
|
||||||
|
Date.strptime(date, import.date_format).iso8601
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed_amount
|
||||||
|
if import.type == "TradeImport"
|
||||||
|
price.to_d * apply_signage_convention(qty.to_d)
|
||||||
|
else
|
||||||
|
apply_signage_convention(amount.to_d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_mappings
|
||||||
|
Import::CategoryMapping.sync(import) if import.column_keys.include?(:category)
|
||||||
|
Import::TagMapping.sync(import) if import.column_keys.include?(:tags)
|
||||||
|
Import::AccountMapping.sync(import) if import.column_keys.include?(:account)
|
||||||
|
Import::AccountTypeMapping.sync(import) if import.column_keys.include?(:entity_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def apply_signage_convention(value)
|
||||||
|
value * (import.signage_convention == "inflows_positive" ? 1 : -1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def required_columns
|
||||||
|
import.required_column_keys.each do |required_key|
|
||||||
|
errors.add(required_key, "is required") if self[required_key].blank?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def date_matches_user_format
|
||||||
|
return if date.blank?
|
||||||
|
|
||||||
|
parsed_date = Date.strptime(date, import.date_format) rescue nil
|
||||||
|
|
||||||
|
if parsed_date.nil?
|
||||||
|
errors.add(:date, "must exactly match the format: #{import.date_format}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def currency_is_valid
|
||||||
|
return true if currency.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
Money::Currency.new(currency)
|
||||||
|
rescue Money::Currency::UnknownCurrencyError
|
||||||
|
errors.add(:currency, "is not a valid currency code")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
36
app/models/import/tag_mapping.rb
Normal file
36
app/models/import/tag_mapping.rb
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
class Import::TagMapping < Import::Mapping
|
||||||
|
class << self
|
||||||
|
def mapping_values(import)
|
||||||
|
import.rows.map(&:tags_list).flatten.uniq
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def selectable_values
|
||||||
|
family_tags = import.family.tags.alphabetically.map { |tag| [ tag.name, tag.id ] }
|
||||||
|
|
||||||
|
unless key.blank?
|
||||||
|
family_tags.unshift [ "Add as new tag", CREATE_NEW_KEY ]
|
||||||
|
end
|
||||||
|
|
||||||
|
family_tags
|
||||||
|
end
|
||||||
|
|
||||||
|
def requires_selection?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def values_count
|
||||||
|
import.rows.map(&:tags_list).flatten.count { |tag| tag == key }
|
||||||
|
end
|
||||||
|
|
||||||
|
def mappable_class
|
||||||
|
Tag
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_mappable!
|
||||||
|
return unless creatable?
|
||||||
|
|
||||||
|
self.mappable = import.family.tags.find_or_create_by!(name: key)
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
end
|
94
app/models/mint_import.rb
Normal file
94
app/models/mint_import.rb
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
class MintImport < Import
|
||||||
|
after_create :set_mappings
|
||||||
|
|
||||||
|
def generate_rows_from_csv
|
||||||
|
rows.destroy_all
|
||||||
|
|
||||||
|
mapped_rows = csv_rows.map do |row|
|
||||||
|
{
|
||||||
|
account: row[account_col_label].to_s,
|
||||||
|
date: row[date_col_label].to_s,
|
||||||
|
amount: signed_csv_amount(row).to_s,
|
||||||
|
currency: (row[currency_col_label] || default_currency).to_s,
|
||||||
|
name: (row[name_col_label] || default_row_name).to_s,
|
||||||
|
category: row[category_col_label].to_s,
|
||||||
|
tags: row[tags_col_label].to_s,
|
||||||
|
notes: row[notes_col_label].to_s
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
rows.insert_all!(mapped_rows)
|
||||||
|
end
|
||||||
|
|
||||||
|
def import!
|
||||||
|
transaction do
|
||||||
|
mappings.each(&:create_mappable!)
|
||||||
|
|
||||||
|
rows.each do |row|
|
||||||
|
account = mappings.accounts.mappable_for(row.account)
|
||||||
|
category = mappings.categories.mappable_for(row.category)
|
||||||
|
tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact
|
||||||
|
|
||||||
|
entry = account.entries.build \
|
||||||
|
date: row.date_iso,
|
||||||
|
amount: row.signed_amount,
|
||||||
|
name: row.name,
|
||||||
|
currency: row.currency,
|
||||||
|
entryable: Account::Transaction.new(category: category, tags: tags, notes: row.notes),
|
||||||
|
import: self
|
||||||
|
|
||||||
|
entry.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mapping_steps
|
||||||
|
[ Import::CategoryMapping, Import::TagMapping, Import::AccountMapping ]
|
||||||
|
end
|
||||||
|
|
||||||
|
def required_column_keys
|
||||||
|
%i[date amount]
|
||||||
|
end
|
||||||
|
|
||||||
|
def column_keys
|
||||||
|
%i[date amount name currency category tags account notes]
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_template
|
||||||
|
template = <<-CSV
|
||||||
|
Date,Amount,Account Name,Description,Category,Labels,Currency,Notes,Transaction Type
|
||||||
|
01/01/2024,-8.55,Checking,Starbucks,Food & Drink,Coffee|Breakfast,USD,Morning coffee,debit
|
||||||
|
04/15/2024,2000,Savings,Paycheck,Income,,USD,Bi-weekly salary,credit
|
||||||
|
CSV
|
||||||
|
|
||||||
|
CSV.parse(template, headers: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed_csv_amount(csv_row)
|
||||||
|
amount = csv_row[amount_col_label]
|
||||||
|
type = csv_row["Transaction Type"]
|
||||||
|
|
||||||
|
if type == "credit"
|
||||||
|
amount.to_d
|
||||||
|
else
|
||||||
|
amount.to_d * -1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_mappings
|
||||||
|
self.signage_convention = "inflows_positive"
|
||||||
|
self.date_col_label = "Date"
|
||||||
|
self.date_format = "%m/%d/%Y"
|
||||||
|
self.name_col_label = "Description"
|
||||||
|
self.amount_col_label = "Amount"
|
||||||
|
self.currency_col_label = "Currency"
|
||||||
|
self.account_col_label = "Account Name"
|
||||||
|
self.category_col_label = "Category"
|
||||||
|
self.tags_col_label = "Labels"
|
||||||
|
self.notes_col_label = "Notes"
|
||||||
|
self.entity_type_col_label = "Transaction Type"
|
||||||
|
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,6 +2,7 @@ class Tag < ApplicationRecord
|
||||||
belongs_to :family
|
belongs_to :family
|
||||||
has_many :taggings, dependent: :destroy
|
has_many :taggings, dependent: :destroy
|
||||||
has_many :transactions, through: :taggings, source: :taggable, source_type: "Account::Transaction"
|
has_many :transactions, through: :taggings, source: :taggable, source_type: "Account::Transaction"
|
||||||
|
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: { scope: :family }
|
validates :name, presence: true, uniqueness: { scope: :family }
|
||||||
|
|
||||||
|
|
52
app/models/trade_import.rb
Normal file
52
app/models/trade_import.rb
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
class TradeImport < Import
|
||||||
|
def import!
|
||||||
|
transaction do
|
||||||
|
mappings.each(&:create_mappable!)
|
||||||
|
|
||||||
|
rows.each do |row|
|
||||||
|
account = mappings.accounts.mappable_for(row.account)
|
||||||
|
security = Security.find_or_create_by(ticker: row.ticker)
|
||||||
|
|
||||||
|
entry = account.entries.build \
|
||||||
|
date: row.date_iso,
|
||||||
|
amount: row.signed_amount,
|
||||||
|
name: row.name,
|
||||||
|
currency: row.currency,
|
||||||
|
entryable: Account::Trade.new(security: security, qty: row.qty, currency: row.currency, price: row.price),
|
||||||
|
import: self
|
||||||
|
|
||||||
|
entry.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mapping_steps
|
||||||
|
[ Import::AccountMapping ]
|
||||||
|
end
|
||||||
|
|
||||||
|
def required_column_keys
|
||||||
|
%i[date ticker qty price]
|
||||||
|
end
|
||||||
|
|
||||||
|
def column_keys
|
||||||
|
%i[date ticker qty price currency account name]
|
||||||
|
end
|
||||||
|
|
||||||
|
def dry_run
|
||||||
|
{
|
||||||
|
transactions: rows.count,
|
||||||
|
accounts: Import::AccountMapping.for_import(self).creational.count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_template
|
||||||
|
template = <<-CSV
|
||||||
|
date*,ticker*,qty*,price*,currency,account,name
|
||||||
|
05/15/2024,AAPL,10,150.00,USD,Trading Account,Apple Inc. Purchase
|
||||||
|
05/16/2024,GOOGL,-5,2500.00,USD,Investment Account,Alphabet Inc. Sale
|
||||||
|
05/17/2024,TSLA,2,700.50,USD,Retirement Account,Tesla Inc. Purchase
|
||||||
|
CSV
|
||||||
|
|
||||||
|
CSV.parse(template, headers: true)
|
||||||
|
end
|
||||||
|
end
|
46
app/models/transaction_import.rb
Normal file
46
app/models/transaction_import.rb
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
class TransactionImport < Import
|
||||||
|
def import!
|
||||||
|
transaction do
|
||||||
|
mappings.each(&:create_mappable!)
|
||||||
|
|
||||||
|
rows.each do |row|
|
||||||
|
account = mappings.accounts.mappable_for(row.account)
|
||||||
|
category = mappings.categories.mappable_for(row.category)
|
||||||
|
tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact
|
||||||
|
|
||||||
|
entry = account.entries.build \
|
||||||
|
date: row.date_iso,
|
||||||
|
amount: row.signed_amount,
|
||||||
|
name: row.name,
|
||||||
|
currency: row.currency,
|
||||||
|
entryable: Account::Transaction.new(category: category, tags: tags, notes: row.notes),
|
||||||
|
import: self
|
||||||
|
|
||||||
|
entry.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def required_column_keys
|
||||||
|
%i[date amount]
|
||||||
|
end
|
||||||
|
|
||||||
|
def column_keys
|
||||||
|
%i[date amount name currency category tags account notes]
|
||||||
|
end
|
||||||
|
|
||||||
|
def mapping_steps
|
||||||
|
[ Import::CategoryMapping, Import::TagMapping, Import::AccountMapping ]
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_template
|
||||||
|
template = <<-CSV
|
||||||
|
date*,amount*,name,currency,category,tags,account,notes
|
||||||
|
05/15/2024,-45.99,Grocery Store,USD,Food,groceries|essentials,Checking Account,Monthly grocery run
|
||||||
|
05/16/2024,1500.00,Salary,,Income,,Main Account,
|
||||||
|
05/17/2024,-12.50,Coffee Shop,,,coffee,,
|
||||||
|
CSV
|
||||||
|
|
||||||
|
CSV.parse(template, headers: true)
|
||||||
|
end
|
||||||
|
end
|
59
app/views/import/cleans/show.html.erb
Normal file
59
app/views/import/cleans/show.html.erb
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<%= content_for :header_nav do %>
|
||||||
|
<%= render "imports/nav", import: @import %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= content_for :previous_path, import_configuration_path(@import) %>
|
||||||
|
|
||||||
|
<div class="space-y-4 mx-auto max-w-screen-lg">
|
||||||
|
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-4">
|
||||||
|
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".title") %></h2>
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @import.cleaned? %>
|
||||||
|
<div class="bg-white border border-alpha-black-100 rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= lucide_icon "check-circle", class: "w-4 h-4 text-green-500" %>
|
||||||
|
<p class="text-green-500">Your data has been cleaned</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= link_to "Next step", import_confirm_path(@import), class: "btn btn--primary" %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-white border border-alpha-black-100 rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= lucide_icon "alert-triangle", class: "w-4 h-4 text-red-500" %>
|
||||||
|
<p class="text-red-500">You have errors in your data</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="bg-gray-50 rounded-lg inline-flex p-1 space-x-2 text-sm text-gray-900 font-medium">
|
||||||
|
<%= link_to "All rows", import_clean_path(@import, per_page: params[:per_page], view: "all"), class: "p-2 rounded-lg #{params[:view] != 'errors' ? 'bg-white' : ''}" %>
|
||||||
|
<%= link_to "Error rows", import_clean_path(@import, per_page: params[:per_page], view: "errors"), class: "p-2 rounded-lg #{params[:view] == 'errors' ? 'bg-white' : ''}" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="pb-12">
|
||||||
|
<div class="bg-gray-25 rounded-xl p-1 mb-6">
|
||||||
|
<div style="grid-template-columns: repeat(<%= @import.column_keys.count %>, 1fr)" class="grid items-center uppercase text-xs font-medium text-gray-500 py-3">
|
||||||
|
<% @import.column_keys.each do |key| %>
|
||||||
|
<div class="px-5"><%= import_col_label(key) %></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-alpha-black-200 rounded-xl shadow-xs divide-y divide-alpha-black-200">
|
||||||
|
<% @rows.each do |row| %>
|
||||||
|
<%= render "import/rows/form", row: row %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed bottom-0 left-1/2 -translate-x-1/2 w-full p-12">
|
||||||
|
<div class="border border-alpha-black-100 rounded-lg p-3 max-w-screen-sm mx-auto bg-white shadow-xs">
|
||||||
|
<%= render "application/pagination", pagy: @pagy %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
9
app/views/import/configurations/_account_import.html.erb
Normal file
9
app/views/import/configurations/_account_import.html.erb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<%# locals: (import:) %>
|
||||||
|
|
||||||
|
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||||
|
<%= form.select :entity_type_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Entity Type" } %>
|
||||||
|
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||||
|
<%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" } %>
|
||||||
|
|
||||||
|
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||||
|
<% end %>
|
25
app/views/import/configurations/_mint_import.html.erb
Normal file
25
app/views/import/configurations/_mint_import.html.erb
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<%# locals: (import:) %>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border border-alpha-black-200 rounded-lg bg-green-500/5 p-5 gap-4">
|
||||||
|
<%= lucide_icon("check-circle", class: "w-5 h-5 shrink-0 text-green-500") %>
|
||||||
|
<p class="text-sm text-gray-900 italic">We have pre-configured your Mint import for you. Please proceed to the next step.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true, disabled: import.complete? %>
|
||||||
|
<%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], { label: true }, required: true, disabled: import.complete? %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= 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? %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" }, disabled: import.complete? %>
|
||||||
|
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" }, disabled: import.complete? %>
|
||||||
|
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" }, disabled: import.complete? %>
|
||||||
|
<%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" }, disabled: import.complete? %>
|
||||||
|
|
||||||
|
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||||
|
<% end %>
|
20
app/views/import/configurations/_trade_import.html.erb
Normal file
20
app/views/import/configurations/_trade_import.html.erb
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<%# locals: (import:) %>
|
||||||
|
|
||||||
|
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %>
|
||||||
|
<%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], label: true, required: true %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= 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 %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %>
|
||||||
|
<%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %>
|
||||||
|
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
||||||
|
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||||
|
|
||||||
|
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||||
|
<% end %>
|
21
app/views/import/configurations/_transaction_import.html.erb
Normal file
21
app/views/import/configurations/_transaction_import.html.erb
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<%# locals: (import:) %>
|
||||||
|
|
||||||
|
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %>
|
||||||
|
<%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], label: true, required: true %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= 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 %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
||||||
|
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||||
|
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" } %>
|
||||||
|
<%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" } %>
|
||||||
|
<%= form.select :notes_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Notes (optional)" } %>
|
||||||
|
|
||||||
|
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||||
|
<% end %>
|
22
app/views/import/configurations/show.html.erb
Normal file
22
app/views/import/configurations/show.html.erb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<%= content_for :header_nav do %>
|
||||||
|
<%= render "imports/nav", import: @import %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= content_for :previous_path, import_upload_path(@import) %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<h1 class="text-3xl text-gray-900 font-medium"><%= t(".title") %></h1>
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-lg">
|
||||||
|
<%= render partial: permitted_import_configuration_path(@import), locals: { import: @import } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-screen-lg my-12">
|
||||||
|
<%= render "imports/table", headers: @import.csv_headers, rows: @import.csv_sample, caption: "Sample data from your uploaded CSV" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
29
app/views/import/confirms/_mappings.html.erb
Normal file
29
app/views/import/confirms/_mappings.html.erb
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<%# locals: (import:, mapping_class:, step_idx:) %>
|
||||||
|
|
||||||
|
<% mappings = mapping_class.for_import(import) %>
|
||||||
|
<% is_last_step = step_idx == import.mapping_steps.count - 1 %>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-gray-25 rounded-xl p-1 space-y-1 w-[650px]">
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-xs font-medium text-gray-500 uppercase px-5 py-3">
|
||||||
|
<p>CSV <%= mapping_label(mapping_class) %></p>
|
||||||
|
<p>Maybe <%= mapping_label(mapping_class) %></p>
|
||||||
|
<p class="justify-self-end">Rows</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-alpha-black-25 rounded-md shadow-xs divide-y divide-alpha-black-100 text-sm">
|
||||||
|
<% mappings.sort_by(&:key).each do |mapping| %>
|
||||||
|
<div class="px-5 py-3 bg-white first:rounded-tl-xl first:rounded-tr-xl last:rounded-bl-xl last:rounded-br-xl">
|
||||||
|
<%= render partial: "import/mappings/form", locals: { mapping: mapping } %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<%= link_to is_last_step ? import_path(import) : url_for(step: step_idx + 2), class: "btn btn--primary w-36 flex items-center justify-between gap-2" do %>
|
||||||
|
<span>Next</span>
|
||||||
|
<%= lucide_icon "arrow-right", class: "w-5 h-5" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
33
app/views/import/confirms/show.html.erb
Normal file
33
app/views/import/confirms/show.html.erb
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<%= content_for :header_nav do %>
|
||||||
|
<%= render "imports/nav", import: @import %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= content_for :previous_path, import_clean_path(@import) %>
|
||||||
|
|
||||||
|
<% step_idx = (params[:step] || "1").to_i - 1 %>
|
||||||
|
<% step_mapping_class = @import.mapping_steps[step_idx] %>
|
||||||
|
|
||||||
|
<div class="space-y-12 mx-auto max-w-md mb-6">
|
||||||
|
<div class="flex justify-center items-center gap-2">
|
||||||
|
<% @import.mapping_steps.each_with_index do |step_mapping_class, idx| %>
|
||||||
|
<% is_active = step_idx == idx %>
|
||||||
|
|
||||||
|
<%= link_to url_for(step: idx + 1), class: "w-5 h-[3px] #{is_active ? 'bg-gray-900' : 'bg-gray-100'} rounded-xl hover:bg-gray-300 transition-colors duration-200" do %>
|
||||||
|
<span class="sr-only">Step <%= idx + 1 %></span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<h1 class="text-3xl text-gray-900 font-medium">
|
||||||
|
<%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %>
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
<%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize) %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-screen-md mx-auto flex justify-center">
|
||||||
|
<%= render partial: "import/confirms/mappings", locals: { import: @import, mapping_class: step_mapping_class, step_idx: step_idx } %>
|
||||||
|
</div>
|
29
app/views/import/mappings/_form.html.erb
Normal file
29
app/views/import/mappings/_form.html.erb
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<%# locals: (mapping:) %>
|
||||||
|
|
||||||
|
<%= styled_form_with model: mapping,
|
||||||
|
scope: :import_mapping,
|
||||||
|
url: import_mapping_path(mapping.import, mapping),
|
||||||
|
class: "grid grid-cols-3 gap-2 items-center",
|
||||||
|
data: { controller: "auto-submit-form" },
|
||||||
|
html: { id: dom_id(mapping, :form) } do |form| %>
|
||||||
|
<span><%= mapping.key.blank? ? "(unassigned)" : mapping.key %></span>
|
||||||
|
|
||||||
|
<% if mapping.mappable_class.present? %>
|
||||||
|
<%= form.hidden_field :mappable_type, value: mapping.mappable_class, id: dom_id(mapping, :mappable_type) %>
|
||||||
|
<%= form.select :mappable_id,
|
||||||
|
mapping.selectable_values,
|
||||||
|
{ container_class: mapping.invalid? ? "border-red-500" : nil, include_blank: mapping.requires_selection? ? "Select an option" : "Leave unassigned", selected: mapping.create_when_empty? ? mapping.class::CREATE_NEW_KEY : mapping.mappable_id },
|
||||||
|
"data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "change", disabled: mapping.import.complete?, id: dom_id(mapping, :mappable_id) %>
|
||||||
|
<% else %>
|
||||||
|
<%= form.select :value, mapping.selectable_values,
|
||||||
|
{ container_class: mapping.invalid? ? "border-red-500" : nil, include_blank: mapping.requires_selection? ? "Select an option" : "Leave unassigned" },
|
||||||
|
"data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "change", disabled: mapping.import.complete?, id: dom_id(mapping, :value) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form.hidden_field :key, value: mapping.key, id: dom_id(mapping, :key) %>
|
||||||
|
<%= form.hidden_field :type, value: mapping.type, id: dom_id(mapping, :type) %>
|
||||||
|
|
||||||
|
<span class="justify-self-end">
|
||||||
|
<%= mapping.values_count %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
27
app/views/import/rows/_form.html.erb
Normal file
27
app/views/import/rows/_form.html.erb
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<%# locals: (row:) %>
|
||||||
|
|
||||||
|
<div style="grid-template-columns: repeat(<%= row.import.column_keys.count %>, 1fr)" class="first:rounded-tl-lg first:rounded-tr-lg last:rounded-bl-lg last:rounded-br-lg grid divide-x divide-alpha-black-200 group">
|
||||||
|
<% row.import.column_keys.each_with_index do |key, idx| %>
|
||||||
|
<%= turbo_frame_tag dom_id(row, key), title: row.valid? ? nil : row.errors.full_messages.join(", ") do %>
|
||||||
|
<%= form_with(
|
||||||
|
model: [row.import, row],
|
||||||
|
scope: :import_row,
|
||||||
|
url: import_row_path(row.import, row),
|
||||||
|
method: :patch,
|
||||||
|
data: {
|
||||||
|
controller: "auto-submit-form",
|
||||||
|
auto_submit_form_trigger_event_value: "blur"
|
||||||
|
}
|
||||||
|
) do |form| %>
|
||||||
|
<%= form.text_field key,
|
||||||
|
"data-auto-submit-form-target": "auto",
|
||||||
|
class: [
|
||||||
|
cell_class(row, key),
|
||||||
|
idx == 0 ? "group-first:rounded-tl-lg group-last:rounded-bl-lg" : "",
|
||||||
|
idx == row.import.column_keys.count - 1 ? "group-first:rounded-tr-lg group-last:rounded-br-lg" : "",
|
||||||
|
],
|
||||||
|
disabled: row.import.complete? %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
1
app/views/import/rows/show.html.erb
Normal file
1
app/views/import/rows/show.html.erb
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<%= render "import/rows/form", row: @row %>
|
69
app/views/import/uploads/show.html.erb
Normal file
69
app/views/import/uploads/show.html.erb
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<%= content_for :header_nav do %>
|
||||||
|
<%= render "imports/nav", import: @import %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= content_for :previous_path, imports_path %>
|
||||||
|
|
||||||
|
<div class="space-y-12">
|
||||||
|
<div class="space-y-4 mx-auto max-w-md">
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<h1 class="text-3xl text-gray-900 font-medium"><%= t(".title") %></h1>
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-controller="tabs" data-tabs-active-class="bg-white" data-tabs-default-tab-value="csv-paste-tab">
|
||||||
|
<div class="flex justify-center mb-4">
|
||||||
|
<div class="bg-gray-50 rounded-lg inline-flex p-1 space-x-2 text-sm text-gray-900 font-medium">
|
||||||
|
<button type="button" data-id="csv-paste-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Copy & Paste</button>
|
||||||
|
<button type="button" data-id="csv-upload-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Upload CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-tabs-target="tab" id="csv-paste-tab">
|
||||||
|
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
|
||||||
|
<%= form.select :col_sep, [["Comma (,)", ","], ["Semicolon (;)", ";"]], label: true %>
|
||||||
|
<%= form.text_area :raw_file_str,
|
||||||
|
rows: 10,
|
||||||
|
required: true,
|
||||||
|
placeholder: "Paste your CSV file contents here",
|
||||||
|
"data-auto-submit-form-target": "auto" %>
|
||||||
|
|
||||||
|
<%= form.submit "Upload CSV", disabled: @import.complete? %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-tabs-target="tab" id="csv-upload-tab" class="hidden">
|
||||||
|
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
|
||||||
|
<%= form.select :col_sep, [["Comma (,)", ","], ["Semicolon (;)", ";"]], label: true %>
|
||||||
|
|
||||||
|
<label for="import_csv_file" class="flex flex-col items-center justify-center w-full h-56 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50">
|
||||||
|
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
|
<%= form.file_field :csv_file, class: "ml-32", "data-auto-submit-form-target": "auto" %>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<%= form.submit "Upload CSV", disabled: @import.complete? %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-alpha-black-25 rounded-xl p-1 mt-5 mx-auto max-w-screen-xl">
|
||||||
|
<div class="text-gray-500 p-2 mb-2">
|
||||||
|
<div class="flex gap-2 mb-2">
|
||||||
|
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
|
||||||
|
<p class="text-sm"><%= t(".instructions_1") %></p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-disc list-inside text-sm pl-8">
|
||||||
|
<li><%= t(".instructions_2") %></li>
|
||||||
|
<li><%= t(".instructions_3") %></li>
|
||||||
|
<li><%= t(".instructions_4") %></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= render partial: "imports/table", locals: { headers: @import.csv_template.headers, rows: @import.csv_template } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,25 +0,0 @@
|
||||||
<%= styled_form_with model: @import, url: load_import_path(@import), class: "space-y-4" do |form| %>
|
|
||||||
<%= form.text_area :raw_file_str,
|
|
||||||
rows: 10,
|
|
||||||
required: true,
|
|
||||||
placeholder: "Paste your CSV file contents here",
|
|
||||||
class: "rounded-md w-full border text-sm border-alpha-black-100 bg-white placeholder:text-gray-400" %>
|
|
||||||
|
|
||||||
<%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_file_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="bg-alpha-black-25 rounded-xl p-1 mt-5">
|
|
||||||
<div class="text-gray-500 p-2 mb-2">
|
|
||||||
<div class="flex gap-2 mb-2">
|
|
||||||
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
|
|
||||||
<p class="text-sm"><%= t(".instructions") %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="list-disc text-sm pl-10">
|
|
||||||
<li><%= t(".requirement1") %></li>
|
|
||||||
<li><%= t(".requirement2") %></li>
|
|
||||||
<li><%= t(".requirement3") %></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<%= render partial: "imports/sample_table" %>
|
|
||||||
</div>
|
|
|
@ -1,39 +0,0 @@
|
||||||
<%= styled_form_with model: @import, url: upload_import_path(@import), class: "dropzone space-y-4", data: { controller: "import-upload", import_upload_accepted_types_value: ["text/csv", "application/csv", ".csv"], import_upload_extension_value: "csv", import_upload_unacceptable_type_label_value: t(".allowed_filetypes") }, method: :patch, multipart: true do |form| %>
|
|
||||||
<div class="flex items-center justify-center w-full">
|
|
||||||
<label for="import_raw_file_str" class="raw-file-drop-box flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50" data-action="dragover->import-upload#dragover dragleave->import-upload#dragleave drop->import-upload#drop">
|
|
||||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
|
||||||
<%= lucide_icon "plus", class: "w-5 h-5 text-gray-500" %>
|
|
||||||
<%= form.file_field :raw_file_str, class: "hidden", direct_upload: false, accept: "text/csv,.csv,application/csv", data: { import_upload_target: "input", action: "change->import-upload#addFile" } %>
|
|
||||||
<p class="mb-2 text-sm text-gray-500 mt-3">Drag and drop your csv file here or <span class="text-black">click to browse</span></p>
|
|
||||||
<p class="text-xs text-gray-500">CSV (Max. 5MB)</p>
|
|
||||||
<div class="csv-preview" data-import-upload-target="preview"></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-alpha-black-25 text-gray text-sm font-medium", data: { import_upload_target: "submit", turbo_confirm: (@import.raw_file_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div id="template-preview" class="hidden">
|
|
||||||
<div class="flex flex-col items-center justify-center">
|
|
||||||
<%= lucide_icon "file-text", class: "w-10 h-10 pt-2 text-black" %>
|
|
||||||
<div class="flex flex-row items-center justify-center gap-0.5">
|
|
||||||
<div><span data-import-upload-target="filename"></span></div>
|
|
||||||
<div><span data-import-upload-target="filesize" class="font-semibold"></span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-alpha-black-25 rounded-xl p-1 mt-5">
|
|
||||||
<div class="text-gray-500 p-2 mb-2">
|
|
||||||
<div class="flex gap-2 mb-2">
|
|
||||||
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
|
|
||||||
<p class="text-sm">
|
|
||||||
<%= t(".instructions") %>
|
|
||||||
<span class="text-black underline">
|
|
||||||
<%= link_to "download this template", "/transactions.csv", download: "" %>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<%= render partial: "imports/sample_table" %>
|
|
||||||
</div>
|
|
|
@ -1,7 +1,7 @@
|
||||||
<div class="flex justify-center items-center py-20">
|
<div class="flex justify-center items-center py-20">
|
||||||
<div class="text-center flex flex-col items-center max-w-[300px]">
|
<div class="text-center flex flex-col items-center max-w-[300px] gap-4">
|
||||||
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".message") %></p>
|
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".message") %></p>
|
||||||
<%= link_to new_import_path(enable_type_selector: true), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
|
<%= link_to new_import_path(enable_type_selector: true), class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: "modal" } do %>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
<span><%= t(".new") %></span>
|
<span><%= t(".new") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
18
app/views/imports/_failure.html.erb
Normal file
18
app/views/imports/_failure.html.erb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<%# locals: (import:) %>
|
||||||
|
|
||||||
|
<div class="h-full flex flex-col justify-center items-center">
|
||||||
|
<div class="space-y-6 max-w-sm">
|
||||||
|
<div class="mx-auto bg-red-500/5 h-8 w-8 rounded-full flex items-center justify-center">
|
||||||
|
<%= lucide_icon "alert-octagon", class: "w-5 h-5 text-red-500" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<h1 class="font-medium text-gray-900 text-center text-3xl">Import failed</h1>
|
||||||
|
<p class="text-sm text-gray-500">Please check that your file format, for any errors and that all required fields are filled, then come back and try again.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= button_to "Try again", publish_import_path(import), class: "btn btn--primary text-center w-full" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,8 +0,0 @@
|
||||||
<%= styled_form_with model: @import do |form| %>
|
|
||||||
<div class="mb-4 space-y-3">
|
|
||||||
<%= form.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".account"), required: true } %>
|
|
||||||
<%= form.collection_select :col_sep, Import::Csv::COL_SEP_LIST, :to_s, -> { t(".col_sep_char.#{_1.ord}") }, { prompt: t(".select_col_sep"), label: t(".col_sep"), required: true } %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700" %>
|
|
||||||
<% end %>
|
|
|
@ -1,10 +1,9 @@
|
||||||
<div id="<%= dom_id import %>" class="flex items-center justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
|
<div id="<%= dom_id import %>" class="flex items-center justify-between mx-4 py-4 border-b last:border-b-0 border-alpha-black-50">
|
||||||
<div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<p class="text-sm text-gray-900">
|
<%= link_to import_path(import), class: "text-sm text-gray-900 hover:underline" do %>
|
||||||
<%= t(".label", account: import.account.name) %>
|
<%= t(".label", type: import.type.titleize, datetime: import.updated_at.strftime("%b %-d, %Y at %l:%M %p")) %>
|
||||||
</p>
|
<% end %>
|
||||||
|
|
||||||
<% if import.pending? %>
|
<% if import.pending? %>
|
||||||
<span class="px-1 py text-xs rounded-full bg-gray-500/5 text-gray-500 border border-alpha-black-50">
|
<span class="px-1 py text-xs rounded-full bg-gray-500/5 text-gray-500 border border-alpha-black-50">
|
||||||
|
@ -25,27 +24,16 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if import.complete? %>
|
|
||||||
<p class="text-xs text-gray-500"><%= t(".completed_on", datetime: import.updated_at.strftime("%Y-%m-%d")) %></p>
|
|
||||||
<% else %>
|
|
||||||
<p class="text-xs text-gray-500"><%= t(".started_on", datetime: import.created_at.strftime("%Y-%m-%d")) %></p>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if import.complete? %>
|
|
||||||
<div class="w-7 h-7 bg-green-500/5 flex items-center justify-center rounded-full">
|
|
||||||
<%= lucide_icon("check", class: "text-green-500 w-4 h-4") %>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<%= contextual_menu do %>
|
<%= contextual_menu do %>
|
||||||
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||||
<%= link_to edit_import_path(import),
|
<%= link_to import_path(import),
|
||||||
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
|
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
|
||||||
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
|
<%= lucide_icon "eye", class: "w-5 h-5 text-gray-500" %>
|
||||||
|
|
||||||
<span><%= t(".edit") %></span>
|
<span><%= t(".view") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% unless import.complete? %>
|
||||||
<%= button_to import_path(import),
|
<%= button_to import_path(import),
|
||||||
method: :delete,
|
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",
|
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
|
||||||
|
@ -54,8 +42,7 @@
|
||||||
|
|
||||||
<span><%= t(".delete") %></span>
|
<span><%= t(".delete") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
19
app/views/imports/_importing.html.erb
Normal file
19
app/views/imports/_importing.html.erb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<%# locals: (import:) %>
|
||||||
|
|
||||||
|
<div class="h-full flex flex-col justify-center items-center">
|
||||||
|
<div class="space-y-6 max-w-sm">
|
||||||
|
<div class="mx-auto bg-gray-500/5 h-8 w-8 rounded-full flex items-center justify-center">
|
||||||
|
<%= lucide_icon "loader", class: "animate-pulse w-5 h-5 text-gray-500" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<h1 class="font-medium text-gray-900 text-center text-3xl">Import in progress</h1>
|
||||||
|
<p class="text-sm text-gray-500">Your import is in progress. Check the imports menu for status updates or click 'Check Status' to refresh the page for updates. Feel free to continue using the app.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<%= link_to "Check status", import_path(import), class: "block btn btn--primary text-center w-full" %>
|
||||||
|
<%= link_to "Back to dashboard", root_path, class: "block btn btn--secondary text-center w-full" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
40
app/views/imports/_nav.html.erb
Normal file
40
app/views/imports/_nav.html.erb
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<%# locals: (import:) %>
|
||||||
|
|
||||||
|
<% steps = [
|
||||||
|
{ name: "Upload", path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 },
|
||||||
|
{ name: "Configure", path: import_configuration_path(import), is_complete: import.configured?, step_number: 2 },
|
||||||
|
{ name: "Clean", path: import_clean_path(import), is_complete: import.cleaned?, step_number: 3 },
|
||||||
|
{ name: "Map", path: import_confirm_path(import), is_complete: import.publishable?, step_number: 4 },
|
||||||
|
{ name: "Confirm", path: import_path(import), is_complete: import.complete?, step_number: 5 }
|
||||||
|
] %>
|
||||||
|
|
||||||
|
<ul class="flex items-center gap-2">
|
||||||
|
<% steps.each_with_index do |step, idx| %>
|
||||||
|
<li class="flex items-center gap-2 group">
|
||||||
|
<% is_current = request.path == step[:path] %>
|
||||||
|
|
||||||
|
<% text_class = if is_current
|
||||||
|
"text-gray-900"
|
||||||
|
else
|
||||||
|
step[:is_complete] ? "text-green-600" : "text-gray-500"
|
||||||
|
end %>
|
||||||
|
<% step_class = if is_current
|
||||||
|
"bg-gray-900 text-white"
|
||||||
|
else
|
||||||
|
step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
|
||||||
|
end %>
|
||||||
|
|
||||||
|
<%= link_to step[:path], class: "flex items-center gap-3" do %>
|
||||||
|
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
|
||||||
|
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
|
||||||
|
<%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span><%= step[:name] %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="h-px bg-alpha-black-200 w-12 group-last:hidden"></div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
|
@ -1,18 +0,0 @@
|
||||||
<% is_current = request.path == step[:path] %>
|
|
||||||
<% text_class = if is_current
|
|
||||||
"text-gray-900"
|
|
||||||
else
|
|
||||||
step[:complete] ? "text-green-600" : "text-gray-500"
|
|
||||||
end %>
|
|
||||||
<% step_class = if is_current
|
|
||||||
"bg-gray-900 text-white"
|
|
||||||
else
|
|
||||||
step[:complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
|
|
||||||
end %>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
|
|
||||||
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
|
|
||||||
<%= step[:complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : step_idx + 1 %>
|
|
||||||
</span>
|
|
||||||
<span><%= step[:name] %></span>
|
|
||||||
</div>
|
|
39
app/views/imports/_ready.html.erb
Normal file
39
app/views/imports/_ready.html.erb
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<%# locals: (import:) %>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2 mb-4 mx-auto max-w-md">
|
||||||
|
<h1 class="text-3xl text-gray-900 font-medium"><%= t(".title") %></h1>
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-screen-sm space-y-4">
|
||||||
|
<div class="bg-gray-25 rounded-xl p-1 space-y-1">
|
||||||
|
<div class="flex justify-between items-center text-xs font-medium text-gray-500 uppercase px-5 py-3">
|
||||||
|
<p>item</p>
|
||||||
|
<p class="justify-self-end">count</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-alpha-black-25 rounded-lg shadow-xs text-sm">
|
||||||
|
<% import.dry_run.each do |key, count| %>
|
||||||
|
<% resource = dry_run_resource(key) %>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2 bg-white px-5 py-3 rounded-tl-lg rounded-tr-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="<%= resource.bg_class %> w-8 h-8 rounded-full flex justify-center items-center">
|
||||||
|
<%= lucide_icon resource.icon, class: "#{resource.text_class} w-5 h-5 shrink-0" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><%= resource.label %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="justify-self-end"><%= count %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if key != import.dry_run.keys.last %>
|
||||||
|
<div class="h-px bg-alpha-black-50 ml-14 mr-5"></div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= button_to "Publish import", publish_import_path(import), class: "btn btn--primary w-full" %>
|
||||||
|
</div>
|
|
@ -1,26 +0,0 @@
|
||||||
<!--TODO: Once we have more styled tables for reference, refactor and DRY this up -->
|
|
||||||
<div class="grid grid-cols-5 border border-alpha-black-200 rounded-md shadow-xs text-sm bg-white">
|
|
||||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tl-md">date</div>
|
|
||||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">name</div>
|
|
||||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">category</div>
|
|
||||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">tags</div>
|
|
||||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tr-md">amount</div>
|
|
||||||
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-01-01</div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Amazon</div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Shopping</div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Tag1|Tag2</div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-24.99</div>
|
|
||||||
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-03-01</div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Spotify</div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200"></div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200"></div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-16.32</div>
|
|
||||||
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-bl-md">2023-01-06</div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Acme</div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Income</div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Tag3</div>
|
|
||||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-br-md">151.22</div>
|
|
||||||
</div>
|
|
18
app/views/imports/_success.html.erb
Normal file
18
app/views/imports/_success.html.erb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<%# locals: (import:) %>
|
||||||
|
|
||||||
|
<div class="h-full flex flex-col justify-center items-center">
|
||||||
|
<div class="space-y-6 max-w-sm">
|
||||||
|
<div class="mx-auto bg-green-500/5 h-8 w-8 rounded-full flex items-center justify-center">
|
||||||
|
<%= lucide_icon "check", class: "w-5 h-5 text-green-500" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<h1 class="font-medium text-gray-900 text-center text-3xl">Import successful</h1>
|
||||||
|
<p class="text-sm text-gray-500">Your imported data has been successfully added to the app and is now ready for use.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= link_to "Back to dashboard", root_path, class: "block btn btn--primary text-center w-full" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
37
app/views/imports/_table.html.erb
Normal file
37
app/views/imports/_table.html.erb
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<%# locals: (headers: [], rows: [], caption: nil) %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="border border-alpha-black-200 rounded-md shadow-xs text-sm bg-white w-full">
|
||||||
|
<div class="grid border-b border-b-alpha-black-200" style="grid-template-columns: repeat(<%= headers.length %>, minmax(0, 1fr))">
|
||||||
|
<% headers.each_with_index do |header, index| %>
|
||||||
|
<div class="
|
||||||
|
bg-gray-25 px-3 py-2.5 font-medium whitespace-nowrap overflow-x-auto
|
||||||
|
first:rounded-tl-md last:rounded-tr-md
|
||||||
|
<%= "border-r border-r-alpha-black-200" unless index == headers.length - 1 %>
|
||||||
|
">
|
||||||
|
<%= header %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% rows.each_with_index do |row, row_index| %>
|
||||||
|
<div class="grid <%= "border-b border-b-alpha-black-200" if row_index < rows.length - 1 || caption %>" style="grid-template-columns: repeat(<%= headers.length %>, minmax(0, 1fr))">
|
||||||
|
<% row.each_with_index do |(header, value), col_index| %>
|
||||||
|
<div class="
|
||||||
|
px-3 py-2.5 whitespace-nowrap overflow-x-auto flex items-start
|
||||||
|
<%= "border-r border-r-alpha-black-200" unless col_index == row.length - 1 %>
|
||||||
|
<%= "rounded-bl-md" if !caption && row_index == rows.length - 1 && col_index == 0 %>
|
||||||
|
<%= "rounded-br-md" if !caption && row_index == rows.length - 1 && col_index == row.length - 1 %>
|
||||||
|
">
|
||||||
|
<%= value %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if caption %>
|
||||||
|
<div class="px-3 py-2.5 text-center text-xs text-gray-900 rounded-b-md italic bg-gray-25 overflow-x-auto">
|
||||||
|
<%= caption %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,65 +0,0 @@
|
||||||
<div class="p-4 space-y-4 max-w-[420px]">
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<h2 class="font-medium text-gray-900"><%= t(".import_transactions") %></h2>
|
|
||||||
<button data-action="modal#close">
|
|
||||||
<%= lucide_icon("x", class: "w-5 h-5 text-gray-900") %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-xl bg-gray-25 p-1">
|
|
||||||
<h3 class="uppercase text-gray-500 text-xs font-medium px-3 py-1.5"><%= t(".sources") %></h3>
|
|
||||||
<ul class="bg-white border border-alpha-black-25 rounded-lg shadow-xs">
|
|
||||||
<li>
|
|
||||||
<% 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 %>
|
|
||||||
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
|
||||||
<%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
|
||||||
<%= t(".resume_latest_import") %>
|
|
||||||
</span>
|
|
||||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="pl-14 pr-3">
|
|
||||||
<div class="h-px bg-alpha-black-50"></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
<li>
|
|
||||||
<%= link_to new_import_path, class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
|
|
||||||
<div class="bg-indigo-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
|
||||||
<%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
|
||||||
<%= t(".import_from_csv") %>
|
|
||||||
</span>
|
|
||||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="pl-14 pr-3">
|
|
||||||
<div class="h-px bg-alpha-black-50"></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
|
|
||||||
<%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
|
|
||||||
<span class="text-sm text-gray-400">
|
|
||||||
<%= t(".import_from_mint") %>
|
|
||||||
</span>
|
|
||||||
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
|
|
||||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pl-14 pr-3">
|
|
||||||
<div class="h-px bg-alpha-black-50"></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,48 +0,0 @@
|
||||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-screen-md w-full py-24">
|
|
||||||
<h1 class="sr-only"><%= t(".clean_import") %></h1>
|
|
||||||
|
|
||||||
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-8">
|
|
||||||
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".clean_and_edit") %></h2>
|
|
||||||
<p class="text-gray-500 text-sm"><%= t(".clean_description") %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-gray-25 rounded-xl p-1 mb-6">
|
|
||||||
<div
|
|
||||||
class="grid items-center uppercase text-xs font-medium text-gray-500 py-3"
|
|
||||||
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
|
|
||||||
<% @import.expected_fields.each do |field| %>
|
|
||||||
<div class="px-5"><%= field.label %></div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white border border-alpha-black-200 rounded-xl shadow-xs divide-y divide-alpha-black-200">
|
|
||||||
<% @import.csv.table.each_with_index do |row, row_index| %>
|
|
||||||
<div
|
|
||||||
class="grid divide-x divide-alpha-black-200"
|
|
||||||
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
|
|
||||||
<% row.fields.each_with_index do |value, col_index| %>
|
|
||||||
<%= form_with model: @import,
|
|
||||||
url: clean_import_url(@import),
|
|
||||||
method: :patch,
|
|
||||||
data: { turbo: false, controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
|
|
||||||
<%= form.fields_for :csv_update do |ff| %>
|
|
||||||
<%= ff.hidden_field :row_idx, value: row_index %>
|
|
||||||
<%= ff.hidden_field :col_idx, value: col_index %>
|
|
||||||
<%= ff.text_field :value, value: value,
|
|
||||||
id: "cell-#{row_index}-#{col_index}",
|
|
||||||
class: "#{@import.csv.cell_valid?(row_index, col_index) ? "focus:border-transparent border-transparent" : "border-red-500"} border px-4 py-3.5 text-sm w-full bg-transparent focus:ring-gray-900 focus:ring-2 focus-visible:outline-none #{table_corner_class(row_index, col_index, @import.csv.table, row.fields)}",
|
|
||||||
data: { "auto-submit-form-target" => "auto" } %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if @import.csv.valid? %>
|
|
||||||
<%= link_to "Next", confirm_import_path(@import), class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium hover:bg-gray-700", data: { turbo: false } %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
|
@ -1,23 +0,0 @@
|
||||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-[400px] w-full py-24 space-y-4">
|
|
||||||
<h1 class="sr-only"><%= t(".configure_title") %></h1>
|
|
||||||
|
|
||||||
<div class="text-center space-y-2">
|
|
||||||
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".configure_subtitle") %></h2>
|
|
||||||
<p class="text-gray-500 text-sm"><%= t(".configure_description") %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= styled_form_with model: @import, url: configure_import_path(@import), class: "space-y-4" do |form| %>
|
|
||||||
<%= form.fields_for :column_mappings do |mappings| %>
|
|
||||||
<% @import.expected_fields.each do |field| %>
|
|
||||||
<%= mappings.select field.key,
|
|
||||||
options_for_select(@import.available_headers, @import.get_selected_header_for_field(field)),
|
|
||||||
label: field.label,
|
|
||||||
include_blank: field.optional? ? t(".optional") : false %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700", data: { turbo_confirm: (@import.column_mappings? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
|
@ -1,18 +0,0 @@
|
||||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-screen-md w-full py-24">
|
|
||||||
<h1 class="sr-only"><%= t(".confirm_title") %></h1>
|
|
||||||
|
|
||||||
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-8">
|
|
||||||
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".confirm_subtitle") %></h2>
|
|
||||||
<p class="text-gray-500 text-sm"><%= t(".confirm_description") %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-8 space-y-4">
|
|
||||||
<%= entries_by_date(@import.dry_run, selectable: false) do |entries| %>
|
|
||||||
<%= render entries, show_tags: true, selectable: false, editable: false %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium hover:bg-gray-700", data: { turbo: false } %>
|
|
||||||
</div>
|
|
|
@ -1,10 +0,0 @@
|
||||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-[400px] w-full py-56">
|
|
||||||
<h1 class="sr-only"><%= t(".edit_title") %></h1>
|
|
||||||
<div class="space-y-2 mb-6 text-center">
|
|
||||||
<p class="text-3xl font-medium text-gray-900"><%= t(".header_text") %></p>
|
|
||||||
<p class="text-gray-500 text-sm"><%= t(".description_text") %></p>
|
|
||||||
</div>
|
|
||||||
<%= render "form", import: @import %>
|
|
||||||
</div>
|
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-xl font-medium text-gray-900"><%= t(".title") %></h1>
|
<h1 class="text-xl font-medium text-gray-900"><%= t(".title") %></h1>
|
||||||
|
|
||||||
<%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
|
<%= link_to new_import_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: :modal } do %>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
<span><%= t(".new") %></span>
|
<span><%= t(".new") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
|
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
|
||||||
|
|
||||||
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
|
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
|
||||||
<%= render @imports.ordered %>
|
<%= render partial: "imports/import", collection: @imports.ordered %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-[550px] w-full py-24 space-y-4">
|
|
||||||
<h1 class="sr-only"><%= t(".load_title") %></h1>
|
|
||||||
|
|
||||||
<div class="text-center space-y-2">
|
|
||||||
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".subtitle") %></h2>
|
|
||||||
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-controller="tabs" data-tabs-active-class="bg-white" data-tabs-default-tab-value="csv-upload-tab">
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<div class="bg-gray-50 rounded-lg inline-flex p-1 space-x-2 text-sm text-gray-900 font-medium">
|
|
||||||
<button data-id="csv-upload-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Upload CSV</button>
|
|
||||||
<button data-id="csv-paste-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Copy & Paste</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div data-tabs-target="tab" id="csv-upload-tab">
|
|
||||||
<%= render partial: "imports/csv_upload", locals: { import: @import } %>
|
|
||||||
</div>
|
|
||||||
<div data-tabs-target="tab" id="csv-paste-tab" class="hidden">
|
|
||||||
<%= render partial: "imports/csv_paste", locals: { import: @import } %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,16 +1,108 @@
|
||||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
|
||||||
|
|
||||||
<% if params[:enable_type_selector].present? %>
|
|
||||||
<%= modal do %>
|
<%= modal do %>
|
||||||
<%= render "type_selector" %>
|
<div class="p-4 space-y-4 max-w-[420px]">
|
||||||
<% end %>
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="font-medium text-gray-900"><%= t(".title") %></h2>
|
||||||
|
<button data-action="modal#close" tabindex="-1">
|
||||||
|
<%= lucide_icon("x", class: "w-5 h-5 text-gray-900") %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-gray-25 p-1">
|
||||||
|
<h3 class="uppercase text-gray-500 text-xs font-medium px-3 py-1.5"><%= t(".sources") %></h3>
|
||||||
|
<ul class="bg-white border border-alpha-black-25 rounded-lg shadow-xs">
|
||||||
|
<li>
|
||||||
|
<% if @pending_import.present? %>
|
||||||
|
<%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||||
|
<%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||||
|
<%= t(".resume") %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="mx-auto max-w-[400px] w-full py-56">
|
<div class="pl-14 pr-3">
|
||||||
<h1 class="sr-only">New import</h1>
|
<div class="h-px bg-alpha-black-50"></div>
|
||||||
<div class="space-y-2 mb-6 text-center">
|
|
||||||
<p class="text-3xl font-medium text-gray-900"><%= t(".header_text") %></p>
|
|
||||||
<p class="text-gray-500 text-sm"><%= t(".description_text") %></p>
|
|
||||||
</div>
|
</div>
|
||||||
<%= render "form", import: @import %>
|
</li>
|
||||||
|
<% end %>
|
||||||
|
<li>
|
||||||
|
<%= button_to imports_path(import: { type: "TransactionImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="bg-indigo-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||||
|
<%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||||
|
<%= t(".import_transactions") %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="pl-14 pr-3">
|
||||||
|
<div class="h-px bg-alpha-black-50"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<%= button_to imports_path(import: { type: "TradeImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="bg-yellow-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||||
|
<%= lucide_icon("square-percent", class: "w-5 h-5 text-yellow-500") %>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||||
|
<%= t(".import_portfolio") %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="pl-14 pr-3">
|
||||||
|
<div class="h-px bg-alpha-black-50"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<%= button_to imports_path(import: { type: "AccountImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="bg-violet-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||||
|
<%= lucide_icon("building", class: "w-5 h-5 text-violet-500") %>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||||
|
<%= t(".import_accounts") %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="pl-14 pr-3">
|
||||||
|
<div class="h-px bg-alpha-black-50"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<%= button_to imports_path(import: { type: "MintImport" }), class: "flex items-center justify-between p-4 group w-full", data: { turbo: false } do %>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
|
||||||
|
<span class="text-sm text-gray-900">
|
||||||
|
<%= t(".import_mint") %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="pl-14 pr-3">
|
||||||
|
<div class="h-px bg-alpha-black-50"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
<div class="mx-auto md:w-2/3 w-full flex">
|
<%= content_for :header_nav do %>
|
||||||
<div class="mx-auto">
|
<%= render "imports/nav", import: @import %>
|
||||||
<% if notice.present? %>
|
|
||||||
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render @import %>
|
<%= content_for :previous_path, import_confirm_path(@import) %>
|
||||||
|
|
||||||
<%= link_to "Edit this import", edit_import_path(@import), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
|
<% if @import.importing? %>
|
||||||
<div class="inline-block ml-2">
|
<%= render "imports/importing", import: @import %>
|
||||||
<%= button_to "Destroy this import", import_path(@import), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
|
<% elsif @import.complete? %>
|
||||||
</div>
|
<%= render "imports/success", import: @import %>
|
||||||
<%= link_to "Back to imports", imports_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
|
<% elsif @import.failed? %>
|
||||||
</div>
|
<%= render "imports/failure", import: @import %>
|
||||||
</div>
|
<% else %>
|
||||||
|
<%= render "imports/ready", import: @import %>
|
||||||
|
<% end %>
|
||||||
|
|
|
@ -1,34 +1,23 @@
|
||||||
<%= content_for :content do %>
|
<%= content_for :content do %>
|
||||||
<div class="flex items-center justify-between p-8">
|
<div class="flex flex-col h-dvh">
|
||||||
<%= link_to root_path do %>
|
<header class="flex items-center justify-between p-8">
|
||||||
<%= image_tag "logo.svg", alt: "Maybe", class: "h-[22px]" %>
|
<%= link_to content_for(:previous_path) || imports_path do %>
|
||||||
|
<%= lucide_icon "arrow-left", class: "w-5 h-5 text-gray-500" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<nav>
|
|
||||||
<div>
|
|
||||||
<ul class="flex items-center gap-2">
|
|
||||||
<% nav_steps(@import).each_with_index do |step, idx| %>
|
|
||||||
<li class="group flex items-center gap-2">
|
|
||||||
<% if step[:path].present? %>
|
|
||||||
<%= link_to step[:path], class: "flex items-center gap-3" do %>
|
|
||||||
<%= render partial: "nav_step", locals: { step: step, step_idx: idx } %>
|
|
||||||
<% end %>
|
|
||||||
<% else %>
|
|
||||||
<%= render partial: "nav_step", locals: { step: step, step_idx: idx } %>
|
|
||||||
<% end %>
|
|
||||||
<% if idx < nav_steps.size %>
|
|
||||||
<div class="h-px bg-alpha-black-200 w-12 group-last:hidden"></div>
|
|
||||||
<% end %>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<%= link_to content_for(:return_to_path) do %>
|
|
||||||
<%= lucide_icon("x", class: "text-gray-500 w-8 h-8 hover:bg-gray-100 rounded-full p-2") %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<%= yield :header_nav %>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<%= link_to imports_path do %>
|
||||||
|
<%= lucide_icon "x", class: "text-gray-500 w-5 h-5" %>
|
||||||
|
<% end %>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-grow px-8 pt-12 pb-32 overflow-y-auto">
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render template: "layouts/application" %>
|
<%= render template: "layouts/application" %>
|
||||||
|
|
|
@ -8,10 +8,18 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= contextual_menu do %>
|
||||||
|
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||||
|
<%= contextual_menu_modal_action_item t(".import"), new_import_path, icon: "hard-drive-upload" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= link_to new_account_path, class: "flex items-center gap-1 btn btn--primary", data: { turbo_frame: "modal" } do %>
|
<%= link_to new_account_path, class: "flex items-center gap-1 btn btn--primary", data: { turbo_frame: "modal" } do %>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
<span><%= t(".new") %></span>
|
<span><%= t(".new") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<% if @accounts.empty? %>
|
<% if @accounts.empty? %>
|
||||||
|
|
|
@ -29,6 +29,10 @@
|
||||||
<li>
|
<li>
|
||||||
<%= sidebar_link_to t(".accounts_label"), accounts_path, icon: "layers" %>
|
<%= sidebar_link_to t(".accounts_label"), accounts_path, icon: "layers" %>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -47,9 +51,6 @@
|
||||||
<li>
|
<li>
|
||||||
<%= sidebar_link_to t(".merchants_label"), merchants_path, icon: "store" %>
|
<%= sidebar_link_to t(".merchants_label"), merchants_path, icon: "store" %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
@ -90,8 +90,42 @@
|
||||||
22
|
22
|
||||||
],
|
],
|
||||||
"note": ""
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"warning_type": "Dynamic Render Path",
|
||||||
|
"warning_code": 15,
|
||||||
|
"fingerprint": "fb6f7abeabc405d6882ffd41dbe8016403ef39307a5c6b4cd7b18adfaf0c24bf",
|
||||||
|
"check_name": "Render",
|
||||||
|
"message": "Render path contains parameter value",
|
||||||
|
"file": "app/views/import/configurations/show.html.erb",
|
||||||
|
"line": 13,
|
||||||
|
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
|
||||||
|
"code": "render(partial => permitted_import_configuration_path(Current.family.imports.find(params[:import_id])), { :locals => ({ :import => Current.family.imports.find(params[:import_id]) }) })",
|
||||||
|
"render_path": [
|
||||||
|
{
|
||||||
|
"type": "controller",
|
||||||
|
"class": "Import::ConfigurationsController",
|
||||||
|
"method": "show",
|
||||||
|
"line": 7,
|
||||||
|
"file": "app/controllers/import/configurations_controller.rb",
|
||||||
|
"rendered": {
|
||||||
|
"name": "import/configurations/show",
|
||||||
|
"file": "app/views/import/configurations/show.html.erb"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updated": "2024-09-09 14:56:48 -0400",
|
"location": {
|
||||||
|
"type": "template",
|
||||||
|
"template": "import/configurations/show"
|
||||||
|
},
|
||||||
|
"user_input": "params[:import_id]",
|
||||||
|
"confidence": "Weak",
|
||||||
|
"cwe_id": [
|
||||||
|
22
|
||||||
|
],
|
||||||
|
"note": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": "2024-09-28 13:27:09 -0400",
|
||||||
"brakeman_version": "6.2.1"
|
"brakeman_version": "6.2.1"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
require "pagy/extras/overflow"
|
require "pagy/extras/overflow"
|
||||||
|
require "pagy/extras/array"
|
||||||
|
|
||||||
Pagy::DEFAULT[:overflow] = :last_page
|
Pagy::DEFAULT[:overflow] = :last_page
|
||||||
|
|
|
@ -1,111 +1,66 @@
|
||||||
---
|
---
|
||||||
en:
|
en:
|
||||||
|
import:
|
||||||
|
cleans:
|
||||||
|
show:
|
||||||
|
description: Edit your data in the table below. Red cells are invalid.
|
||||||
|
title: Clean your data
|
||||||
|
configurations:
|
||||||
|
show:
|
||||||
|
description: Select the columns that correspond to each field in your CSV.
|
||||||
|
title: Configure your import
|
||||||
|
confirms:
|
||||||
|
show:
|
||||||
|
account_mapping_description: Assign all of your imported file's accounts to
|
||||||
|
Maybe's existing accounts. You can also add new accounts or leave them
|
||||||
|
uncategorized.
|
||||||
|
account_mapping_title: Assign your accounts
|
||||||
|
account_type_mapping_description: Assign all of your imported file's account
|
||||||
|
types to Maybe's
|
||||||
|
account_type_mapping_title: Assign your account types
|
||||||
|
category_mapping_description: Assign all of your imported file's categories
|
||||||
|
to Maybe's existing categories. You can also add new categories or leave
|
||||||
|
them uncategorized.
|
||||||
|
category_mapping_title: Assign your categories
|
||||||
|
tag_mapping_description: Assign all of your imported file's tags to Maybe's
|
||||||
|
existing tags. You can also add new tags or leave them uncategorized.
|
||||||
|
tag_mapping_title: Assign your tags
|
||||||
|
uploads:
|
||||||
|
show:
|
||||||
|
description: Paste or upload your CSV file below.
|
||||||
|
instructions_1: Below is an example CSV with columns available for import.
|
||||||
|
instructions_2: Your CSV must have a header row
|
||||||
|
instructions_3: You may name your columns anything you like. You will map
|
||||||
|
them at a later step.
|
||||||
|
instructions_4: Columns marked with an asterisk (*) are required data.
|
||||||
|
title: Import your data
|
||||||
imports:
|
imports:
|
||||||
clean:
|
|
||||||
clean_and_edit: Clean and edit your data
|
|
||||||
clean_description: Edit your transactions in the table below. Click on any cell
|
|
||||||
to change the date, name, category, or amount.
|
|
||||||
clean_import: Clean import
|
|
||||||
invalid_csv: Please load a CSV first
|
|
||||||
configure:
|
|
||||||
configure_description: Select the columns that match the necessary data fields,
|
|
||||||
so that the columns in your CSV can be correctly mapped with our format.
|
|
||||||
configure_subtitle: Setup your CSV file
|
|
||||||
configure_title: Configure import
|
|
||||||
confirm_accept: Change mappings
|
|
||||||
confirm_body: Changing your mappings may erase any edits you have made to the
|
|
||||||
CSV so far.
|
|
||||||
confirm_title: Are you sure?
|
|
||||||
invalid_csv: Please load a CSV first
|
|
||||||
next: Next
|
|
||||||
optional: "(optional) No column selected"
|
|
||||||
confirm:
|
|
||||||
confirm_description: Preview your transactions below and check to see if there
|
|
||||||
are any changes that are required.
|
|
||||||
confirm_subtitle: Confirm your transactions
|
|
||||||
confirm_title: Confirm import
|
|
||||||
invalid_data: You have invalid data, please fix before continuing
|
|
||||||
create:
|
|
||||||
import_created: Import created
|
|
||||||
csv_paste:
|
|
||||||
confirm_accept: Yep, start over!
|
|
||||||
confirm_body: This will reset your import. Any changes you have made to the
|
|
||||||
CSV will be erased.
|
|
||||||
confirm_title: Are you sure?
|
|
||||||
instructions: Your CSV should have the following columns and formats for the
|
|
||||||
best import experience.
|
|
||||||
next: Next
|
|
||||||
requirement1: Dates must be in ISO 8601 format (YYYY-MM-DD)
|
|
||||||
requirement2: Negative transaction is an "outflow" (expense), positive is an
|
|
||||||
"inflow" (income)
|
|
||||||
requirement3: Can have 0 or more tags separated by |
|
|
||||||
csv_upload:
|
|
||||||
allowed_filetypes: Only CSV files are allowed.
|
|
||||||
confirm_accept: Yep, start over!
|
|
||||||
confirm_body: This will reset your import. Any changes you have made to the
|
|
||||||
CSV will be erased.
|
|
||||||
confirm_title: Are you sure?
|
|
||||||
instructions: The csv file must be in the format below. You can also reuse and
|
|
||||||
next: Next
|
|
||||||
destroy:
|
|
||||||
import_destroyed: Import destroyed
|
|
||||||
edit:
|
|
||||||
description_text: Importing transactions can only be done for one account at
|
|
||||||
a time. You will need to go through this process again for other accounts.
|
|
||||||
edit_title: Edit import
|
|
||||||
header_text: Select the account your transactions will belong to
|
|
||||||
empty:
|
empty:
|
||||||
message: No imports to show
|
message: No imports yet.
|
||||||
new: New Import
|
new: New import
|
||||||
form:
|
|
||||||
account: Account
|
|
||||||
col_sep: CSV column separator
|
|
||||||
col_sep_char:
|
|
||||||
'44': Comma (,)
|
|
||||||
'59': Semicolon (;)
|
|
||||||
next: Next
|
|
||||||
select_account: Select account
|
|
||||||
select_col_sep: Select CSV column separator
|
|
||||||
import:
|
import:
|
||||||
complete: Complete
|
complete: Complete
|
||||||
completed_on: Completed on %{datetime}
|
|
||||||
delete: Delete
|
delete: Delete
|
||||||
edit: Edit
|
|
||||||
failed: Failed
|
failed: Failed
|
||||||
in_progress: In progress
|
in_progress: In progress
|
||||||
label: 'Import for: %{account}'
|
label: "%{type}: %{datetime}"
|
||||||
started_on: Started on %{datetime}
|
|
||||||
uploading: Processing rows
|
uploading: Processing rows
|
||||||
|
view: View
|
||||||
index:
|
index:
|
||||||
imports: Imports
|
imports: Imports
|
||||||
new: New import
|
new: New import
|
||||||
title: Imports
|
title: Imports
|
||||||
load:
|
|
||||||
description: Create a spreadsheet or upload an exported CSV from your financial
|
|
||||||
institution.
|
|
||||||
load_title: Load import
|
|
||||||
subtitle: Import your transactions
|
|
||||||
load_csv:
|
|
||||||
import_loaded: Import CSV loaded
|
|
||||||
new:
|
new:
|
||||||
description_text: Importing transactions can only be done for one account at
|
description: You can manually import various types of data via CSV or use one
|
||||||
a time. You will need to go through this process again for other accounts.
|
of our import templates like Mint.
|
||||||
header_text: Select the account your transactions will belong to
|
import_accounts: Import accounts
|
||||||
publish:
|
import_mint: Import from Mint
|
||||||
import_published: Import has started in the background
|
import_portfolio: Import investments
|
||||||
invalid_data: Your import is invalid
|
|
||||||
type_selector:
|
|
||||||
description: You can manually import transactions from CSVs or other financial
|
|
||||||
apps like Mint.
|
|
||||||
import_from_csv: New import from CSV
|
|
||||||
import_from_mint: Import from Mint
|
|
||||||
import_transactions: Import transactions
|
import_transactions: Import transactions
|
||||||
resume_latest_import: Resume latest import
|
resume: Resume latest import
|
||||||
soon: Soon
|
|
||||||
sources: Sources
|
sources: Sources
|
||||||
update:
|
title: New CSV Import
|
||||||
import_updated: Import updated
|
ready:
|
||||||
update_mappings:
|
description: Here's a summary of the new items that will be added to your account
|
||||||
column_mappings_saved: Column mappings saved
|
once you publish this import.
|
||||||
upload_csv:
|
title: Confirm your import data
|
||||||
import_loaded: CSV File loaded
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ en:
|
||||||
assets: Assets
|
assets: Assets
|
||||||
debts: Debts
|
debts: Debts
|
||||||
greeting: Welcome back, %{name}
|
greeting: Welcome back, %{name}
|
||||||
|
import: Import
|
||||||
income: Income
|
income: Income
|
||||||
investing: Investing (coming soon...)
|
investing: Investing (coming soon...)
|
||||||
net_worth: Net Worth
|
net_worth: Net Worth
|
||||||
|
|
|
@ -21,23 +21,6 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :imports, except: :show do
|
|
||||||
member do
|
|
||||||
get "load"
|
|
||||||
patch "load" => "imports#load_csv"
|
|
||||||
patch "upload" => "imports#upload_csv"
|
|
||||||
|
|
||||||
get "configure"
|
|
||||||
patch "configure" => "imports#update_mappings"
|
|
||||||
|
|
||||||
get "clean"
|
|
||||||
patch "clean" => "imports#update_csv"
|
|
||||||
|
|
||||||
get "confirm"
|
|
||||||
patch "confirm" => "imports#publish"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
resources :tags, except: %i[show destroy] do
|
resources :tags, except: %i[show destroy] do
|
||||||
resources :deletions, only: %i[new create], module: :tag
|
resources :deletions, only: %i[new create], module: :tag
|
||||||
end
|
end
|
||||||
|
@ -56,6 +39,18 @@ Rails.application.routes.draw do
|
||||||
resources :transfers, only: %i[new create destroy]
|
resources :transfers, only: %i[new create destroy]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :imports, only: %i[index new show create destroy] do
|
||||||
|
post :publish, on: :member
|
||||||
|
|
||||||
|
resource :upload, only: %i[show update], module: :import
|
||||||
|
resource :configuration, only: %i[show update], module: :import
|
||||||
|
resource :clean, only: :show, module: :import
|
||||||
|
resource :confirm, only: :show, module: :import
|
||||||
|
|
||||||
|
resources :rows, only: %i[show update], module: :import
|
||||||
|
resources :mappings, only: :update, module: :import
|
||||||
|
end
|
||||||
|
|
||||||
resources :accounts do
|
resources :accounts do
|
||||||
collection do
|
collection do
|
||||||
get :summary
|
get :summary
|
||||||
|
|
28
db/migrate/20240921170426_change_import_owner.rb
Normal file
28
db/migrate/20240921170426_change_import_owner.rb
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
class ChangeImportOwner < ActiveRecord::Migration[7.2]
|
||||||
|
def up
|
||||||
|
add_reference :imports, :family, foreign_key: true, type: :uuid
|
||||||
|
add_column :imports, :original_account_id, :uuid
|
||||||
|
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE imports
|
||||||
|
SET family_id = (SELECT family_id FROM accounts WHERE accounts.id = imports.account_id),
|
||||||
|
original_account_id = account_id
|
||||||
|
SQL
|
||||||
|
|
||||||
|
remove_reference :imports, :account, foreign_key: true, type: :uuid
|
||||||
|
change_column_null :imports, :family_id, false
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
add_reference :imports, :account, foreign_key: true, type: :uuid
|
||||||
|
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE imports
|
||||||
|
SET account_id = original_account_id
|
||||||
|
SQL
|
||||||
|
|
||||||
|
remove_reference :imports, :family, foreign_key: true, type: :uuid
|
||||||
|
remove_column :imports, :original_account_id, :uuid
|
||||||
|
change_column_null :imports, :account_id, false
|
||||||
|
end
|
||||||
|
end
|
55
db/migrate/20240925112218_add_import_types.rb
Normal file
55
db/migrate/20240925112218_add_import_types.rb
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
class AddImportTypes < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
change_table :imports do |t|
|
||||||
|
t.string :type, null: false
|
||||||
|
t.string :date_col_label, default: "date"
|
||||||
|
t.string :amount_col_label, default: "amount"
|
||||||
|
t.string :name_col_label, default: "name"
|
||||||
|
t.string :category_col_label, default: "category"
|
||||||
|
t.string :tags_col_label, default: "tags"
|
||||||
|
t.string :account_col_label, default: "account"
|
||||||
|
t.string :qty_col_label, default: "qty"
|
||||||
|
t.string :ticker_col_label, default: "ticker"
|
||||||
|
t.string :price_col_label, default: "price"
|
||||||
|
t.string :entity_type_col_label, default: "type"
|
||||||
|
t.string :notes_col_label, default: "notes"
|
||||||
|
t.string :currency_col_label, default: "currency"
|
||||||
|
t.string :date_format, default: "%m/%d/%Y"
|
||||||
|
t.string :signage_convention, default: "inflows_positive"
|
||||||
|
t.string :error
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add import references so we can associate imported resources after the import
|
||||||
|
add_reference :account_entries, :import, foreign_key: true, type: :uuid
|
||||||
|
add_reference :accounts, :import, foreign_key: true, type: :uuid
|
||||||
|
|
||||||
|
create_table :import_rows, id: :uuid do |t|
|
||||||
|
t.references :import, null: false, foreign_key: true, type: :uuid
|
||||||
|
t.string :account
|
||||||
|
t.string :date
|
||||||
|
t.string :qty
|
||||||
|
t.string :ticker
|
||||||
|
t.string :price
|
||||||
|
t.string :amount
|
||||||
|
t.string :currency
|
||||||
|
t.string :name
|
||||||
|
t.string :category
|
||||||
|
t.string :tags
|
||||||
|
t.string :entity_type
|
||||||
|
t.text :notes
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :import_mappings, id: :uuid do |t|
|
||||||
|
t.string :type, null: false
|
||||||
|
t.string :key
|
||||||
|
t.string :value
|
||||||
|
t.boolean :create_when_empty, default: true
|
||||||
|
t.references :import, null: false, type: :uuid
|
||||||
|
t.references :mappable, polymorphic: true, type: :uuid
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
67
db/schema.rb
generated
67
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do
|
ActiveRecord::Schema[7.2].define(version: 2024_09_25_112218) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -44,7 +44,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.uuid "transfer_id"
|
t.uuid "transfer_id"
|
||||||
t.boolean "marked_as_transfer", default: false, null: false
|
t.boolean "marked_as_transfer", default: false, null: false
|
||||||
|
t.uuid "import_id"
|
||||||
t.index ["account_id"], name: "index_account_entries_on_account_id"
|
t.index ["account_id"], name: "index_account_entries_on_account_id"
|
||||||
|
t.index ["import_id"], name: "index_account_entries_on_import_id"
|
||||||
t.index ["transfer_id"], name: "index_account_entries_on_transfer_id"
|
t.index ["transfer_id"], name: "index_account_entries_on_transfer_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -118,9 +120,11 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do
|
||||||
t.boolean "is_active", default: true, null: false
|
t.boolean "is_active", default: true, null: false
|
||||||
t.date "last_sync_date"
|
t.date "last_sync_date"
|
||||||
t.uuid "institution_id"
|
t.uuid "institution_id"
|
||||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||||
|
t.uuid "import_id"
|
||||||
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
|
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
|
||||||
t.index ["family_id"], name: "index_accounts_on_family_id"
|
t.index ["family_id"], name: "index_accounts_on_family_id"
|
||||||
|
t.index ["import_id"], name: "index_accounts_on_import_id"
|
||||||
t.index ["institution_id"], name: "index_accounts_on_institution_id"
|
t.index ["institution_id"], name: "index_accounts_on_institution_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -300,8 +304,40 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do
|
||||||
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
|
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "import_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "type", null: false
|
||||||
|
t.string "key"
|
||||||
|
t.string "value"
|
||||||
|
t.boolean "create_when_empty", default: true
|
||||||
|
t.uuid "import_id", null: false
|
||||||
|
t.string "mappable_type"
|
||||||
|
t.uuid "mappable_id"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["import_id"], name: "index_import_mappings_on_import_id"
|
||||||
|
t.index ["mappable_type", "mappable_id"], name: "index_import_mappings_on_mappable"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "import_rows", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "import_id", null: false
|
||||||
|
t.string "account"
|
||||||
|
t.string "date"
|
||||||
|
t.string "qty"
|
||||||
|
t.string "ticker"
|
||||||
|
t.string "price"
|
||||||
|
t.string "amount"
|
||||||
|
t.string "currency"
|
||||||
|
t.string "name"
|
||||||
|
t.string "category"
|
||||||
|
t.string "tags"
|
||||||
|
t.string "entity_type"
|
||||||
|
t.text "notes"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["import_id"], name: "index_import_rows_on_import_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "account_id", null: false
|
|
||||||
t.jsonb "column_mappings"
|
t.jsonb "column_mappings"
|
||||||
t.enum "status", default: "pending", enum_type: "import_status"
|
t.enum "status", default: "pending", enum_type: "import_status"
|
||||||
t.string "raw_file_str"
|
t.string "raw_file_str"
|
||||||
|
@ -309,7 +345,25 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "col_sep", default: ","
|
t.string "col_sep", default: ","
|
||||||
t.index ["account_id"], name: "index_imports_on_account_id"
|
t.uuid "family_id", null: false
|
||||||
|
t.uuid "original_account_id"
|
||||||
|
t.string "type", null: false
|
||||||
|
t.string "date_col_label", default: "date"
|
||||||
|
t.string "amount_col_label", default: "amount"
|
||||||
|
t.string "name_col_label", default: "name"
|
||||||
|
t.string "category_col_label", default: "category"
|
||||||
|
t.string "tags_col_label", default: "tags"
|
||||||
|
t.string "account_col_label", default: "account"
|
||||||
|
t.string "qty_col_label", default: "qty"
|
||||||
|
t.string "ticker_col_label", default: "ticker"
|
||||||
|
t.string "price_col_label", default: "price"
|
||||||
|
t.string "entity_type_col_label", default: "type"
|
||||||
|
t.string "notes_col_label", default: "notes"
|
||||||
|
t.string "currency_col_label", default: "currency"
|
||||||
|
t.string "date_format", default: "%m/%d/%Y"
|
||||||
|
t.string "signage_convention", default: "inflows_positive"
|
||||||
|
t.string "error"
|
||||||
|
t.index ["family_id"], name: "index_imports_on_family_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "institutions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "institutions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
@ -452,6 +506,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do
|
||||||
add_foreign_key "account_balances", "accounts", on_delete: :cascade
|
add_foreign_key "account_balances", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "account_entries", "account_transfers", column: "transfer_id"
|
add_foreign_key "account_entries", "account_transfers", column: "transfer_id"
|
||||||
add_foreign_key "account_entries", "accounts"
|
add_foreign_key "account_entries", "accounts"
|
||||||
|
add_foreign_key "account_entries", "imports"
|
||||||
add_foreign_key "account_holdings", "accounts"
|
add_foreign_key "account_holdings", "accounts"
|
||||||
add_foreign_key "account_holdings", "securities"
|
add_foreign_key "account_holdings", "securities"
|
||||||
add_foreign_key "account_syncs", "accounts"
|
add_foreign_key "account_syncs", "accounts"
|
||||||
|
@ -459,11 +514,13 @@ ActiveRecord::Schema[7.2].define(version: 2024_09_11_143158) do
|
||||||
add_foreign_key "account_transactions", "categories", on_delete: :nullify
|
add_foreign_key "account_transactions", "categories", on_delete: :nullify
|
||||||
add_foreign_key "account_transactions", "merchants"
|
add_foreign_key "account_transactions", "merchants"
|
||||||
add_foreign_key "accounts", "families"
|
add_foreign_key "accounts", "families"
|
||||||
|
add_foreign_key "accounts", "imports"
|
||||||
add_foreign_key "accounts", "institutions"
|
add_foreign_key "accounts", "institutions"
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
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 "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "categories", "families"
|
add_foreign_key "categories", "families"
|
||||||
add_foreign_key "imports", "accounts"
|
add_foreign_key "import_rows", "imports"
|
||||||
|
add_foreign_key "imports", "families"
|
||||||
add_foreign_key "institutions", "families"
|
add_foreign_key "institutions", "families"
|
||||||
add_foreign_key "merchants", "families"
|
add_foreign_key "merchants", "families"
|
||||||
add_foreign_key "taggings", "tags"
|
add_foreign_key "taggings", "tags"
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
date,name,category,tags,amount
|
|
||||||
2024-01-01,Amazon,Shopping,Tag1|Tag2,-24.99
|
|
||||||
2024-03-01,Spotify,,,-16.32
|
|
||||||
2023-01-06,Acme,Income,Tag3,151.22
|
|
|
25
test/controllers/import/cleans_controller_test.rb
Normal file
25
test/controllers/import/cleans_controller_test.rb
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Import::CleansControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows if configured" do
|
||||||
|
import = imports(:transaction)
|
||||||
|
|
||||||
|
TransactionImport.any_instance.stubs(:configured?).returns(true)
|
||||||
|
|
||||||
|
get import_clean_path(import)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects if not configured" do
|
||||||
|
import = imports(:transaction)
|
||||||
|
|
||||||
|
TransactionImport.any_instance.stubs(:configured?).returns(false)
|
||||||
|
|
||||||
|
get import_clean_path(import)
|
||||||
|
assert_redirected_to import_configuration_path(import)
|
||||||
|
end
|
||||||
|
end
|
33
test/controllers/import/configurations_controller_test.rb
Normal file
33
test/controllers/import/configurations_controller_test.rb
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Import::ConfigurationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
@import = imports(:transaction)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "show" do
|
||||||
|
get import_configuration_url(@import)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updating a valid configuration regenerates rows" do
|
||||||
|
TransactionImport.any_instance.expects(:generate_rows_from_csv).once
|
||||||
|
|
||||||
|
patch import_configuration_url(@import), params: {
|
||||||
|
import: {
|
||||||
|
date_col_label: "Date",
|
||||||
|
date_format: "%Y-%m-%d",
|
||||||
|
name_col_label: "Name",
|
||||||
|
category_col_label: "Category",
|
||||||
|
tags_col_label: "Tags",
|
||||||
|
amount_col_label: "Amount",
|
||||||
|
signage_convention: "inflows_positive",
|
||||||
|
account_col_label: "Account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to import_clean_url(@import)
|
||||||
|
assert_equal "Import configured successfully.", flash[:notice]
|
||||||
|
end
|
||||||
|
end
|
26
test/controllers/import/confirms_controller_test.rb
Normal file
26
test/controllers/import/confirms_controller_test.rb
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Import::ConfirmsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows if cleaned" do
|
||||||
|
import = imports(:transaction)
|
||||||
|
|
||||||
|
TransactionImport.any_instance.stubs(:cleaned?).returns(true)
|
||||||
|
|
||||||
|
get import_confirm_path(import)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects if not cleaned" do
|
||||||
|
import = imports(:transaction)
|
||||||
|
|
||||||
|
TransactionImport.any_instance.stubs(:cleaned?).returns(false)
|
||||||
|
|
||||||
|
get import_confirm_path(import)
|
||||||
|
assert_redirected_to import_clean_path(import)
|
||||||
|
assert_equal "You have invalid data, please edit until all errors are resolved", flash[:alert]
|
||||||
|
end
|
||||||
|
end
|
29
test/controllers/import/mappings_controller_test.rb
Normal file
29
test/controllers/import/mappings_controller_test.rb
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Import::MappingsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
|
||||||
|
@import = imports(:transaction)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates mapping" do
|
||||||
|
mapping = import_mappings(:one)
|
||||||
|
new_category = categories(:income)
|
||||||
|
|
||||||
|
patch import_mapping_path(@import, mapping), params: {
|
||||||
|
import_mapping: {
|
||||||
|
mappable_type: "Category",
|
||||||
|
mappable_id: new_category.id,
|
||||||
|
key: "Food"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping.reload
|
||||||
|
|
||||||
|
assert_equal new_category, mapping.mappable
|
||||||
|
assert_equal "Food", mapping.key
|
||||||
|
|
||||||
|
assert_redirected_to import_confirm_path(@import)
|
||||||
|
end
|
||||||
|
end
|
79
test/controllers/import/rows_controller_test.rb
Normal file
79
test/controllers/import/rows_controller_test.rb
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Import::RowsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
|
||||||
|
@import = imports(:transaction)
|
||||||
|
@row = import_rows(:one)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "show transaction row" do
|
||||||
|
get import_row_path(@import, @row)
|
||||||
|
|
||||||
|
assert_row_fields(@row, [ :date, :name, :amount, :currency, :category, :tags, :account, :notes ])
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "show trade row" do
|
||||||
|
import = @user.family.imports.create!(type: "TradeImport")
|
||||||
|
row = import.rows.create!(date: "01/01/2024", currency: "USD", qty: 10, price: 100, ticker: "AAPL")
|
||||||
|
|
||||||
|
get import_row_path(import, row)
|
||||||
|
|
||||||
|
assert_row_fields(row, [ :date, :ticker, :qty, :price, :currency, :account, :name ])
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "show account row" do
|
||||||
|
import = @user.family.imports.create!(type: "AccountImport")
|
||||||
|
row = import.rows.create!(name: "Test Account", amount: 10000, currency: "USD")
|
||||||
|
|
||||||
|
get import_row_path(import, row)
|
||||||
|
|
||||||
|
assert_row_fields(row, [ :entity_type, :name, :amount, :currency ])
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "show mint row" do
|
||||||
|
import = @user.family.imports.create!(type: "MintImport")
|
||||||
|
row = import.rows.create!(date: "01/01/2024", amount: 100, currency: "USD")
|
||||||
|
|
||||||
|
get import_row_path(import, row)
|
||||||
|
|
||||||
|
assert_row_fields(row, [ :date, :name, :amount, :currency, :category, :tags, :account, :notes ])
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update" do
|
||||||
|
patch import_row_path(@import, @row), params: {
|
||||||
|
import_row: {
|
||||||
|
account: "Checking Account",
|
||||||
|
date: "2024-01-01",
|
||||||
|
qty: nil,
|
||||||
|
ticker: nil,
|
||||||
|
price: nil,
|
||||||
|
amount: 100,
|
||||||
|
currency: "USD",
|
||||||
|
name: "Test",
|
||||||
|
category: "Food",
|
||||||
|
tags: "grocery, dinner",
|
||||||
|
entity_type: nil,
|
||||||
|
notes: "Weekly shopping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to import_row_path(@import, @row)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def assert_row_fields(row, fields)
|
||||||
|
fields.each do |field|
|
||||||
|
assert_select "turbo-frame##{dom_id(row, field)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
46
test/controllers/import/uploads_controller_test.rb
Normal file
46
test/controllers/import/uploads_controller_test.rb
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Import::UploadsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
@import = imports(:transaction)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "show" do
|
||||||
|
get import_upload_url(@import)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uploads valid csv by copy and pasting" do
|
||||||
|
patch import_upload_url(@import), params: {
|
||||||
|
import: {
|
||||||
|
raw_file_str: file_fixture("imports/valid.csv").read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to import_configuration_url(@import)
|
||||||
|
assert_equal "CSV uploaded successfully.", flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uploads valid csv by file" do
|
||||||
|
patch import_upload_url(@import), params: {
|
||||||
|
import: {
|
||||||
|
csv_file: file_fixture_upload("imports/valid.csv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to import_configuration_url(@import)
|
||||||
|
assert_equal "CSV uploaded successfully.", flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalid csv cannot be uploaded" do
|
||||||
|
patch import_upload_url(@import), params: {
|
||||||
|
import: {
|
||||||
|
csv_file: file_fixture_upload("imports/invalid.csv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
assert_equal "Must be valid CSV with headers and at least one row of data", flash[:alert]
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,20 +1,13 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ImportsControllerTest < ActionDispatch::IntegrationTest
|
class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
include ImportTestHelper
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
sign_in @user = users(:family_admin)
|
sign_in @user = users(:family_admin)
|
||||||
@empty_import = imports(:empty_import)
|
|
||||||
|
|
||||||
@loaded_import = @empty_import.dup
|
|
||||||
@loaded_import.update! raw_file_str: valid_csv_str
|
|
||||||
|
|
||||||
@completed_import = imports(:completed_import)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should get index" do
|
test "gets index" do
|
||||||
get imports_url
|
get imports_url
|
||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
|
|
||||||
@user.family.imports.ordered.each do |import|
|
@user.family.imports.ordered.each do |import|
|
||||||
|
@ -22,152 +15,44 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should get new" do
|
test "gets new" do
|
||||||
get new_import_url
|
get new_import_url
|
||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
|
|
||||||
|
assert_select "turbo-frame#modal"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should create import" do
|
test "creates import" do
|
||||||
assert_difference("Import.count") do
|
assert_difference "Import.count", 1 do
|
||||||
post imports_url, params: { import: { account_id: @user.family.accounts.first.id, col_sep: "," } }
|
post imports_url, params: {
|
||||||
end
|
|
||||||
|
|
||||||
assert_redirected_to load_import_path(Import.ordered.first)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should get edit" do
|
|
||||||
get edit_import_url(@empty_import)
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should update import" do
|
|
||||||
patch import_url(@empty_import), params: { import: { account_id: @empty_import.account_id, col_sep: "," } }
|
|
||||||
assert_redirected_to load_import_path(@empty_import)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should destroy import" do
|
|
||||||
assert_difference("Import.count", -1) do
|
|
||||||
delete import_url(@empty_import)
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_redirected_to imports_url
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should get load" do
|
|
||||||
get load_import_url(@empty_import)
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should save raw CSV if valid" do
|
|
||||||
patch load_import_url(@empty_import), params: { import: { raw_file_str: valid_csv_str } }
|
|
||||||
|
|
||||||
assert_redirected_to configure_import_path(@empty_import)
|
|
||||||
assert_equal "Import CSV loaded", flash[:notice]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should upload CSV file if valid" do
|
|
||||||
Tempfile.open([ "transactions.csv", ".csv" ]) do |temp|
|
|
||||||
CSV.open(temp, "wb", headers: true) do |csv|
|
|
||||||
valid_csv_str.split("\n").each { |row| csv << row.split(",") }
|
|
||||||
end
|
|
||||||
|
|
||||||
patch upload_import_url(@empty_import), params: { import: { raw_file_str: Rack::Test::UploadedFile.new(temp, ".csv") } }
|
|
||||||
assert_redirected_to configure_import_path(@empty_import)
|
|
||||||
assert_equal "CSV File loaded", flash[:notice]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should flash error message if invalid CSV input" do
|
|
||||||
patch load_import_url(@empty_import), params: { import: { raw_file_str: malformed_csv_str } }
|
|
||||||
|
|
||||||
assert_response :unprocessable_entity
|
|
||||||
assert_equal "Raw file str is not a valid CSV format", flash[:alert]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should flash error message if invalid CSV file upload" do
|
|
||||||
Tempfile.open([ "transactions.csv", ".csv" ]) do |temp|
|
|
||||||
temp.write(malformed_csv_str)
|
|
||||||
temp.rewind
|
|
||||||
|
|
||||||
patch upload_import_url(@empty_import), params: { import: { raw_file_str: Rack::Test::UploadedFile.new(temp, ".csv") } }
|
|
||||||
assert_response :unprocessable_entity
|
|
||||||
assert_equal "Raw file str is not a valid CSV format", flash[:alert]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should flash error message if no fileprovided for upload" do
|
|
||||||
patch upload_import_url(@empty_import), params: { import: { raw_file_str: nil } }
|
|
||||||
assert_response :unprocessable_entity
|
|
||||||
assert_equal "Please select a file to upload", flash[:alert]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should get configure" do
|
|
||||||
get configure_import_url(@loaded_import)
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should redirect back to load step with an alert message if not loaded" do
|
|
||||||
get configure_import_url(@empty_import)
|
|
||||||
assert_equal "Please load a CSV first", flash[:alert]
|
|
||||||
assert_redirected_to load_import_path(@empty_import)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should update mappings" do
|
|
||||||
patch configure_import_url(@loaded_import), params: {
|
|
||||||
import: {
|
import: {
|
||||||
column_mappings: {
|
type: "TransactionImport"
|
||||||
date: "date",
|
|
||||||
name: "name",
|
|
||||||
category: "category",
|
|
||||||
amount: "amount"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
assert_redirected_to clean_import_path(@loaded_import)
|
|
||||||
assert_equal "Column mappings saved", flash[:notice]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can update a cell" do
|
assert_redirected_to import_upload_url(Import.all.ordered.first)
|
||||||
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
|
end
|
||||||
|
|
||||||
test "should get clean" do
|
test "publishes import" do
|
||||||
get clean_import_url(@loaded_import)
|
import = imports(:transaction)
|
||||||
assert_response :success
|
|
||||||
|
TransactionImport.any_instance.expects(:publish_later).once
|
||||||
|
|
||||||
|
post publish_import_url(import)
|
||||||
|
|
||||||
|
assert_equal "Your import has started in the background.", flash[:notice]
|
||||||
|
assert_redirected_to import_path(import)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should get confirm if all values are valid" do
|
test "destroys import" do
|
||||||
get confirm_import_url(@loaded_import)
|
import = imports(:transaction)
|
||||||
assert_response :success
|
|
||||||
|
assert_difference "Import.count", -1 do
|
||||||
|
delete import_url(import)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should redirect back to clean if data is invalid" do
|
|
||||||
@empty_import.update! raw_file_str: valid_csv_with_invalid_values
|
|
||||||
|
|
||||||
get confirm_import_url(@empty_import)
|
|
||||||
assert_equal "You have invalid data, please fix before continuing", flash[:alert]
|
|
||||||
assert_redirected_to clean_import_path(@empty_import)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should confirm import" do
|
|
||||||
patch confirm_import_url(@loaded_import)
|
|
||||||
assert_redirected_to imports_path
|
assert_redirected_to imports_path
|
||||||
assert_equal "Import has started in the background", flash[:notice]
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
5
test/fixtures/files/imports/accounts.csv
vendored
Normal file
5
test/fixtures/files/imports/accounts.csv
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
type,name,amount,currency
|
||||||
|
Checking,Main Checking Account,5000.00,USD
|
||||||
|
Savings,Emergency Fund,10000.00,USD
|
||||||
|
Credit Card,Rewards Credit Card,-1500.00,USD
|
||||||
|
Investment,Retirement Portfolio,75000.00,USD
|
|
3
test/fixtures/files/imports/invalid.csv
vendored
Normal file
3
test/fixtures/files/imports/invalid.csv
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
name,age
|
||||||
|
"John Doe,23
|
||||||
|
"Jane Doe",25
|
Can't render this file because it contains an unexpected character in line 3 and column 1.
|
11
test/fixtures/files/imports/mint.csv
vendored
Normal file
11
test/fixtures/files/imports/mint.csv
vendored
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
Date,Description,Original Description,Amount,Transaction Type,Category,Account Name,Labels,Notes
|
||||||
|
05/01/2023,Grocery Store,SAFEWAY #1234,78.32,debit,Groceries,Checking Account,,
|
||||||
|
05/02/2023,Gas Station,SHELL OIL 57442893,-45.67,credit,Gas & Fuel,Credit Card,,
|
||||||
|
05/03/2023,Monthly Rent,AUTOPAY MORTGAGE,1500.00,debit,Mortgage & Rent,Checking Account,,
|
||||||
|
05/04/2023,Paycheck,ACME CORP PAYROLL,-2500.00,credit,Paycheck,Checking Account,Income,
|
||||||
|
05/05/2023,Restaurant,CHIPOTLE MEX GRILL,15.75,debit,Restaurants,Credit Card,,
|
||||||
|
05/06/2023,Online Shopping,AMAZON.COM,32.99,debit,Shopping,Credit Card,,
|
||||||
|
05/07/2023,Utility Bill,CITY POWER & LIGHT,89.50,debit,Utilities,Checking Account,,
|
||||||
|
05/08/2023,Coffee Shop,STARBUCKS,4.25,debit,Coffee Shops,Credit Card,,
|
||||||
|
05/09/2023,Gym Membership,FITNESS WORLD,49.99,debit,Gym,Checking Account,Health,Monthly membership
|
||||||
|
05/10/2023,Movie Theater,AMC THEATERS #123,24.50,debit,Movies & DVDs,Credit Card,Entertainment,
|
|
11
test/fixtures/files/imports/trades.csv
vendored
Normal file
11
test/fixtures/files/imports/trades.csv
vendored
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
date,ticker,qty,price,amount,account,name
|
||||||
|
2023-01-15,AAPL,10,150.25,1502.50,Brokerage Account,Buy Apple Inc
|
||||||
|
2023-02-03,GOOGL,5,2100.75,10503.75,Retirement Account,Buy Alphabet Inc
|
||||||
|
2023-03-10,MSFT,15,245.50,3682.50,Brokerage Account,Buy Microsoft Corp
|
||||||
|
2023-04-05,AMZN,8,3200.00,25600.00,Brokerage Account,Buy Amazon.com Inc
|
||||||
|
2023-05-20,TSLA,20,180.75,3615.00,Retirement Account,Buy Tesla Inc
|
||||||
|
2023-06-15,AAPL,-5,170.50,-852.50,Brokerage Account,Sell Apple Inc
|
||||||
|
2023-07-02,GOOGL,-2,2250.00,-4500.00,Retirement Account,Sell Alphabet Inc
|
||||||
|
2023-08-18,NVDA,12,450.25,5403.00,Brokerage Account,Buy NVIDIA Corp
|
||||||
|
2023-09-07,MSFT,-7,300.75,-2105.25,Brokerage Account,Sell Microsoft Corp
|
||||||
|
2023-10-01,META,25,310.50,7762.50,Retirement Account,Buy Meta Platforms Inc
|
|
10
test/fixtures/files/imports/transactions.csv
vendored
Normal file
10
test/fixtures/files/imports/transactions.csv
vendored
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
Date,Name,Amount,Category,Tags,Account,Notes
|
||||||
|
2023-05-01,Grocery Store,-89.75,Food,Groceries,Checking Account,Weekly grocery shopping
|
||||||
|
2023-05-03,Electric Company,-120.50,Utilities,Bills|Home,Credit Card,Monthly electricity bill
|
||||||
|
2023-05-05,Coffee Shop,-4.25,Food,Coffee|Work,Debit Card,Morning coffee
|
||||||
|
2023-05-07,Gas Station,-45.00,Transportation,Car|Fuel,Credit Card,Fill up car tank
|
||||||
|
2023-05-10,Online Retailer,-79.99,Shopping,Clothing,Credit Card,New shoes purchase
|
||||||
|
2023-05-12,Restaurant,-65.30,Food,Dining Out|Date Night,Checking Account,Dinner with partner
|
||||||
|
2023-05-15,Mobile Phone Provider,-55.00,Utilities,Bills|Communication,Debit Card,Monthly phone bill
|
||||||
|
2023-05-18,Movie Theater,-24.00,Entertainment,Movies,Credit Card,Weekend movie night
|
||||||
|
2023-05-20,Pharmacy,-32.50,Health,Medicine,Debit Card,Prescription refill
|
|
6
test/fixtures/import/mappings.yml
vendored
Normal file
6
test/fixtures/import/mappings.yml
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
one:
|
||||||
|
import: transaction
|
||||||
|
key: Food
|
||||||
|
type: Import::CategoryMapping
|
||||||
|
mappable: food_and_drink
|
||||||
|
mappable_type: Category
|
5
test/fixtures/import/rows.yml
vendored
Normal file
5
test/fixtures/import/rows.yml
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
one:
|
||||||
|
import: transaction
|
||||||
|
date: 01/01/2024
|
||||||
|
amount: 100
|
||||||
|
currency: USD
|
23
test/fixtures/imports.yml
vendored
23
test/fixtures/imports.yml
vendored
|
@ -1,20 +1,3 @@
|
||||||
empty_import:
|
transaction:
|
||||||
account: depository
|
family: dylan_family
|
||||||
created_at: <%= 1.minute.ago %>
|
type: TransactionImport
|
||||||
|
|
||||||
completed_import:
|
|
||||||
account: depository
|
|
||||||
column_mappings:
|
|
||||||
date: date
|
|
||||||
name: name
|
|
||||||
category: category
|
|
||||||
amount: amount
|
|
||||||
raw_file_str: |
|
|
||||||
date,name,category,tags,amount
|
|
||||||
2024-01-01,Starbucks drink,Food & Drink,Test Tag,-20
|
|
||||||
normalized_csv_str: |
|
|
||||||
date,name,category,tags,amount
|
|
||||||
2024-01-01,Starbucks drink,Food & Drink,Test Tag,-20
|
|
||||||
created_at: <%= 2.days.ago %>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
57
test/interfaces/import_interface_test.rb
Normal file
57
test/interfaces/import_interface_test.rb
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module ImportInterfaceTest
|
||||||
|
extend ActiveSupport::Testing::Declarative
|
||||||
|
|
||||||
|
test "import interface" do
|
||||||
|
assert_respond_to @subject, :publish
|
||||||
|
assert_respond_to @subject, :publish_later
|
||||||
|
assert_respond_to @subject, :generate_rows_from_csv
|
||||||
|
assert_respond_to @subject, :csv_rows
|
||||||
|
assert_respond_to @subject, :csv_headers
|
||||||
|
assert_respond_to @subject, :csv_sample
|
||||||
|
assert_respond_to @subject, :uploaded?
|
||||||
|
assert_respond_to @subject, :configured?
|
||||||
|
assert_respond_to @subject, :cleaned?
|
||||||
|
assert_respond_to @subject, :publishable?
|
||||||
|
assert_respond_to @subject, :importing?
|
||||||
|
assert_respond_to @subject, :complete?
|
||||||
|
assert_respond_to @subject, :failed?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "publishes later" do
|
||||||
|
import = imports(:transaction)
|
||||||
|
|
||||||
|
import.stubs(:publishable?).returns(true)
|
||||||
|
|
||||||
|
assert_enqueued_with job: ImportJob, args: [ import ] do
|
||||||
|
import.publish_later
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal "importing", import.reload.status
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raises if not publishable" do
|
||||||
|
import = imports(:transaction)
|
||||||
|
|
||||||
|
import.stubs(:publishable?).returns(false)
|
||||||
|
|
||||||
|
assert_raises(RuntimeError, "Import is not publishable") do
|
||||||
|
import.publish_later
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles publish errors" do
|
||||||
|
import = imports(:transaction)
|
||||||
|
|
||||||
|
import.stubs(:publishable?).returns(true)
|
||||||
|
import.stubs(:import!).raises(StandardError, "Failed to publish")
|
||||||
|
|
||||||
|
assert_nil import.error
|
||||||
|
|
||||||
|
import.publish
|
||||||
|
|
||||||
|
assert_equal "Failed to publish", import.error
|
||||||
|
assert_equal "failed", import.status
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,19 +1,10 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ImportJobTest < ActiveJob::TestCase
|
class ImportJobTest < ActiveJob::TestCase
|
||||||
include ImportTestHelper
|
|
||||||
|
|
||||||
test "import is published" do
|
test "import is published" do
|
||||||
import = imports(:empty_import)
|
import = imports(:transaction)
|
||||||
import.update! raw_file_str: valid_csv_str
|
import.expects(:publish).once
|
||||||
|
|
||||||
assert import.pending?
|
ImportJob.perform_now(import)
|
||||||
|
|
||||||
perform_enqueued_jobs do
|
|
||||||
ImportJob.perform_later(import)
|
|
||||||
end
|
|
||||||
|
|
||||||
assert import.reload.complete?
|
|
||||||
assert import.account.balances.present?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -99,20 +99,4 @@ class Account::EntryTest < ActiveSupport::TestCase
|
||||||
assert create_transaction(amount: -10).inflow?
|
assert create_transaction(amount: -10).inflow?
|
||||||
assert create_transaction(amount: 10).outflow?
|
assert create_transaction(amount: 10).outflow?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "cannot sell more shares of stock than owned" do
|
|
||||||
account = families(:empty).accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Investment.new
|
|
||||||
security = securities(:aapl)
|
|
||||||
|
|
||||||
error = assert_raises ActiveRecord::RecordInvalid do
|
|
||||||
account.entries.create! \
|
|
||||||
date: Date.current,
|
|
||||||
amount: 100,
|
|
||||||
currency: "USD",
|
|
||||||
name: "Sell 10 shares of AMZN",
|
|
||||||
entryable: Account::Trade.new(qty: -10, price: 200, security: security)
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_match /cannot sell 10.0 shares of AAPL because you only own 0.0 shares/, error.message
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,129 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class Import::CsvTest < ActiveSupport::TestCase
|
|
||||||
include ImportTestHelper
|
|
||||||
|
|
||||||
setup do
|
|
||||||
@csv = Import::Csv.new(valid_csv_str)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot define validator for non-existent header" do
|
|
||||||
assert_raises do
|
|
||||||
@csv.define_validator "invalid", method(:validate_iso_date)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "csv with no validators is valid" do
|
|
||||||
assert @csv.cell_valid?(0, 0)
|
|
||||||
assert @csv.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "valid csv values" do
|
|
||||||
@csv.define_validator "date", method(:validate_iso_date)
|
|
||||||
|
|
||||||
assert_equal "2024-01-01", @csv.table[0][0]
|
|
||||||
assert @csv.cell_valid?(0, 0)
|
|
||||||
assert @csv.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "invalid csv values" do
|
|
||||||
invalid_csv = Import::Csv.new valid_csv_with_invalid_values
|
|
||||||
|
|
||||||
invalid_csv.define_validator "date", method(:validate_iso_date)
|
|
||||||
|
|
||||||
assert_equal "invalid_date", invalid_csv.table[0][0]
|
|
||||||
assert_not invalid_csv.cell_valid?(0, 0)
|
|
||||||
assert_not invalid_csv.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "CSV with semicolon column separator" do
|
|
||||||
csv = Import::Csv.new(valid_csv_str_with_semicolon_separator, col_sep: ";")
|
|
||||||
|
|
||||||
assert_equal %w[date name category tags amount], csv.table.headers
|
|
||||||
assert_equal 4, csv.table.size
|
|
||||||
assert_equal "Paycheck", csv.table[3][1]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "csv with additional columns and empty values" do
|
|
||||||
csv = Import::Csv.new valid_csv_with_missing_data
|
|
||||||
assert csv.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "updating a cell returns a copy of the original csv" do
|
|
||||||
original_date = "2024-01-01"
|
|
||||||
new_date = "2024-01-01"
|
|
||||||
|
|
||||||
assert_equal original_date, @csv.table[0][0]
|
|
||||||
updated = @csv.update_cell(0, 0, new_date)
|
|
||||||
|
|
||||||
assert_equal original_date, @csv.table[0][0]
|
|
||||||
assert_equal new_date, updated[0][0]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can create CSV with expected columns and field mappings with validators" do
|
|
||||||
date_field = Import::Field.new \
|
|
||||||
key: "date",
|
|
||||||
label: "Date",
|
|
||||||
validator: method(:validate_iso_date)
|
|
||||||
|
|
||||||
name_field = Import::Field.new \
|
|
||||||
key: "name",
|
|
||||||
label: "Name"
|
|
||||||
|
|
||||||
fields = [ date_field, name_field ]
|
|
||||||
|
|
||||||
raw_file_str = <<-ROWS
|
|
||||||
date,Custom Field Header,extra_field
|
|
||||||
invalid_date_value,Starbucks drink,Food
|
|
||||||
2024-01-02,Amazon stuff,Shopping
|
|
||||||
ROWS
|
|
||||||
|
|
||||||
mappings = {
|
|
||||||
"name" => "Custom Field Header"
|
|
||||||
}
|
|
||||||
|
|
||||||
csv = Import::Csv.create_with_field_mappings(raw_file_str, fields, mappings)
|
|
||||||
|
|
||||||
assert_equal %w[date name], csv.table.headers
|
|
||||||
assert_equal 2, csv.table.size
|
|
||||||
assert_equal "Amazon stuff", csv.table[1][1]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can create CSV with expected columns, field mappings with validators and semicolon column separator" do
|
|
||||||
date_field = Import::Field.new \
|
|
||||||
key: "date",
|
|
||||||
label: "Date",
|
|
||||||
validator: method(:validate_iso_date)
|
|
||||||
|
|
||||||
name_field = Import::Field.new \
|
|
||||||
key: "name",
|
|
||||||
label: "Name"
|
|
||||||
|
|
||||||
fields = [ date_field, name_field ]
|
|
||||||
|
|
||||||
raw_file_str = <<-ROWS
|
|
||||||
date;Custom Field Header;extra_field
|
|
||||||
invalid_date_value;Starbucks drink;Food
|
|
||||||
2024-01-02;Amazon stuff;Shopping
|
|
||||||
ROWS
|
|
||||||
|
|
||||||
mappings = {
|
|
||||||
"name" => "Custom Field Header"
|
|
||||||
}
|
|
||||||
|
|
||||||
csv = Import::Csv.create_with_field_mappings(raw_file_str, fields, mappings, ";")
|
|
||||||
|
|
||||||
assert_equal %w[date name], csv.table.headers
|
|
||||||
assert_equal 2, csv.table.size
|
|
||||||
assert_equal "Amazon stuff", csv.table[1][1]
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def validate_iso_date(value)
|
|
||||||
Date.iso8601(value)
|
|
||||||
true
|
|
||||||
rescue
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,28 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class Import::FieldTest < ActiveSupport::TestCase
|
|
||||||
test "key is always a string" do
|
|
||||||
field1 = Import::Field.new label: "Test", key: "test"
|
|
||||||
field2 = Import::Field.new label: "Test2", key: :test2
|
|
||||||
|
|
||||||
assert_equal "test", field1.key
|
|
||||||
assert_equal "test2", field2.key
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can set and override a validator for a field" do
|
|
||||||
field = Import::Field.new \
|
|
||||||
label: "Test",
|
|
||||||
key: "Test",
|
|
||||||
validator: ->(val) { val == 42 }
|
|
||||||
|
|
||||||
assert field.validate(42)
|
|
||||||
assert_not field.validate(41)
|
|
||||||
|
|
||||||
field.define_validator do |value|
|
|
||||||
value == 100
|
|
||||||
end
|
|
||||||
|
|
||||||
assert field.validate(100)
|
|
||||||
assert_not field.validate(42)
|
|
||||||
end
|
|
||||||
end
|
|
7
test/models/import/mapping_test.rb
Normal file
7
test/models/import/mapping_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Import::MappingTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
7
test/models/import/row_test.rb
Normal file
7
test/models/import/row_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Import::RowTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
|
@ -1,115 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class ImportTest < ActiveSupport::TestCase
|
|
||||||
include ImportTestHelper, ActiveJob::TestHelper
|
|
||||||
|
|
||||||
setup do
|
|
||||||
@empty_import = imports(:empty_import)
|
|
||||||
|
|
||||||
@loaded_import = @empty_import.dup
|
|
||||||
@loaded_import.update! raw_file_str: valid_csv_str
|
|
||||||
end
|
|
||||||
|
|
||||||
test "validates the correct col_sep" do
|
|
||||||
assert_equal ",", @empty_import.col_sep
|
|
||||||
|
|
||||||
assert @empty_import.valid?
|
|
||||||
|
|
||||||
@empty_import.col_sep = "invalid"
|
|
||||||
assert @empty_import.invalid?
|
|
||||||
|
|
||||||
@empty_import.col_sep = ","
|
|
||||||
assert @empty_import.valid?
|
|
||||||
|
|
||||||
@empty_import.col_sep = ";"
|
|
||||||
assert @empty_import.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "raw csv input must conform to csv spec" do
|
|
||||||
@empty_import.raw_file_str = malformed_csv_str
|
|
||||||
assert_not @empty_import.valid?
|
|
||||||
|
|
||||||
@empty_import.raw_file_str = valid_csv_str
|
|
||||||
assert @empty_import.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can update csv value without affecting raw input" do
|
|
||||||
assert_equal "Starbucks drink", @loaded_import.csv.table[0][1]
|
|
||||||
|
|
||||||
prior_raw_file_str_value = @loaded_import.raw_file_str
|
|
||||||
prior_normalized_csv_str_value = @loaded_import.normalized_csv_str
|
|
||||||
|
|
||||||
@loaded_import.update_csv! \
|
|
||||||
row_idx: 0,
|
|
||||||
col_idx: 1,
|
|
||||||
value: "new_category"
|
|
||||||
|
|
||||||
assert_equal "new_category", @loaded_import.csv.table[0][1]
|
|
||||||
assert_equal prior_raw_file_str_value, @loaded_import.raw_file_str
|
|
||||||
assert_not_equal prior_normalized_csv_str_value, @loaded_import.normalized_csv_str
|
|
||||||
end
|
|
||||||
|
|
||||||
test "publishes later" do
|
|
||||||
assert_enqueued_with(job: ImportJob) do
|
|
||||||
@loaded_import.publish_later
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "publishes a valid import" do
|
|
||||||
# Import has 3 unique categories: "Food & Drink", "Income", and "Shopping" (x2)
|
|
||||||
# Fixtures already define "Food & Drink" and "Income", so these should not be created
|
|
||||||
# "Shopping" is a new category, but should only be created 1x during import
|
|
||||||
assert_difference \
|
|
||||||
-> { Account::Transaction.count } => 4,
|
|
||||||
-> { Account::Entry.count } => 4,
|
|
||||||
-> { Category.count } => 1,
|
|
||||||
-> { Tagging.count } => 4,
|
|
||||||
-> { Tag.count } => 2 do
|
|
||||||
@loaded_import.publish
|
|
||||||
end
|
|
||||||
|
|
||||||
@loaded_import.reload
|
|
||||||
|
|
||||||
assert @loaded_import.complete?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "publishes a valid import with missing data" do
|
|
||||||
@empty_import.update! raw_file_str: valid_csv_with_missing_data
|
|
||||||
assert_difference -> { Category.count } => 1,
|
|
||||||
-> { Account::Transaction.count } => 2,
|
|
||||||
-> { Account::Entry.count } => 2 do
|
|
||||||
@empty_import.publish
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_not_nil Account::Entry.find_sole_by(name: Import::FALLBACK_TRANSACTION_NAME)
|
|
||||||
|
|
||||||
@empty_import.reload
|
|
||||||
|
|
||||||
assert @empty_import.complete?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "failed publish results in error status" do
|
|
||||||
@empty_import.update! raw_file_str: valid_csv_with_invalid_values
|
|
||||||
|
|
||||||
assert_difference "Account::Transaction.count", 0 do
|
|
||||||
@empty_import.publish
|
|
||||||
end
|
|
||||||
|
|
||||||
@empty_import.reload
|
|
||||||
assert @empty_import.failed?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can create transactions from csv with custom column separator" do
|
|
||||||
loaded_import = @empty_import.dup
|
|
||||||
|
|
||||||
loaded_import.update! raw_file_str: valid_csv_str_with_semicolon_separator, col_sep: ";"
|
|
||||||
transactions = loaded_import.dry_run
|
|
||||||
|
|
||||||
assert_equal 4, transactions.count
|
|
||||||
|
|
||||||
data = transactions.first.as_json(only: [ :name, :amount, :date ])
|
|
||||||
assert_equal data, { "amount" => "8.55", "date" => "2024-01-01", "name" => "Starbucks drink" }
|
|
||||||
|
|
||||||
assert_equal valid_csv_str, loaded_import.normalized_csv_str
|
|
||||||
end
|
|
||||||
end
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue