mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +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
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,56 +2,21 @@ class Account::EntriesController < ApplicationController
|
|||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[edit update show destroy]
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
@pagy, @entries = pagy(@account.entries.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")
|
||||
@pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def entryable_view_path(action)
|
||||
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes)
|
||||
def entries_scope
|
||||
scope = Current.family.entries
|
||||
scope = scope.where(account: @account) if @account
|
||||
scope
|
||||
end
|
||||
|
||||
def search_params
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
class Account::HoldingsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_holding, only: %i[show destroy]
|
||||
|
||||
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
|
||||
|
||||
def show
|
||||
|
@ -13,16 +14,17 @@ class Account::HoldingsController < ApplicationController
|
|||
|
||||
def destroy
|
||||
@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
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_holding
|
||||
@holding = @account.holdings.current.find(params[:id])
|
||||
@holding = Current.family.holdings.current.find(params[:id])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,69 +1,37 @@
|
|||
class Account::TradesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
include EntryableResource
|
||||
|
||||
before_action :set_account
|
||||
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
|
||||
permitted_entryable_attributes :id, :qty, :price
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
def build_entry
|
||||
Account::TradeBuilder.new(create_entry_params)
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
def create_entry_params
|
||||
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
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(
|
||||
:type, :date, :qty, :ticker, :price, :amount, :notes, :excluded, :currency, :transfer_account_id, :entryable_type,
|
||||
entryable_attributes: [
|
||||
:id,
|
||||
:qty,
|
||||
:ticker,
|
||||
:price
|
||||
]
|
||||
)
|
||||
.merge(account: @account)
|
||||
def update_entry_params
|
||||
return entry_params unless entry_params[:entryable_attributes].present?
|
||||
|
||||
update_params = entry_params
|
||||
update_params = update_params.merge(entryable_type: "Account::Trade")
|
||||
|
||||
qty = update_params[:entryable_attributes][:qty]
|
||||
price = update_params[:entryable_attributes][:price]
|
||||
|
||||
if qty.present? && price.present?
|
||||
qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d
|
||||
update_params[:entryable_attributes][:qty] = qty
|
||||
update_params[:amount] = qty * price.to_d
|
||||
end
|
||||
|
||||
update_params.except(:nature)
|
||||
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
|
||||
layout :with_sidebar
|
||||
include EntryableResource
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: :update
|
||||
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
|
||||
|
||||
def index
|
||||
@pagy, @entries = pagy(
|
||||
@account.entries.account_transactions.reverse_chronological,
|
||||
limit: params[:per_page] || "10"
|
||||
)
|
||||
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 update
|
||||
prev_amount = @entry.amount
|
||||
prev_date = @entry.date
|
||||
|
||||
@entry.update!(entry_params.except(:origin))
|
||||
@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 do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
@entry,
|
||||
partial: "account/entries/entry",
|
||||
locals: entry_locals.merge(entry: @entry)
|
||||
)
|
||||
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
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def entry_locals
|
||||
{
|
||||
selectable: entry_params[:origin].present?,
|
||||
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
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,38 +1,3 @@
|
|||
class Account::ValuationsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
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
|
||||
include EntryableResource
|
||||
end
|
||||
|
|
|
@ -31,6 +31,11 @@ class AccountsController < ApplicationController
|
|||
redirect_to account_path(@account)
|
||||
end
|
||||
|
||||
def chart
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
render layout: "application"
|
||||
end
|
||||
|
||||
def sync_all
|
||||
unless Current.family.syncing?
|
||||
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
|
||||
def import
|
||||
SecuritiesImportJob.perform_later(params[:exchange_mic])
|
||||
def index
|
||||
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
|
||||
|
||||
private
|
||||
def country_code_filter
|
||||
filter = params[:country_code]
|
||||
filter = "#{filter},US" unless filter == "US"
|
||||
filter
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,94 +13,13 @@ class TransactionsController < ApplicationController
|
|||
}
|
||||
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
|
||||
|
||||
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
|
||||
params.fetch(:q, {})
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||
end
|
||||
|
||||
def transaction_entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
|
||||
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
|
||||
.permit(
|
||||
:start_date, :end_date, :search, :amount,
|
||||
:amount_operator, accounts: [], account_ids: [],
|
||||
categories: [], merchants: [], types: [], tags: []
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -62,9 +62,9 @@ module ApplicationHelper
|
|||
# <div>Content here</div>
|
||||
# <% end %>
|
||||
#
|
||||
def drawer(&block)
|
||||
def drawer(reload_on_close: false, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/drawer", locals: { content: content }
|
||||
render partial: "shared/drawer", locals: { content:, reload_on_close: }
|
||||
end
|
||||
|
||||
def disclosure(title, &block)
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
import "@hotwired/turbo-rails";
|
||||
import "controllers";
|
||||
|
||||
Turbo.StreamActions.redirect = function () {
|
||||
Turbo.visit(this.target);
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ const application = Application.start();
|
|||
application.debug = false;
|
||||
window.Stimulus = application;
|
||||
|
||||
Turbo.setConfirmMethod((message) => {
|
||||
Turbo.config.forms.confirm = (message) => {
|
||||
const dialog = document.getElementById("turbo-confirm");
|
||||
|
||||
try {
|
||||
|
@ -52,6 +52,6 @@ Turbo.setConfirmMethod((message) => {
|
|||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export { application };
|
||||
|
|
|
@ -2,6 +2,10 @@ import { Controller } from "@hotwired/stimulus";
|
|||
|
||||
// Connects to data-controller="modal"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
reloadOnClose: { type: Boolean, default: false },
|
||||
};
|
||||
|
||||
connect() {
|
||||
if (this.element.open) return;
|
||||
this.element.showModal();
|
||||
|
@ -10,11 +14,15 @@ export default class extends Controller {
|
|||
// Hide the dialog when the user clicks outside of it
|
||||
clickOutside(e) {
|
||||
if (e.target === this.element) {
|
||||
this.element.close();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.close();
|
||||
|
||||
if (this.reloadOnCloseValue) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,71 +1,11 @@
|
|||
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"
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"typeInput",
|
||||
"tickerInput",
|
||||
"amountInput",
|
||||
"transferAccountInput",
|
||||
"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,
|
||||
};
|
||||
// Reloads the page with a new type without closing the modal
|
||||
async changeType(event) {
|
||||
const url = new URL(event.params.url, window.location.origin);
|
||||
url.searchParams.set(event.params.key, event.target.value);
|
||||
Turbo.visit(url, { frame: "modal" });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ class Account < ApplicationRecord
|
|||
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
||||
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
|
||||
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 :issues, as: :issuable, dependent: :destroy
|
||||
|
||||
|
|
|
@ -30,10 +30,10 @@ class Account::Entry < ApplicationRecord
|
|||
}
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_entry&.date
|
||||
sync_start_date = if destroyed?
|
||||
previous_entry&.date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
[ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
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
|
||||
prefix = sell? ? "Sell " : "Buy "
|
||||
generated = prefix + "#{qty.abs} shares of #{security.ticker}"
|
||||
entry.name || generated
|
||||
prefix + "#{qty.abs} shares of #{security.ticker}"
|
||||
end
|
||||
|
||||
def unrealized_gain_loss
|
||||
|
|
|
@ -1,33 +1,103 @@
|
|||
class Account::TradeBuilder < Account::EntryBuilder
|
||||
class Account::TradeBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[buy sell].freeze
|
||||
|
||||
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 }
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :type, :transfer_account_id
|
||||
|
||||
def save
|
||||
if valid?
|
||||
create_entry
|
||||
buildable.save
|
||||
end
|
||||
|
||||
def errors
|
||||
buildable.errors
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
buildable.sync_account_later
|
||||
end
|
||||
|
||||
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
|
||||
account.entries.account_trades.create! \
|
||||
def build_trade
|
||||
account.entries.new(
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: account.currency,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Account::Trade.new(
|
||||
security: security,
|
||||
qty: signed_qty,
|
||||
price: price.to_d,
|
||||
currency: account.currency
|
||||
price: price,
|
||||
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
|
||||
|
||||
def security
|
||||
|
@ -40,14 +110,4 @@ class Account::TradeBuilder < Account::EntryBuilder
|
|||
|
||||
security
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
||||
def sync_account_later
|
||||
entries.each(&:sync_account_later)
|
||||
end
|
||||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:)
|
||||
outflow = from_account.entries.build \
|
||||
|
|
|
@ -35,8 +35,9 @@ module Accountable
|
|||
end
|
||||
|
||||
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(
|
||||
account,
|
||||
target: "chart_account_#{account.id}",
|
||||
|
|
|
@ -15,6 +15,7 @@ class Family < ApplicationRecord
|
|||
has_many :categories, dependent: :destroy
|
||||
has_many :merchants, dependent: :destroy
|
||||
has_many :issues, through: :accounts
|
||||
has_many :holdings, through: :accounts
|
||||
has_many :plaid_items, dependent: :destroy
|
||||
|
||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||
|
|
|
@ -56,7 +56,7 @@ class Investment < ApplicationRecord
|
|||
end
|
||||
|
||||
def post_sync
|
||||
broadcast_remove_to(account, target: "syncing-notification")
|
||||
broadcast_remove_to(account, target: "syncing-notice")
|
||||
|
||||
broadcast_replace_to(
|
||||
account,
|
||||
|
|
|
@ -134,12 +134,12 @@ class Provider::Synth
|
|||
|
||||
securities = parsed.dig("data").map do |security|
|
||||
{
|
||||
symbol: security.dig("symbol"),
|
||||
ticker: security.dig("symbol"),
|
||||
name: security.dig("name"),
|
||||
logo_url: security.dig("logo_url"),
|
||||
exchange_acronym: security.dig("exchange", "acronym"),
|
||||
exchange_mic: security.dig("exchange", "mic_code"),
|
||||
exchange_country_code: security.dig("exchange", "country_code")
|
||||
country_code: security.dig("exchange", "country_code")
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -8,17 +8,33 @@ class Security < ApplicationRecord
|
|||
validates :ticker, presence: true
|
||||
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
|
||||
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
|
||||
return nil if @current_price.nil?
|
||||
Money.new(@current_price.price, @current_price.currency)
|
||||
end
|
||||
|
||||
def to_combobox_display
|
||||
"#{ticker} (#{exchange_acronym})"
|
||||
def to_combobox_option
|
||||
SynthComboboxOption.new(
|
||||
symbol: ticker,
|
||||
name: name,
|
||||
logo_url: logo_url,
|
||||
exchange_acronym: exchange_acronym,
|
||||
exchange_mic: exchange_mic,
|
||||
exchange_country_code: country_code
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def upcase_ticker
|
||||
|
|
|
@ -1,22 +1,8 @@
|
|||
class Security::SynthComboboxOption
|
||||
include ActiveModel::Model
|
||||
include Providable
|
||||
|
||||
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
|
||||
"#{symbol}|#{exchange_mic}|#{exchange_acronym}|#{exchange_country_code}" # submitted by combobox as value
|
||||
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 %>
|
||||
<%= 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 %>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
||||
</button>
|
||||
|
|
|
@ -9,13 +9,13 @@
|
|||
<%= tag.span t(".new") %>
|
||||
</button>
|
||||
<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") %>
|
||||
<%= tag.span t(".new_balance"), class: "text-sm" %>
|
||||
<% end %>
|
||||
|
||||
<% 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") %>
|
||||
<%= tag.span t(".new_transaction"), class: "text-sm" %>
|
||||
<% end %>
|
||||
|
@ -75,7 +75,7 @@
|
|||
|
||||
<div class="space-y-4">
|
||||
<%= entries_by_date(@entries) do |entries| %>
|
||||
<%= render entries, show_balance: true, origin: "account" %>
|
||||
<%= render entries, show_balance: true %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full" %>
|
||||
|
||||
<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 %>
|
||||
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
||||
|
|
|
@ -101,10 +101,10 @@
|
|||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_holding_path(@holding.account, @holding),
|
||||
account_holding_path(@holding),
|
||||
method: :delete,
|
||||
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>
|
||||
</details>
|
||||
|
|
|
@ -1,35 +1,51 @@
|
|||
<%# locals: (entry:) %>
|
||||
|
||||
<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" },
|
||||
model: entry,
|
||||
scope: :account_entry,
|
||||
url: account_trades_path(entry.account) do |form| %>
|
||||
<% type = params[:type] || "buy" %>
|
||||
|
||||
<%= styled_form_with model: entry, url: account_trades_path, data: { controller: "trade-form" } do |form| %>
|
||||
|
||||
<%= form.hidden_field :account_id %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% if entry.errors.any? %>
|
||||
<%= render "shared/form_errors", model: entry %>
|
||||
<% end %>
|
||||
|
||||
<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" } } %>
|
||||
<div data-trade-form-target="tickerInput">
|
||||
<%= form.select :type, [
|
||||
["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">
|
||||
<%= form.combobox :ticker, securities_account_trades_path(entry.account), label: t(".holding"), placeholder: t(".ticker_placeholder") %>
|
||||
</div>
|
||||
<%= form.combobox :ticker, securities_path(country_code: Current.family.country), label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %>
|
||||
</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>
|
||||
<%= form.money_field :amount, label: t(".amount") %>
|
||||
</div>
|
||||
<% unless %w[buy sell].include?(type) %>
|
||||
<%= form.money_field :amount, label: t(".amount"), required: true %>
|
||||
<% 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") } %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div data-trade-form-target="qtyInput">
|
||||
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any" %>
|
||||
</div>
|
||||
|
||||
<div data-trade-form-target="priceInput">
|
||||
<%= form.money_field :price, label: t(".price"), currency_value_override: "USD", disable_currency: true %>
|
||||
</div>
|
||||
<% if %w[buy sell].include?(type) %>
|
||||
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any", required: true %>
|
||||
<%= form.money_field :price, label: t(".price"), required: true %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= 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 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">
|
||||
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
||||
</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 %>
|
||||
|
||||
<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">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
|
@ -16,12 +16,12 @@
|
|||
<%= trade.name.first.upcase %>
|
||||
</div>
|
||||
|
||||
<div class="truncate text-gray-900">
|
||||
<div class="truncate">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, trade.name %>
|
||||
<% else %>
|
||||
<%= link_to trade.name,
|
||||
account_entry_path(account, entry),
|
||||
account_entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
<% end %>
|
||||
|
@ -31,7 +31,9 @@
|
|||
</div>
|
||||
|
||||
<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 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 %>
|
||||
<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>
|
||||
<% trade = @entry.account_trade %>
|
||||
|
||||
<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 -->
|
||||
<%= disclosure t(".details") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_trade_path(account, entry),
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_trade_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.date_field :date,
|
||||
label: t(".date_label"),
|
||||
max: Date.current,
|
||||
max: Date.today,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= 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"),
|
||||
step: "any",
|
||||
value: trade.qty.abs,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.money_field :price,
|
||||
label: t(".cost_per_share_label"),
|
||||
disable_currency: true,
|
||||
|
@ -91,8 +45,8 @@
|
|||
<!-- Additional Section -->
|
||||
<%= disclosure t(".additional") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_trade_path(account, entry),
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_trade_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.text_area :notes,
|
||||
|
@ -108,8 +62,8 @@
|
|||
<%= disclosure t(".settings") do %>
|
||||
<div class="pb-4">
|
||||
<!-- Exclude Trade Form -->
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_trade_path(account, entry),
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_trade_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||
|
@ -136,11 +90,11 @@
|
|||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_entry_path(account, entry),
|
||||
account_entry_path(@entry),
|
||||
method: :delete,
|
||||
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>
|
||||
<% 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>
|
||||
<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: :income, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "income" %>
|
||||
<%= 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: :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 %>
|
||||
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
|
||||
<%= tag.span t(".transfer") %>
|
||||
|
@ -12,9 +17,14 @@
|
|||
|
||||
<section class="space-y-2 overflow-hidden">
|
||||
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
|
||||
|
||||
<% 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.hidden_field :entryable_type, value: "Account::Transaction" %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
|
||||
<% 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">
|
||||
<%= 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",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
|
@ -28,14 +28,14 @@
|
|||
</button>
|
||||
<% 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",
|
||||
title: "Edit",
|
||||
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
|
||||
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
|
||||
<% 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">
|
||||
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
||||
</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 %>
|
||||
|
||||
<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 %>
|
||||
<% else %>
|
||||
<%= 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 },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
<% end %>
|
||||
|
@ -43,7 +43,7 @@
|
|||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-1 col-span-2">
|
||||
<%= render "categories/menu", transaction: transaction, origin: origin %>
|
||||
<%= render "categories/menu", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<% unless show_balance %>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<dialog data-controller="modal"
|
||||
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">
|
||||
<%= 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>
|
||||
<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="flex justify-between items-center">
|
||||
<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",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
<%= modal_form_wrapper title: t(".new_transaction") do %>
|
||||
<%= render "form", transaction: @transaction, entry: @entry %>
|
||||
<%= render "form", entry: @entry %>
|
||||
<% end %>
|
|
@ -1,39 +1,14 @@
|
|||
<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %>
|
||||
|
||||
<% 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>
|
||||
<%= drawer(reload_on_close: true) do %>
|
||||
<%= render "account/transactions/header", entry: @entry %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Overview Section -->
|
||||
<%= disclosure t(".overview") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_transaction_path(account, entry),
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.hidden_field :origin, value: origin %>
|
||||
<%= f.text_field :name,
|
||||
label: t(".name_label"),
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
@ -43,25 +18,25 @@
|
|||
max: Date.current,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<% unless @entry.marked_as_transfer? %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= f.select :nature,
|
||||
[["Expense", "expense"], ["Income", "income"]],
|
||||
{ container_class: "w-1/3", label: t(".nature"), selected: entry.amount.negative? ? "income" : "expense" },
|
||||
[["Expense", "outflow"], ["Income", "inflow"]],
|
||||
{ container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" },
|
||||
{ data: { "auto-submit-form-target": "auto" } } %>
|
||||
|
||||
<%= f.money_field :amount, label: t(".amount"),
|
||||
container_class: "w-2/3",
|
||||
auto_submit: true,
|
||||
min: 0,
|
||||
value: entry.amount.abs %>
|
||||
value: @entry.amount.abs %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= f.select :account,
|
||||
options_for_select(
|
||||
Current.family.accounts.alphabetically.pluck(:name, :id),
|
||||
entry.account_id
|
||||
@entry.account_id
|
||||
),
|
||||
{ label: t(".account_label") },
|
||||
{ disabled: true } %>
|
||||
|
@ -72,55 +47,45 @@
|
|||
<!-- Details Section -->
|
||||
<%= disclosure t(".details") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_transaction_path(account, entry),
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.hidden_field :origin, value: origin %>
|
||||
<% unless @entry.marked_as_transfer? %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<%= ef.collection_select :category_id,
|
||||
Current.family.categories.alphabetically,
|
||||
:id, :name,
|
||||
{ prompt: t(".category_placeholder"),
|
||||
label: t(".category_label"),
|
||||
class: "text-gray-400" },
|
||||
{ label: t(".category_label"),
|
||||
class: "text-gray-400", include_blank: t(".uncategorized") },
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<%= ef.collection_select :merchant_id,
|
||||
Current.family.merchants.alphabetically,
|
||||
:id, :name,
|
||||
{ prompt: t(".merchant_placeholder"),
|
||||
{ include_blank: t(".none"),
|
||||
label: t(".merchant_label"),
|
||||
class: "text-gray-400" },
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
|
||||
<%= ef.select :tag_ids,
|
||||
options_for_select(
|
||||
Current.family.tags.alphabetically.pluck(:name, :id),
|
||||
transaction.tag_ids
|
||||
),
|
||||
{
|
||||
include_blank: t(".none"),
|
||||
multiple: true,
|
||||
label: t(".tags_label"),
|
||||
container_class: "h-40"
|
||||
},
|
||||
{ "data-auto-submit-form-target": "auto" } %>
|
||||
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= styled_form_with model: [account, entry],
|
||||
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"),
|
||||
placeholder: t(".note_placeholder"),
|
||||
rows: 5,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
@ -129,11 +94,10 @@
|
|||
<%= disclosure t(".settings") do %>
|
||||
<div class="pb-4">
|
||||
<!-- Exclude Transaction Form -->
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_transaction_path(account, entry),
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
class: "p-3",
|
||||
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="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
|
||||
|
@ -158,7 +122,7 @@
|
|||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_entry_path(account, entry),
|
||||
account_entry_path(@entry),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm
|
||||
font-medium border border-alpha-black-200",
|
||||
|
|
|
@ -8,12 +8,12 @@
|
|||
|
||||
<section>
|
||||
<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" %>
|
||||
<%= tag.span t(".expense") %>
|
||||
<% 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" %>
|
||||
<%= tag.span t(".income") %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<%# 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: {
|
||||
title: t(".remove_transfer"),
|
||||
body: t(".remove_transfer_body"),
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
<%# locals: (entry:) %>
|
||||
|
||||
<%= styled_form_with model: [entry.account, entry],
|
||||
url: entry.new_record? ? account_valuations_path(entry.account) : account_entry_path(entry.account, entry),
|
||||
class: "space-y-4",
|
||||
data: { turbo: false } do |form| %>
|
||||
<%= styled_form_with model: entry, url: account_valuations_path, class: "space-y-4" do |form| %>
|
||||
<%= form.hidden_field :account_id %>
|
||||
|
||||
<% if entry.errors.any? %>
|
||||
<%= render "shared/form_errors", model: entry %>
|
||||
<% end %>
|
||||
|
||||
<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.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 %>
|
||||
<% valuation = entry.account_valuation %>
|
||||
|
@ -21,7 +21,7 @@
|
|||
<%= content_tag :p, entry.name %>
|
||||
<% else %>
|
||||
<%= link_to valuation.name,
|
||||
account_entry_path(account, entry),
|
||||
account_entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,30 +1,14 @@
|
|||
<% entry, account = @entry, @entry.account %>
|
||||
|
||||
<%= drawer do %>
|
||||
<header class="mb-4 space-y-1">
|
||||
<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>
|
||||
<%= drawer(reload_on_close: true) do %>
|
||||
<%= render "account/valuations/header", entry: %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Overview Section -->
|
||||
<%= disclosure t(".overview") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_entry_path(account, entry),
|
||||
<%= styled_form_with model: entry,
|
||||
url: account_entry_path(entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.text_field :name,
|
||||
|
@ -48,8 +32,8 @@
|
|||
<!-- Details Section -->
|
||||
<%= disclosure t(".details") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_entry_path(account, entry),
|
||||
<%= styled_form_with model: entry,
|
||||
url: account_entry_path(entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.text_area :notes,
|
||||
|
@ -72,7 +56,7 @@
|
|||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_entry_path(account, entry),
|
||||
account_entry_path(entry),
|
||||
method: :delete,
|
||||
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" } %>
|
||||
|
|
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:) %>
|
||||
|
||||
<%= 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" %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
<%# locals: (account:, title: nil, tooltip: nil, **args) %>
|
||||
|
||||
<% period = Period.from_param(params[:period]) %>
|
||||
<% series = account.series(period: period) %>
|
||||
<% trend = series.trend %>
|
||||
<% 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 class="p-4 flex justify-between">
|
||||
<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="flex justify-between px-4 pt-4 mb-2">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= tag.p title || default_value_title, class: "text-sm font-medium text-gray-500" %>
|
||||
|
@ -14,19 +12,6 @@
|
|||
</div>
|
||||
|
||||
<%= 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>
|
||||
|
||||
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||
|
@ -34,7 +19,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="h-64 flex items-center justify-center text-2xl font-bold">
|
||||
<%= render "shared/line_chart", series: series %>
|
||||
</div>
|
||||
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.name) do %>
|
||||
<%= render "accounts/chart_loader" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
<%= link_to key.titleize,
|
||||
account_path(account, tab: key),
|
||||
data: { turbo: false },
|
||||
class: [
|
||||
"px-2 py-1.5 rounded-md border border-transparent",
|
||||
"bg-white shadow-xs border-alpha-black-50": is_selected
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
<%# locals: (transaction:, origin: nil) %>
|
||||
<div class="relative" data-controller="menu">
|
||||
<%# locals: (transaction:) %>
|
||||
|
||||
<div class="relative" data-controller="menu" id="<%= dom_id(transaction, :category_menu) %>">
|
||||
<button data-menu-target="button" class="flex cursor-pointer">
|
||||
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
|
||||
</button>
|
||||
<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">
|
||||
<%= 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">
|
||||
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,17 @@
|
|||
<%# locals: (category:, origin: nil) %>
|
||||
<%# locals: (category:) %>
|
||||
<% 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 %>
|
||||
<%= 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">
|
||||
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
||||
</span>
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
<%= t(".no_categories") %>
|
||||
</div>
|
||||
<% @categories.each do |category| %>
|
||||
<%= render partial: "category/dropdowns/row", locals: { category:, origin: params[:origin] } %>
|
||||
<%= render partial: "category/dropdowns/row", locals: { category: } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<hr>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<%# 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" %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
<%# 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 class="p-4 flex justify-between">
|
||||
<div class="space-y-2">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<%# 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" %>
|
||||
<% end %>
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
<%= render_flash_notifications %>
|
||||
|
||||
<% if Current.family&.syncing? %>
|
||||
<%= render "shared/notification", id: "syncing-notification", type: :processing, message: t(".syncing") %>
|
||||
<%= render "shared/syncing_notice" %>
|
||||
<% end %>
|
||||
</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 %>
|
||||
<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 justify-end items-center p-4">
|
||||
<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 %>
|
||||
<% 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",
|
||||
id: type == :processing ? "syncing-notification" : id,
|
||||
data: {
|
||||
controller: "element-removal",
|
||||
action: action
|
||||
|
@ -20,8 +19,6 @@
|
|||
<div class="flex h-full items-center justify-center rounded-full bg-error">
|
||||
<%= lucide_icon "x", class: "w-3 h-3" %>
|
||||
</div>
|
||||
<% when :processing %>
|
||||
<%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %>
|
||||
<% end %>
|
||||
</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>
|
||||
<% 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") %>
|
||||
<p class="text-sm font-medium">New transaction</p>
|
||||
<% end %>
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
</div>
|
||||
<div class="space-y-6">
|
||||
<%= entries_by_date(@transaction_entries, totals: true) do |entries| %>
|
||||
<%= render entries, origin: "transactions" %>
|
||||
<%= render entries %>
|
||||
<% end %>
|
||||
</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": ""
|
||||
},
|
||||
{
|
||||
"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_code": 15,
|
||||
|
@ -115,6 +138,6 @@
|
|||
"note": ""
|
||||
}
|
||||
],
|
||||
"updated": "2024-11-02 15:02:28 -0400",
|
||||
"updated": "2024-11-27 15:33:53 -0500",
|
||||
"brakeman_version": "6.2.2"
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
en:
|
||||
account:
|
||||
entries:
|
||||
create:
|
||||
success: Entry created
|
||||
destroy:
|
||||
success: Entry deleted
|
||||
empty:
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
en:
|
||||
account:
|
||||
holdings:
|
||||
destroy:
|
||||
success: Holding deleted
|
||||
holding:
|
||||
per_share: per share
|
||||
shares: "%{qty} shares"
|
||||
|
|
|
@ -2,9 +2,6 @@
|
|||
en:
|
||||
account:
|
||||
trades:
|
||||
create:
|
||||
failure: Something went wrong
|
||||
success: Transaction created successfully.
|
||||
form:
|
||||
account: Transfer account (optional)
|
||||
account_prompt: Search account
|
||||
|
@ -15,6 +12,15 @@ en:
|
|||
submit: Add transaction
|
||||
ticker_placeholder: AAPL
|
||||
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:
|
||||
amount: Amount
|
||||
new: New transaction
|
||||
|
@ -27,7 +33,6 @@ en:
|
|||
show:
|
||||
additional: Additional
|
||||
cost_per_share_label: Cost per Share
|
||||
current_market_price_label: Current Market Price
|
||||
date_label: Date
|
||||
delete: Delete
|
||||
delete_subtitle: This action cannot be undone
|
||||
|
@ -37,12 +42,5 @@ en:
|
|||
exclude_title: Exclude from analytics
|
||||
note_label: Note
|
||||
note_placeholder: Add any additional notes here...
|
||||
overview: Overview
|
||||
purchase_price_label: Purchase Price
|
||||
purchase_qty_label: Purchase Quantity
|
||||
quantity_label: Quantity
|
||||
settings: Settings
|
||||
symbol_label: Symbol
|
||||
total_return_label: Unrealized gain/loss
|
||||
update:
|
||||
success: Trade updated successfully.
|
||||
|
|
|
@ -2,11 +2,44 @@
|
|||
en:
|
||||
account:
|
||||
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:
|
||||
new: New transaction
|
||||
no_transactions: No transactions for this account yet.
|
||||
transaction: transaction
|
||||
transactions: Transactions
|
||||
mark_transfers:
|
||||
success: Marked as transfers
|
||||
new:
|
||||
new_transaction: New transaction
|
||||
selection_bar:
|
||||
mark_transfers: Mark as transfers?
|
||||
mark_transfers_confirm: Mark as transfers
|
||||
|
@ -16,7 +49,6 @@ en:
|
|||
account_label: Account
|
||||
amount: Amount
|
||||
category_label: Category
|
||||
category_placeholder: Select a category
|
||||
date_label: Date
|
||||
delete: Delete
|
||||
delete_subtitle: This permanently deletes the transaction, affects your historical
|
||||
|
@ -27,13 +59,14 @@ en:
|
|||
analytics.
|
||||
exclude_title: Exclude transaction
|
||||
merchant_label: Merchant
|
||||
merchant_placeholder: Select a merchant
|
||||
name_label: Name
|
||||
nature: Type
|
||||
none: "(none)"
|
||||
note_label: Notes
|
||||
note_placeholder: Enter a note
|
||||
overview: Overview
|
||||
settings: Settings
|
||||
tags_label: Tags
|
||||
update:
|
||||
success: Transaction updated successfully.
|
||||
uncategorized: "(uncategorized)"
|
||||
unmark_transfers:
|
||||
success: Transfer removed
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
en:
|
||||
account:
|
||||
valuations:
|
||||
create:
|
||||
success: Valuation created successfully.
|
||||
form:
|
||||
amount: Amount
|
||||
submit: Add balance update
|
||||
header:
|
||||
balance: Balance
|
||||
index:
|
||||
change: change
|
||||
date: date
|
||||
|
@ -18,7 +18,6 @@ en:
|
|||
title: New balance
|
||||
show:
|
||||
amount: Amount
|
||||
balance: Balance
|
||||
date_label: Date
|
||||
delete: Delete
|
||||
delete_subtitle: This action cannot be undone
|
||||
|
|
|
@ -31,10 +31,11 @@ en:
|
|||
manual_entry: Enter account balance
|
||||
title: How would you like to add it?
|
||||
title: What would you like to add?
|
||||
chart:
|
||||
no_change: no change
|
||||
show:
|
||||
chart:
|
||||
balance: Balance
|
||||
no_change: no change
|
||||
owed: Amount owed
|
||||
menu:
|
||||
confirm_accept: Delete "%{name}"
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
---
|
||||
en:
|
||||
layouts:
|
||||
application:
|
||||
syncing: Syncing account data...
|
||||
auth:
|
||||
existing_account: Already have an account?
|
||||
no_account: New to Maybe?
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
---
|
||||
en:
|
||||
shared:
|
||||
syncing_notice:
|
||||
syncing: Syncing accounts data...
|
||||
confirm_modal:
|
||||
accept: Confirm
|
||||
body_html: "<p>You will not be able to undo this decision</p>"
|
||||
|
|
|
@ -1,37 +1,6 @@
|
|||
---
|
||||
en:
|
||||
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:
|
||||
edit_categories: Edit categories
|
||||
edit_imports: Edit imports
|
||||
|
@ -41,10 +10,6 @@ en:
|
|||
index:
|
||||
transaction: transaction
|
||||
transactions: transactions
|
||||
mark_transfers:
|
||||
success: Marked as transfer
|
||||
new:
|
||||
new_transaction: New transaction
|
||||
searches:
|
||||
filters:
|
||||
amount_filter:
|
||||
|
@ -77,5 +42,3 @@ en:
|
|||
equal_to: equal to
|
||||
greater_than: greater than
|
||||
less_than: less than
|
||||
unmark_transfers:
|
||||
success: Transfer removed
|
||||
|
|
|
@ -69,22 +69,42 @@ Rails.application.routes.draw do
|
|||
|
||||
member do
|
||||
post :sync
|
||||
get :chart
|
||||
end
|
||||
end
|
||||
|
||||
scope module: :account do
|
||||
namespace :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
|
||||
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 :entries, only: %i[index edit update show destroy]
|
||||
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
|
||||
# Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123
|
||||
direct :account do |model, options|
|
||||
|
@ -104,15 +124,7 @@ Rails.application.routes.draw do
|
|||
resources :other_assets, except: :index
|
||||
resources :other_liabilities, except: :index
|
||||
|
||||
resources :transactions, only: %i[index new create] do
|
||||
collection do
|
||||
post "bulk_delete"
|
||||
get "bulk_edit"
|
||||
post "bulk_update"
|
||||
post "mark_transfers"
|
||||
post "unmark_transfers"
|
||||
end
|
||||
end
|
||||
resources :securities, only: :index
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -508,6 +508,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_22_183828) do
|
|||
t.string "country_code"
|
||||
t.string "exchange_mic"
|
||||
t.string "exchange_acronym"
|
||||
t.string "logo_url"
|
||||
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
|
||||
end
|
||||
|
|
|
@ -3,63 +3,11 @@ require "test_helper"
|
|||
class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
@transaction = account_entries :transaction
|
||||
@valuation = account_entries :valuation
|
||||
@trade = account_entries :trade
|
||||
@entry = account_entries(:transaction)
|
||||
end
|
||||
|
||||
# =================
|
||||
# Shared
|
||||
# =================
|
||||
|
||||
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
|
||||
|
||||
test "gets show" do
|
||||
[ @transaction, @valuation, @trade ].each do |entry|
|
||||
get account_entry_url(entry.account, entry)
|
||||
test "gets index" do
|
||||
get account_entries_path(account_id: @entry.account.id)
|
||||
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
|
||||
|
|
|
@ -8,12 +8,12 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest
|
|||
end
|
||||
|
||||
test "gets holdings" do
|
||||
get account_holdings_url(@account)
|
||||
get account_holdings_url(account_id: @account.id)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "gets holding" do
|
||||
get account_holding_path(@account, @holding)
|
||||
get account_holding_path(@holding)
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
@ -21,10 +21,10 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest
|
|||
test "destroys holding and associated entries" do
|
||||
assert_difference -> { Account::Holding.count } => -1,
|
||||
-> { Account::Entry.count } => -1 do
|
||||
delete account_holding_path(@account, @holding)
|
||||
delete account_holding_path(@holding)
|
||||
end
|
||||
|
||||
assert_redirected_to account_holdings_path(@account)
|
||||
assert_empty @account.entries.where(entryable: @account.trades.where(security: @holding.security))
|
||||
assert_redirected_to account_path(@holding.account)
|
||||
assert_empty @holding.account.entries.where(entryable: @holding.account.trades.where(security: @holding.security))
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,19 +1,36 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
||||
include EntryableResourceInterfaceTest
|
||||
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
@entry = account_entries :trade
|
||||
@entry = account_entries(:trade)
|
||||
end
|
||||
|
||||
test "should get index" do
|
||||
get account_trades_url(@entry.account)
|
||||
assert_response :success
|
||||
test "updates trade entry" do
|
||||
assert_no_difference [ "Account::Entry.count", "Account::Trade.count" ] do
|
||||
patch account_trade_url(@entry), params: {
|
||||
account_entry: {
|
||||
currency: "USD",
|
||||
entryable_attributes: {
|
||||
id: @entry.entryable_id,
|
||||
qty: 20,
|
||||
price: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
test "should get new" do
|
||||
get new_account_trade_url(@entry.account)
|
||||
assert_response :success
|
||||
@entry.reload
|
||||
|
||||
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
|
||||
|
||||
test "creates deposit entry" do
|
||||
|
@ -22,9 +39,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_difference -> { Account::Entry.count } => 2,
|
||||
-> { Account::Transaction.count } => 2,
|
||||
-> { Account::Transfer.count } => 1 do
|
||||
post account_trades_url(@entry.account), params: {
|
||||
post account_trades_url, params: {
|
||||
account_entry: {
|
||||
type: "transfer_in",
|
||||
account_id: @entry.account_id,
|
||||
type: "deposit",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
currency: "USD",
|
||||
|
@ -42,9 +60,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_difference -> { Account::Entry.count } => 2,
|
||||
-> { Account::Transaction.count } => 2,
|
||||
-> { Account::Transfer.count } => 1 do
|
||||
post account_trades_url(@entry.account), params: {
|
||||
post account_trades_url, params: {
|
||||
account_entry: {
|
||||
type: "transfer_out",
|
||||
account_id: @entry.account_id,
|
||||
type: "withdrawal",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
currency: "USD",
|
||||
|
@ -60,9 +79,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_difference -> { Account::Entry.count } => 1,
|
||||
-> { Account::Transaction.count } => 1,
|
||||
-> { Account::Transfer.count } => 0 do
|
||||
post account_trades_url(@entry.account), params: {
|
||||
post account_trades_url, params: {
|
||||
account_entry: {
|
||||
type: "transfer_out",
|
||||
account_id: @entry.account_id,
|
||||
type: "withdrawal",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
currency: "USD"
|
||||
|
@ -79,8 +99,9 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
test "creates interest entry" 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_id: @entry.account_id,
|
||||
type: "interest",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
|
@ -97,13 +118,15 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
test "creates trade buy entry" 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_id: @entry.account_id,
|
||||
type: "buy",
|
||||
date: Date.current,
|
||||
ticker: "NVDA (NASDAQ)",
|
||||
qty: 10,
|
||||
price: 10
|
||||
price: 10,
|
||||
currency: "USD"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
@ -112,15 +135,16 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
assert created_entry.amount.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_redirected_to @entry.account
|
||||
assert_redirected_to account_url(created_entry.account)
|
||||
end
|
||||
|
||||
test "creates trade sell entry" 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_id: @entry.account_id,
|
||||
type: "sell",
|
||||
ticker: "AAPL (NYSE)",
|
||||
date: Date.current,
|
||||
|
@ -135,8 +159,8 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
assert created_entry.amount.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_redirected_to @entry.account
|
||||
assert_redirected_to account_url(created_entry.account)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,40 +1,117 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
include EntryableResourceInterfaceTest
|
||||
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
@entry = account_entries :transaction
|
||||
@entry = account_entries(:transaction)
|
||||
end
|
||||
|
||||
test "should get index" do
|
||||
get account_transactions_url(@entry.account)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "update" do
|
||||
assert_no_difference [ "Account::Entry.count", "Account::Transaction.count" ] do
|
||||
patch account_transaction_url(@entry.account, @entry), params: {
|
||||
test "creates with transaction details" do
|
||||
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
|
||||
post account_transactions_url, params: {
|
||||
account_entry: {
|
||||
name: "Name",
|
||||
account_id: @entry.account_id,
|
||||
name: "New transaction",
|
||||
date: Date.current,
|
||||
currency: "USD",
|
||||
amount: 100,
|
||||
nature: "income",
|
||||
nature: "inflow",
|
||||
entryable_attributes: {
|
||||
tag_ids: [ Tag.first.id, Tag.second.id ],
|
||||
category_id: Category.first.id,
|
||||
merchant_id: Merchant.first.id
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
created_entry = Account::Entry.order(:created_at).last
|
||||
|
||||
assert_redirected_to account_url(created_entry.account)
|
||||
assert_equal "Entry created", flash[:notice]
|
||||
assert_enqueued_with(job: SyncJob)
|
||||
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,
|
||||
notes: "test notes",
|
||||
excluded: false
|
||||
merchant_id: Merchant.first.id
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_equal "Transaction updated successfully.", flash[:notice]
|
||||
assert_redirected_to account_entry_url(@entry.account, @entry)
|
||||
@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
|
||||
|
|
|
@ -1,36 +1,11 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest
|
||||
include EntryableResourceInterfaceTest
|
||||
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
@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)
|
||||
@entry = account_entries(:valuation)
|
||||
end
|
||||
|
||||
test "error when valuation already exists for date" do
|
||||
|
@ -44,7 +19,43 @@ class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest
|
|||
}
|
||||
end
|
||||
|
||||
assert_equal "Date has already been taken", flash[:alert]
|
||||
assert_redirected_to @entry.account
|
||||
assert_response :unprocessable_entity
|
||||
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
|
||||
|
|
|
@ -8,83 +8,6 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
|||
@transaction = account_entries(:transaction)
|
||||
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
|
||||
family = families(:empty)
|
||||
sign_in family.users.first
|
||||
|
@ -135,46 +58,4 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
assert_dom "#" + dom_id(sorted_transactions.last), count: 1
|
||||
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
|
||||
|
|
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
|
||||
|
||||
Security::SynthComboboxOption.stubs(:find_in_synth).returns([
|
||||
Security::SynthComboboxOption.new(
|
||||
symbol: "AAPL",
|
||||
Security.stubs(:search).returns([
|
||||
Security.new(
|
||||
ticker: "AAPL",
|
||||
name: "Apple Inc.",
|
||||
logo_url: "https://logo.synthfinance.com/ticker/AAPL",
|
||||
exchange_acronym: "NASDAQ",
|
||||
|
@ -37,7 +37,7 @@ class TradesTest < ApplicationSystemTestCase
|
|||
visit_account_trades
|
||||
|
||||
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"
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue