mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Feature: Add "amount type" configuration column for CSV imports (#1947)
* Rough draft * Schema conflict update * Implement signage * Update system tests * Lint fixes
This commit is contained in:
parent
8cf077f28d
commit
c88fe2e3b2
21 changed files with 366 additions and 83 deletions
|
@ -36,7 +36,9 @@ class Import::ConfigurationsController < ApplicationController
|
|||
:currency_col_label,
|
||||
:date_format,
|
||||
:number_format,
|
||||
:signage_convention
|
||||
:signage_convention,
|
||||
:amount_type_strategy,
|
||||
:amount_type_inflow_value,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
118
app/javascript/controllers/import_controller.js
Normal file
118
app/javascript/controllers/import_controller.js
Normal file
|
@ -0,0 +1,118 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="import"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
csv: { type: Array, default: [] },
|
||||
amountTypeColumnKey: { type: String, default: "" },
|
||||
};
|
||||
|
||||
static targets = [
|
||||
"signedAmountFieldset",
|
||||
"customColumnFieldset",
|
||||
"amountTypeValue",
|
||||
"amountTypeStrategySelect",
|
||||
];
|
||||
|
||||
connect() {
|
||||
if (
|
||||
this.amountTypeStrategySelectTarget.value === "custom_column" &&
|
||||
this.amountTypeColumnKeyValue
|
||||
) {
|
||||
this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue);
|
||||
}
|
||||
}
|
||||
|
||||
handleAmountTypeStrategyChange(event) {
|
||||
const amountTypeStrategy = event.target.value;
|
||||
|
||||
if (amountTypeStrategy === "custom_column") {
|
||||
this.#enableCustomColumnFieldset();
|
||||
|
||||
if (this.amountTypeColumnKeyValue) {
|
||||
this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (amountTypeStrategy === "signed_amount") {
|
||||
this.#enableSignedAmountFieldset();
|
||||
}
|
||||
}
|
||||
|
||||
handleAmountTypeChange(event) {
|
||||
const amountTypeColumnKey = event.target.value;
|
||||
|
||||
this.#showAmountTypeValueTargets(amountTypeColumnKey);
|
||||
}
|
||||
|
||||
#showAmountTypeValueTargets(amountTypeColumnKey) {
|
||||
const selectableValues = this.#uniqueValuesForColumn(amountTypeColumnKey);
|
||||
|
||||
this.amountTypeValueTarget.classList.remove("hidden");
|
||||
this.amountTypeValueTarget.classList.add("flex");
|
||||
|
||||
const select = this.amountTypeValueTarget.querySelector("select");
|
||||
const currentValue = select.value;
|
||||
select.options.length = 0;
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Only add the prompt if there's no current value
|
||||
if (!currentValue) {
|
||||
fragment.appendChild(new Option("Select value", ""));
|
||||
}
|
||||
|
||||
selectableValues.forEach((value) => {
|
||||
const option = new Option(value, value);
|
||||
if (value === currentValue) {
|
||||
option.selected = true;
|
||||
}
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
|
||||
select.appendChild(fragment);
|
||||
}
|
||||
|
||||
#uniqueValuesForColumn(column) {
|
||||
const colIdx = this.csvValue[0].indexOf(column);
|
||||
const values = this.csvValue.slice(1).map((row) => row[colIdx]);
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
#enableCustomColumnFieldset() {
|
||||
this.customColumnFieldsetTarget.classList.remove("hidden");
|
||||
this.signedAmountFieldsetTarget.classList.add("hidden");
|
||||
|
||||
// Set required on custom column fields
|
||||
this.customColumnFieldsetTarget
|
||||
.querySelectorAll("select, input")
|
||||
.forEach((field) => {
|
||||
field.setAttribute("required", "");
|
||||
});
|
||||
|
||||
// Remove required from signed amount fields
|
||||
this.signedAmountFieldsetTarget
|
||||
.querySelectorAll("select, input")
|
||||
.forEach((field) => {
|
||||
field.removeAttribute("required");
|
||||
});
|
||||
}
|
||||
|
||||
#enableSignedAmountFieldset() {
|
||||
this.customColumnFieldsetTarget.classList.add("hidden");
|
||||
this.signedAmountFieldsetTarget.classList.remove("hidden");
|
||||
|
||||
// Remove required from custom column fields
|
||||
this.customColumnFieldsetTarget
|
||||
.querySelectorAll("select, input")
|
||||
.forEach((field) => {
|
||||
field.removeAttribute("required");
|
||||
});
|
||||
|
||||
// Set required on signed amount fields
|
||||
this.signedAmountFieldsetTarget
|
||||
.querySelectorAll("select, input")
|
||||
.forEach((field) => {
|
||||
field.setAttribute("required", "");
|
||||
});
|
||||
}
|
||||
}
|
|
@ -10,6 +10,8 @@ class Import < ApplicationRecord
|
|||
"1,234" => { separator: "", delimiter: "," } # Zero-decimal currencies like JPY
|
||||
}.freeze
|
||||
|
||||
AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :account, optional: true
|
||||
|
||||
|
@ -27,8 +29,9 @@ class Import < ApplicationRecord
|
|||
}, validate: true, default: "pending"
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
validates :amount_type_strategy, inclusion: { in: AMOUNT_TYPE_STRATEGIES }
|
||||
validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) }
|
||||
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }
|
||||
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }, allow_nil: true
|
||||
validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys }
|
||||
|
||||
has_many :rows, dependent: :destroy
|
||||
|
|
|
@ -44,7 +44,19 @@ class Import::Row < ApplicationRecord
|
|||
|
||||
# In the Maybe system, positive amounts == "outflows", so we must reverse signage
|
||||
def apply_transaction_signage_convention(value)
|
||||
value * (import.signage_convention == "inflows_positive" ? -1 : 1)
|
||||
if import.amount_type_strategy == "signed_amount"
|
||||
value * (import.signage_convention == "inflows_positive" ? -1 : 1)
|
||||
elsif import.amount_type_strategy == "custom_column"
|
||||
inflow_value = import.amount_type_inflow_value
|
||||
|
||||
if entity_type == inflow_value
|
||||
value * -1
|
||||
else
|
||||
value
|
||||
end
|
||||
else
|
||||
raise "Unknown amount type strategy for import: #{import.amount_type_strategy}"
|
||||
end
|
||||
end
|
||||
|
||||
def required_columns
|
||||
|
|
|
@ -31,6 +31,7 @@ class TradeImport < Import
|
|||
),
|
||||
)
|
||||
end
|
||||
|
||||
Trade.import!(trades, recursive: true)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -48,6 +48,12 @@ class TransactionImport < Import
|
|||
base
|
||||
end
|
||||
|
||||
def selectable_amount_type_values
|
||||
return [] if entity_type_col_label.nil?
|
||||
|
||||
csv_rows.map { |row| row[entity_type_col_label] }.uniq
|
||||
end
|
||||
|
||||
def csv_template
|
||||
template = <<-CSV
|
||||
date*,amount*,name,currency,category,tags,account,notes
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<%# locals: (import:) %>
|
||||
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" do |form| %>
|
||||
<%= form.select :entity_type_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Entity Type" } %>
|
||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||
<%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" } %>
|
||||
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %>
|
||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name" }, required: true %>
|
||||
<%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" }, required: true %>
|
||||
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %>
|
||||
|
||||
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||
<% end %>
|
||||
|
|
|
@ -5,18 +5,18 @@
|
|||
<p class="text-sm text-primary 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">
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" do |form| %>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true, disabled: import.complete? %>
|
||||
<%= form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format_label")}, label: true, required: true, disabled: import.complete? %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<%= 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>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" }, disabled: import.complete? %>
|
||||
<%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
|
||||
</div>
|
||||
|
|
|
@ -1,31 +1,31 @@
|
|||
<%# locals: (import:) %>
|
||||
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" do |form| %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Select column", label: "Date" }, required: true %>
|
||||
<%= form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format_label")}, label: true, required: true %>
|
||||
</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 class="flex items-center gap-4">
|
||||
<%= form.select :qty_col_label, import.csv_headers, { include_blank: "Select column", label: "Quantity" }, required: true %>
|
||||
<%= form.select :signage_convention, [["Buys are positive qty", "inflows_positive"], ["Buys are negative qty", "inflows_negative"]], label: true, required: true %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %>
|
||||
<%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
|
||||
</div>
|
||||
|
||||
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %>
|
||||
<%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Exchange Operating MIC" } %>
|
||||
<%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %>
|
||||
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Select column", label: "Ticker" }, required: true %>
|
||||
<%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Stock exchange code" } %>
|
||||
<%= form.select :price_col_label, import.csv_headers, { include_blank: "Select column", label: "Price" }, required: true %>
|
||||
|
||||
<% unless import.account.present? %>
|
||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account" } %>
|
||||
<% end %>
|
||||
|
||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name" } %>
|
||||
|
||||
<% unless Security.provider %>
|
||||
<div class="alert alert-warning">
|
||||
|
|
|
@ -1,29 +1,113 @@
|
|||
<%# locals: (import:) %>
|
||||
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||
<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, Family::DATE_FORMATS, { label: t(".date_format_label")}, label: true, required: true %>
|
||||
<%= styled_form_with model: @import,
|
||||
url: import_configuration_path(@import),
|
||||
scope: :import,
|
||||
method: :patch,
|
||||
class: "space-y-4",
|
||||
data: {
|
||||
controller: "import",
|
||||
import_csv_value: import.csv_rows.to_json,
|
||||
import_amount_type_column_key_value: @import.entity_type_col_label
|
||||
} do |form| %>
|
||||
|
||||
<%# Date Configuration %>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= form.select :date_col_label,
|
||||
import.csv_headers,
|
||||
{ label: "Date", prompt: "Select column" },
|
||||
required: true %>
|
||||
<%= form.select :date_format,
|
||||
Family::DATE_FORMATS,
|
||||
{ label: t(".date_format_label"), prompt: "Select format" },
|
||||
required: true %>
|
||||
</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 %>
|
||||
<%# Amount Configuration %>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= form.select :amount_col_label,
|
||||
import.csv_headers,
|
||||
{ label: "Amount", container_class: "w-2/5", prompt: "Select column" },
|
||||
required: true %>
|
||||
<%= form.select :currency_col_label,
|
||||
import.csv_headers,
|
||||
{ include_blank: "Default", label: "Currency", container_class: "w-1/5" } %>
|
||||
<%= form.select :number_format,
|
||||
Import::NUMBER_FORMATS.keys,
|
||||
{ label: "Format", prompt: "Select format", container_class: "w-2/5" },
|
||||
required: true %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %>
|
||||
<%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
|
||||
</div>
|
||||
<%# Amount Type Strategy %>
|
||||
<%= form.select :amount_type_strategy,
|
||||
Import::AMOUNT_TYPE_STRATEGIES.map { |strategy| [strategy.humanize, strategy] },
|
||||
{ label: "Amount type strategy", prompt: "Select strategy" },
|
||||
required: true,
|
||||
data: {
|
||||
action: "import#handleAmountTypeStrategyChange",
|
||||
import_target: "amountTypeStrategySelect"
|
||||
} %>
|
||||
|
||||
<% unless import.account.present? %>
|
||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
||||
<%# Signed Amount Configuration %>
|
||||
<%= tag.fieldset data: { import_target: "signedAmountFieldset" },
|
||||
class: @import.amount_type_strategy == "signed_amount" ? "block" : "hidden" do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm shrink-0 text-secondary">↪</span>
|
||||
<%= form.select :signage_convention,
|
||||
[["Incomes are positive", "inflows_positive"], ["Incomes are negative", "inflows_negative"]],
|
||||
{ label: "Amount type", prompt: "Select convention" },
|
||||
required: @import.amount_type_strategy == "signed_amount" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" } %>
|
||||
<%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" } %>
|
||||
<%= form.select :notes_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Notes (optional)" } %>
|
||||
<%# Custom Column Configuration %>
|
||||
<%= tag.fieldset data: { import_target: "customColumnFieldset" },
|
||||
class: @import.amount_type_strategy == "custom_column" ? "block" : "hidden" do %>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="shrink-0 text-secondary">↪</span>
|
||||
<span class="text-secondary">Set</span>
|
||||
<%= form.select :entity_type_col_label,
|
||||
import.csv_headers,
|
||||
{ prompt: "Select column", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
|
||||
required: @import.amount_type_strategy == "custom_column",
|
||||
data: { action: "import#handleAmountTypeChange" } %>
|
||||
<span class="text-secondary">as amount type column</span>
|
||||
</div>
|
||||
|
||||
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||
<div class="items-center gap-2 text-sm <%= @import.entity_type_col_label.nil? ? "hidden" : "flex" %>" data-import-target="amountTypeValue">
|
||||
<span class="shrink-0 text-secondary">↪</span>
|
||||
<span class="text-secondary">Set</span>
|
||||
<%= form.select :amount_type_inflow_value,
|
||||
@import.selectable_amount_type_values,
|
||||
{ prompt: "Select column", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
|
||||
required: @import.amount_type_strategy == "custom_column" %>
|
||||
<span class="text-secondary">as "income" (inflow) value</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Optional Fields %>
|
||||
<% unless import.account.present? %>
|
||||
<%= form.select :account_col_label,
|
||||
import.csv_headers,
|
||||
{ include_blank: "Leave empty", label: "Account" } %>
|
||||
<% end %>
|
||||
|
||||
<%= form.select :name_col_label,
|
||||
import.csv_headers,
|
||||
{ include_blank: "Leave empty", label: "Name" } %>
|
||||
<%= form.select :category_col_label,
|
||||
import.csv_headers,
|
||||
{ include_blank: "Leave empty", label: "Category" } %>
|
||||
<%= form.select :tags_col_label,
|
||||
import.csv_headers,
|
||||
{ include_blank: "Leave empty", label: "Tags" } %>
|
||||
<%= form.select :notes_col_label,
|
||||
import.csv_headers,
|
||||
{ include_blank: "Leave empty", label: "Notes" } %>
|
||||
|
||||
<%= form.submit "Apply configuration",
|
||||
class: "w-full btn btn--primary",
|
||||
disabled: import.complete? %>
|
||||
<% end %>
|
||||
|
|
|
@ -24,18 +24,15 @@
|
|||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="text-3xl text-primary font-medium"><%= t(".title") %></h1>
|
||||
<p class="text-secondary text-sm"><%= t(".description") %></p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg space-y-3">
|
||||
<div class="mx-auto max-w-lg space-y-4">
|
||||
<%= render partial: permitted_import_configuration_path(@import), locals: { import: @import } %>
|
||||
<%= render "imports/table", headers: @import.csv_headers, rows: @import.csv_sample, caption: "Sample data from your uploaded CSV" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-5xl my-12">
|
||||
<%= render "imports/table", headers: @import.csv_headers, rows: @import.csv_sample, caption: "Sample data from your uploaded CSV" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html class="h-full text-primary overflow-hidden lg:overflow-auto font-sans <%= @os %>" lang="en" data-controller="theme" data-theme-user-preference-value="<%= Current.user&.theme || 'system' %>">
|
||||
<html class="h-full text-primary overflow-hidden lg:overflow-auto font-sans <%= @os %>" lang="en" data-controller="theme" data-theme-user-preference-value="<%= Current.user&.theme || "system" %>">
|
||||
<head>
|
||||
<%= render "layouts/shared/head" %>
|
||||
<%= yield :head %>
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
default_currency: Current.family.currency,
|
||||
required: true %>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= loan_form.number_field :interest_rate,
|
||||
label: t("loans.form.interest_rate"),
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
multiple: true,
|
||||
label: t(".tags_label"),
|
||||
container_class: "h-40"
|
||||
}%>
|
||||
} %>
|
||||
<% end %>
|
||||
<%= f.text_area :notes,
|
||||
label: t(".note_label"),
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<div class="flex items-center gap-1 text-secondary">
|
||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
|
||||
<%= link_to new_transactions_bulk_update_path,
|
||||
<%= link_to new_transactions_bulk_update_path,
|
||||
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
|
||||
title: "Edit",
|
||||
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
|
||||
|
|
20
db/migrate/20250304200956_add_signage_type_to_imports.rb
Normal file
20
db/migrate/20250304200956_add_signage_type_to_imports.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
class AddSignageTypeToImports < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
change_column_default :imports, :date_col_label, from: "date", to: nil
|
||||
change_column_default :imports, :amount_col_label, from: "amount", to: nil
|
||||
change_column_default :imports, :name_col_label, from: "name", to: nil
|
||||
change_column_default :imports, :category_col_label, from: "category", to: nil
|
||||
change_column_default :imports, :tags_col_label, from: "tags", to: nil
|
||||
change_column_default :imports, :account_col_label, from: "account", to: nil
|
||||
change_column_default :imports, :qty_col_label, from: "qty", to: nil
|
||||
change_column_default :imports, :ticker_col_label, from: "ticker", to: nil
|
||||
change_column_default :imports, :price_col_label, from: "price", to: nil
|
||||
change_column_default :imports, :entity_type_col_label, from: "entity_type", to: nil
|
||||
change_column_default :imports, :notes_col_label, from: "notes", to: nil
|
||||
change_column_default :imports, :currency_col_label, from: "currency", to: nil
|
||||
|
||||
# User can select "custom", then assign "debit" or "credit" (or custom value) to determine inflow/outflow of txn
|
||||
add_column :imports, :amount_type_strategy, :string, default: "signed_amount"
|
||||
add_column :imports, :amount_type_inflow_value, :string
|
||||
end
|
||||
end
|
26
db/schema.rb
generated
26
db/schema.rb
generated
|
@ -299,23 +299,25 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_13_141446) do
|
|||
t.uuid "family_id", null: false
|
||||
t.uuid "account_id"
|
||||
t.string "type", null: false
|
||||
t.string "date_col_label", default: "date"
|
||||
t.string "amount_col_label", default: "amount"
|
||||
t.string "name_col_label", default: "name"
|
||||
t.string "category_col_label", default: "category"
|
||||
t.string "tags_col_label", default: "tags"
|
||||
t.string "account_col_label", default: "account"
|
||||
t.string "qty_col_label", default: "qty"
|
||||
t.string "ticker_col_label", default: "ticker"
|
||||
t.string "price_col_label", default: "price"
|
||||
t.string "entity_type_col_label", default: "type"
|
||||
t.string "notes_col_label", default: "notes"
|
||||
t.string "currency_col_label", default: "currency"
|
||||
t.string "date_col_label"
|
||||
t.string "amount_col_label"
|
||||
t.string "name_col_label"
|
||||
t.string "category_col_label"
|
||||
t.string "tags_col_label"
|
||||
t.string "account_col_label"
|
||||
t.string "qty_col_label"
|
||||
t.string "ticker_col_label"
|
||||
t.string "price_col_label"
|
||||
t.string "entity_type_col_label"
|
||||
t.string "notes_col_label"
|
||||
t.string "currency_col_label"
|
||||
t.string "date_format", default: "%m/%d/%Y"
|
||||
t.string "signage_convention", default: "inflows_positive"
|
||||
t.string "error"
|
||||
t.string "number_format"
|
||||
t.string "exchange_operating_mic_col_label"
|
||||
t.string "amount_type_strategy", default: "signed_amount"
|
||||
t.string "amount_type_inflow_value"
|
||||
t.index ["family_id"], name: "index_imports_on_family_id"
|
||||
end
|
||||
|
||||
|
|
|
@ -277,6 +277,7 @@ module ImportInterfaceTest
|
|||
csv_data = "date,amount,name\n01/01/2024,1234.56,Test"
|
||||
import.update!(raw_file_str: csv_data)
|
||||
import.update!(
|
||||
date_col_label: "date",
|
||||
date_format: "%Y-%m-%d" # Does not match the raw CSV date, so rows will be invalid, but still generated
|
||||
)
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@ class TradeImportTest < ActiveSupport::TestCase
|
|||
CSV
|
||||
|
||||
@import.update!(
|
||||
account: accounts(:depository),
|
||||
raw_file_str: import,
|
||||
date_col_label: "date",
|
||||
ticker_col_label: "ticker",
|
||||
|
@ -52,16 +53,11 @@ class TradeImportTest < ActiveSupport::TestCase
|
|||
|
||||
@import.reload
|
||||
|
||||
assert_difference [
|
||||
-> { Entry.count },
|
||||
-> { Trade.count }
|
||||
], 2 do
|
||||
assert_difference [
|
||||
-> { Security.count },
|
||||
-> { Account.count }
|
||||
], 1 do
|
||||
@import.publish
|
||||
end
|
||||
assert_difference -> { Entry.count } => 2,
|
||||
-> { Trade.count } => 2,
|
||||
-> { Security.count } => 1,
|
||||
-> { Account.count } => 1 do
|
||||
@import.publish
|
||||
end
|
||||
|
||||
assert_equal "complete", @import.status
|
||||
|
|
|
@ -68,4 +68,36 @@ class TransactionImportTest < ActiveSupport::TestCase
|
|||
|
||||
assert_equal "complete", @import.status
|
||||
end
|
||||
|
||||
test "imports transactions with separate type column for signage convention" do
|
||||
import = <<~CSV
|
||||
date,amount,amount_type
|
||||
01/01/2024,100,debit
|
||||
01/02/2024,200,credit
|
||||
01/03/2024,300,debit
|
||||
CSV
|
||||
|
||||
@import.update!(
|
||||
account: accounts(:depository),
|
||||
raw_file_str: import,
|
||||
date_col_label: "date",
|
||||
date_format: "%m/%d/%Y",
|
||||
amount_col_label: "amount",
|
||||
entity_type_col_label: "amount_type",
|
||||
amount_type_inflow_value: "debit",
|
||||
amount_type_strategy: "custom_column",
|
||||
signage_convention: nil # Explicitly set to nil to prove this is not needed
|
||||
)
|
||||
|
||||
@import.generate_rows_from_csv
|
||||
|
||||
@import.reload
|
||||
|
||||
assert_difference -> { Entry.count } => 3,
|
||||
-> { Transaction.count } => 3 do
|
||||
@import.publish
|
||||
end
|
||||
|
||||
assert_equal [ -100, 200, -300 ], @import.entries.map(&:amount)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -21,14 +21,14 @@ class ImportsTest < ApplicationSystemTestCase
|
|||
|
||||
find('input[type="submit"][value="Upload CSV"]').click
|
||||
|
||||
select "Date", from: "Date*"
|
||||
select "YYYY-MM-DD", from: "Date format"
|
||||
select "Amount", from: "Amount"
|
||||
select "Account", from: "Account (optional)"
|
||||
select "Name", from: "Name (optional)"
|
||||
select "Category", from: "Category (optional)"
|
||||
select "Tags", from: "Tags (optional)"
|
||||
select "Notes", from: "Notes (optional)"
|
||||
select "Date", from: "import[date_col_label]"
|
||||
select "YYYY-MM-DD", from: "import[date_format]"
|
||||
select "Amount", from: "import[amount_col_label]"
|
||||
select "Account", from: "import[account_col_label]"
|
||||
select "Name", from: "import[name_col_label]"
|
||||
select "Category", from: "import[category_col_label]"
|
||||
select "Tags", from: "import[tags_col_label]"
|
||||
select "Notes", from: "import[notes_col_label]"
|
||||
|
||||
click_on "Apply configuration"
|
||||
|
||||
|
@ -67,7 +67,12 @@ class ImportsTest < ApplicationSystemTestCase
|
|||
|
||||
find('input[type="submit"][value="Upload CSV"]').click
|
||||
|
||||
select "YYYY-MM-DD", from: "Date format"
|
||||
select "date", from: "import[date_col_label]"
|
||||
select "YYYY-MM-DD", from: "import[date_format]"
|
||||
select "qty", from: "import[qty_col_label]"
|
||||
select "ticker", from: "import[ticker_col_label]"
|
||||
select "price", from: "import[price_col_label]"
|
||||
select "account", from: "import[account_col_label]"
|
||||
|
||||
click_on "Apply configuration"
|
||||
|
||||
|
@ -100,6 +105,10 @@ class ImportsTest < ApplicationSystemTestCase
|
|||
|
||||
find('input[type="submit"][value="Upload CSV"]').click
|
||||
|
||||
select "type", from: "import[entity_type_col_label]"
|
||||
select "name", from: "import[name_col_label]"
|
||||
select "amount", from: "import[amount_col_label]"
|
||||
|
||||
click_on "Apply configuration"
|
||||
|
||||
click_on "Next step"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue