mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-23 15:19:38 +02:00
Improve account transaction, trade, and valuation editing and sync experience (#1506)
* Consolidate entry controller logic * Transaction builder * Update trades controller to use new params * Load account charts in turbo frames, fix PG overflow * Consolidate tests * Tests passing * Remove unused code * Add client side trade form validations
This commit is contained in:
parent
76f2714006
commit
c3248cd796
97 changed files with 1103 additions and 1159 deletions
|
@ -1,14 +1,7 @@
|
||||||
class Account::CashesController < ApplicationController
|
class Account::CashesController < ApplicationController
|
||||||
layout :with_sidebar
|
layout :with_sidebar
|
||||||
|
|
||||||
before_action :set_account
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@account = Current.family.accounts.find(params[:account_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_account
|
|
||||||
@account = Current.family.accounts.find(params[:account_id])
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,56 +2,21 @@ class Account::EntriesController < ApplicationController
|
||||||
layout :with_sidebar
|
layout :with_sidebar
|
||||||
|
|
||||||
before_action :set_account
|
before_action :set_account
|
||||||
before_action :set_entry, only: %i[edit update show destroy]
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@q = search_params
|
@q = search_params
|
||||||
@pagy, @entries = pagy(@account.entries.search(@q).reverse_chronological, limit: params[:per_page] || "10")
|
@pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10")
|
||||||
end
|
|
||||||
|
|
||||||
def edit
|
|
||||||
render entryable_view_path(:edit)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
prev_amount = @entry.amount
|
|
||||||
prev_date = @entry.date
|
|
||||||
|
|
||||||
@entry.update!(entry_params)
|
|
||||||
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
|
|
||||||
|
|
||||||
respond_to do |format|
|
|
||||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
|
||||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def show
|
|
||||||
render entryable_view_path(:show)
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy
|
|
||||||
@entry.destroy!
|
|
||||||
@entry.sync_account_later
|
|
||||||
redirect_to account_url(@entry.account), notice: t(".success")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def entryable_view_path(action)
|
|
||||||
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_account
|
def set_account
|
||||||
@account = Current.family.accounts.find(params[:account_id])
|
@account = Current.family.accounts.find(params[:account_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_entry
|
def entries_scope
|
||||||
@entry = @account.entries.find(params[:id])
|
scope = Current.family.entries
|
||||||
end
|
scope = scope.where(account: @account) if @account
|
||||||
|
scope
|
||||||
def entry_params
|
|
||||||
params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def search_params
|
def search_params
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
class Account::HoldingsController < ApplicationController
|
class Account::HoldingsController < ApplicationController
|
||||||
layout :with_sidebar
|
layout :with_sidebar
|
||||||
|
|
||||||
before_action :set_account
|
|
||||||
before_action :set_holding, only: %i[show destroy]
|
before_action :set_holding, only: %i[show destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@holdings = @account.holdings.current
|
@account = Current.family.accounts.find(params[:account_id])
|
||||||
|
@holdings = Current.family.holdings.current
|
||||||
|
@holdings = @holdings.where(account: @account) if @account
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
@ -13,16 +14,17 @@ class Account::HoldingsController < ApplicationController
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@holding.destroy_holding_and_entries!
|
@holding.destroy_holding_and_entries!
|
||||||
redirect_back_or_to account_holdings_path(@account)
|
|
||||||
|
flash[:notice] = t(".success")
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_back_or_to account_path(@holding.account) }
|
||||||
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account)) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_account
|
|
||||||
@account = Current.family.accounts.find(params[:account_id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_holding
|
def set_holding
|
||||||
@holding = @account.holdings.current.find(params[:id])
|
@holding = Current.family.holdings.current.find(params[:id])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,69 +1,37 @@
|
||||||
class Account::TradesController < ApplicationController
|
class Account::TradesController < ApplicationController
|
||||||
layout :with_sidebar
|
include EntryableResource
|
||||||
|
|
||||||
before_action :set_account
|
permitted_entryable_attributes :id, :qty, :price
|
||||||
before_action :set_entry, only: :update
|
|
||||||
|
|
||||||
def new
|
|
||||||
@entry = @account.entries.account_trades.new(
|
|
||||||
currency: @account.currency,
|
|
||||||
entryable_attributes: {}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def index
|
|
||||||
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[Account::Trade Account::Transaction])
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
@builder = Account::EntryBuilder.new(entry_params)
|
|
||||||
|
|
||||||
if entry = @builder.save
|
|
||||||
entry.sync_account_later
|
|
||||||
redirect_to @account, notice: t(".success")
|
|
||||||
else
|
|
||||||
flash[:alert] = t(".failure")
|
|
||||||
redirect_back_or_to @account
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
@entry.update!(entry_params)
|
|
||||||
|
|
||||||
respond_to do |format|
|
|
||||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
|
||||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def securities
|
|
||||||
query = params[:q]
|
|
||||||
return render json: [] if query.blank? || query.length < 2 || query.length > 100
|
|
||||||
|
|
||||||
@securities = Security::SynthComboboxOption.find_in_synth(query)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
def build_entry
|
||||||
def set_account
|
Account::TradeBuilder.new(create_entry_params)
|
||||||
@account = Current.family.accounts.find(params[:account_id])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_entry
|
def create_entry_params
|
||||||
@entry = @account.entries.find(params[:id])
|
params.require(:account_entry).permit(
|
||||||
|
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
|
||||||
|
).tap do |params|
|
||||||
|
account_id = params.delete(:account_id)
|
||||||
|
params[:account] = Current.family.accounts.find(account_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def entry_params
|
def update_entry_params
|
||||||
params.require(:account_entry)
|
return entry_params unless entry_params[:entryable_attributes].present?
|
||||||
.permit(
|
|
||||||
:type, :date, :qty, :ticker, :price, :amount, :notes, :excluded, :currency, :transfer_account_id, :entryable_type,
|
update_params = entry_params
|
||||||
entryable_attributes: [
|
update_params = update_params.merge(entryable_type: "Account::Trade")
|
||||||
:id,
|
|
||||||
:qty,
|
qty = update_params[:entryable_attributes][:qty]
|
||||||
:ticker,
|
price = update_params[:entryable_attributes][:price]
|
||||||
:price
|
|
||||||
]
|
if qty.present? && price.present?
|
||||||
)
|
qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d
|
||||||
.merge(account: @account)
|
update_params[:entryable_attributes][:qty] = qty
|
||||||
|
update_params[:amount] = qty * price.to_d
|
||||||
|
end
|
||||||
|
|
||||||
|
update_params.except(:nature)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
22
app/controllers/account/transaction_categories_controller.rb
Normal file
22
app/controllers/account/transaction_categories_controller.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
class Account::TransactionCategoriesController < ApplicationController
|
||||||
|
def update
|
||||||
|
@entry = Current.family.entries.account_transactions.find(params[:transaction_id])
|
||||||
|
@entry.update!(entry_params)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_back_or_to account_transaction_path(@entry) }
|
||||||
|
format.turbo_stream do
|
||||||
|
render turbo_stream: turbo_stream.replace(
|
||||||
|
"category_menu_account_transaction_#{@entry.account_transaction_id}",
|
||||||
|
partial: "categories/menu",
|
||||||
|
locals: { transaction: @entry.account_transaction }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def entry_params
|
||||||
|
params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,74 +1,55 @@
|
||||||
class Account::TransactionsController < ApplicationController
|
class Account::TransactionsController < ApplicationController
|
||||||
layout :with_sidebar
|
include EntryableResource
|
||||||
|
|
||||||
before_action :set_account
|
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
|
||||||
before_action :set_entry, only: :update
|
|
||||||
|
|
||||||
def index
|
def bulk_delete
|
||||||
@pagy, @entries = pagy(
|
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||||
@account.entries.account_transactions.reverse_chronological,
|
destroyed.map(&:account).uniq.each(&:sync_later)
|
||||||
limit: params[:per_page] || "10"
|
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def bulk_edit
|
||||||
prev_amount = @entry.amount
|
end
|
||||||
prev_date = @entry.date
|
|
||||||
|
|
||||||
@entry.update!(entry_params.except(:origin))
|
def bulk_update
|
||||||
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
|
updated = Current.family
|
||||||
|
.entries
|
||||||
|
.where(id: bulk_update_params[:entry_ids])
|
||||||
|
.bulk_update!(bulk_update_params)
|
||||||
|
|
||||||
respond_to do |format|
|
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
end
|
||||||
format.turbo_stream do
|
|
||||||
render turbo_stream: turbo_stream.replace(
|
def mark_transfers
|
||||||
@entry,
|
Current.family
|
||||||
partial: "account/entries/entry",
|
.entries
|
||||||
locals: entry_locals.merge(entry: @entry)
|
.where(id: bulk_update_params[:entry_ids])
|
||||||
)
|
.mark_transfers!
|
||||||
end
|
|
||||||
end
|
redirect_back_or_to transactions_url, notice: t(".success")
|
||||||
|
end
|
||||||
|
|
||||||
|
def unmark_transfers
|
||||||
|
Current.family
|
||||||
|
.entries
|
||||||
|
.where(id: bulk_update_params[:entry_ids])
|
||||||
|
.update_all marked_as_transfer: false
|
||||||
|
|
||||||
|
redirect_back_or_to transactions_url, notice: t(".success")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_account
|
def bulk_delete_params
|
||||||
@account = Current.family.accounts.find(params[:account_id])
|
params.require(:bulk_delete).permit(entry_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_entry
|
def bulk_update_params
|
||||||
@entry = @account.entries.find(params[:id])
|
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def entry_locals
|
def search_params
|
||||||
{
|
params.fetch(:q, {})
|
||||||
selectable: entry_params[:origin].present?,
|
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||||
show_balance: entry_params[:origin] == "account",
|
|
||||||
origin: entry_params[:origin]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def entry_params
|
|
||||||
params.require(:account_entry)
|
|
||||||
.permit(
|
|
||||||
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature, :origin,
|
|
||||||
entryable_attributes: [
|
|
||||||
:id,
|
|
||||||
:category_id,
|
|
||||||
:merchant_id,
|
|
||||||
{ tag_ids: [] }
|
|
||||||
]
|
|
||||||
).tap do |permitted_params|
|
|
||||||
nature = permitted_params.delete(:nature)
|
|
||||||
|
|
||||||
if permitted_params[:amount]
|
|
||||||
amount_value = permitted_params[:amount].to_d
|
|
||||||
|
|
||||||
if nature == "income"
|
|
||||||
amount_value *= -1
|
|
||||||
end
|
|
||||||
|
|
||||||
permitted_params[:amount] = amount_value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,38 +1,3 @@
|
||||||
class Account::ValuationsController < ApplicationController
|
class Account::ValuationsController < ApplicationController
|
||||||
layout :with_sidebar
|
include EntryableResource
|
||||||
|
|
||||||
before_action :set_account
|
|
||||||
|
|
||||||
def new
|
|
||||||
@entry = @account.entries.account_valuations.new(
|
|
||||||
currency: @account.currency,
|
|
||||||
entryable_attributes: {}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
@entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {}))
|
|
||||||
|
|
||||||
if @entry.save
|
|
||||||
@entry.sync_account_later
|
|
||||||
redirect_back_or_to account_valuations_path(@account), notice: t(".success")
|
|
||||||
else
|
|
||||||
flash[:alert] = @entry.errors.full_messages.to_sentence
|
|
||||||
redirect_to @account
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def index
|
|
||||||
@entries = @account.entries.account_valuations.reverse_chronological
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_account
|
|
||||||
@account = Current.family.accounts.find(params[:account_id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def entry_params
|
|
||||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -31,6 +31,11 @@ class AccountsController < ApplicationController
|
||||||
redirect_to account_path(@account)
|
redirect_to account_path(@account)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def chart
|
||||||
|
@account = Current.family.accounts.find(params[:id])
|
||||||
|
render layout: "application"
|
||||||
|
end
|
||||||
|
|
||||||
def sync_all
|
def sync_all
|
||||||
unless Current.family.syncing?
|
unless Current.family.syncing?
|
||||||
Current.family.sync_later
|
Current.family.sync_later
|
||||||
|
|
126
app/controllers/concerns/entryable_resource.rb
Normal file
126
app/controllers/concerns/entryable_resource.rb
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
module EntryableResource
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
layout :with_sidebar
|
||||||
|
before_action :set_entry, only: %i[show update destroy]
|
||||||
|
end
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
def permitted_entryable_attributes(*attrs)
|
||||||
|
@permitted_entryable_attributes = attrs if attrs.any?
|
||||||
|
@permitted_entryable_attributes ||= [ :id ]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
account = Current.family.accounts.find_by(id: params[:account_id])
|
||||||
|
|
||||||
|
@entry = Current.family.entries.new(
|
||||||
|
account: account,
|
||||||
|
currency: account ? account.currency : Current.family.currency,
|
||||||
|
entryable: entryable_type.new
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@entry = build_entry
|
||||||
|
|
||||||
|
if @entry.save
|
||||||
|
@entry.sync_account_later
|
||||||
|
|
||||||
|
flash[:notice] = t("account.entries.create.success")
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||||
|
|
||||||
|
redirect_target_url = request.referer || account_path(@entry.account)
|
||||||
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @entry.update(update_entry_params)
|
||||||
|
@entry.sync_account_later
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
|
||||||
|
format.turbo_stream do
|
||||||
|
render turbo_stream: turbo_stream.replace(
|
||||||
|
"header_account_entry_#{@entry.id}",
|
||||||
|
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
||||||
|
locals: { entry: @entry }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
account = @entry.account
|
||||||
|
@entry.destroy!
|
||||||
|
@entry.sync_account_later
|
||||||
|
|
||||||
|
flash[:notice] = t("account.entries.destroy.success")
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_back_or_to account_path(account) }
|
||||||
|
|
||||||
|
redirect_target_url = request.referer || account_path(@entry.account)
|
||||||
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def entryable_type
|
||||||
|
permitted_entryable_types = %w[Account::Transaction Account::Valuation Account::Trade]
|
||||||
|
klass = params[:entryable_type] || "Account::#{controller_name.classify}"
|
||||||
|
klass.constantize if permitted_entryable_types.include?(klass)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_entry
|
||||||
|
@entry = Current.family.entries.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_entry
|
||||||
|
Current.family.entries.new(create_entry_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_entry_params
|
||||||
|
prepared_entry_params
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_entry_params
|
||||||
|
prepared_entry_params.merge({
|
||||||
|
entryable_type: entryable_type.name,
|
||||||
|
entryable_attributes: entry_params[:entryable_attributes] || {}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepared_entry_params
|
||||||
|
default_params = entry_params.except(:nature)
|
||||||
|
default_params = default_params.merge(entryable_type: entryable_type.name) if entry_params[:entryable_attributes].present?
|
||||||
|
|
||||||
|
if entry_params[:nature].present? && entry_params[:amount].present?
|
||||||
|
signed_amount = entry_params[:nature] == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
|
||||||
|
default_params = default_params.merge(amount: signed_amount)
|
||||||
|
end
|
||||||
|
|
||||||
|
default_params
|
||||||
|
end
|
||||||
|
|
||||||
|
def entry_params
|
||||||
|
params.require(:account_entry).permit(
|
||||||
|
:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||||
|
entryable_attributes: self.class.permitted_entryable_attributes
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,5 +1,18 @@
|
||||||
class SecuritiesController < ApplicationController
|
class SecuritiesController < ApplicationController
|
||||||
def import
|
def index
|
||||||
SecuritiesImportJob.perform_later(params[:exchange_mic])
|
query = params[:q]
|
||||||
|
return render json: [] if query.blank? || query.length < 2 || query.length > 100
|
||||||
|
|
||||||
|
@securities = Security.search({
|
||||||
|
search: query,
|
||||||
|
country: country_code_filter
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def country_code_filter
|
||||||
|
filter = params[:country_code]
|
||||||
|
filter = "#{filter},US" unless filter == "US"
|
||||||
|
filter
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,94 +13,13 @@ class TransactionsController < ApplicationController
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
|
||||||
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
|
|
||||||
if params[:account_id]
|
|
||||||
e.account = Current.family.accounts.find(params[:account_id])
|
|
||||||
e.currency = e.account.currency
|
|
||||||
else
|
|
||||||
e.currency = Current.family.currency
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
@entry = Current.family
|
|
||||||
.accounts
|
|
||||||
.find(params[:account_entry][:account_id])
|
|
||||||
.entries
|
|
||||||
.create!(transaction_entry_params.merge(amount: amount))
|
|
||||||
|
|
||||||
@entry.sync_account_later
|
|
||||||
redirect_back_or_to @entry.account, notice: t(".success")
|
|
||||||
end
|
|
||||||
|
|
||||||
def bulk_delete
|
|
||||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
|
||||||
destroyed.map(&:account).uniq.each(&:sync_later)
|
|
||||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
|
||||||
end
|
|
||||||
|
|
||||||
def bulk_edit
|
|
||||||
end
|
|
||||||
|
|
||||||
def bulk_update
|
|
||||||
updated = Current.family
|
|
||||||
.entries
|
|
||||||
.where(id: bulk_update_params[:entry_ids])
|
|
||||||
.bulk_update!(bulk_update_params)
|
|
||||||
|
|
||||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
|
||||||
end
|
|
||||||
|
|
||||||
def mark_transfers
|
|
||||||
Current.family
|
|
||||||
.entries
|
|
||||||
.where(id: bulk_update_params[:entry_ids])
|
|
||||||
.mark_transfers!
|
|
||||||
|
|
||||||
redirect_back_or_to transactions_url, notice: t(".success")
|
|
||||||
end
|
|
||||||
|
|
||||||
def unmark_transfers
|
|
||||||
Current.family
|
|
||||||
.entries
|
|
||||||
.where(id: bulk_update_params[:entry_ids])
|
|
||||||
.update_all marked_as_transfer: false
|
|
||||||
|
|
||||||
redirect_back_or_to transactions_url, notice: t(".success")
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def amount
|
|
||||||
if nature.income?
|
|
||||||
transaction_entry_params[:amount].to_d * -1
|
|
||||||
else
|
|
||||||
transaction_entry_params[:amount].to_d
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def nature
|
|
||||||
params[:account_entry][:nature].to_s.inquiry
|
|
||||||
end
|
|
||||||
|
|
||||||
def bulk_delete_params
|
|
||||||
params.require(:bulk_delete).permit(entry_ids: [])
|
|
||||||
end
|
|
||||||
|
|
||||||
def bulk_update_params
|
|
||||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
|
||||||
end
|
|
||||||
|
|
||||||
def search_params
|
def search_params
|
||||||
params.fetch(:q, {})
|
params.fetch(:q, {})
|
||||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
.permit(
|
||||||
end
|
:start_date, :end_date, :search, :amount,
|
||||||
|
:amount_operator, accounts: [], account_ids: [],
|
||||||
def transaction_entry_params
|
categories: [], merchants: [], types: [], tags: []
|
||||||
params.require(:account_entry)
|
)
|
||||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
|
|
||||||
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -62,9 +62,9 @@ module ApplicationHelper
|
||||||
# <div>Content here</div>
|
# <div>Content here</div>
|
||||||
# <% end %>
|
# <% end %>
|
||||||
#
|
#
|
||||||
def drawer(&block)
|
def drawer(reload_on_close: false, &block)
|
||||||
content = capture &block
|
content = capture &block
|
||||||
render partial: "shared/drawer", locals: { content: content }
|
render partial: "shared/drawer", locals: { content:, reload_on_close: }
|
||||||
end
|
end
|
||||||
|
|
||||||
def disclosure(title, &block)
|
def disclosure(title, &block)
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||||
import "@hotwired/turbo-rails";
|
import "@hotwired/turbo-rails";
|
||||||
import "controllers";
|
import "controllers";
|
||||||
|
|
||||||
|
Turbo.StreamActions.redirect = function () {
|
||||||
|
Turbo.visit(this.target);
|
||||||
|
};
|
||||||
|
|
|
@ -6,7 +6,7 @@ const application = Application.start();
|
||||||
application.debug = false;
|
application.debug = false;
|
||||||
window.Stimulus = application;
|
window.Stimulus = application;
|
||||||
|
|
||||||
Turbo.setConfirmMethod((message) => {
|
Turbo.config.forms.confirm = (message) => {
|
||||||
const dialog = document.getElementById("turbo-confirm");
|
const dialog = document.getElementById("turbo-confirm");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -52,6 +52,6 @@ Turbo.setConfirmMethod((message) => {
|
||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
export { application };
|
export { application };
|
||||||
|
|
|
@ -2,6 +2,10 @@ import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
// Connects to data-controller="modal"
|
// Connects to data-controller="modal"
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
reloadOnClose: { type: Boolean, default: false },
|
||||||
|
};
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
if (this.element.open) return;
|
if (this.element.open) return;
|
||||||
this.element.showModal();
|
this.element.showModal();
|
||||||
|
@ -10,11 +14,15 @@ export default class extends Controller {
|
||||||
// Hide the dialog when the user clicks outside of it
|
// Hide the dialog when the user clicks outside of it
|
||||||
clickOutside(e) {
|
clickOutside(e) {
|
||||||
if (e.target === this.element) {
|
if (e.target === this.element) {
|
||||||
this.element.close();
|
this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.element.close();
|
this.element.close();
|
||||||
|
|
||||||
|
if (this.reloadOnCloseValue) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,71 +1,11 @@
|
||||||
import { Controller } from "@hotwired/stimulus";
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
const TRADE_TYPES = {
|
|
||||||
BUY: "buy",
|
|
||||||
SELL: "sell",
|
|
||||||
TRANSFER_IN: "transfer_in",
|
|
||||||
TRANSFER_OUT: "transfer_out",
|
|
||||||
INTEREST: "interest",
|
|
||||||
};
|
|
||||||
|
|
||||||
const FIELD_VISIBILITY = {
|
|
||||||
[TRADE_TYPES.BUY]: { ticker: true, qty: true, price: true },
|
|
||||||
[TRADE_TYPES.SELL]: { ticker: true, qty: true, price: true },
|
|
||||||
[TRADE_TYPES.TRANSFER_IN]: { amount: true, transferAccount: true },
|
|
||||||
[TRADE_TYPES.TRANSFER_OUT]: { amount: true, transferAccount: true },
|
|
||||||
[TRADE_TYPES.INTEREST]: { amount: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Connects to data-controller="trade-form"
|
// Connects to data-controller="trade-form"
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = [
|
// Reloads the page with a new type without closing the modal
|
||||||
"typeInput",
|
async changeType(event) {
|
||||||
"tickerInput",
|
const url = new URL(event.params.url, window.location.origin);
|
||||||
"amountInput",
|
url.searchParams.set(event.params.key, event.target.value);
|
||||||
"transferAccountInput",
|
Turbo.visit(url, { frame: "modal" });
|
||||||
"qtyInput",
|
|
||||||
"priceInput",
|
|
||||||
];
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.handleTypeChange = this.handleTypeChange.bind(this);
|
|
||||||
this.typeInputTarget.addEventListener("change", this.handleTypeChange);
|
|
||||||
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
this.typeInputTarget.removeEventListener("change", this.handleTypeChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTypeChange(event) {
|
|
||||||
this.updateFields(event.target.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFields(type) {
|
|
||||||
const visibleFields = FIELD_VISIBILITY[type] || {};
|
|
||||||
|
|
||||||
Object.entries(this.fieldTargets).forEach(([field, target]) => {
|
|
||||||
const isVisible = visibleFields[field] || false;
|
|
||||||
|
|
||||||
// Update visibility
|
|
||||||
target.hidden = !isVisible;
|
|
||||||
|
|
||||||
// Update required status based on visibility
|
|
||||||
if (isVisible) {
|
|
||||||
target.setAttribute("required", "");
|
|
||||||
} else {
|
|
||||||
target.removeAttribute("required");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get fieldTargets() {
|
|
||||||
return {
|
|
||||||
ticker: this.tickerInputTarget,
|
|
||||||
amount: this.amountInputTarget,
|
|
||||||
transferAccount: this.transferAccountInputTarget,
|
|
||||||
qty: this.qtyInputTarget,
|
|
||||||
price: this.priceInputTarget,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ class Account < ApplicationRecord
|
||||||
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, class_name: "Account::Holding"
|
||||||
has_many :balances, dependent: :destroy
|
has_many :balances, dependent: :destroy
|
||||||
has_many :issues, as: :issuable, dependent: :destroy
|
has_many :issues, as: :issuable, dependent: :destroy
|
||||||
|
|
||||||
|
|
|
@ -30,10 +30,10 @@ class Account::Entry < ApplicationRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
def sync_account_later
|
def sync_account_later
|
||||||
if destroyed?
|
sync_start_date = if destroyed?
|
||||||
sync_start_date = previous_entry&.date
|
previous_entry&.date
|
||||||
else
|
else
|
||||||
sync_start_date = [ date_previously_was, date ].compact.min
|
[ date_previously_was, date ].compact.min
|
||||||
end
|
end
|
||||||
|
|
||||||
account.sync_later(start_date: sync_start_date)
|
account.sync_later(start_date: sync_start_date)
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
class Account::EntryBuilder
|
|
||||||
include ActiveModel::Model
|
|
||||||
|
|
||||||
TYPES = %w[income expense buy sell interest transfer_in transfer_out].freeze
|
|
||||||
|
|
||||||
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id
|
|
||||||
|
|
||||||
validates :type, inclusion: { in: TYPES }
|
|
||||||
|
|
||||||
def save
|
|
||||||
if valid?
|
|
||||||
create_builder.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def create_builder
|
|
||||||
case type
|
|
||||||
when "buy", "sell"
|
|
||||||
create_trade_builder
|
|
||||||
else
|
|
||||||
create_transaction_builder
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_trade_builder
|
|
||||||
Account::TradeBuilder.new \
|
|
||||||
type: type,
|
|
||||||
date: date,
|
|
||||||
qty: qty,
|
|
||||||
ticker: ticker,
|
|
||||||
price: price,
|
|
||||||
account: account
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_transaction_builder
|
|
||||||
Account::TransactionBuilder.new \
|
|
||||||
type: type,
|
|
||||||
date: date,
|
|
||||||
amount: amount,
|
|
||||||
account: account,
|
|
||||||
currency: currency,
|
|
||||||
transfer_account_id: transfer_account_id
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -28,8 +28,7 @@ class Account::Trade < ApplicationRecord
|
||||||
|
|
||||||
def name
|
def name
|
||||||
prefix = sell? ? "Sell " : "Buy "
|
prefix = sell? ? "Sell " : "Buy "
|
||||||
generated = prefix + "#{qty.abs} shares of #{security.ticker}"
|
prefix + "#{qty.abs} shares of #{security.ticker}"
|
||||||
entry.name || generated
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def unrealized_gain_loss
|
def unrealized_gain_loss
|
||||||
|
|
|
@ -1,33 +1,103 @@
|
||||||
class Account::TradeBuilder < Account::EntryBuilder
|
class Account::TradeBuilder
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
|
|
||||||
TYPES = %w[buy sell].freeze
|
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||||
|
:price, :ticker, :type, :transfer_account_id
|
||||||
attr_accessor :type, :qty, :price, :ticker, :date, :account
|
|
||||||
|
|
||||||
validates :type, :qty, :price, :ticker, :date, presence: true
|
|
||||||
validates :price, numericality: { greater_than: 0 }
|
|
||||||
validates :type, inclusion: { in: TYPES }
|
|
||||||
|
|
||||||
def save
|
def save
|
||||||
if valid?
|
buildable.save
|
||||||
create_entry
|
end
|
||||||
end
|
|
||||||
|
def errors
|
||||||
|
buildable.errors
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_account_later
|
||||||
|
buildable.sync_account_later
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
def buildable
|
||||||
|
case type
|
||||||
|
when "buy", "sell"
|
||||||
|
build_trade
|
||||||
|
when "deposit", "withdrawal"
|
||||||
|
build_transfer
|
||||||
|
when "interest"
|
||||||
|
build_interest
|
||||||
|
else
|
||||||
|
raise "Unknown trade type: #{type}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def create_entry
|
def build_trade
|
||||||
account.entries.account_trades.create! \
|
account.entries.new(
|
||||||
date: date,
|
date: date,
|
||||||
amount: amount,
|
amount: signed_amount,
|
||||||
currency: account.currency,
|
currency: currency,
|
||||||
entryable: Account::Trade.new(
|
entryable: Account::Trade.new(
|
||||||
security: security,
|
|
||||||
qty: signed_qty,
|
qty: signed_qty,
|
||||||
price: price.to_d,
|
price: price,
|
||||||
currency: account.currency
|
currency: currency,
|
||||||
|
security: security
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_transfer
|
||||||
|
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
|
||||||
|
|
||||||
|
if transfer_account
|
||||||
|
from_account = type == "withdrawal" ? account : transfer_account
|
||||||
|
to_account = type == "withdrawal" ? transfer_account : account
|
||||||
|
|
||||||
|
Account::Transfer.build_from_accounts(
|
||||||
|
from_account,
|
||||||
|
to_account,
|
||||||
|
date: date,
|
||||||
|
amount: signed_amount
|
||||||
|
)
|
||||||
|
else
|
||||||
|
account.entries.build(
|
||||||
|
name: signed_amount < 0 ? "Deposit from #{account.name}" : "Withdrawal to #{account.name}",
|
||||||
|
date: date,
|
||||||
|
amount: signed_amount,
|
||||||
|
currency: currency,
|
||||||
|
marked_as_transfer: true,
|
||||||
|
entryable: Account::Transaction.new
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_interest
|
||||||
|
account.entries.build(
|
||||||
|
name: "Interest payment",
|
||||||
|
date: date,
|
||||||
|
amount: signed_amount,
|
||||||
|
currency: currency,
|
||||||
|
entryable: Account::Transaction.new
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed_qty
|
||||||
|
return nil unless type.in?([ "buy", "sell" ])
|
||||||
|
|
||||||
|
type == "sell" ? -qty.to_d : qty.to_d
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed_amount
|
||||||
|
case type
|
||||||
|
when "buy", "sell"
|
||||||
|
signed_qty * price.to_d
|
||||||
|
when "deposit", "withdrawal"
|
||||||
|
type == "deposit" ? -amount.to_d : amount.to_d
|
||||||
|
when "interest"
|
||||||
|
amount.to_d * -1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def family
|
||||||
|
account.family
|
||||||
end
|
end
|
||||||
|
|
||||||
def security
|
def security
|
||||||
|
@ -40,14 +110,4 @@ class Account::TradeBuilder < Account::EntryBuilder
|
||||||
|
|
||||||
security
|
security
|
||||||
end
|
end
|
||||||
|
|
||||||
def amount
|
|
||||||
price.to_d * signed_qty
|
|
||||||
end
|
|
||||||
|
|
||||||
def signed_qty
|
|
||||||
_qty = qty.to_d
|
|
||||||
_qty = _qty * -1 if type == "sell"
|
|
||||||
_qty
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,64 +0,0 @@
|
||||||
class Account::TransactionBuilder
|
|
||||||
include ActiveModel::Model
|
|
||||||
|
|
||||||
TYPES = %w[income expense interest transfer_in transfer_out].freeze
|
|
||||||
|
|
||||||
attr_accessor :type, :amount, :date, :account, :currency, :transfer_account_id
|
|
||||||
|
|
||||||
validates :type, :amount, :date, presence: true
|
|
||||||
validates :type, inclusion: { in: TYPES }
|
|
||||||
|
|
||||||
def save
|
|
||||||
if valid?
|
|
||||||
transfer? ? create_transfer : create_transaction
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def transfer?
|
|
||||||
%w[transfer_in transfer_out].include?(type)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_transfer
|
|
||||||
return create_unlinked_transfer(account.id, signed_amount) if transfer_account_id.blank?
|
|
||||||
|
|
||||||
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
|
|
||||||
to_account_id = type == "transfer_in" ? account.id : transfer_account_id
|
|
||||||
|
|
||||||
outflow = create_unlinked_transfer(from_account_id, signed_amount.abs)
|
|
||||||
inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1)
|
|
||||||
|
|
||||||
Account::Transfer.create! entries: [ outflow, inflow ]
|
|
||||||
|
|
||||||
inflow
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_unlinked_transfer(account_id, amount)
|
|
||||||
build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_transaction
|
|
||||||
build_entry(account.id, signed_amount).tap(&:save!)
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_entry(account_id, amount, marked_as_transfer: false)
|
|
||||||
Account::Entry.new \
|
|
||||||
account_id: account_id,
|
|
||||||
name: marked_as_transfer ? (amount < 0 ? "Deposit" : "Withdrawal") : "Interest",
|
|
||||||
amount: amount,
|
|
||||||
currency: currency,
|
|
||||||
date: date,
|
|
||||||
marked_as_transfer: marked_as_transfer,
|
|
||||||
entryable: Account::Transaction.new
|
|
||||||
end
|
|
||||||
|
|
||||||
def signed_amount
|
|
||||||
case type
|
|
||||||
when "expense", "transfer_out"
|
|
||||||
amount.to_d
|
|
||||||
else
|
|
||||||
amount.to_d * -1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -48,6 +48,10 @@ class Account::Transfer < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sync_account_later
|
||||||
|
entries.each(&:sync_account_later)
|
||||||
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def build_from_accounts(from_account, to_account, date:, amount:)
|
def build_from_accounts(from_account, to_account, date:, amount:)
|
||||||
outflow = from_account.entries.build \
|
outflow = from_account.entries.build \
|
||||||
|
|
|
@ -35,8 +35,9 @@ module Accountable
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_sync
|
def post_sync
|
||||||
broadcast_remove_to(account, target: "syncing-notification")
|
broadcast_remove_to(account.family, target: "syncing-notice")
|
||||||
|
|
||||||
|
# Broadcast a simple replace event that the controller can handle
|
||||||
broadcast_replace_to(
|
broadcast_replace_to(
|
||||||
account,
|
account,
|
||||||
target: "chart_account_#{account.id}",
|
target: "chart_account_#{account.id}",
|
||||||
|
|
|
@ -15,6 +15,7 @@ class Family < ApplicationRecord
|
||||||
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
|
||||||
|
has_many :holdings, through: :accounts
|
||||||
has_many :plaid_items, dependent: :destroy
|
has_many :plaid_items, dependent: :destroy
|
||||||
|
|
||||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||||
|
|
|
@ -56,7 +56,7 @@ class Investment < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_sync
|
def post_sync
|
||||||
broadcast_remove_to(account, target: "syncing-notification")
|
broadcast_remove_to(account, target: "syncing-notice")
|
||||||
|
|
||||||
broadcast_replace_to(
|
broadcast_replace_to(
|
||||||
account,
|
account,
|
||||||
|
|
|
@ -134,12 +134,12 @@ class Provider::Synth
|
||||||
|
|
||||||
securities = parsed.dig("data").map do |security|
|
securities = parsed.dig("data").map do |security|
|
||||||
{
|
{
|
||||||
symbol: security.dig("symbol"),
|
ticker: security.dig("symbol"),
|
||||||
name: security.dig("name"),
|
name: security.dig("name"),
|
||||||
logo_url: security.dig("logo_url"),
|
logo_url: security.dig("logo_url"),
|
||||||
exchange_acronym: security.dig("exchange", "acronym"),
|
exchange_acronym: security.dig("exchange", "acronym"),
|
||||||
exchange_mic: security.dig("exchange", "mic_code"),
|
exchange_mic: security.dig("exchange", "mic_code"),
|
||||||
exchange_country_code: security.dig("exchange", "country_code")
|
country_code: security.dig("exchange", "country_code")
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -8,17 +8,33 @@ class Security < ApplicationRecord
|
||||||
validates :ticker, presence: true
|
validates :ticker, presence: true
|
||||||
validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false }
|
validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false }
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def search(query)
|
||||||
|
security_prices_provider.search_securities(
|
||||||
|
query: query[:search],
|
||||||
|
dataset: "limited",
|
||||||
|
country_code: query[:country]
|
||||||
|
).securities.map { |attrs| new(**attrs) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def current_price
|
def current_price
|
||||||
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
|
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
|
||||||
return nil if @current_price.nil?
|
return nil if @current_price.nil?
|
||||||
Money.new(@current_price.price, @current_price.currency)
|
Money.new(@current_price.price, @current_price.currency)
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_combobox_display
|
def to_combobox_option
|
||||||
"#{ticker} (#{exchange_acronym})"
|
SynthComboboxOption.new(
|
||||||
|
symbol: ticker,
|
||||||
|
name: name,
|
||||||
|
logo_url: logo_url,
|
||||||
|
exchange_acronym: exchange_acronym,
|
||||||
|
exchange_mic: exchange_mic,
|
||||||
|
exchange_country_code: country_code
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def upcase_ticker
|
def upcase_ticker
|
||||||
|
|
|
@ -1,22 +1,8 @@
|
||||||
class Security::SynthComboboxOption
|
class Security::SynthComboboxOption
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
include Providable
|
|
||||||
|
|
||||||
attr_accessor :symbol, :name, :logo_url, :exchange_acronym, :exchange_mic, :exchange_country_code
|
attr_accessor :symbol, :name, :logo_url, :exchange_acronym, :exchange_mic, :exchange_country_code
|
||||||
|
|
||||||
class << self
|
|
||||||
def find_in_synth(query)
|
|
||||||
country = Current.family.country
|
|
||||||
country = "#{country},US" unless country == "US"
|
|
||||||
|
|
||||||
security_prices_provider.search_securities(
|
|
||||||
query:,
|
|
||||||
dataset: "limited",
|
|
||||||
country_code: country
|
|
||||||
).securities.map { |attrs| new(**attrs) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def id
|
def id
|
||||||
"#{symbol}|#{exchange_mic}|#{exchange_acronym}|#{exchange_country_code}" # submitted by combobox as value
|
"#{symbol}|#{exchange_mic}|#{exchange_acronym}|#{exchange_country_code}" # submitted by combobox as value
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
|
<%# locals: (entry:, selectable: true, show_balance: false) %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(entry) do %>
|
<%= turbo_frame_tag dom_id(entry) do %>
|
||||||
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance:, origin: } %>
|
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance: } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 text-gray-500">
|
<div class="flex items-center gap-1 text-gray-500">
|
||||||
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
<%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
||||||
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
|
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
|
||||||
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -9,13 +9,13 @@
|
||||||
<%= tag.span t(".new") %>
|
<%= tag.span t(".new") %>
|
||||||
</button>
|
</button>
|
||||||
<div data-menu-target="content" class="z-10 hidden bg-white rounded-lg border border-alpha-black-25 shadow-xs p-1">
|
<div data-menu-target="content" class="z-10 hidden bg-white rounded-lg border border-alpha-black-25 shadow-xs p-1">
|
||||||
<%= link_to new_account_valuation_path(@account), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
|
<%= link_to new_account_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
|
||||||
<%= lucide_icon("circle-dollar-sign", class: "text-gray-500 w-5 h-5") %>
|
<%= lucide_icon("circle-dollar-sign", class: "text-gray-500 w-5 h-5") %>
|
||||||
<%= tag.span t(".new_balance"), class: "text-sm" %>
|
<%= tag.span t(".new_balance"), class: "text-sm" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% unless @account.crypto? %>
|
<% unless @account.crypto? %>
|
||||||
<%= link_to @account.investment? ? new_account_trade_path(@account) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
|
<%= link_to @account.investment? ? new_account_trade_path(account_id: @account.id) : new_account_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
|
||||||
<%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %>
|
<%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %>
|
||||||
<%= tag.span t(".new_transaction"), class: "text-sm" %>
|
<%= tag.span t(".new_transaction"), class: "text-sm" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -75,7 +75,7 @@
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<%= entries_by_date(@entries) do |entries| %>
|
<%= entries_by_date(@entries) do |entries| %>
|
||||||
<%= render entries, show_balance: true, origin: "account" %>
|
<%= render entries, show_balance: true %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full" %>
|
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full" %>
|
||||||
|
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
|
<%= link_to holding.name, account_holding_path(holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
|
||||||
|
|
||||||
<% if holding.amount %>
|
<% if holding.amount %>
|
||||||
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
||||||
|
|
|
@ -101,10 +101,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= button_to t(".delete"),
|
<%= button_to t(".delete"),
|
||||||
account_holding_path(@holding.account, @holding),
|
account_holding_path(@holding),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
||||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
data: { turbo_confirm: true } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
@ -1,35 +1,51 @@
|
||||||
<%# locals: (entry:) %>
|
<%# locals: (entry:) %>
|
||||||
|
|
||||||
<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" },
|
<% type = params[:type] || "buy" %>
|
||||||
model: entry,
|
|
||||||
scope: :account_entry,
|
<%= styled_form_with model: entry, url: account_trades_path, data: { controller: "trade-form" } do |form| %>
|
||||||
url: account_trades_path(entry.account) do |form| %>
|
|
||||||
|
<%= form.hidden_field :account_id %>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<% if entry.errors.any? %>
|
||||||
|
<%= render "shared/form_errors", model: entry %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
|
<%= form.select :type, [
|
||||||
<div data-trade-form-target="tickerInput">
|
["Buy", "buy"],
|
||||||
|
["Sell", "sell"],
|
||||||
|
["Deposit", "deposit"],
|
||||||
|
["Withdrawal", "withdrawal"],
|
||||||
|
["Interest", "interest"]
|
||||||
|
],
|
||||||
|
{ label: t(".type"), selected: type },
|
||||||
|
{ data: {
|
||||||
|
action: "trade-form#changeType",
|
||||||
|
trade_form_url_param: new_account_trade_path(account_id: entry.account_id),
|
||||||
|
trade_form_key_param: "type",
|
||||||
|
}} %>
|
||||||
|
|
||||||
|
<% if %w[buy sell].include?(type) %>
|
||||||
<div class="form-field combobox">
|
<div class="form-field combobox">
|
||||||
<%= form.combobox :ticker, securities_account_trades_path(entry.account), label: t(".holding"), placeholder: t(".ticker_placeholder") %>
|
<%= form.combobox :ticker, securities_path(country_code: Current.family.country), label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
|
|
||||||
<%= form.date_field :date, label: true, value: Date.today %>
|
<%= form.date_field :date, label: true, value: Date.today, required: true %>
|
||||||
|
|
||||||
<div data-trade-form-target="amountInput" hidden>
|
<% unless %w[buy sell].include?(type) %>
|
||||||
<%= form.money_field :amount, label: t(".amount") %>
|
<%= form.money_field :amount, label: t(".amount"), required: true %>
|
||||||
</div>
|
<% end %>
|
||||||
|
|
||||||
<div data-trade-form-target="transferAccountInput" hidden>
|
<% if %w[deposit withdrawal].include?(type) %>
|
||||||
<%= form.collection_select :transfer_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %>
|
<%= form.collection_select :transfer_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %>
|
||||||
</div>
|
<% end %>
|
||||||
|
|
||||||
<div data-trade-form-target="qtyInput">
|
<% if %w[buy sell].include?(type) %>
|
||||||
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any" %>
|
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any", required: true %>
|
||||||
</div>
|
<%= form.money_field :price, label: t(".price"), required: true %>
|
||||||
|
<% end %>
|
||||||
<div data-trade-form-target="priceInput">
|
|
||||||
<%= form.money_field :price, label: t(".price"), currency_value_override: "USD", disable_currency: true %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form.submit t(".submit") %>
|
<%= form.submit t(".submit") %>
|
||||||
|
|
68
app/views/account/trades/_header.html.erb
Normal file
68
app/views/account/trades/_header.html.erb
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
<%# locals: (entry:) %>
|
||||||
|
|
||||||
|
<div id="<%= dom_id(entry, :header) %>">
|
||||||
|
<%= tag.header class: "mb-4 space-y-1" do %>
|
||||||
|
<span class="text-gray-500 text-sm">
|
||||||
|
<%= entry.amount.negative? ? t(".sell") : t(".buy") %>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<h3 class="font-medium">
|
||||||
|
<span class="text-2xl">
|
||||||
|
<%= format_money entry.amount_money %>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-lg text-gray-500">
|
||||||
|
<%= entry.currency %>
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
<%= I18n.l(entry.date, format: :long) %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% trade = entry.account_trade %>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<%= disclosure t(".overview") do %>
|
||||||
|
<div class="pb-4">
|
||||||
|
<dl class="space-y-3 px-3 py-2">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<dt class="text-gray-500"><%= t(".symbol_label") %></dt>
|
||||||
|
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if trade.buy? %>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
|
||||||
|
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<dt class="text-gray-500"><%= t(".purchase_price_label") %></dt>
|
||||||
|
<dd class="text-gray-900"><%= format_money trade.price_money %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if trade.security.current_price.present? %>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<dt class="text-gray-500"><%= t(".current_market_price_label") %></dt>
|
||||||
|
<dd class="text-gray-900"><%= format_money trade.security.current_price %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if trade.buy? && trade.unrealized_gain_loss.present? %>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
|
||||||
|
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">
|
||||||
|
<%= render "shared/trend_change", trend: trade.unrealized_gain_loss %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,11 +0,0 @@
|
||||||
<div class="flex items-center">
|
|
||||||
<%= image_tag(security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
<%= security.name.presence || security.symbol %>
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
<%= "#{security.symbol} (#{security.exchange_acronym})" %>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -6,7 +6,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 text-gray-500">
|
<div class="flex items-center gap-1 text-gray-500">
|
||||||
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
<%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
||||||
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
|
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
|
||||||
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
|
<%# locals: (entry:, selectable: true, show_balance: false) %>
|
||||||
|
|
||||||
<% trade, account = entry.account_trade, entry.account %>
|
<% trade, account = entry.account_trade, entry.account %>
|
||||||
|
|
||||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
|
||||||
<div class="col-span-8 flex items-center gap-4">
|
<div class="col-span-8 flex items-center gap-4">
|
||||||
<% if selectable %>
|
<% if selectable %>
|
||||||
<%= check_box_tag dom_id(entry, "selection"),
|
<%= check_box_tag dom_id(entry, "selection"),
|
||||||
|
@ -16,12 +16,12 @@
|
||||||
<%= trade.name.first.upcase %>
|
<%= trade.name.first.upcase %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="truncate text-gray-900">
|
<div class="truncate">
|
||||||
<% if entry.new_record? %>
|
<% if entry.new_record? %>
|
||||||
<%= content_tag :p, trade.name %>
|
<%= content_tag :p, trade.name %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to trade.name,
|
<%= link_to trade.name,
|
||||||
account_entry_path(account, entry),
|
account_entry_path(entry),
|
||||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||||
class: "hover:underline hover:text-gray-800" %>
|
class: "hover:underline hover:text-gray-800" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -31,7 +31,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 justify-self-end font-medium text-sm">
|
<div class="col-span-2 justify-self-end font-medium text-sm">
|
||||||
<%= tag.span format_money(entry.amount_money) %>
|
<%= content_tag :p,
|
||||||
|
format_money(-entry.amount_money),
|
||||||
|
class: ["text-green-600": entry.amount.negative?] %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 justify-self-end">
|
<div class="col-span-2 justify-self-end">
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
<%= async_combobox_options @securities,
|
|
||||||
render_in: { partial: "account/trades/security" } %>
|
|
|
@ -1,83 +1,37 @@
|
||||||
<% entry, trade, account = @entry, @entry.account_trade, @entry.account %>
|
<%= drawer(reload_on_close: true) do %>
|
||||||
|
<%= render "account/trades/header", entry: @entry %>
|
||||||
|
|
||||||
<%= drawer do %>
|
<% trade = @entry.account_trade %>
|
||||||
<header class="mb-4 space-y-1">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<h3 class="font-medium">
|
|
||||||
<span class="text-2xl">
|
|
||||||
<%= format_money -entry.amount_money %>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="text-lg text-gray-500">
|
|
||||||
<%= entry.currency %>
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
<%= I18n.l(entry.date, format: :long) %>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<!-- Overview Section -->
|
|
||||||
<%= disclosure t(".overview") do %>
|
|
||||||
<div class="pb-4">
|
|
||||||
<dl class="space-y-3 px-3 py-2">
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<dt class="text-gray-500"><%= t(".symbol_label") %></dt>
|
|
||||||
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if trade.buy? %>
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
|
|
||||||
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<dt class="text-gray-500"><%= t(".purchase_price_label") %></dt>
|
|
||||||
<dd class="text-gray-900"><%= format_money trade.price_money %></dd>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if trade.security.current_price.present? %>
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<dt class="text-gray-500"><%= t(".current_market_price_label") %></dt>
|
|
||||||
<dd class="text-gray-900"><%= format_money trade.security.current_price %></dd>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if trade.buy? && trade.unrealized_gain_loss.present? %>
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
|
|
||||||
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">
|
|
||||||
<%= render "shared/trend_change", trend: trade.unrealized_gain_loss %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<!-- Details Section -->
|
<!-- Details Section -->
|
||||||
<%= disclosure t(".details") do %>
|
<%= disclosure t(".details") do %>
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<%= styled_form_with model: [account, entry],
|
<%= styled_form_with model: @entry,
|
||||||
url: account_trade_path(account, entry),
|
url: account_trade_path(@entry),
|
||||||
class: "space-y-2",
|
class: "space-y-2",
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<%= f.date_field :date,
|
<%= f.date_field :date,
|
||||||
label: t(".date_label"),
|
label: t(".date_label"),
|
||||||
max: Date.current,
|
max: Date.today,
|
||||||
"data-auto-submit-form-target": "auto" %>
|
"data-auto-submit-form-target": "auto" %>
|
||||||
|
|
||||||
<%= f.fields_for :entryable do |ef| %>
|
<div class="flex items-center gap-2">
|
||||||
<%= ef.number_field :qty,
|
<%= f.select :nature,
|
||||||
|
[["Buy", "outflow"], ["Sell", "inflow"]],
|
||||||
|
{ container_class: "w-1/3", label: "Type", selected: @entry.amount.negative? ? "inflow" : "outflow" },
|
||||||
|
{ data: { "auto-submit-form-target": "auto" } } %>
|
||||||
|
|
||||||
|
<%= f.fields_for :entryable do |ef| %>
|
||||||
|
<%= ef.number_field :qty,
|
||||||
label: t(".quantity_label"),
|
label: t(".quantity_label"),
|
||||||
step: "any",
|
step: "any",
|
||||||
|
value: trade.qty.abs,
|
||||||
"data-auto-submit-form-target": "auto" %>
|
"data-auto-submit-form-target": "auto" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= f.fields_for :entryable do |ef| %>
|
||||||
<%= ef.money_field :price,
|
<%= ef.money_field :price,
|
||||||
label: t(".cost_per_share_label"),
|
label: t(".cost_per_share_label"),
|
||||||
disable_currency: true,
|
disable_currency: true,
|
||||||
|
@ -91,8 +45,8 @@
|
||||||
<!-- Additional Section -->
|
<!-- Additional Section -->
|
||||||
<%= disclosure t(".additional") do %>
|
<%= disclosure t(".additional") do %>
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<%= styled_form_with model: [account, entry],
|
<%= styled_form_with model: @entry,
|
||||||
url: account_trade_path(account, entry),
|
url: account_trade_path(@entry),
|
||||||
class: "space-y-2",
|
class: "space-y-2",
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<%= f.text_area :notes,
|
<%= f.text_area :notes,
|
||||||
|
@ -108,8 +62,8 @@
|
||||||
<%= disclosure t(".settings") do %>
|
<%= disclosure t(".settings") do %>
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<!-- Exclude Trade Form -->
|
<!-- Exclude Trade Form -->
|
||||||
<%= styled_form_with model: [account, entry],
|
<%= styled_form_with model: @entry,
|
||||||
url: account_trade_path(account, entry),
|
url: account_trade_path(@entry),
|
||||||
class: "p-3",
|
class: "p-3",
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||||
|
@ -136,11 +90,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= button_to t(".delete"),
|
<%= button_to t(".delete"),
|
||||||
account_entry_path(account, entry),
|
account_entry_path(@entry),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm
|
class: "rounded-lg px-3 py-2 text-red-500 text-sm
|
||||||
font-medium border border-alpha-black-200",
|
font-medium border border-alpha-black-200",
|
||||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
data: { turbo_confirm: true } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
<%= styled_form_with model: @entry, url: transactions_path, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
<%= styled_form_with model: @entry, url: account_transactions_path, class: "space-y-4" do |f| %>
|
||||||
|
|
||||||
|
<% if entry.errors.any? %>
|
||||||
|
<%= render "shared/form_errors", model: entry %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
||||||
<%= radio_tab_tag form: f, name: :nature, value: :expense, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "expense" || params[:nature].nil? %>
|
<%= radio_tab_tag form: f, name: :nature, value: :outflow, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "outflow" || params[:nature].nil? %>
|
||||||
<%= radio_tab_tag form: f, name: :nature, value: :income, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "income" %>
|
<%= radio_tab_tag form: f, name: :nature, value: :inflow, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "inflow" %>
|
||||||
<%= link_to new_account_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %>
|
<%= link_to new_account_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %>
|
||||||
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
|
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
|
||||||
<%= tag.span t(".transfer") %>
|
<%= tag.span t(".transfer") %>
|
||||||
|
@ -12,9 +17,14 @@
|
||||||
|
|
||||||
<section class="space-y-2 overflow-hidden">
|
<section class="space-y-2 overflow-hidden">
|
||||||
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
|
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
|
||||||
<%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
|
|
||||||
|
<% if @entry.account_id %>
|
||||||
|
<%= f.hidden_field :account_id %>
|
||||||
|
<% else %>
|
||||||
|
<%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= f.money_field :amount, label: t(".amount"), required: true %>
|
<%= f.money_field :amount, label: t(".amount"), required: true %>
|
||||||
<%= f.hidden_field :entryable_type, value: "Account::Transaction" %>
|
|
||||||
<%= f.fields_for :entryable do |ef| %>
|
<%= f.fields_for :entryable do |ef| %>
|
||||||
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
|
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
|
||||||
<% end %>
|
<% end %>
|
23
app/views/account/transactions/_header.html.erb
Normal file
23
app/views/account/transactions/_header.html.erb
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<%# locals: (entry:) %>
|
||||||
|
|
||||||
|
<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<h3 class="font-medium">
|
||||||
|
<span class="text-2xl">
|
||||||
|
<%= format_money -entry.amount_money %>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-lg text-gray-500">
|
||||||
|
<%= entry.currency %>
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<% if entry.marked_as_transfer? %>
|
||||||
|
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
<%= I18n.l(entry.date, format: :long) %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
|
@ -8,7 +8,7 @@
|
||||||
<div class="flex items-center gap-1 text-gray-500">
|
<div class="flex items-center gap-1 text-gray-500">
|
||||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
|
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
|
||||||
|
|
||||||
<%= form_with url: mark_transfers_transactions_path,
|
<%= form_with url: mark_transfers_account_transactions_path,
|
||||||
scope: "bulk_update",
|
scope: "bulk_update",
|
||||||
data: {
|
data: {
|
||||||
turbo_frame: "_top",
|
turbo_frame: "_top",
|
||||||
|
@ -28,14 +28,14 @@
|
||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= link_to bulk_edit_transactions_path,
|
<%= link_to bulk_edit_account_transactions_path,
|
||||||
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
|
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
|
||||||
title: "Edit",
|
title: "Edit",
|
||||||
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
|
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
|
||||||
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
|
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
<%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
||||||
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
|
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
|
||||||
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
|
<%# locals: (entry:, selectable: true, show_balance: false) %>
|
||||||
<% transaction, account = entry.account_transaction, entry.account %>
|
<% transaction, account = entry.account_transaction, entry.account %>
|
||||||
|
|
||||||
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
|
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
|
||||||
|
@ -20,7 +20,7 @@
|
||||||
<%= content_tag :p, transaction.name %>
|
<%= content_tag :p, transaction.name %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to transaction.name,
|
<%= link_to transaction.name,
|
||||||
entry.transfer.present? ? account_transfer_path(entry.transfer, origin:) : account_entry_path(account, entry, origin:),
|
entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry),
|
||||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||||
class: "hover:underline hover:text-gray-800" %>
|
class: "hover:underline hover:text-gray-800" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="flex items-center gap-1 col-span-2">
|
<div class="flex items-center gap-1 col-span-2">
|
||||||
<%= render "categories/menu", transaction: transaction, origin: origin %>
|
<%= render "categories/menu", transaction: transaction %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% unless show_balance %>
|
<% unless show_balance %>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" do %>
|
<%= turbo_frame_tag "bulk_transaction_edit_drawer" do %>
|
||||||
<dialog data-controller="modal"
|
<dialog data-controller="modal"
|
||||||
data-action="click->modal#clickOutside"
|
data-action="click->modal#clickOutside"
|
||||||
class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4">
|
class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4">
|
||||||
<%= styled_form_with url: bulk_update_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
|
<%= styled_form_with url: bulk_update_account_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
|
||||||
<div class="flex h-full flex-col justify-between p-4">
|
<div class="flex h-full flex-col justify-between p-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex h-9 items-center justify-end">
|
<div class="flex h-9 items-center justify-end">
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h3 class="font-medium text-lg"><%= t(".transactions") %></h3>
|
<h3 class="font-medium text-lg"><%= t(".transactions") %></h3>
|
||||||
<%= link_to new_transaction_path(account_id: @account),
|
<%= link_to new_account_transaction_path(account_id: @account),
|
||||||
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
|
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
|
||||||
data: { turbo_frame: :modal } do %>
|
data: { turbo_frame: :modal } do %>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
<%= modal_form_wrapper title: t(".new_transaction") do %>
|
<%= modal_form_wrapper title: t(".new_transaction") do %>
|
||||||
<%= render "form", transaction: @transaction, entry: @entry %>
|
<%= render "form", entry: @entry %>
|
||||||
<% end %>
|
<% end %>
|
|
@ -1,39 +1,14 @@
|
||||||
<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %>
|
<%= drawer(reload_on_close: true) do %>
|
||||||
|
<%= render "account/transactions/header", entry: @entry %>
|
||||||
<% origin = params[:origin] %>
|
|
||||||
|
|
||||||
<%= drawer do %>
|
|
||||||
<header class="mb-4 space-y-1">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<h3 class="font-medium">
|
|
||||||
<span class="text-2xl">
|
|
||||||
<%= format_money -entry.amount_money %>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="text-lg text-gray-500">
|
|
||||||
<%= entry.currency %>
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<% if entry.marked_as_transfer? %>
|
|
||||||
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
<%= I18n.l(entry.date, format: :long) %>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<!-- Overview Section -->
|
<!-- Overview Section -->
|
||||||
<%= disclosure t(".overview") do %>
|
<%= disclosure t(".overview") do %>
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<%= styled_form_with model: [account, entry],
|
<%= styled_form_with model: @entry,
|
||||||
url: account_transaction_path(account, entry),
|
url: account_transaction_path(@entry),
|
||||||
class: "space-y-2",
|
class: "space-y-2",
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<%= f.hidden_field :origin, value: origin %>
|
|
||||||
<%= f.text_field :name,
|
<%= f.text_field :name,
|
||||||
label: t(".name_label"),
|
label: t(".name_label"),
|
||||||
"data-auto-submit-form-target": "auto" %>
|
"data-auto-submit-form-target": "auto" %>
|
||||||
|
@ -43,25 +18,25 @@
|
||||||
max: Date.current,
|
max: Date.current,
|
||||||
"data-auto-submit-form-target": "auto" %>
|
"data-auto-submit-form-target": "auto" %>
|
||||||
|
|
||||||
<% unless entry.marked_as_transfer? %>
|
<% unless @entry.marked_as_transfer? %>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<%= f.select :nature,
|
<%= f.select :nature,
|
||||||
[["Expense", "expense"], ["Income", "income"]],
|
[["Expense", "outflow"], ["Income", "inflow"]],
|
||||||
{ container_class: "w-1/3", label: t(".nature"), selected: entry.amount.negative? ? "income" : "expense" },
|
{ container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" },
|
||||||
{ data: { "auto-submit-form-target": "auto" } } %>
|
{ data: { "auto-submit-form-target": "auto" } } %>
|
||||||
|
|
||||||
<%= f.money_field :amount, label: t(".amount"),
|
<%= f.money_field :amount, label: t(".amount"),
|
||||||
container_class: "w-2/3",
|
container_class: "w-2/3",
|
||||||
auto_submit: true,
|
auto_submit: true,
|
||||||
min: 0,
|
min: 0,
|
||||||
value: entry.amount.abs %>
|
value: @entry.amount.abs %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= f.select :account,
|
<%= f.select :account,
|
||||||
options_for_select(
|
options_for_select(
|
||||||
Current.family.accounts.alphabetically.pluck(:name, :id),
|
Current.family.accounts.alphabetically.pluck(:name, :id),
|
||||||
entry.account_id
|
@entry.account_id
|
||||||
),
|
),
|
||||||
{ label: t(".account_label") },
|
{ label: t(".account_label") },
|
||||||
{ disabled: true } %>
|
{ disabled: true } %>
|
||||||
|
@ -72,55 +47,45 @@
|
||||||
<!-- Details Section -->
|
<!-- Details Section -->
|
||||||
<%= disclosure t(".details") do %>
|
<%= disclosure t(".details") do %>
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<%= styled_form_with model: [account, entry],
|
<%= styled_form_with model: @entry,
|
||||||
url: account_transaction_path(account, entry),
|
url: account_transaction_path(@entry),
|
||||||
class: "space-y-2",
|
class: "space-y-2",
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<%= f.hidden_field :origin, value: origin %>
|
<% unless @entry.marked_as_transfer? %>
|
||||||
<%= f.fields_for :entryable do |ef| %>
|
<%= f.fields_for :entryable do |ef| %>
|
||||||
<% unless entry.marked_as_transfer? %>
|
|
||||||
<%= ef.collection_select :category_id,
|
<%= ef.collection_select :category_id,
|
||||||
Current.family.categories.alphabetically,
|
Current.family.categories.alphabetically,
|
||||||
:id, :name,
|
:id, :name,
|
||||||
{ prompt: t(".category_placeholder"),
|
{ label: t(".category_label"),
|
||||||
label: t(".category_label"),
|
class: "text-gray-400", include_blank: t(".uncategorized") },
|
||||||
class: "text-gray-400" },
|
|
||||||
"data-auto-submit-form-target": "auto" %>
|
"data-auto-submit-form-target": "auto" %>
|
||||||
|
|
||||||
<%= ef.collection_select :merchant_id,
|
<%= ef.collection_select :merchant_id,
|
||||||
Current.family.merchants.alphabetically,
|
Current.family.merchants.alphabetically,
|
||||||
:id, :name,
|
:id, :name,
|
||||||
{ prompt: t(".merchant_placeholder"),
|
{ include_blank: t(".none"),
|
||||||
label: t(".merchant_label"),
|
label: t(".merchant_label"),
|
||||||
class: "text-gray-400" },
|
class: "text-gray-400" },
|
||||||
"data-auto-submit-form-target": "auto" %>
|
"data-auto-submit-form-target": "auto" %>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= ef.select :tag_ids,
|
<%= ef.select :tag_ids,
|
||||||
options_for_select(
|
|
||||||
Current.family.tags.alphabetically.pluck(:name, :id),
|
Current.family.tags.alphabetically.pluck(:name, :id),
|
||||||
transaction.tag_ids
|
{
|
||||||
),
|
include_blank: t(".none"),
|
||||||
{
|
multiple: true,
|
||||||
multiple: true,
|
label: t(".tags_label"),
|
||||||
label: t(".tags_label"),
|
container_class: "h-40"
|
||||||
container_class: "h-40"
|
},
|
||||||
},
|
|
||||||
{ "data-auto-submit-form-target": "auto" } %>
|
{ "data-auto-submit-form-target": "auto" } %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= styled_form_with model: [account, entry],
|
<%= f.text_area :notes,
|
||||||
url: account_transaction_path(account, entry),
|
|
||||||
class: "space-y-2",
|
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
|
||||||
<%= f.hidden_field :origin, value: origin %>
|
|
||||||
<%= f.text_area :notes,
|
|
||||||
label: t(".note_label"),
|
label: t(".note_label"),
|
||||||
placeholder: t(".note_placeholder"),
|
placeholder: t(".note_placeholder"),
|
||||||
rows: 5,
|
rows: 5,
|
||||||
"data-auto-submit-form-target": "auto" %>
|
"data-auto-submit-form-target": "auto" %>
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -129,11 +94,10 @@
|
||||||
<%= disclosure t(".settings") do %>
|
<%= disclosure t(".settings") do %>
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<!-- Exclude Transaction Form -->
|
<!-- Exclude Transaction Form -->
|
||||||
<%= styled_form_with model: [account, entry],
|
<%= styled_form_with model: @entry,
|
||||||
url: account_transaction_path(account, entry),
|
url: account_transaction_path(@entry),
|
||||||
class: "p-3",
|
class: "p-3",
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<%= f.hidden_field :origin, value: origin %>
|
|
||||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||||
<div class="text-sm space-y-1">
|
<div class="text-sm space-y-1">
|
||||||
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
|
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
|
||||||
|
@ -158,7 +122,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= button_to t(".delete"),
|
<%= button_to t(".delete"),
|
||||||
account_entry_path(account, entry),
|
account_entry_path(@entry),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm
|
class: "rounded-lg px-3 py-2 text-red-500 text-sm
|
||||||
font-medium border border-alpha-black-200",
|
font-medium border border-alpha-black-200",
|
||||||
|
|
|
@ -8,12 +8,12 @@
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
||||||
<%= link_to new_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
|
<%= link_to new_account_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
|
||||||
<%= lucide_icon "minus-circle", class: "w-5 h-5" %>
|
<%= lucide_icon "minus-circle", class: "w-5 h-5" %>
|
||||||
<%= tag.span t(".expense") %>
|
<%= tag.span t(".expense") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= link_to new_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
|
<%= link_to new_account_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
|
||||||
<%= lucide_icon "plus-circle", class: "w-5 h-5" %>
|
<%= lucide_icon "plus-circle", class: "w-5 h-5" %>
|
||||||
<%= tag.span t(".income") %>
|
<%= tag.span t(".income") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%# locals: (entry:) %>
|
<%# locals: (entry:) %>
|
||||||
|
|
||||||
<%= form_with url: unmark_transfers_transactions_path, class: "flex items-center", data: {
|
<%= form_with url: unmark_transfers_account_transactions_path, class: "flex items-center", data: {
|
||||||
turbo_confirm: {
|
turbo_confirm: {
|
||||||
title: t(".remove_transfer"),
|
title: t(".remove_transfer"),
|
||||||
body: t(".remove_transfer_body"),
|
body: t(".remove_transfer_body"),
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
<%# locals: (entry:) %>
|
<%# locals: (entry:) %>
|
||||||
|
|
||||||
<%= styled_form_with model: [entry.account, entry],
|
<%= styled_form_with model: entry, url: account_valuations_path, class: "space-y-4" do |form| %>
|
||||||
url: entry.new_record? ? account_valuations_path(entry.account) : account_entry_path(entry.account, entry),
|
<%= form.hidden_field :account_id %>
|
||||||
class: "space-y-4",
|
|
||||||
data: { turbo: false } do |form| %>
|
<% if entry.errors.any? %>
|
||||||
|
<%= render "shared/form_errors", model: entry %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %>
|
<%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %>
|
||||||
<%= form.money_field :amount, label: t(".amount"), required: true %>
|
<%= form.money_field :amount, label: t(".amount"), required: true %>
|
||||||
|
|
19
app/views/account/valuations/_header.html.erb
Normal file
19
app/views/account/valuations/_header.html.erb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<%# locals: (entry:) %>
|
||||||
|
|
||||||
|
<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %>
|
||||||
|
<span class="text-gray-500 text-sm">
|
||||||
|
<%= t(".balance") %>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<h3 class="font-medium">
|
||||||
|
<span class="text-2xl">
|
||||||
|
<%= format_money entry.amount_money %>
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
<%= I18n.l(entry.date, format: :long) %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
|
@ -1,4 +1,4 @@
|
||||||
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
|
<%# locals: (entry:, selectable: true, show_balance: false) %>
|
||||||
|
|
||||||
<% account = entry.account %>
|
<% account = entry.account %>
|
||||||
<% valuation = entry.account_valuation %>
|
<% valuation = entry.account_valuation %>
|
||||||
|
@ -21,7 +21,7 @@
|
||||||
<%= content_tag :p, entry.name %>
|
<%= content_tag :p, entry.name %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to valuation.name,
|
<%= link_to valuation.name,
|
||||||
account_entry_path(account, entry),
|
account_entry_path(entry),
|
||||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||||
class: "hover:underline hover:text-gray-800" %>
|
class: "hover:underline hover:text-gray-800" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,30 +1,14 @@
|
||||||
<% entry, account = @entry, @entry.account %>
|
<% entry, account = @entry, @entry.account %>
|
||||||
|
|
||||||
<%= drawer do %>
|
<%= drawer(reload_on_close: true) do %>
|
||||||
<header class="mb-4 space-y-1">
|
<%= render "account/valuations/header", entry: %>
|
||||||
<span class="text-gray-500 text-sm">
|
|
||||||
<%= t(".balance") %>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<h3 class="font-medium">
|
|
||||||
<span class="text-2xl">
|
|
||||||
<%= format_money entry.amount_money %>
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
<%= I18n.l(entry.date, format: :long) %>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<!-- Overview Section -->
|
<!-- Overview Section -->
|
||||||
<%= disclosure t(".overview") do %>
|
<%= disclosure t(".overview") do %>
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<%= styled_form_with model: [account, entry],
|
<%= styled_form_with model: entry,
|
||||||
url: account_entry_path(account, entry),
|
url: account_entry_path(entry),
|
||||||
class: "space-y-2",
|
class: "space-y-2",
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<%= f.text_field :name,
|
<%= f.text_field :name,
|
||||||
|
@ -48,8 +32,8 @@
|
||||||
<!-- Details Section -->
|
<!-- Details Section -->
|
||||||
<%= disclosure t(".details") do %>
|
<%= disclosure t(".details") do %>
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<%= styled_form_with model: [account, entry],
|
<%= styled_form_with model: entry,
|
||||||
url: account_entry_path(account, entry),
|
url: account_entry_path(entry),
|
||||||
class: "space-y-2",
|
class: "space-y-2",
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<%= f.text_area :notes,
|
<%= f.text_area :notes,
|
||||||
|
@ -72,7 +56,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= button_to t(".delete"),
|
<%= button_to t(".delete"),
|
||||||
account_entry_path(account, entry),
|
account_entry_path(entry),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
||||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
||||||
|
|
5
app/views/accounts/_chart_loader.html.erb
Normal file
5
app/views/accounts/_chart_loader.html.erb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<div class="h-10">
|
||||||
|
</div>
|
||||||
|
<div class="h-64 flex items-center justify-center">
|
||||||
|
<p class="text-gray-500 animate-pulse text-sm">Loading...</p>
|
||||||
|
</div>
|
32
app/views/accounts/chart.html.erb
Normal file
32
app/views/accounts/chart.html.erb
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<% period = Period.from_param(params[:period]) %>
|
||||||
|
<% series = @account.series(period: period) %>
|
||||||
|
<% trend = series.trend %>
|
||||||
|
|
||||||
|
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
||||||
|
<div class="px-4">
|
||||||
|
<% if trend.direction.flat? %>
|
||||||
|
<%= tag.span t(".no_change"), class: "text-gray-500" %>
|
||||||
|
<% else %>
|
||||||
|
<%= tag.span "#{trend.value.positive? ? "+" : ""}#{format_money(trend.value)}", style: "color: #{trend.color}" %>
|
||||||
|
<% unless trend.percent.infinite? %>
|
||||||
|
<%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= tag.span period_label(period), class: "text-gray-500" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-64">
|
||||||
|
<% if series %>
|
||||||
|
<div
|
||||||
|
id="lineChart"
|
||||||
|
class="w-full h-full"
|
||||||
|
data-controller="time-series-chart"
|
||||||
|
data-time-series-chart-data-value="<%= series.to_json %>"></div>
|
||||||
|
<% else %>
|
||||||
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
|
<p class="text-gray-500">No data available for the selected period.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
|
@ -1,5 +1,5 @@
|
||||||
<%# locals: (account:) %>
|
<%# locals: (account:) %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account) do %>
|
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id) do %>
|
||||||
<%= render "account/entries/loading" %>
|
<%= render "account/entries/loading" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
<%# locals: (account:, title: nil, tooltip: nil, **args) %>
|
<%# locals: (account:, title: nil, tooltip: nil, **args) %>
|
||||||
|
|
||||||
<% period = Period.from_param(params[:period]) %>
|
<% period = Period.from_param(params[:period]) %>
|
||||||
<% series = account.series(period: period) %>
|
|
||||||
<% trend = series.trend %>
|
|
||||||
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
|
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
|
||||||
|
|
||||||
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
|
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg space-y-2">
|
||||||
<div class="p-4 flex justify-between">
|
<div class="flex justify-between px-4 pt-4 mb-2">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<%= tag.p title || default_value_title, class: "text-sm font-medium text-gray-500" %>
|
<%= tag.p title || default_value_title, class: "text-sm font-medium text-gray-500" %>
|
||||||
|
@ -14,19 +12,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
|
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
|
||||||
|
|
||||||
<div>
|
|
||||||
<% if trend.direction.flat? %>
|
|
||||||
<%= tag.span t(".no_change"), class: "text-gray-500" %>
|
|
||||||
<% else %>
|
|
||||||
<%= tag.span "#{trend.value.positive? ? "+" : ""}#{format_money(trend.value)}", style: "color: #{trend.color}" %>
|
|
||||||
<% unless trend.percent.infinite? %>
|
|
||||||
<%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= tag.span period_label(period), class: "text-gray-500" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||||
|
@ -34,7 +19,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-64 flex items-center justify-center text-2xl font-bold">
|
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.name) do %>
|
||||||
<%= render "shared/line_chart", series: series %>
|
<%= render "accounts/chart_loader" %>
|
||||||
</div>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
<%= link_to key.titleize,
|
<%= link_to key.titleize,
|
||||||
account_path(account, tab: key),
|
account_path(account, tab: key),
|
||||||
|
data: { turbo: false },
|
||||||
class: [
|
class: [
|
||||||
"px-2 py-1.5 rounded-md border border-transparent",
|
"px-2 py-1.5 rounded-md border border-transparent",
|
||||||
"bg-white shadow-xs border-alpha-black-50": is_selected
|
"bg-white shadow-xs border-alpha-black-50": is_selected
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
<%# locals: (transaction:, origin: nil) %>
|
<%# locals: (transaction:) %>
|
||||||
<div class="relative" data-controller="menu">
|
|
||||||
|
<div class="relative" data-controller="menu" id="<%= dom_id(transaction, :category_menu) %>">
|
||||||
<button data-menu-target="button" class="flex cursor-pointer">
|
<button data-menu-target="button" class="flex cursor-pointer">
|
||||||
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
|
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
|
||||||
</button>
|
</button>
|
||||||
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
|
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
|
||||||
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||||
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id, origin: origin), loading: :lazy do %>
|
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
|
||||||
<div class="p-6 flex items-center justify-center">
|
<div class="p-6 flex items-center justify-center">
|
||||||
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
|
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,8 +1,17 @@
|
||||||
<%# locals: (category:, origin: nil) %>
|
<%# locals: (category:) %>
|
||||||
<% is_selected = category.id === @selected_category&.id %>
|
<% is_selected = category.id === @selected_category&.id %>
|
||||||
|
|
||||||
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
|
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
|
||||||
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry, account_entry: { origin: origin,entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
<%= button_to account_transaction_category_path(
|
||||||
|
@transaction.entry,
|
||||||
|
account_entry: {
|
||||||
|
entryable_type: "Account::Transaction",
|
||||||
|
entryable_attributes: { id: @transaction.id, category_id: category.id }
|
||||||
|
}
|
||||||
|
),
|
||||||
|
method: :patch,
|
||||||
|
class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
||||||
|
|
||||||
<span class="w-5 h-5">
|
<span class="w-5 h-5">
|
||||||
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
<%= t(".no_categories") %>
|
<%= t(".no_categories") %>
|
||||||
</div>
|
</div>
|
||||||
<% @categories.each do |category| %>
|
<% @categories.each do |category| %>
|
||||||
<%= render partial: "category/dropdowns/row", locals: { category:, origin: params[:origin] } %>
|
<%= render partial: "category/dropdowns/row", locals: { category: } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%# locals: (account:) %>
|
<%# locals: (account:) %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account) do %>
|
<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account_id: account.id) do %>
|
||||||
<%= render "account/entries/loading" %>
|
<%= render "account/entries/loading" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
<%# locals: (account:, **args) %>
|
<%# locals: (account:, **args) %>
|
||||||
|
|
||||||
<% period = Period.from_param(params[:period]) %>
|
|
||||||
<% series = account.series(period: period) %>
|
|
||||||
<% trend = series.trend %>
|
|
||||||
|
|
||||||
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
|
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
|
||||||
<div class="p-4 flex justify-between">
|
<div class="p-4 flex justify-between">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%# locals: (account:) %>
|
<%# locals: (account:) %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account) do %>
|
<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account_id: account.id) do %>
|
||||||
<%= render "account/entries/loading" %>
|
<%= render "account/entries/loading" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
<%= render_flash_notifications %>
|
<%= render_flash_notifications %>
|
||||||
|
|
||||||
<% if Current.family&.syncing? %>
|
<% if Current.family&.syncing? %>
|
||||||
<%= render "shared/notification", id: "syncing-notification", type: :processing, message: t(".syncing") %>
|
<%= render "shared/syncing_notice" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
11
app/views/securities/_combobox_security.turbo_stream.erb
Normal file
11
app/views/securities/_combobox_security.turbo_stream.erb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= image_tag(combobox_security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
<%= combobox_security.name.presence || combobox_security.symbol %>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
<%= "#{combobox_security.symbol} (#{combobox_security.exchange_acronym})" %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
2
app/views/securities/index.turbo_stream.erb
Normal file
2
app/views/securities/index.turbo_stream.erb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<%= async_combobox_options @securities.map(&:to_combobox_option),
|
||||||
|
render_in: { partial: "securities/combobox_security" } %>
|
|
@ -1,5 +1,10 @@
|
||||||
|
<%# locals: (content:, reload_on_close: false) %>
|
||||||
|
|
||||||
<%= turbo_frame_tag "drawer" do %>
|
<%= turbo_frame_tag "drawer" do %>
|
||||||
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none" data-controller="modal" data-action="click->modal#clickOutside">
|
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none"
|
||||||
|
data-controller="modal"
|
||||||
|
data-action="click->modal#clickOutside"
|
||||||
|
data-modal-reload-on-close-value="<%= reload_on_close %>">
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div class="flex justify-end items-center p-4">
|
<div class="flex justify-end items-center p-4">
|
||||||
<div data-action="click->modal#close" class="cursor-pointer p-2">
|
<div data-action="click->modal#close" class="cursor-pointer p-2">
|
||||||
|
|
6
app/views/shared/_form_errors.html.erb
Normal file
6
app/views/shared/_form_errors.html.erb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<%# locals: (model:) %>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= lucide_icon("alert-circle", class: "text-red-500 w-4 h-4 shrink-0") %>
|
||||||
|
<p class="text-red-500 text-sm"><%= model.errors.full_messages.to_sentence %></p>
|
||||||
|
</div>
|
|
@ -1,10 +1,9 @@
|
||||||
<%# locals: (message:, type: "notice", id: nil, **_opts) %>
|
<%# locals: (message:, type: "notice", **_opts) %>
|
||||||
|
|
||||||
<% type = type.to_sym %>
|
<% type = type.to_sym %>
|
||||||
<% action = "animationend->element-removal#remove" if type == :notice %>
|
<% action = "animationend->element-removal#remove" if type == :notice %>
|
||||||
|
|
||||||
<%= tag.div class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25",
|
<%= tag.div class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25",
|
||||||
id: type == :processing ? "syncing-notification" : id,
|
|
||||||
data: {
|
data: {
|
||||||
controller: "element-removal",
|
controller: "element-removal",
|
||||||
action: action
|
action: action
|
||||||
|
@ -20,8 +19,6 @@
|
||||||
<div class="flex h-full items-center justify-center rounded-full bg-error">
|
<div class="flex h-full items-center justify-center rounded-full bg-error">
|
||||||
<%= lucide_icon "x", class: "w-3 h-3" %>
|
<%= lucide_icon "x", class: "w-3 h-3" %>
|
||||||
</div>
|
</div>
|
||||||
<% when :processing %>
|
|
||||||
<%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
7
app/views/shared/_syncing_notice.html.erb
Normal file
7
app/views/shared/_syncing_notice.html.erb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<%= tag.div id: "syncing-notice", class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25" do %>
|
||||||
|
<div class="h-5 w-5 shrink-0 p-px text-white">
|
||||||
|
<%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= tag.p t(".syncing"), class: "text-gray-900 text-sm font-medium" %>
|
||||||
|
<% end %>
|
|
@ -16,7 +16,7 @@
|
||||||
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
|
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
|
<%= link_to new_account_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
<p class="text-sm font-medium">New transaction</p>
|
<p class="text-sm font-medium">New transaction</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<%= entries_by_date(@transaction_entries, totals: true) do |entries| %>
|
<%= entries_by_date(@transaction_entries, totals: true) do |entries| %>
|
||||||
<%= render entries, origin: "transactions" %>
|
<%= render entries %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
<% content_for :sidebar do %>
|
|
||||||
<%= render "settings/nav" %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h1 class="text-gray-900 text-xl font-medium mb-4">Rules</h1>
|
|
||||||
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
|
||||||
<div class="flex justify-center items-center py-20">
|
|
||||||
<p class="text-gray-500">Transaction rules coming soon...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<%= previous_setting("Merchants", merchants_path) %>
|
|
||||||
<%= next_setting("Imports", imports_path) %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -80,6 +80,29 @@
|
||||||
],
|
],
|
||||||
"note": ""
|
"note": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"warning_type": "Mass Assignment",
|
||||||
|
"warning_code": 105,
|
||||||
|
"fingerprint": "f158202dcc66f2273ddea5e5296bad7146a50ca6667f49c77372b5b234542334",
|
||||||
|
"check_name": "PermitAttributes",
|
||||||
|
"message": "Potentially dangerous key allowed for mass assignment",
|
||||||
|
"file": "app/controllers/concerns/entryable_resource.rb",
|
||||||
|
"line": 122,
|
||||||
|
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
|
||||||
|
"code": "params.require(:account_entry).permit(:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_attributes => self.class.permitted_entryable_attributes)",
|
||||||
|
"render_path": null,
|
||||||
|
"location": {
|
||||||
|
"type": "method",
|
||||||
|
"class": "EntryableResource",
|
||||||
|
"method": "entry_params"
|
||||||
|
},
|
||||||
|
"user_input": ":account_id",
|
||||||
|
"confidence": "High",
|
||||||
|
"cwe_id": [
|
||||||
|
915
|
||||||
|
],
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"warning_type": "Dynamic Render Path",
|
"warning_type": "Dynamic Render Path",
|
||||||
"warning_code": 15,
|
"warning_code": 15,
|
||||||
|
@ -115,6 +138,6 @@
|
||||||
"note": ""
|
"note": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updated": "2024-11-02 15:02:28 -0400",
|
"updated": "2024-11-27 15:33:53 -0500",
|
||||||
"brakeman_version": "6.2.2"
|
"brakeman_version": "6.2.2"
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
en:
|
en:
|
||||||
account:
|
account:
|
||||||
entries:
|
entries:
|
||||||
|
create:
|
||||||
|
success: Entry created
|
||||||
destroy:
|
destroy:
|
||||||
success: Entry deleted
|
success: Entry deleted
|
||||||
empty:
|
empty:
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
en:
|
en:
|
||||||
account:
|
account:
|
||||||
holdings:
|
holdings:
|
||||||
|
destroy:
|
||||||
|
success: Holding deleted
|
||||||
holding:
|
holding:
|
||||||
per_share: per share
|
per_share: per share
|
||||||
shares: "%{qty} shares"
|
shares: "%{qty} shares"
|
||||||
|
|
|
@ -2,9 +2,6 @@
|
||||||
en:
|
en:
|
||||||
account:
|
account:
|
||||||
trades:
|
trades:
|
||||||
create:
|
|
||||||
failure: Something went wrong
|
|
||||||
success: Transaction created successfully.
|
|
||||||
form:
|
form:
|
||||||
account: Transfer account (optional)
|
account: Transfer account (optional)
|
||||||
account_prompt: Search account
|
account_prompt: Search account
|
||||||
|
@ -15,6 +12,15 @@ en:
|
||||||
submit: Add transaction
|
submit: Add transaction
|
||||||
ticker_placeholder: AAPL
|
ticker_placeholder: AAPL
|
||||||
type: Type
|
type: Type
|
||||||
|
header:
|
||||||
|
buy: Buy
|
||||||
|
current_market_price_label: Current Market Price
|
||||||
|
overview: Overview
|
||||||
|
purchase_price_label: Purchase Price
|
||||||
|
purchase_qty_label: Purchase Quantity
|
||||||
|
sell: Sell
|
||||||
|
symbol_label: Symbol
|
||||||
|
total_return_label: Unrealized gain/loss
|
||||||
index:
|
index:
|
||||||
amount: Amount
|
amount: Amount
|
||||||
new: New transaction
|
new: New transaction
|
||||||
|
@ -27,7 +33,6 @@ en:
|
||||||
show:
|
show:
|
||||||
additional: Additional
|
additional: Additional
|
||||||
cost_per_share_label: Cost per Share
|
cost_per_share_label: Cost per Share
|
||||||
current_market_price_label: Current Market Price
|
|
||||||
date_label: Date
|
date_label: Date
|
||||||
delete: Delete
|
delete: Delete
|
||||||
delete_subtitle: This action cannot be undone
|
delete_subtitle: This action cannot be undone
|
||||||
|
@ -37,12 +42,5 @@ en:
|
||||||
exclude_title: Exclude from analytics
|
exclude_title: Exclude from analytics
|
||||||
note_label: Note
|
note_label: Note
|
||||||
note_placeholder: Add any additional notes here...
|
note_placeholder: Add any additional notes here...
|
||||||
overview: Overview
|
|
||||||
purchase_price_label: Purchase Price
|
|
||||||
purchase_qty_label: Purchase Quantity
|
|
||||||
quantity_label: Quantity
|
quantity_label: Quantity
|
||||||
settings: Settings
|
settings: Settings
|
||||||
symbol_label: Symbol
|
|
||||||
total_return_label: Unrealized gain/loss
|
|
||||||
update:
|
|
||||||
success: Trade updated successfully.
|
|
||||||
|
|
|
@ -2,11 +2,44 @@
|
||||||
en:
|
en:
|
||||||
account:
|
account:
|
||||||
transactions:
|
transactions:
|
||||||
|
bulk_delete:
|
||||||
|
success: "%{count} transactions deleted"
|
||||||
|
bulk_edit:
|
||||||
|
cancel: Cancel
|
||||||
|
category_label: Category
|
||||||
|
category_placeholder: Select a category
|
||||||
|
date_label: Date
|
||||||
|
details: Details
|
||||||
|
merchant_label: Merchant
|
||||||
|
merchant_placeholder: Select a merchant
|
||||||
|
note_label: Notes
|
||||||
|
note_placeholder: Enter a note that will be applied to selected transactions
|
||||||
|
overview: Overview
|
||||||
|
save: Save
|
||||||
|
bulk_update:
|
||||||
|
success: "%{count} transactions updated"
|
||||||
|
form:
|
||||||
|
account: Account
|
||||||
|
account_prompt: Select an Account
|
||||||
|
amount: Amount
|
||||||
|
category: Category
|
||||||
|
category_prompt: Select a Category
|
||||||
|
date: Date
|
||||||
|
description: Description
|
||||||
|
description_placeholder: Describe transaction
|
||||||
|
expense: Expense
|
||||||
|
income: Income
|
||||||
|
submit: Add transaction
|
||||||
|
transfer: Transfer
|
||||||
index:
|
index:
|
||||||
new: New transaction
|
new: New transaction
|
||||||
no_transactions: No transactions for this account yet.
|
no_transactions: No transactions for this account yet.
|
||||||
transaction: transaction
|
transaction: transaction
|
||||||
transactions: Transactions
|
transactions: Transactions
|
||||||
|
mark_transfers:
|
||||||
|
success: Marked as transfers
|
||||||
|
new:
|
||||||
|
new_transaction: New transaction
|
||||||
selection_bar:
|
selection_bar:
|
||||||
mark_transfers: Mark as transfers?
|
mark_transfers: Mark as transfers?
|
||||||
mark_transfers_confirm: Mark as transfers
|
mark_transfers_confirm: Mark as transfers
|
||||||
|
@ -16,7 +49,6 @@ en:
|
||||||
account_label: Account
|
account_label: Account
|
||||||
amount: Amount
|
amount: Amount
|
||||||
category_label: Category
|
category_label: Category
|
||||||
category_placeholder: Select a category
|
|
||||||
date_label: Date
|
date_label: Date
|
||||||
delete: Delete
|
delete: Delete
|
||||||
delete_subtitle: This permanently deletes the transaction, affects your historical
|
delete_subtitle: This permanently deletes the transaction, affects your historical
|
||||||
|
@ -27,13 +59,14 @@ en:
|
||||||
analytics.
|
analytics.
|
||||||
exclude_title: Exclude transaction
|
exclude_title: Exclude transaction
|
||||||
merchant_label: Merchant
|
merchant_label: Merchant
|
||||||
merchant_placeholder: Select a merchant
|
|
||||||
name_label: Name
|
name_label: Name
|
||||||
nature: Type
|
nature: Type
|
||||||
|
none: "(none)"
|
||||||
note_label: Notes
|
note_label: Notes
|
||||||
note_placeholder: Enter a note
|
note_placeholder: Enter a note
|
||||||
overview: Overview
|
overview: Overview
|
||||||
settings: Settings
|
settings: Settings
|
||||||
tags_label: Tags
|
tags_label: Tags
|
||||||
update:
|
uncategorized: "(uncategorized)"
|
||||||
success: Transaction updated successfully.
|
unmark_transfers:
|
||||||
|
success: Transfer removed
|
||||||
|
|
|
@ -2,11 +2,11 @@
|
||||||
en:
|
en:
|
||||||
account:
|
account:
|
||||||
valuations:
|
valuations:
|
||||||
create:
|
|
||||||
success: Valuation created successfully.
|
|
||||||
form:
|
form:
|
||||||
amount: Amount
|
amount: Amount
|
||||||
submit: Add balance update
|
submit: Add balance update
|
||||||
|
header:
|
||||||
|
balance: Balance
|
||||||
index:
|
index:
|
||||||
change: change
|
change: change
|
||||||
date: date
|
date: date
|
||||||
|
@ -18,7 +18,6 @@ en:
|
||||||
title: New balance
|
title: New balance
|
||||||
show:
|
show:
|
||||||
amount: Amount
|
amount: Amount
|
||||||
balance: Balance
|
|
||||||
date_label: Date
|
date_label: Date
|
||||||
delete: Delete
|
delete: Delete
|
||||||
delete_subtitle: This action cannot be undone
|
delete_subtitle: This action cannot be undone
|
||||||
|
|
|
@ -31,10 +31,11 @@ en:
|
||||||
manual_entry: Enter account balance
|
manual_entry: Enter account balance
|
||||||
title: How would you like to add it?
|
title: How would you like to add it?
|
||||||
title: What would you like to add?
|
title: What would you like to add?
|
||||||
|
chart:
|
||||||
|
no_change: no change
|
||||||
show:
|
show:
|
||||||
chart:
|
chart:
|
||||||
balance: Balance
|
balance: Balance
|
||||||
no_change: no change
|
|
||||||
owed: Amount owed
|
owed: Amount owed
|
||||||
menu:
|
menu:
|
||||||
confirm_accept: Delete "%{name}"
|
confirm_accept: Delete "%{name}"
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
---
|
---
|
||||||
en:
|
en:
|
||||||
layouts:
|
layouts:
|
||||||
application:
|
|
||||||
syncing: Syncing account data...
|
|
||||||
auth:
|
auth:
|
||||||
existing_account: Already have an account?
|
existing_account: Already have an account?
|
||||||
no_account: New to Maybe?
|
no_account: New to Maybe?
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
---
|
---
|
||||||
en:
|
en:
|
||||||
shared:
|
shared:
|
||||||
|
syncing_notice:
|
||||||
|
syncing: Syncing accounts data...
|
||||||
confirm_modal:
|
confirm_modal:
|
||||||
accept: Confirm
|
accept: Confirm
|
||||||
body_html: "<p>You will not be able to undo this decision</p>"
|
body_html: "<p>You will not be able to undo this decision</p>"
|
||||||
|
|
|
@ -1,37 +1,6 @@
|
||||||
---
|
---
|
||||||
en:
|
en:
|
||||||
transactions:
|
transactions:
|
||||||
bulk_delete:
|
|
||||||
success: "%{count} transactions deleted"
|
|
||||||
bulk_edit:
|
|
||||||
cancel: Cancel
|
|
||||||
category_label: Category
|
|
||||||
category_placeholder: Select a category
|
|
||||||
date_label: Date
|
|
||||||
details: Details
|
|
||||||
merchant_label: Merchant
|
|
||||||
merchant_placeholder: Select a merchant
|
|
||||||
note_label: Notes
|
|
||||||
note_placeholder: Enter a note that will be applied to selected transactions
|
|
||||||
overview: Overview
|
|
||||||
save: Save
|
|
||||||
bulk_update:
|
|
||||||
success: "%{count} transactions updated"
|
|
||||||
create:
|
|
||||||
success: New transaction created successfully
|
|
||||||
form:
|
|
||||||
account: Account
|
|
||||||
account_prompt: Select an Account
|
|
||||||
amount: Amount
|
|
||||||
category: Category
|
|
||||||
category_prompt: Select a Category
|
|
||||||
date: Date
|
|
||||||
description: Description
|
|
||||||
description_placeholder: Describe transaction
|
|
||||||
expense: Expense
|
|
||||||
income: Income
|
|
||||||
submit: Add transaction
|
|
||||||
transfer: Transfer
|
|
||||||
header:
|
header:
|
||||||
edit_categories: Edit categories
|
edit_categories: Edit categories
|
||||||
edit_imports: Edit imports
|
edit_imports: Edit imports
|
||||||
|
@ -41,10 +10,6 @@ en:
|
||||||
index:
|
index:
|
||||||
transaction: transaction
|
transaction: transaction
|
||||||
transactions: transactions
|
transactions: transactions
|
||||||
mark_transfers:
|
|
||||||
success: Marked as transfer
|
|
||||||
new:
|
|
||||||
new_transaction: New transaction
|
|
||||||
searches:
|
searches:
|
||||||
filters:
|
filters:
|
||||||
amount_filter:
|
amount_filter:
|
||||||
|
@ -77,5 +42,3 @@ en:
|
||||||
equal_to: equal to
|
equal_to: equal to
|
||||||
greater_than: greater than
|
greater_than: greater than
|
||||||
less_than: less than
|
less_than: less than
|
||||||
unmark_transfers:
|
|
||||||
success: Transfer removed
|
|
||||||
|
|
|
@ -69,22 +69,42 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
member do
|
member do
|
||||||
post :sync
|
post :sync
|
||||||
end
|
get :chart
|
||||||
|
|
||||||
scope module: :account do
|
|
||||||
resources :holdings, only: %i[index new show destroy]
|
|
||||||
resources :cashes, only: :index
|
|
||||||
|
|
||||||
resources :transactions, only: %i[index update]
|
|
||||||
resources :valuations, only: %i[index new create]
|
|
||||||
resources :trades, only: %i[index new create update] do
|
|
||||||
get :securities, on: :collection
|
|
||||||
end
|
|
||||||
|
|
||||||
resources :entries, only: %i[index edit update show destroy]
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
namespace :account do
|
||||||
|
resources :holdings, only: %i[index new show destroy]
|
||||||
|
resources :cashes, only: :index
|
||||||
|
|
||||||
|
resources :entries, only: :index
|
||||||
|
|
||||||
|
resources :transactions, only: %i[show new create update destroy] do
|
||||||
|
resource :category, only: :update, controller: :transaction_categories
|
||||||
|
|
||||||
|
collection do
|
||||||
|
post "bulk_delete"
|
||||||
|
get "bulk_edit"
|
||||||
|
post "bulk_update"
|
||||||
|
post "mark_transfers"
|
||||||
|
post "unmark_transfers"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
resources :valuations, only: %i[show new create update destroy]
|
||||||
|
resources :trades, only: %i[show new create update destroy]
|
||||||
|
end
|
||||||
|
|
||||||
|
direct :account_entry do |entry, options|
|
||||||
|
if entry.new_record?
|
||||||
|
route_for "account_#{entry.entryable_name.pluralize}", options
|
||||||
|
else
|
||||||
|
route_for entry.entryable_name, entry, options
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
resources :transactions, only: :index
|
||||||
|
|
||||||
# Convenience routes for polymorphic paths
|
# Convenience routes for polymorphic paths
|
||||||
# Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123
|
# Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123
|
||||||
direct :account do |model, options|
|
direct :account do |model, options|
|
||||||
|
@ -104,15 +124,7 @@ Rails.application.routes.draw do
|
||||||
resources :other_assets, except: :index
|
resources :other_assets, except: :index
|
||||||
resources :other_liabilities, except: :index
|
resources :other_liabilities, except: :index
|
||||||
|
|
||||||
resources :transactions, only: %i[index new create] do
|
resources :securities, only: :index
|
||||||
collection do
|
|
||||||
post "bulk_delete"
|
|
||||||
get "bulk_edit"
|
|
||||||
post "bulk_update"
|
|
||||||
post "mark_transfers"
|
|
||||||
post "unmark_transfers"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
resources :invite_codes, only: %i[index create]
|
resources :invite_codes, only: %i[index create]
|
||||||
|
|
||||||
|
|
5
db/migrate/20241126211249_add_logo_url_to_security.rb
Normal file
5
db/migrate/20241126211249_add_logo_url_to_security.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class AddLogoUrlToSecurity < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :securities, :logo_url, :string
|
||||||
|
end
|
||||||
|
end
|
3
db/schema.rb
generated
3
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_11_22_183828) do
|
ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) 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"
|
||||||
|
@ -508,6 +508,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_22_183828) do
|
||||||
t.string "country_code"
|
t.string "country_code"
|
||||||
t.string "exchange_mic"
|
t.string "exchange_mic"
|
||||||
t.string "exchange_acronym"
|
t.string "exchange_acronym"
|
||||||
|
t.string "logo_url"
|
||||||
t.index ["country_code"], name: "index_securities_on_country_code"
|
t.index ["country_code"], name: "index_securities_on_country_code"
|
||||||
t.index ["ticker", "exchange_mic"], name: "index_securities_on_ticker_and_exchange_mic", unique: true
|
t.index ["ticker", "exchange_mic"], name: "index_securities_on_ticker_and_exchange_mic", unique: true
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,63 +3,11 @@ require "test_helper"
|
||||||
class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
|
class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
|
||||||
setup do
|
setup do
|
||||||
sign_in @user = users(:family_admin)
|
sign_in @user = users(:family_admin)
|
||||||
@transaction = account_entries :transaction
|
@entry = account_entries(:transaction)
|
||||||
@valuation = account_entries :valuation
|
|
||||||
@trade = account_entries :trade
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# =================
|
test "gets index" do
|
||||||
# Shared
|
get account_entries_path(account_id: @entry.account.id)
|
||||||
# =================
|
assert_response :success
|
||||||
|
|
||||||
test "should destroy entry" do
|
|
||||||
[ @transaction, @valuation, @trade ].each do |entry|
|
|
||||||
assert_difference -> { Account::Entry.count } => -1, -> { entry.entryable_class.count } => -1 do
|
|
||||||
delete account_entry_url(entry.account, entry)
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_redirected_to account_url(entry.account)
|
|
||||||
assert_enqueued_with(job: SyncJob)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "gets show" do
|
|
||||||
[ @transaction, @valuation, @trade ].each do |entry|
|
|
||||||
get account_entry_url(entry.account, entry)
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "gets edit" do
|
|
||||||
[ @valuation ].each do |entry|
|
|
||||||
get edit_account_entry_url(entry.account, entry)
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can update generic entry" do
|
|
||||||
[ @transaction, @valuation, @trade ].each do |entry|
|
|
||||||
assert_no_difference_in_entries do
|
|
||||||
patch account_entry_url(entry.account, entry), params: {
|
|
||||||
account_entry: {
|
|
||||||
name: "Name",
|
|
||||||
date: Date.current,
|
|
||||||
currency: "USD",
|
|
||||||
amount: 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_redirected_to account_entry_url(entry.account, entry)
|
|
||||||
assert_enqueued_with(job: SyncJob)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
# Simple guard to verify that nested attributes are passed the record ID to avoid new creation of record
|
|
||||||
# See `update_only` option of accepts_nested_attributes_for
|
|
||||||
def assert_no_difference_in_entries(&block)
|
|
||||||
assert_no_difference [ "Account::Entry.count", "Account::Transaction.count", "Account::Valuation.count" ], &block
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,12 +8,12 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "gets holdings" do
|
test "gets holdings" do
|
||||||
get account_holdings_url(@account)
|
get account_holdings_url(account_id: @account.id)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
|
||||||
test "gets holding" do
|
test "gets holding" do
|
||||||
get account_holding_path(@account, @holding)
|
get account_holding_path(@holding)
|
||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
@ -21,10 +21,10 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest
|
||||||
test "destroys holding and associated entries" do
|
test "destroys holding and associated entries" do
|
||||||
assert_difference -> { Account::Holding.count } => -1,
|
assert_difference -> { Account::Holding.count } => -1,
|
||||||
-> { Account::Entry.count } => -1 do
|
-> { Account::Entry.count } => -1 do
|
||||||
delete account_holding_path(@account, @holding)
|
delete account_holding_path(@holding)
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_redirected_to account_holdings_path(@account)
|
assert_redirected_to account_path(@holding.account)
|
||||||
assert_empty @account.entries.where(entryable: @account.trades.where(security: @holding.security))
|
assert_empty @holding.account.entries.where(entryable: @holding.account.trades.where(security: @holding.security))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,19 +1,36 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
include EntryableResourceInterfaceTest
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
sign_in @user = users(:family_admin)
|
sign_in @user = users(:family_admin)
|
||||||
@entry = account_entries :trade
|
@entry = account_entries(:trade)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should get index" do
|
test "updates trade entry" do
|
||||||
get account_trades_url(@entry.account)
|
assert_no_difference [ "Account::Entry.count", "Account::Trade.count" ] do
|
||||||
assert_response :success
|
patch account_trade_url(@entry), params: {
|
||||||
end
|
account_entry: {
|
||||||
|
currency: "USD",
|
||||||
|
entryable_attributes: {
|
||||||
|
id: @entry.entryable_id,
|
||||||
|
qty: 20,
|
||||||
|
price: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
test "should get new" do
|
@entry.reload
|
||||||
get new_account_trade_url(@entry.account)
|
|
||||||
assert_response :success
|
assert_enqueued_with job: SyncJob
|
||||||
|
|
||||||
|
assert_equal 20, @entry.account_trade.qty
|
||||||
|
assert_equal 20, @entry.account_trade.price
|
||||||
|
assert_equal "USD", @entry.currency
|
||||||
|
|
||||||
|
assert_redirected_to account_url(@entry.account)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "creates deposit entry" do
|
test "creates deposit entry" do
|
||||||
|
@ -22,9 +39,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_difference -> { Account::Entry.count } => 2,
|
assert_difference -> { Account::Entry.count } => 2,
|
||||||
-> { Account::Transaction.count } => 2,
|
-> { Account::Transaction.count } => 2,
|
||||||
-> { Account::Transfer.count } => 1 do
|
-> { Account::Transfer.count } => 1 do
|
||||||
post account_trades_url(@entry.account), params: {
|
post account_trades_url, params: {
|
||||||
account_entry: {
|
account_entry: {
|
||||||
type: "transfer_in",
|
account_id: @entry.account_id,
|
||||||
|
type: "deposit",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
amount: 10,
|
amount: 10,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
|
@ -42,9 +60,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_difference -> { Account::Entry.count } => 2,
|
assert_difference -> { Account::Entry.count } => 2,
|
||||||
-> { Account::Transaction.count } => 2,
|
-> { Account::Transaction.count } => 2,
|
||||||
-> { Account::Transfer.count } => 1 do
|
-> { Account::Transfer.count } => 1 do
|
||||||
post account_trades_url(@entry.account), params: {
|
post account_trades_url, params: {
|
||||||
account_entry: {
|
account_entry: {
|
||||||
type: "transfer_out",
|
account_id: @entry.account_id,
|
||||||
|
type: "withdrawal",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
amount: 10,
|
amount: 10,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
|
@ -60,9 +79,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_difference -> { Account::Entry.count } => 1,
|
assert_difference -> { Account::Entry.count } => 1,
|
||||||
-> { Account::Transaction.count } => 1,
|
-> { Account::Transaction.count } => 1,
|
||||||
-> { Account::Transfer.count } => 0 do
|
-> { Account::Transfer.count } => 0 do
|
||||||
post account_trades_url(@entry.account), params: {
|
post account_trades_url, params: {
|
||||||
account_entry: {
|
account_entry: {
|
||||||
type: "transfer_out",
|
account_id: @entry.account_id,
|
||||||
|
type: "withdrawal",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
amount: 10,
|
amount: 10,
|
||||||
currency: "USD"
|
currency: "USD"
|
||||||
|
@ -79,8 +99,9 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
test "creates interest entry" do
|
test "creates interest entry" do
|
||||||
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
|
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
|
||||||
post account_trades_url(@entry.account), params: {
|
post account_trades_url, params: {
|
||||||
account_entry: {
|
account_entry: {
|
||||||
|
account_id: @entry.account_id,
|
||||||
type: "interest",
|
type: "interest",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
amount: 10,
|
amount: 10,
|
||||||
|
@ -97,13 +118,15 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
test "creates trade buy entry" do
|
test "creates trade buy entry" do
|
||||||
assert_difference [ "Account::Entry.count", "Account::Trade.count", "Security.count" ], 1 do
|
assert_difference [ "Account::Entry.count", "Account::Trade.count", "Security.count" ], 1 do
|
||||||
post account_trades_url(@entry.account), params: {
|
post account_trades_url, params: {
|
||||||
account_entry: {
|
account_entry: {
|
||||||
|
account_id: @entry.account_id,
|
||||||
type: "buy",
|
type: "buy",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
ticker: "NVDA (NASDAQ)",
|
ticker: "NVDA (NASDAQ)",
|
||||||
qty: 10,
|
qty: 10,
|
||||||
price: 10
|
price: 10,
|
||||||
|
currency: "USD"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
@ -112,15 +135,16 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
assert created_entry.amount.positive?
|
assert created_entry.amount.positive?
|
||||||
assert created_entry.account_trade.qty.positive?
|
assert created_entry.account_trade.qty.positive?
|
||||||
assert_equal "Transaction created successfully.", flash[:notice]
|
assert_equal "Entry created", flash[:notice]
|
||||||
assert_enqueued_with job: SyncJob
|
assert_enqueued_with job: SyncJob
|
||||||
assert_redirected_to @entry.account
|
assert_redirected_to account_url(created_entry.account)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "creates trade sell entry" do
|
test "creates trade sell entry" do
|
||||||
assert_difference [ "Account::Entry.count", "Account::Trade.count" ], 1 do
|
assert_difference [ "Account::Entry.count", "Account::Trade.count" ], 1 do
|
||||||
post account_trades_url(@entry.account), params: {
|
post account_trades_url, params: {
|
||||||
account_entry: {
|
account_entry: {
|
||||||
|
account_id: @entry.account_id,
|
||||||
type: "sell",
|
type: "sell",
|
||||||
ticker: "AAPL (NYSE)",
|
ticker: "AAPL (NYSE)",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
|
@ -135,8 +159,8 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
assert created_entry.amount.negative?
|
assert created_entry.amount.negative?
|
||||||
assert created_entry.account_trade.qty.negative?
|
assert created_entry.account_trade.qty.negative?
|
||||||
assert_equal "Transaction created successfully.", flash[:notice]
|
assert_equal "Entry created", flash[:notice]
|
||||||
assert_enqueued_with job: SyncJob
|
assert_enqueued_with job: SyncJob
|
||||||
assert_redirected_to @entry.account
|
assert_redirected_to account_url(created_entry.account)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,40 +1,117 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
include EntryableResourceInterfaceTest
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
sign_in @user = users(:family_admin)
|
sign_in @user = users(:family_admin)
|
||||||
@entry = account_entries :transaction
|
@entry = account_entries(:transaction)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should get index" do
|
test "creates with transaction details" do
|
||||||
get account_transactions_url(@entry.account)
|
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
|
||||||
assert_response :success
|
post account_transactions_url, params: {
|
||||||
end
|
|
||||||
|
|
||||||
test "update" do
|
|
||||||
assert_no_difference [ "Account::Entry.count", "Account::Transaction.count" ] do
|
|
||||||
patch account_transaction_url(@entry.account, @entry), params: {
|
|
||||||
account_entry: {
|
account_entry: {
|
||||||
name: "Name",
|
account_id: @entry.account_id,
|
||||||
|
name: "New transaction",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
amount: 100,
|
amount: 100,
|
||||||
nature: "income",
|
nature: "inflow",
|
||||||
entryable_type: @entry.entryable_type,
|
|
||||||
entryable_attributes: {
|
entryable_attributes: {
|
||||||
id: @entry.entryable_id,
|
|
||||||
tag_ids: [ Tag.first.id, Tag.second.id ],
|
tag_ids: [ Tag.first.id, Tag.second.id ],
|
||||||
category_id: Category.first.id,
|
category_id: Category.first.id,
|
||||||
merchant_id: Merchant.first.id,
|
merchant_id: Merchant.first.id
|
||||||
notes: "test notes",
|
|
||||||
excluded: false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Transaction updated successfully.", flash[:notice]
|
created_entry = Account::Entry.order(:created_at).last
|
||||||
assert_redirected_to account_entry_url(@entry.account, @entry)
|
|
||||||
|
assert_redirected_to account_url(created_entry.account)
|
||||||
|
assert_equal "Entry created", flash[:notice]
|
||||||
assert_enqueued_with(job: SyncJob)
|
assert_enqueued_with(job: SyncJob)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "updates with transaction details" do
|
||||||
|
assert_no_difference [ "Account::Entry.count", "Account::Transaction.count" ] do
|
||||||
|
patch account_transaction_url(@entry), params: {
|
||||||
|
account_entry: {
|
||||||
|
name: "Updated name",
|
||||||
|
date: Date.current,
|
||||||
|
currency: "USD",
|
||||||
|
amount: 100,
|
||||||
|
nature: "inflow",
|
||||||
|
entryable_type: @entry.entryable_type,
|
||||||
|
notes: "test notes",
|
||||||
|
excluded: false,
|
||||||
|
entryable_attributes: {
|
||||||
|
id: @entry.entryable_id,
|
||||||
|
tag_ids: [ Tag.first.id, Tag.second.id ],
|
||||||
|
category_id: Category.first.id,
|
||||||
|
merchant_id: Merchant.first.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@entry.reload
|
||||||
|
|
||||||
|
assert_equal "Updated name", @entry.name
|
||||||
|
assert_equal Date.current, @entry.date
|
||||||
|
assert_equal "USD", @entry.currency
|
||||||
|
assert_equal -100, @entry.amount
|
||||||
|
assert_equal [ Tag.first.id, Tag.second.id ], @entry.entryable.tag_ids.sort
|
||||||
|
assert_equal Category.first.id, @entry.entryable.category_id
|
||||||
|
assert_equal Merchant.first.id, @entry.entryable.merchant_id
|
||||||
|
assert_equal "test notes", @entry.notes
|
||||||
|
assert_equal false, @entry.excluded
|
||||||
|
|
||||||
|
assert_equal "Entry updated", flash[:notice]
|
||||||
|
assert_redirected_to account_url(@entry.account)
|
||||||
|
assert_enqueued_with(job: SyncJob)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can destroy many transactions at once" do
|
||||||
|
transactions = @user.family.entries.account_transactions
|
||||||
|
delete_count = transactions.size
|
||||||
|
|
||||||
|
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do
|
||||||
|
post bulk_delete_account_transactions_url, params: {
|
||||||
|
bulk_delete: {
|
||||||
|
entry_ids: transactions.pluck(:id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to transactions_url
|
||||||
|
assert_equal "#{delete_count} transactions deleted", flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update many transactions at once" do
|
||||||
|
transactions = @user.family.entries.account_transactions
|
||||||
|
|
||||||
|
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do
|
||||||
|
post bulk_update_account_transactions_url, params: {
|
||||||
|
bulk_update: {
|
||||||
|
entry_ids: transactions.map(&:id),
|
||||||
|
date: 1.day.ago.to_date,
|
||||||
|
category_id: Category.second.id,
|
||||||
|
merchant_id: Merchant.second.id,
|
||||||
|
notes: "Updated note"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to transactions_url
|
||||||
|
assert_equal "#{transactions.count} transactions updated", flash[:notice]
|
||||||
|
|
||||||
|
transactions.reload.each do |transaction|
|
||||||
|
assert_equal 1.day.ago.to_date, transaction.date
|
||||||
|
assert_equal Category.second, transaction.account_transaction.category
|
||||||
|
assert_equal Merchant.second, transaction.account_transaction.merchant
|
||||||
|
assert_equal "Updated note", transaction.notes
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,36 +1,11 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest
|
class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
include EntryableResourceInterfaceTest
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
sign_in @user = users(:family_admin)
|
sign_in @user = users(:family_admin)
|
||||||
@entry = account_entries :valuation
|
@entry = account_entries(:valuation)
|
||||||
end
|
|
||||||
|
|
||||||
test "should get index" do
|
|
||||||
get account_valuations_url(@entry.account)
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should get new" do
|
|
||||||
get new_account_valuation_url(@entry.account)
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "create" do
|
|
||||||
assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do
|
|
||||||
post account_valuations_url(@entry.account), params: {
|
|
||||||
account_entry: {
|
|
||||||
name: "Manual valuation",
|
|
||||||
amount: 19800,
|
|
||||||
date: Date.current,
|
|
||||||
currency: "USD"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_equal "Valuation created successfully.", flash[:notice]
|
|
||||||
assert_enqueued_with job: SyncJob
|
|
||||||
assert_redirected_to account_valuations_path(@entry.account)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "error when valuation already exists for date" do
|
test "error when valuation already exists for date" do
|
||||||
|
@ -44,7 +19,43 @@ class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Date has already been taken", flash[:alert]
|
assert_response :unprocessable_entity
|
||||||
assert_redirected_to @entry.account
|
end
|
||||||
|
|
||||||
|
test "creates entry with basic attributes" do
|
||||||
|
assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do
|
||||||
|
post account_valuations_url, params: {
|
||||||
|
account_entry: {
|
||||||
|
name: "New entry",
|
||||||
|
amount: 10000,
|
||||||
|
currency: "USD",
|
||||||
|
date: Date.current,
|
||||||
|
account_id: @entry.account_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
created_entry = Account::Entry.order(created_at: :desc).first
|
||||||
|
|
||||||
|
assert_enqueued_with job: SyncJob
|
||||||
|
|
||||||
|
assert_redirected_to account_url(created_entry.account)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates entry with basic attributes" do
|
||||||
|
assert_no_difference [ "Account::Entry.count", "Account::Valuation.count" ] do
|
||||||
|
patch account_valuation_url(@entry), params: {
|
||||||
|
account_entry: {
|
||||||
|
name: "Updated entry",
|
||||||
|
amount: 20000,
|
||||||
|
currency: "USD",
|
||||||
|
date: Date.current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_enqueued_with job: SyncJob
|
||||||
|
|
||||||
|
assert_redirected_to account_url(@entry.account)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,83 +8,6 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
@transaction = account_entries(:transaction)
|
@transaction = account_entries(:transaction)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should get new" do
|
|
||||||
get new_transaction_url
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "prefills account_id" do
|
|
||||||
get new_transaction_url(account_id: @transaction.account.id)
|
|
||||||
assert_response :success
|
|
||||||
assert_select "option[selected][value='#{@transaction.account.id}']"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should create transaction" do
|
|
||||||
account = @user.family.accounts.first
|
|
||||||
entry_params = {
|
|
||||||
account_id: account.id,
|
|
||||||
amount: 100.45,
|
|
||||||
currency: "USD",
|
|
||||||
date: Date.current,
|
|
||||||
name: "Test transaction",
|
|
||||||
entryable_type: "Account::Transaction",
|
|
||||||
entryable_attributes: { category_id: categories(:food_and_drink).id }
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
|
|
||||||
post transactions_url, params: { account_entry: entry_params }
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_equal entry_params[:amount].to_d, Account::Transaction.order(created_at: :desc).first.entry.amount
|
|
||||||
assert_equal "New transaction created successfully", flash[:notice]
|
|
||||||
assert_enqueued_with(job: SyncJob)
|
|
||||||
assert_redirected_to account_url(account)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "expenses are positive" do
|
|
||||||
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], 1) do
|
|
||||||
post transactions_url, params: {
|
|
||||||
account_entry: {
|
|
||||||
nature: "expense",
|
|
||||||
account_id: @transaction.account_id,
|
|
||||||
amount: @transaction.amount,
|
|
||||||
currency: @transaction.currency,
|
|
||||||
date: @transaction.date,
|
|
||||||
name: @transaction.name,
|
|
||||||
entryable_type: "Account::Transaction",
|
|
||||||
entryable_attributes: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
created_entry = Account::Entry.order(created_at: :desc).first
|
|
||||||
|
|
||||||
assert_redirected_to account_url(@transaction.account)
|
|
||||||
assert created_entry.amount.positive?, "Amount should be positive"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "incomes are negative" do
|
|
||||||
assert_difference("Account::Transaction.count") do
|
|
||||||
post transactions_url, params: {
|
|
||||||
account_entry: {
|
|
||||||
nature: "income",
|
|
||||||
account_id: @transaction.account_id,
|
|
||||||
amount: @transaction.amount,
|
|
||||||
currency: @transaction.currency,
|
|
||||||
date: @transaction.date,
|
|
||||||
name: @transaction.name,
|
|
||||||
entryable_type: "Account::Transaction",
|
|
||||||
entryable_attributes: { category_id: categories(:food_and_drink).id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
created_entry = Account::Entry.order(created_at: :desc).first
|
|
||||||
|
|
||||||
assert_redirected_to account_url(@transaction.account)
|
|
||||||
assert created_entry.amount.negative?, "Amount should be negative"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "transaction count represents filtered total" do
|
test "transaction count represents filtered total" do
|
||||||
family = families(:empty)
|
family = families(:empty)
|
||||||
sign_in family.users.first
|
sign_in family.users.first
|
||||||
|
@ -135,46 +58,4 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
assert_dom "#" + dom_id(sorted_transactions.last), count: 1
|
assert_dom "#" + dom_id(sorted_transactions.last), count: 1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can destroy many transactions at once" do
|
|
||||||
transactions = @user.family.entries.account_transactions
|
|
||||||
delete_count = transactions.size
|
|
||||||
|
|
||||||
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do
|
|
||||||
post bulk_delete_transactions_url, params: {
|
|
||||||
bulk_delete: {
|
|
||||||
entry_ids: transactions.pluck(:id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_redirected_to transactions_url
|
|
||||||
assert_equal "#{delete_count} transactions deleted", flash[:notice]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can update many transactions at once" do
|
|
||||||
transactions = @user.family.entries.account_transactions
|
|
||||||
|
|
||||||
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do
|
|
||||||
post bulk_update_transactions_url, params: {
|
|
||||||
bulk_update: {
|
|
||||||
entry_ids: transactions.map(&:id),
|
|
||||||
date: 1.day.ago.to_date,
|
|
||||||
category_id: Category.second.id,
|
|
||||||
merchant_id: Merchant.second.id,
|
|
||||||
notes: "Updated note"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_redirected_to transactions_url
|
|
||||||
assert_equal "#{transactions.count} transactions updated", flash[:notice]
|
|
||||||
|
|
||||||
transactions.reload.each do |transaction|
|
|
||||||
assert_equal 1.day.ago.to_date, transaction.date
|
|
||||||
assert_equal Category.second, transaction.account_transaction.category
|
|
||||||
assert_equal Merchant.second, transaction.account_transaction.merchant
|
|
||||||
assert_equal "Updated note", transaction.notes
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
25
test/interfaces/entryable_resource_interface_test.rb
Normal file
25
test/interfaces/entryable_resource_interface_test.rb
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module EntryableResourceInterfaceTest
|
||||||
|
extend ActiveSupport::Testing::Declarative
|
||||||
|
|
||||||
|
test "shows new form" do
|
||||||
|
get new_polymorphic_url(@entry.entryable)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows editing drawer" do
|
||||||
|
get account_entry_url(@entry)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "destroys entry" do
|
||||||
|
assert_difference "Account::Entry.count", -1 do
|
||||||
|
delete account_entry_url(@entry)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_enqueued_with job: SyncJob
|
||||||
|
|
||||||
|
assert_redirected_to account_url(@entry.account)
|
||||||
|
end
|
||||||
|
end
|
|
@ -10,9 +10,9 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
|
|
||||||
visit_account_trades
|
visit_account_trades
|
||||||
|
|
||||||
Security::SynthComboboxOption.stubs(:find_in_synth).returns([
|
Security.stubs(:search).returns([
|
||||||
Security::SynthComboboxOption.new(
|
Security.new(
|
||||||
symbol: "AAPL",
|
ticker: "AAPL",
|
||||||
name: "Apple Inc.",
|
name: "Apple Inc.",
|
||||||
logo_url: "https://logo.synthfinance.com/ticker/AAPL",
|
logo_url: "https://logo.synthfinance.com/ticker/AAPL",
|
||||||
exchange_acronym: "NASDAQ",
|
exchange_acronym: "NASDAQ",
|
||||||
|
@ -37,7 +37,7 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
visit_account_trades
|
visit_account_trades
|
||||||
|
|
||||||
within_trades do
|
within_trades do
|
||||||
assert_text "Purchase 10 shares of AAPL"
|
assert_text "Buy 10.0 shares of AAPL"
|
||||||
assert_text "Buy #{shares_qty} shares of AAPL"
|
assert_text "Buy #{shares_qty} shares of AAPL"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue