mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Account:: namespace simplifications and cleanup (#2110)
* Flatten Holding model * Flatten balance model * Entries domain renames * Fix valuations reference * Fix trades stream * Fix brakeman warnings * Fix tests * Replace existing entryable type references in DB
This commit is contained in:
parent
f181ba941f
commit
e657c40d19
172 changed files with 1297 additions and 1258 deletions
|
@ -71,7 +71,7 @@ Due to the open-source nature of this project, we have chosen Minitest + Fixture
|
|||
|
||||
- Always use Minitest and fixtures for testing.
|
||||
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
|
||||
- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence
|
||||
|
||||
#### Convention 5a: Write minimal, effective tests
|
||||
|
@ -87,26 +87,26 @@ Below are examples of necessary vs. unnecessary tests:
|
|||
# GOOD!!
|
||||
# Necessary test - in this case, we're testing critical domain business logic
|
||||
test "syncs balances" do
|
||||
Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||
|
||||
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||
|
||||
Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
]
|
||||
)
|
||||
|
||||
assert_difference "@account.balances.count", 2 do
|
||||
Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||
Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||
end
|
||||
end
|
||||
|
||||
# BAD!!
|
||||
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
|
||||
test "saves balance" do
|
||||
balance_record = Account::Balance.new(balance: 100, currency: "USD")
|
||||
balance_record = Balance.new(balance: 100, currency: "USD")
|
||||
|
||||
assert balance_record.save
|
||||
end
|
||||
|
|
|
@ -55,29 +55,29 @@ All balances are calculated daily by [balance_calculator.rb](mdc:app/models/acco
|
|||
|
||||
### Account Holdings
|
||||
|
||||
An account [holding.rb](mdc:app/models/account/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`.
|
||||
An account [holding.rb](mdc:app/models/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`.
|
||||
|
||||
For investment accounts with holdings, [holding_calculator.rb](mdc:app/models/account/holding_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb).
|
||||
For investment accounts with holdings, [base_calculator.rb](mdc:app/models/holding/base_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb).
|
||||
|
||||
### Account Entries
|
||||
|
||||
An account [entry.rb](mdc:app/models/account/entry.rb) is also a Rails "delegated type". `Account::Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/account/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`.
|
||||
An account [entry.rb](mdc:app/models/entry.rb) is also a Rails "delegated type". `Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`.
|
||||
|
||||
The `amount` of an [entry.rb](mdc:app/models/account/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example:
|
||||
The `amount` of an [entry.rb](mdc:app/models/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example:
|
||||
|
||||
- A negative amount for a credit card account represents a "payment" to that account, which _reduces_ its balance (since it is a `liability`)
|
||||
- A negative amount for a checking account represents an "income" to that account, which _increases_ its balance (since it is an `asset`)
|
||||
- A negative amount for an investment/brokerage trade represents a "sell" transaction, which _increases_ the cash balance of the account
|
||||
|
||||
There are 3 entry types, defined as [entryable.rb](mdc:app/models/account/entryable.rb) records:
|
||||
There are 3 entry types, defined as [entryable.rb](mdc:app/models/entryable.rb) records:
|
||||
|
||||
- `Account::Valuation` - an account [valuation.rb](mdc:app/models/account/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Account::Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today.
|
||||
- `Account::Transaction` - an account [transaction.rb](mdc:app/models/account/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense".
|
||||
- `Account::Trade` - an account [trade.rb](mdc:app/models/account/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`.
|
||||
- `Valuation` - an account [valuation.rb](mdc:app/models/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today.
|
||||
- `Transaction` - an account [transaction.rb](mdc:app/models/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense".
|
||||
- `Trade` - an account [trade.rb](mdc:app/models/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`.
|
||||
|
||||
### Account Transfers
|
||||
|
||||
A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/account/transaction.rb) and an outflow [transaction.rb](mdc:app/models/account/transaction.rb). The Maybe system auto-matches transfers based on the following criteria:
|
||||
A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/transaction.rb) and an outflow [transaction.rb](mdc:app/models/transaction.rb). The Maybe system auto-matches transfers based on the following criteria:
|
||||
|
||||
- Must be from different accounts
|
||||
- Must be within 4 days of each other
|
||||
|
@ -115,10 +115,10 @@ The most important type of sync is the account sync. It is orchestrated by the
|
|||
|
||||
- Auto-matches transfer records for the account
|
||||
- Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb)
|
||||
- Balances are dependent on the calculation of [holding.rb](mdc:app/models/account/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb)
|
||||
- Balances are dependent on the calculation of [holding.rb](mdc:app/models/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb)
|
||||
- Enriches transaction data if enabled by user
|
||||
|
||||
An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated.
|
||||
An account sync happens every time an [entry.rb](mdc:app/models/entry.rb) is updated.
|
||||
|
||||
### Plaid Item Syncs
|
||||
|
||||
|
@ -126,7 +126,7 @@ A Plaid Item sync is an ETL (extract, transform, load) operation:
|
|||
|
||||
1. [plaid_item.rb](mdc:app/models/plaid_item.rb) fetches data from the external Plaid API
|
||||
2. [plaid_item.rb](mdc:app/models/plaid_item.rb) creates and loads this data to [plaid_account.rb](mdc:app/models/plaid_account.rb) records
|
||||
3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/account/entry.rb), the internal Maybe representations of the data.
|
||||
3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/entry.rb), the internal Maybe representations of the data.
|
||||
|
||||
### Family Syncs
|
||||
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
class Account::TradesController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
permitted_entryable_attributes :id, :qty, :price
|
||||
|
||||
private
|
||||
def build_entry
|
||||
Account::TradeBuilder.new(create_entry_params)
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_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 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
|
|
@ -1,22 +0,0 @@
|
|||
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,37 +0,0 @@
|
|||
class Account::TransactionsController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
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: [], tag_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
|
||||
end
|
|
@ -1,3 +0,0 @@
|
|||
class Account::ValuationsController < ApplicationController
|
||||
include EntryableResource
|
||||
end
|
|
@ -11,14 +11,14 @@ class BudgetCategoriesController < ApplicationController
|
|||
|
||||
if params[:id] == BudgetCategory.uncategorized.id
|
||||
@budget_category = @budget.uncategorized_budget_category
|
||||
@recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil })
|
||||
@recent_transactions = @recent_transactions.where(transactions: { category_id: nil })
|
||||
else
|
||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
||||
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = transactions.category_id")
|
||||
.where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id)
|
||||
end
|
||||
|
||||
@recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3)
|
||||
@recent_transactions = @recent_transactions.order("entries.date DESC, ABS(entries.amount) DESC").take(3)
|
||||
end
|
||||
|
||||
def update
|
||||
|
|
|
@ -2,14 +2,9 @@ module EntryableResource
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_entry, only: %i[show update destroy]
|
||||
end
|
||||
include StreamExtensions, ActionView::RecordIdentifier
|
||||
|
||||
class_methods do
|
||||
def permitted_entryable_attributes(*attrs)
|
||||
@permitted_entryable_attributes = attrs if attrs.any?
|
||||
@permitted_entryable_attributes ||= [ :id ]
|
||||
end
|
||||
before_action :set_entry, only: %i[show update destroy]
|
||||
end
|
||||
|
||||
def show
|
||||
|
@ -21,49 +16,16 @@ module EntryableResource
|
|||
@entry = Current.family.entries.new(
|
||||
account: account,
|
||||
currency: account ? account.currency : Current.family.currency,
|
||||
entryable: entryable_type.new
|
||||
entryable: entryable
|
||||
)
|
||||
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
|
||||
raise NotImplementedError, "Entryable resources must implement #create"
|
||||
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 }
|
||||
),
|
||||
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
raise NotImplementedError, "Entryable resources must implement #update"
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -71,58 +33,15 @@ module EntryableResource
|
|||
@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
|
||||
redirect_back_or_to account_path(account), notice: t("account.entries.destroy.success")
|
||||
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)
|
||||
def entryable
|
||||
controller_name.classify.constantize.new
|
||||
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, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: self.class.permitted_entryable_attributes
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
20
app/controllers/concerns/stream_extensions.rb
Normal file
20
app/controllers/concerns/stream_extensions.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
module StreamExtensions
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def stream_redirect_to(path, notice: nil, alert: nil)
|
||||
custom_stream_redirect(path, notice: notice, alert: alert)
|
||||
end
|
||||
|
||||
def stream_redirect_back_or_to(path, notice: nil, alert: nil)
|
||||
custom_stream_redirect(path, redirect_back: true, notice: notice, alert: alert)
|
||||
end
|
||||
|
||||
private
|
||||
def custom_stream_redirect(path, redirect_back: false, notice: nil, alert: nil)
|
||||
flash[:notice] = notice if notice.present?
|
||||
flash[:alert] = alert if alert.present?
|
||||
|
||||
redirect_target_url = redirect_back ? request.referer : path
|
||||
render turbo_stream: turbo_stream.action(:redirect, redirect_target_url)
|
||||
end
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
class Account::HoldingsController < ApplicationController
|
||||
class HoldingsController < ApplicationController
|
||||
before_action :set_holding, only: %i[show destroy]
|
||||
|
||||
def index
|
79
app/controllers/trades_controller.rb
Normal file
79
app/controllers/trades_controller.rb
Normal file
|
@ -0,0 +1,79 @@
|
|||
class TradesController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
def create
|
||||
@entry = build_entry
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = t("entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) }
|
||||
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("entries.update.success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"header_entry_#{@entry.id}",
|
||||
partial: "trades/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace("entry_#{@entry.id}", partial: "entries/entry", locals: { entry: @entry })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def build_entry
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
TradeBuilder.new(create_entry_params.merge(account: account))
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(
|
||||
:name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: [ :id, :qty, :price ]
|
||||
)
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
params.require(:entry).permit(
|
||||
:date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
)
|
||||
end
|
||||
|
||||
def update_entry_params
|
||||
return entry_params unless entry_params[:entryable_attributes].present?
|
||||
|
||||
update_params = entry_params
|
||||
update_params = update_params.merge(entryable_type: "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/transaction_categories_controller.rb
Normal file
22
app/controllers/transaction_categories_controller.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
class TransactionCategoriesController < ApplicationController
|
||||
def update
|
||||
@entry = Current.family.entries.transactions.find(params[:transaction_id])
|
||||
@entry.update!(entry_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transaction_path(@entry) }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"category_menu_transaction_#{@entry.transaction_id}",
|
||||
partial: "categories/menu",
|
||||
locals: { transaction: @entry.transaction }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])
|
||||
end
|
||||
end
|
12
app/controllers/transactions/bulk_deletions_controller.rb
Normal file
12
app/controllers/transactions/bulk_deletions_controller.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
class Transactions::BulkDeletionsController < ApplicationController
|
||||
def create
|
||||
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: "#{destroyed.count} transaction#{destroyed.count == 1 ? "" : "s"} deleted"
|
||||
end
|
||||
|
||||
private
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
end
|
19
app/controllers/transactions/bulk_updates_controller.rb
Normal file
19
app/controllers/transactions/bulk_updates_controller.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
class Transactions::BulkUpdatesController < ApplicationController
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
redirect_back_or_to transactions_path, notice: "#{updated} transactions updated"
|
||||
end
|
||||
|
||||
private
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update)
|
||||
.permit(:date, :notes, :category_id, :merchant_id, entry_ids: [], tag_ids: [])
|
||||
end
|
||||
end
|
|
@ -1,5 +1,5 @@
|
|||
class TransactionsController < ApplicationController
|
||||
include ScrollFocusable
|
||||
include ScrollFocusable, EntryableResource
|
||||
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
|
@ -48,7 +48,62 @@ class TransactionsController < ApplicationController
|
|||
redirect_to transactions_path(updated_params)
|
||||
end
|
||||
|
||||
def create
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
@entry = account.entries.new(entry_params)
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = "Transaction created"
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
dom_id(@entry, :header),
|
||||
partial: "transactions/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace(@entry)
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
entry_params = params.require(:entry).permit(
|
||||
:name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
|
||||
entryable_attributes: [ :id, :category_id, :merchant_id, { tag_ids: [] } ]
|
||||
)
|
||||
|
||||
nature = entry_params.delete(:nature)
|
||||
|
||||
if nature.present? && entry_params[:amount].present?
|
||||
signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
|
||||
entry_params = entry_params.merge(amount: signed_amount)
|
||||
end
|
||||
|
||||
entry_params
|
||||
end
|
||||
|
||||
def search_params
|
||||
cleaned_params = params.fetch(:q, {})
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
class Account::TransferMatchesController < ApplicationController
|
||||
class TransferMatchesController < ApplicationController
|
||||
before_action :set_entry
|
||||
|
||||
def new
|
||||
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||
@transfer_match_candidates = @entry.account_transaction.transfer_match_candidates
|
||||
@transfer_match_candidates = @entry.transaction.transfer_match_candidates
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -11,7 +11,7 @@ class Account::TransferMatchesController < ApplicationController
|
|||
@transfer.save!
|
||||
@transfer.sync_account_later
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
redirect_back_or_to transactions_path, notice: "Transfer created"
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -27,7 +27,7 @@ class Account::TransferMatchesController < ApplicationController
|
|||
if transfer_match_params[:method] == "new"
|
||||
target_account = Current.family.accounts.find(transfer_match_params[:target_account_id])
|
||||
|
||||
missing_transaction = Account::Transaction.new(
|
||||
missing_transaction = Transaction.new(
|
||||
entry: target_account.entries.build(
|
||||
amount: @entry.amount * -1,
|
||||
currency: @entry.currency,
|
||||
|
@ -37,8 +37,8 @@ class Account::TransferMatchesController < ApplicationController
|
|||
)
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction,
|
||||
outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction
|
||||
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.transaction,
|
||||
outflow_transaction: @entry.amount.positive? ? @entry.transaction : missing_transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
|
@ -46,8 +46,8 @@ class Account::TransferMatchesController < ApplicationController
|
|||
target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id])
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction,
|
||||
outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction
|
||||
inflow_transaction: @entry.amount.negative? ? @entry.transaction : target_transaction.transaction,
|
||||
outflow_transaction: @entry.amount.negative? ? target_transaction.transaction : @entry.transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
49
app/controllers/valuations_controller.rb
Normal file
49
app/controllers/valuations_controller.rb
Normal file
|
@ -0,0 +1,49 @@
|
|||
class ValuationsController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
def create
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
@entry = account.entries.new(entry_params.merge(entryable: Valuation.new))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = "Balance created"
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: "Balance updated" }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
dom_id(@entry, :header),
|
||||
partial: "valuations/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace(@entry)
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:entry)
|
||||
.permit(:name, :enriched_name, :date, :amount, :currency, :notes)
|
||||
end
|
||||
end
|
|
@ -1,8 +1,8 @@
|
|||
module Account::EntriesHelper
|
||||
module EntriesHelper
|
||||
def entries_by_date(entries, totals: false)
|
||||
transfer_groups = entries.group_by do |entry|
|
||||
# Only check for transfer if it's a transaction
|
||||
next nil unless entry.entryable_type == "Account::Transaction"
|
||||
next nil unless entry.entryable_type == "Transaction"
|
||||
entry.entryable.transfer&.id
|
||||
end
|
||||
|
||||
|
@ -12,7 +12,7 @@ module Account::EntriesHelper
|
|||
grouped_entries
|
||||
else
|
||||
grouped_entries.reject do |e|
|
||||
e.entryable_type == "Account::Transaction" &&
|
||||
e.entryable_type == "Transaction" &&
|
||||
e.entryable.transfer_as_inflow.present?
|
||||
end
|
||||
end
|
||||
|
@ -25,7 +25,7 @@ module Account::EntriesHelper
|
|||
|
||||
next if content.blank?
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: }
|
||||
render partial: "entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: }
|
||||
end.compact.join.html_safe
|
||||
end
|
||||
|
|
@ -7,11 +7,11 @@ class Account < ApplicationRecord
|
|||
belongs_to :import, optional: true
|
||||
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
has_many :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, class_name: "Account::Holding"
|
||||
has_many :entries, dependent: :destroy
|
||||
has_many :transactions, through: :entries, source: :entryable, source_type: "Transaction"
|
||||
has_many :valuations, through: :entries, source: :entryable, source_type: "Valuation"
|
||||
has_many :trades, through: :entries, source: :entryable, source_type: "Trade"
|
||||
has_many :holdings, dependent: :destroy
|
||||
has_many :balances, dependent: :destroy
|
||||
|
||||
monetize :balance, :cash_balance
|
||||
|
@ -43,14 +43,14 @@ class Account < ApplicationRecord
|
|||
date: Date.current,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
entryable: Valuation.new
|
||||
)
|
||||
account.entries.build(
|
||||
name: "Initial Balance",
|
||||
date: 1.day.ago.to_date,
|
||||
amount: initial_balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
entryable: Valuation.new
|
||||
)
|
||||
|
||||
account.save!
|
||||
|
@ -113,7 +113,7 @@ class Account < ApplicationRecord
|
|||
end
|
||||
|
||||
def update_balance!(balance)
|
||||
valuation = entries.account_valuations.find_by(date: Date.current)
|
||||
valuation = entries.valuations.find_by(date: Date.current)
|
||||
|
||||
if valuation
|
||||
valuation.update! amount: balance
|
||||
|
@ -123,7 +123,7 @@ class Account < ApplicationRecord
|
|||
name: "Balance update",
|
||||
amount: balance,
|
||||
currency: currency,
|
||||
entryable: Account::Valuation.new
|
||||
entryable: Valuation.new
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -148,7 +148,7 @@ class Account < ApplicationRecord
|
|||
end
|
||||
|
||||
def first_valuation
|
||||
entries.account_valuations.order(:date).first
|
||||
entries.valuations.order(:date).first
|
||||
end
|
||||
|
||||
def first_valuation_amount
|
||||
|
|
|
@ -7,7 +7,7 @@ module Account::Chartable
|
|||
|
||||
series_interval = interval || period.interval
|
||||
|
||||
balances = Account::Balance.find_by_sql([
|
||||
balances = Balance.find_by_sql([
|
||||
balance_series_query,
|
||||
{
|
||||
start_date: period.start_date,
|
||||
|
@ -61,7 +61,7 @@ module Account::Chartable
|
|||
COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates
|
||||
FROM dates d
|
||||
LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql})
|
||||
LEFT JOIN account_balances ab ON (
|
||||
LEFT JOIN balances ab ON (
|
||||
ab.date = d.date AND
|
||||
ab.currency = accounts.currency AND
|
||||
ab.account_id = accounts.id
|
||||
|
|
|
@ -2,9 +2,9 @@ module Account::Enrichable
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
def enrich_data
|
||||
total_unenriched = entries.account_transactions
|
||||
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
|
||||
total_unenriched = entries.transactions
|
||||
.joins("JOIN transactions at ON at.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where("entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
|
||||
.count
|
||||
|
||||
if total_unenriched > 0
|
||||
|
@ -63,7 +63,7 @@ module Account::Enrichable
|
|||
transactions.active
|
||||
.includes(:merchant, :category)
|
||||
.where(
|
||||
"account_entries.enriched_at IS NULL",
|
||||
"entries.enriched_at IS NULL",
|
||||
"OR merchant_id IS NULL",
|
||||
"OR category_id IS NULL"
|
||||
)
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
class Account::Valuation < ApplicationRecord
|
||||
include Account::Entryable
|
||||
end
|
|
@ -20,7 +20,7 @@ class AccountImport < Import
|
|||
currency: row.currency,
|
||||
date: Date.current,
|
||||
name: "Imported account value",
|
||||
entryable: Account::Valuation.new
|
||||
entryable: Valuation.new
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class Account::Balance < ApplicationRecord
|
||||
class Balance < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :account
|
|
@ -1,4 +1,4 @@
|
|||
class Account::Balance::BaseCalculator
|
||||
class Balance::BaseCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
|
@ -13,11 +13,11 @@ class Account::Balance::BaseCalculator
|
|||
|
||||
private
|
||||
def sync_cache
|
||||
@sync_cache ||= Account::Balance::SyncCache.new(account)
|
||||
@sync_cache ||= Balance::SyncCache.new(account)
|
||||
end
|
||||
|
||||
def build_balance(date, cash_balance, holdings_value)
|
||||
Account::Balance.new(
|
||||
Balance.new(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
balance: holdings_value + cash_balance,
|
|
@ -1,4 +1,4 @@
|
|||
class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator
|
||||
class Balance::ForwardCalculator < Balance::BaseCalculator
|
||||
private
|
||||
def calculate_balances
|
||||
current_cash_balance = 0
|
|
@ -1,4 +1,4 @@
|
|||
class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator
|
||||
class Balance::ReverseCalculator < Balance::BaseCalculator
|
||||
private
|
||||
def calculate_balances
|
||||
current_cash_balance = account.cash_balance
|
|
@ -1,10 +1,10 @@
|
|||
class Account::Balance::SyncCache
|
||||
class Balance::SyncCache
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def get_valuation(date)
|
||||
converted_entries.find { |e| e.date == date && e.account_valuation? }
|
||||
converted_entries.find { |e| e.date == date && e.valuation? }
|
||||
end
|
||||
|
||||
def get_holdings(date)
|
||||
|
@ -12,7 +12,7 @@ class Account::Balance::SyncCache
|
|||
end
|
||||
|
||||
def get_entries(date)
|
||||
converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) }
|
||||
converted_entries.select { |e| e.date == date && (e.transaction? || e.trade?) }
|
||||
end
|
||||
|
||||
private
|
|
@ -1,4 +1,4 @@
|
|||
class Account::Balance::Syncer
|
||||
class Balance::Syncer
|
||||
attr_reader :account, :strategy
|
||||
|
||||
def initialize(account, strategy:)
|
||||
|
@ -7,7 +7,7 @@ class Account::Balance::Syncer
|
|||
end
|
||||
|
||||
def sync_balances
|
||||
Account::Balance.transaction do
|
||||
Balance.transaction do
|
||||
sync_holdings
|
||||
calculate_balances
|
||||
|
||||
|
@ -26,7 +26,7 @@ class Account::Balance::Syncer
|
|||
|
||||
private
|
||||
def sync_holdings
|
||||
@holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings
|
||||
@holdings = Holding::Syncer.new(account, strategy: strategy).sync_holdings
|
||||
end
|
||||
|
||||
def update_account_info
|
||||
|
@ -63,9 +63,9 @@ class Account::Balance::Syncer
|
|||
|
||||
def calculator
|
||||
if strategy == :reverse
|
||||
Account::Balance::ReverseCalculator.new(account)
|
||||
Balance::ReverseCalculator.new(account)
|
||||
else
|
||||
Account::Balance::ForwardCalculator.new(account)
|
||||
Balance::ForwardCalculator.new(account)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2,7 +2,7 @@
|
|||
# In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances
|
||||
# to show users how each entry affects their balances. This class calculates intraday balances by
|
||||
# interpolating between end-of-day balances.
|
||||
class Account::BalanceTrendCalculator
|
||||
class Balance::TrendCalculator
|
||||
BalanceTrend = Struct.new(:trend, :cash, keyword_init: true)
|
||||
|
||||
class << self
|
||||
|
@ -48,12 +48,12 @@ class Account::BalanceTrendCalculator
|
|||
todays_entries = entries.select { |e| e.date == entry.date }
|
||||
|
||||
todays_entries.each_with_index do |e, idx|
|
||||
if e.account_valuation?
|
||||
if e.valuation?
|
||||
current_balance = e.amount
|
||||
current_cash_balance = e.amount
|
||||
else
|
||||
multiplier = e.account.liability? ? 1 : -1
|
||||
balance_change = e.account_trade? ? 0 : multiplier * e.amount
|
||||
balance_change = e.trade? ? 0 : multiplier * e.amount
|
||||
cash_change = multiplier * e.amount
|
||||
|
||||
current_balance = prior_balance + balance_change
|
|
@ -1,5 +1,5 @@
|
|||
class Category < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
has_many :transactions, dependent: :nullify, class_name: "Transaction"
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
|
||||
belongs_to :family
|
||||
|
|
|
@ -361,7 +361,7 @@ class Demo::Generator
|
|||
unknown = Security.find_by(ticker: "UNKNOWN")
|
||||
|
||||
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
|
||||
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD")
|
||||
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Trade.new(qty: 20, price: 5, security: unknown, currency: "USD")
|
||||
|
||||
trades = [
|
||||
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
|
||||
|
@ -382,7 +382,7 @@ class Demo::Generator
|
|||
amount: qty * price,
|
||||
currency: "USD",
|
||||
name: name_prefix + "#{qty} shares of #{security.ticker}",
|
||||
entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security)
|
||||
entryable: Trade.new(qty: qty, price: price, currency: "USD", security: security)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -450,20 +450,20 @@ class Demo::Generator
|
|||
entry_defaults = {
|
||||
date: Faker::Number.between(from: 0, to: 730).days.ago.to_date,
|
||||
currency: "USD",
|
||||
entryable: Account::Transaction.new(transaction_attributes)
|
||||
entryable: Transaction.new(transaction_attributes)
|
||||
}
|
||||
|
||||
Account::Entry.create! entry_defaults.merge(entry_attributes)
|
||||
Entry.create! entry_defaults.merge(entry_attributes)
|
||||
end
|
||||
|
||||
def create_valuation!(account, date, amount)
|
||||
Account::Entry.create! \
|
||||
Entry.create! \
|
||||
account: account,
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: "USD",
|
||||
name: "Balance update",
|
||||
entryable: Account::Valuation.new
|
||||
entryable: Valuation.new
|
||||
end
|
||||
|
||||
def random_family_record(model, family)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class Account::Entry < ApplicationRecord
|
||||
class Entry < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
@ -7,11 +7,11 @@ class Account::Entry < ApplicationRecord
|
|||
belongs_to :transfer, optional: true
|
||||
belongs_to :import, optional: true
|
||||
|
||||
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
|
||||
delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy
|
||||
accepts_nested_attributes_for :entryable
|
||||
|
||||
validates :date, :name, :amount, :currency, presence: true
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }
|
||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||
|
||||
scope :active, -> {
|
||||
|
@ -21,7 +21,7 @@ class Account::Entry < ApplicationRecord
|
|||
scope :chronological, -> {
|
||||
order(
|
||||
date: :asc,
|
||||
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
created_at: :asc
|
||||
)
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ class Account::Entry < ApplicationRecord
|
|||
scope :reverse_chronological, -> {
|
||||
order(
|
||||
date: :desc,
|
||||
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
created_at: :desc
|
||||
)
|
||||
}
|
||||
|
@ -44,7 +44,7 @@ class Account::Entry < ApplicationRecord
|
|||
end
|
||||
|
||||
def balance_trend(entries, balances)
|
||||
Account::BalanceTrendCalculator.new(self, entries, balances).trend
|
||||
Balance::TrendCalculator.new(self, entries, balances).trend
|
||||
end
|
||||
|
||||
def display_name
|
||||
|
@ -53,7 +53,7 @@ class Account::Entry < ApplicationRecord
|
|||
|
||||
class << self
|
||||
def search(params)
|
||||
Account::EntrySearch.new(params).build_query(all)
|
||||
EntrySearch.new(params).build_query(all)
|
||||
end
|
||||
|
||||
# arbitrary cutoff date to avoid expensive sync operations
|
|
@ -1,4 +1,4 @@
|
|||
class Account::EntrySearch
|
||||
class EntrySearch
|
||||
include ActiveModel::Model
|
||||
include ActiveModel::Attributes
|
||||
|
||||
|
@ -16,7 +16,7 @@ class Account::EntrySearch
|
|||
return scope if search.blank?
|
||||
|
||||
query = scope
|
||||
query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
|
||||
query = query.where("entries.name ILIKE :search OR entries.enriched_name ILIKE :search",
|
||||
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
|
||||
)
|
||||
query
|
||||
|
@ -26,8 +26,8 @@ class Account::EntrySearch
|
|||
return scope if start_date.blank? && end_date.blank?
|
||||
|
||||
query = scope
|
||||
query = query.where("account_entries.date >= ?", start_date) if start_date.present?
|
||||
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
|
||||
query = query.where("entries.date >= ?", start_date) if start_date.present?
|
||||
query = query.where("entries.date <= ?", end_date) if end_date.present?
|
||||
query
|
||||
end
|
||||
|
||||
|
@ -38,11 +38,11 @@ class Account::EntrySearch
|
|||
|
||||
case amount_operator
|
||||
when "equal"
|
||||
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs)
|
||||
query = query.where("ABS(ABS(entries.amount) - ?) <= 0.01", amount.to_f.abs)
|
||||
when "less"
|
||||
query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs)
|
||||
query = query.where("ABS(entries.amount) < ?", amount.to_f.abs)
|
||||
when "greater"
|
||||
query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs)
|
||||
query = query.where("ABS(entries.amount) > ?", amount.to_f.abs)
|
||||
end
|
||||
|
||||
query
|
|
@ -1,7 +1,7 @@
|
|||
module Account::Entryable
|
||||
module Entryable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
TYPES = %w[Account::Valuation Account::Transaction Account::Trade]
|
||||
TYPES = %w[Valuation Transaction Trade]
|
||||
|
||||
def self.from_type(entryable_type)
|
||||
entryable_type.presence_in(TYPES).constantize
|
||||
|
@ -12,18 +12,18 @@ module Account::Entryable
|
|||
|
||||
scope :with_entry, -> { joins(:entry) }
|
||||
|
||||
scope :active, -> { with_entry.merge(Account::Entry.active) }
|
||||
scope :active, -> { with_entry.merge(Entry.active) }
|
||||
|
||||
scope :in_period, ->(period) {
|
||||
with_entry.where(account_entries: { date: period.start_date..period.end_date })
|
||||
with_entry.where(entries: { date: period.start_date..period.end_date })
|
||||
}
|
||||
|
||||
scope :reverse_chronological, -> {
|
||||
with_entry.merge(Account::Entry.reverse_chronological)
|
||||
with_entry.merge(Entry.reverse_chronological)
|
||||
}
|
||||
|
||||
scope :chronological, -> {
|
||||
with_entry.merge(Account::Entry.chronological)
|
||||
with_entry.merge(Entry.chronological)
|
||||
}
|
||||
end
|
||||
end
|
|
@ -1,12 +1,12 @@
|
|||
module Family::AutoTransferMatchable
|
||||
def transfer_match_candidates
|
||||
Account::Entry.select([
|
||||
Entry.select([
|
||||
"inflow_candidates.entryable_id as inflow_transaction_id",
|
||||
"outflow_candidates.entryable_id as outflow_transaction_id",
|
||||
"ABS(inflow_candidates.date - outflow_candidates.date) as date_diff"
|
||||
]).from("account_entries inflow_candidates")
|
||||
]).from("entries inflow_candidates")
|
||||
.joins("
|
||||
JOIN account_entries outflow_candidates ON (
|
||||
JOIN entries outflow_candidates ON (
|
||||
inflow_candidates.amount < 0 AND
|
||||
outflow_candidates.amount > 0 AND
|
||||
inflow_candidates.amount = -outflow_candidates.amount AND
|
||||
|
@ -29,7 +29,7 @@ module Family::AutoTransferMatchable
|
|||
.where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id)
|
||||
.where("inflow_accounts.is_active = true")
|
||||
.where("outflow_accounts.is_active = true")
|
||||
.where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'")
|
||||
.where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'")
|
||||
.where(existing_transfers: { id: nil })
|
||||
.order("date_diff ASC") # Closest matches first
|
||||
end
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class Account::Holding < ApplicationRecord
|
||||
class Holding < ApplicationRecord
|
||||
include Monetizable, Gapfillable
|
||||
|
||||
monetize :amount
|
||||
|
@ -27,9 +27,9 @@ class Account::Holding < ApplicationRecord
|
|||
|
||||
# Basic approximation of cost-basis
|
||||
def avg_cost
|
||||
avg_cost = account.entries.account_trades
|
||||
.joins("INNER JOIN account_trades ON account_trades.id = account_entries.entryable_id")
|
||||
.where("account_trades.security_id = ? AND account_trades.qty > 0 AND account_entries.date <= ?", security.id, date)
|
||||
avg_cost = account.entries.trades
|
||||
.joins("INNER JOIN trades ON trades.id = entries.entryable_id")
|
||||
.where("trades.security_id = ? AND trades.qty > 0 AND entries.date <= ?", security.id, date)
|
||||
.average(:price)
|
||||
|
||||
Money.new(avg_cost || price, currency)
|
|
@ -1,4 +1,4 @@
|
|||
class Account::Holding::BaseCalculator
|
||||
class Holding::BaseCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
|
@ -8,13 +8,13 @@ class Account::Holding::BaseCalculator
|
|||
def calculate
|
||||
Rails.logger.tagged(self.class.name) do
|
||||
holdings = calculate_holdings
|
||||
Account::Holding.gapfill(holdings)
|
||||
Holding.gapfill(holdings)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def portfolio_cache
|
||||
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
|
||||
@portfolio_cache ||= Holding::PortfolioCache.new(account)
|
||||
end
|
||||
|
||||
def empty_portfolio
|
||||
|
@ -49,7 +49,7 @@ class Account::Holding::BaseCalculator
|
|||
next
|
||||
end
|
||||
|
||||
Account::Holding.new(
|
||||
Holding.new(
|
||||
account_id: account.id,
|
||||
security_id: security_id,
|
||||
date: date,
|
|
@ -1,7 +1,7 @@
|
|||
class Account::Holding::ForwardCalculator < Account::Holding::BaseCalculator
|
||||
class Holding::ForwardCalculator < Holding::BaseCalculator
|
||||
private
|
||||
def portfolio_cache
|
||||
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
|
||||
@portfolio_cache ||= Holding::PortfolioCache.new(account)
|
||||
end
|
||||
|
||||
def calculate_holdings
|
|
@ -1,4 +1,4 @@
|
|||
module Account::Holding::Gapfillable
|
||||
module Holding::Gapfillable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
|
@ -19,7 +19,7 @@ module Account::Holding::Gapfillable
|
|||
previous_holding = holding
|
||||
else
|
||||
# Create a new holding based on the previous day's data
|
||||
filled_holdings << Account::Holding.new(
|
||||
filled_holdings << Holding.new(
|
||||
account: previous_holding.account,
|
||||
security: previous_holding.security,
|
||||
date: date,
|
|
@ -1,4 +1,4 @@
|
|||
class Account::Holding::PortfolioCache
|
||||
class Holding::PortfolioCache
|
||||
attr_reader :account, :use_holdings
|
||||
|
||||
class SecurityNotFound < StandardError
|
||||
|
@ -49,7 +49,7 @@ class Account::Holding::PortfolioCache
|
|||
PriceWithPriority = Data.define(:price, :priority)
|
||||
|
||||
def trades
|
||||
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
|
||||
@trades ||= account.entries.includes(entryable: :security).trades.chronological.to_a
|
||||
end
|
||||
|
||||
def holdings
|
|
@ -1,10 +1,10 @@
|
|||
class Account::Holding::ReverseCalculator < Account::Holding::BaseCalculator
|
||||
class Holding::ReverseCalculator < Holding::BaseCalculator
|
||||
private
|
||||
# Reverse calculators will use the existing holdings as a source of security ids and prices
|
||||
# since it is common for a provider to supply "current day" holdings but not all the historical
|
||||
# trades that make up those holdings.
|
||||
def portfolio_cache
|
||||
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account, use_holdings: true)
|
||||
@portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true)
|
||||
end
|
||||
|
||||
def calculate_holdings
|
|
@ -1,4 +1,4 @@
|
|||
class Account::Holding::Syncer
|
||||
class Holding::Syncer
|
||||
def initialize(account, strategy:)
|
||||
@account = account
|
||||
@strategy = strategy
|
||||
|
@ -36,7 +36,7 @@ class Account::Holding::Syncer
|
|||
end
|
||||
|
||||
def purge_stale_holdings
|
||||
portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq
|
||||
portfolio_security_ids = account.entries.trades.map { |entry| entry.entryable.security_id }.uniq
|
||||
|
||||
# If there are no securities in the portfolio, delete all holdings
|
||||
if portfolio_security_ids.empty?
|
||||
|
@ -50,9 +50,9 @@ class Account::Holding::Syncer
|
|||
|
||||
def calculator
|
||||
if strategy == :reverse
|
||||
Account::Holding::ReverseCalculator.new(account)
|
||||
Holding::ReverseCalculator.new(account)
|
||||
else
|
||||
Account::Holding::ForwardCalculator.new(account)
|
||||
Holding::ForwardCalculator.new(account)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -34,7 +34,7 @@ class Import < ApplicationRecord
|
|||
has_many :rows, dependent: :destroy
|
||||
has_many :mappings, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
has_many :entries, dependent: :destroy
|
||||
|
||||
class << self
|
||||
def parse_csv_str(csv_str, col_sep: ",")
|
||||
|
|
|
@ -63,7 +63,7 @@ class Import::Row < ApplicationRecord
|
|||
return
|
||||
end
|
||||
|
||||
min_date = Account::Entry.min_supported_date
|
||||
min_date = Entry.min_supported_date
|
||||
max_date = Date.current
|
||||
|
||||
if parsed_date < min_date || parsed_date > max_date
|
||||
|
|
|
@ -11,13 +11,13 @@ module IncomeStatement::BaseQuery
|
|||
COUNT(ae.id) as transactions_count,
|
||||
BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates
|
||||
FROM (#{transactions_scope.to_sql}) at
|
||||
JOIN account_entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Account::Transaction'
|
||||
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
||||
LEFT JOIN categories c ON c.id = at.category_id
|
||||
LEFT JOIN (
|
||||
SELECT t.*, t.id as transfer_id, a.accountable_type
|
||||
FROM transfers t
|
||||
JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id
|
||||
AND ae.entryable_type = 'Account::Transaction'
|
||||
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
|
||||
AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
) transfer_info ON (
|
||||
transfer_info.inflow_transaction_id = at.id OR
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Merchant < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
has_many :transactions, dependent: :nullify, class_name: "Transaction"
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
|
|
|
@ -35,7 +35,7 @@ class MintImport < Import
|
|||
name: row.name,
|
||||
currency: row.currency,
|
||||
notes: row.notes,
|
||||
entryable: Account::Transaction.new(category: category, tags: tags),
|
||||
entryable: Transaction.new(category: category, tags: tags),
|
||||
import: self
|
||||
|
||||
entry.save!
|
||||
|
|
|
@ -87,7 +87,7 @@ class PlaidAccount < ApplicationRecord
|
|||
t.amount = plaid_txn.amount
|
||||
t.currency = plaid_txn.iso_currency_code
|
||||
t.date = plaid_txn.date
|
||||
t.entryable = Account::Transaction.new(
|
||||
t.entryable = Transaction.new(
|
||||
category: get_category(plaid_txn.personal_finance_category.primary),
|
||||
merchant: get_merchant(plaid_txn.merchant_name)
|
||||
)
|
||||
|
@ -120,7 +120,7 @@ class PlaidAccount < ApplicationRecord
|
|||
e.amount = loan_data.origination_principal_amount
|
||||
e.currency = account.currency
|
||||
e.date = loan_data.origination_date
|
||||
e.entryable = Account::Valuation.new
|
||||
e.entryable = Valuation.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -31,7 +31,7 @@ class PlaidInvestmentSync
|
|||
t.amount = transaction.amount
|
||||
t.currency = transaction.iso_currency_code
|
||||
t.date = transaction.date
|
||||
t.entryable = Account::Transaction.new
|
||||
t.entryable = Transaction.new
|
||||
end
|
||||
else
|
||||
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|
|
||||
|
@ -39,7 +39,7 @@ class PlaidInvestmentSync
|
|||
t.amount = transaction.quantity * transaction.price
|
||||
t.currency = transaction.iso_currency_code
|
||||
t.date = transaction.date
|
||||
t.entryable = Account::Trade.new(
|
||||
t.entryable = Trade.new(
|
||||
security: security,
|
||||
qty: transaction.quantity,
|
||||
price: transaction.price,
|
||||
|
|
|
@ -44,6 +44,6 @@ class Property < ApplicationRecord
|
|||
|
||||
private
|
||||
def first_valuation_amount
|
||||
account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money
|
||||
account.entries.valuations.order(:date).first&.amount_money || account.balance_money
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class RejectedTransfer < ApplicationRecord
|
||||
belongs_to :inflow_transaction, class_name: "Account::Transaction"
|
||||
belongs_to :outflow_transaction, class_name: "Account::Transaction"
|
||||
belongs_to :inflow_transaction, class_name: "Transaction"
|
||||
belongs_to :outflow_transaction, class_name: "Transaction"
|
||||
end
|
||||
|
|
|
@ -3,7 +3,7 @@ class Security < ApplicationRecord
|
|||
|
||||
before_save :upcase_ticker
|
||||
|
||||
has_many :trades, dependent: :nullify, class_name: "Account::Trade"
|
||||
has_many :trades, dependent: :nullify, class_name: "Trade"
|
||||
has_many :prices, dependent: :destroy
|
||||
|
||||
validates :ticker, presence: true
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
class Tag < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :taggings, dependent: :destroy
|
||||
has_many :transactions, through: :taggings, source: :taggable, source_type: "Account::Transaction"
|
||||
has_many :transactions, through: :taggings, source: :taggable, source_type: "Transaction"
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :family }
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Account::Trade < ApplicationRecord
|
||||
include Account::Entryable, Monetizable
|
||||
class Trade < ApplicationRecord
|
||||
include Entryable, Monetizable
|
||||
|
||||
monetize :price
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
class Account::TradeBuilder
|
||||
class TradeBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
|
@ -46,7 +46,7 @@ class Account::TradeBuilder
|
|||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Account::Trade.new(
|
||||
entryable: Trade.new(
|
||||
qty: signed_qty,
|
||||
price: price,
|
||||
currency: currency,
|
||||
|
@ -74,7 +74,7 @@ class Account::TradeBuilder
|
|||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Account::Transaction.new
|
||||
entryable: Transaction.new
|
||||
)
|
||||
end
|
||||
end
|
||||
|
@ -85,7 +85,7 @@ class Account::TradeBuilder
|
|||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Account::Transaction.new
|
||||
entryable: Transaction.new
|
||||
)
|
||||
end
|
||||
|
|
@ -16,12 +16,12 @@ class TradeImport < Import
|
|||
exchange_operating_mic: row.exchange_operating_mic
|
||||
)
|
||||
|
||||
Account::Trade.new(
|
||||
Trade.new(
|
||||
security: security,
|
||||
qty: row.qty,
|
||||
currency: row.currency.presence || mapped_account.currency,
|
||||
price: row.price,
|
||||
entry: Account::Entry.new(
|
||||
entry: Entry.new(
|
||||
account: mapped_account,
|
||||
date: row.date_iso,
|
||||
amount: row.signed_amount,
|
||||
|
@ -31,7 +31,7 @@ class TradeImport < Import
|
|||
),
|
||||
)
|
||||
end
|
||||
Account::Trade.import!(trades, recursive: true)
|
||||
Trade.import!(trades, recursive: true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Account::Transaction < ApplicationRecord
|
||||
include Account::Entryable, Transferable, Provided
|
||||
class Transaction < ApplicationRecord
|
||||
include Entryable, Transferable, Provided
|
||||
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
|
@ -11,7 +11,7 @@ class Account::Transaction < ApplicationRecord
|
|||
|
||||
class << self
|
||||
def search(params)
|
||||
Account::TransactionSearch.new(params).build_query(all)
|
||||
Search.new(params).build_query(all)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
module Account::Transaction::Provided
|
||||
module Transaction::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def fetch_enrichment_info
|
|
@ -1,4 +1,4 @@
|
|||
class Account::TransactionSearch
|
||||
class Transaction::Search
|
||||
include ActiveModel::Model
|
||||
include ActiveModel::Attributes
|
||||
|
||||
|
@ -22,10 +22,10 @@ class Account::TransactionSearch
|
|||
query = apply_type_filter(query, types)
|
||||
query = apply_merchant_filter(query, merchants)
|
||||
query = apply_tag_filter(query, tags)
|
||||
query = Account::EntrySearch.apply_search_filter(query, search)
|
||||
query = Account::EntrySearch.apply_date_filters(query, start_date, end_date)
|
||||
query = Account::EntrySearch.apply_amount_filter(query, amount, amount_operator)
|
||||
query = Account::EntrySearch.apply_accounts_filter(query, accounts, account_ids)
|
||||
query = EntrySearch.apply_search_filter(query, search)
|
||||
query = EntrySearch.apply_date_filters(query, start_date, end_date)
|
||||
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
|
||||
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
|
||||
|
||||
query
|
||||
end
|
||||
|
@ -36,12 +36,12 @@ class Account::TransactionSearch
|
|||
LEFT JOIN (
|
||||
SELECT t.*, t.id as transfer_id, a.accountable_type
|
||||
FROM transfers t
|
||||
JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id
|
||||
AND ae.entryable_type = 'Account::Transaction'
|
||||
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
|
||||
AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
) transfer_info ON (
|
||||
transfer_info.inflow_transaction_id = account_transactions.id OR
|
||||
transfer_info.outflow_transaction_id = account_transactions.id
|
||||
transfer_info.inflow_transaction_id = transactions.id OR
|
||||
transfer_info.outflow_transaction_id = transactions.id
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
@ -68,8 +68,8 @@ class Account::TransactionSearch
|
|||
return query if types.sort == [ "expense", "income", "transfer" ]
|
||||
|
||||
transfer_condition = "transfer_info.transfer_id IS NOT NULL"
|
||||
expense_condition = "account_entries.amount >= 0"
|
||||
income_condition = "account_entries.amount <= 0"
|
||||
expense_condition = "entries.amount >= 0"
|
||||
income_condition = "entries.amount <= 0"
|
||||
|
||||
condition = case types.sort
|
||||
when [ "transfer" ]
|
|
@ -1,4 +1,4 @@
|
|||
module Account::Transaction::Transferable
|
||||
module Transaction::Transferable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
|
@ -13,10 +13,10 @@ class TransactionImport < Import
|
|||
category = mappings.categories.mappable_for(row.category)
|
||||
tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact
|
||||
|
||||
Account::Transaction.new(
|
||||
Transaction.new(
|
||||
category: category,
|
||||
tags: tags,
|
||||
entry: Account::Entry.new(
|
||||
entry: Entry.new(
|
||||
account: mapped_account,
|
||||
date: row.date_iso,
|
||||
amount: row.signed_amount,
|
||||
|
@ -28,7 +28,7 @@ class TransactionImport < Import
|
|||
)
|
||||
end
|
||||
|
||||
Account::Transaction.import!(transactions, recursive: true)
|
||||
Transaction.import!(transactions, recursive: true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
class Transfer < ApplicationRecord
|
||||
belongs_to :inflow_transaction, class_name: "Account::Transaction"
|
||||
belongs_to :outflow_transaction, class_name: "Account::Transaction"
|
||||
belongs_to :inflow_transaction, class_name: "Transaction"
|
||||
belongs_to :outflow_transaction, class_name: "Transaction"
|
||||
|
||||
enum :status, { pending: "pending", confirmed: "confirmed" }
|
||||
|
||||
|
@ -23,22 +23,22 @@ class Transfer < ApplicationRecord
|
|||
end
|
||||
|
||||
new(
|
||||
inflow_transaction: Account::Transaction.new(
|
||||
inflow_transaction: Transaction.new(
|
||||
entry: to_account.entries.build(
|
||||
amount: converted_amount.amount.abs * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: "Transfer from #{from_account.name}",
|
||||
entryable: Account::Transaction.new
|
||||
entryable: Transaction.new
|
||||
)
|
||||
),
|
||||
outflow_transaction: Account::Transaction.new(
|
||||
outflow_transaction: Transaction.new(
|
||||
entry: from_account.entries.build(
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
entryable: Account::Transaction.new
|
||||
entryable: Transaction.new
|
||||
)
|
||||
),
|
||||
status: "confirmed"
|
||||
|
|
3
app/models/valuation.rb
Normal file
3
app/models/valuation.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class Valuation < ApplicationRecord
|
||||
include Entryable
|
||||
end
|
|
@ -31,6 +31,6 @@ class Vehicle < ApplicationRecord
|
|||
|
||||
private
|
||||
def first_valuation_amount
|
||||
account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money
|
||||
account.entries.valuations.order(:date).first&.amount_money || account.balance_money
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
<%# 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-secondary">
|
||||
<%= entry.currency %>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<% if entry.account_transaction.transfer? %>
|
||||
<%= lucide_icon "arrow-left-right", class: "text-secondary mt-1 w-5 h-5" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-secondary">
|
||||
<%= I18n.l(entry.date, format: :long) %>
|
||||
</span>
|
||||
<% end %>
|
|
@ -1,3 +0,0 @@
|
|||
<%= modal_form_wrapper title: t(".new_transaction") do %>
|
||||
<%= render "form", entry: @entry %>
|
||||
<% end %>
|
|
@ -11,13 +11,13 @@
|
|||
<%= tag.span t(".new") %>
|
||||
</button>
|
||||
<div data-menu-target="content" class="z-10 hidden bg-container rounded-lg border border-alpha-black-25 shadow-xs p-1">
|
||||
<%= 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 %>
|
||||
<%= link_to new_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-secondary 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_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 %>
|
||||
<%= link_to @account.investment? ? new_trade_path(account_id: @account.id) : 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 %>
|
||||
<%= lucide_icon("credit-card", class: "text-secondary w-5 h-5") %>
|
||||
<%= tag.span t(".new_transaction"), class: "text-sm" %>
|
||||
<% end %>
|
||||
|
@ -59,7 +59,7 @@
|
|||
bulk_select_plural_label_value: t(".entries")
|
||||
} do %>
|
||||
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
|
||||
<%= render "account/entries/selection_bar" %>
|
||||
<%= render "entries/selection_bar" %>
|
||||
</div>
|
||||
|
||||
<div class="grid bg-container-inset rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-5 py-3 mb-4">
|
||||
|
@ -76,7 +76,7 @@
|
|||
<div>
|
||||
<div class="rounded-tl-lg rounded-tr-lg bg-container border-alpha-black-25 shadow-xs">
|
||||
<div class="space-y-4">
|
||||
<% calculator = Account::BalanceTrendCalculator.for(@entries) %>
|
||||
<% calculator = Balance::TrendCalculator.for(@entries) %>
|
||||
<%= entries_by_date(@entries) do |entries| %>
|
||||
<% entries.each do |entry| %>
|
||||
<%= render entry, balance_trend: calculator&.trend_for(entry), view_ctx: "account" %>
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full hover:bg-gray-25 focus-within:bg-gray-25",
|
||||
{ "bg-gray-25": is_selected }],
|
||||
data: { filter_name: category.name } do %>
|
||||
<%= button_to account_transaction_category_path(
|
||||
<%= button_to transaction_category_path(
|
||||
@transaction.entry,
|
||||
account_entry: {
|
||||
entryable_type: "Account::Transaction",
|
||||
entry: {
|
||||
entryable_type: "Transaction",
|
||||
entryable_attributes: { id: @transaction.id, category_id: category.id }
|
||||
}
|
||||
),
|
||||
|
|
|
@ -30,10 +30,10 @@
|
|||
<hr>
|
||||
<div class="relative p-1.5 w-full">
|
||||
<% if @transaction.category %>
|
||||
<%= button_to account_transaction_path(@transaction.entry),
|
||||
<%= button_to transaction_path(@transaction.entry),
|
||||
method: :patch,
|
||||
data: { turbo_frame: dom_id(@transaction.entry) },
|
||||
params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
||||
params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
||||
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-gray-100" do %>
|
||||
<%= lucide_icon "minus", class: "w-5 h-5" %>
|
||||
|
||||
|
@ -42,7 +42,7 @@
|
|||
<% end %>
|
||||
|
||||
<% unless @transaction.transfer? %>
|
||||
<%= link_to new_account_transaction_transfer_match_path(@transaction.entry),
|
||||
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
|
||||
|
@ -53,11 +53,11 @@
|
|||
|
||||
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form_with url: account_transaction_path(@transaction.entry),
|
||||
<%= form_with url: transaction_path(@transaction.entry),
|
||||
method: :patch,
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.hidden_field "account_entry[excluded]", value: !@transaction.entry.excluded %>
|
||||
<%= f.check_box "account_entry[excluded]",
|
||||
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
|
||||
<%= f.check_box "entry[excluded]",
|
||||
checked: @transaction.entry.excluded,
|
||||
class: "checkbox checkbox--light",
|
||||
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 text-secondary">
|
||||
<%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
||||
<%= form_with url: transactions_bulk_deletion_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>
|
|
@ -6,7 +6,7 @@
|
|||
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full", loading: "lazy" %>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
<%= link_to holding.name, account_holding_path(holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
|
||||
<%= link_to holding.name, holding_path(holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
|
||||
|
||||
<% if holding.amount %>
|
||||
<%= tag.p holding.ticker, class: "text-secondary text-xs uppercase" %>
|
|
@ -2,7 +2,7 @@
|
|||
<div class="bg-container space-y-4 p-5 rounded-xl shadow-border-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
|
||||
<%= link_to new_account_trade_path(account_id: @account.id),
|
||||
<%= link_to new_trade_path(account_id: @account.id),
|
||||
id: dom_id(@account, "new_trade"),
|
||||
data: { turbo_frame: :modal },
|
||||
class: "flex gap-1 font-medium items-center bg-gray-50 text-primary p-2 rounded-lg" do %>
|
||||
|
@ -21,12 +21,12 @@
|
|||
</div>
|
||||
|
||||
<div class="rounded-lg bg-container shadow-border-xs">
|
||||
<%= render "account/holdings/cash", account: @account %>
|
||||
<%= render "holdings/cash", account: @account %>
|
||||
|
||||
<%= render "account/holdings/ruler" %>
|
||||
<%= render "holdings/ruler" %>
|
||||
|
||||
<% if @account.current_holdings.any? %>
|
||||
<%= render partial: "account/holdings/holding", collection: @account.current_holdings, spacer_template: "ruler" %>
|
||||
<%= render partial: "holdings/holding", collection: @account.current_holdings, spacer_template: "ruler" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
|
@ -71,9 +71,9 @@
|
|||
|
||||
<p><%= t(
|
||||
".trade_history_entry",
|
||||
qty: trade_entry.account_trade.qty,
|
||||
security: trade_entry.account_trade.security.ticker,
|
||||
price: trade_entry.account_trade.price_money.format
|
||||
qty: trade_entry.trade.qty,
|
||||
security: trade_entry.trade.security.ticker,
|
||||
price: trade_entry.trade.price_money.format
|
||||
) %></p>
|
||||
</div>
|
||||
</li>
|
||||
|
@ -102,7 +102,7 @@
|
|||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_holding_path(@holding),
|
||||
holding_path(@holding),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
|
||||
data: { turbo_confirm: true } %>
|
|
@ -1,5 +1,5 @@
|
|||
<%# locals: (account:) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account_id: account.id) do %>
|
||||
<%= render "account/entries/loading" %>
|
||||
<%= turbo_frame_tag dom_id(account, :holdings), src: holdings_path(account_id: account.id) do %>
|
||||
<%= render "entries/loading" %>
|
||||
<% end %>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<% type = params[:type] || "buy" %>
|
||||
|
||||
<%= styled_form_with model: entry, url: account_trades_path, data: { controller: "trade-form" } do |form| %>
|
||||
<%= styled_form_with model: entry, url: trades_path, data: { controller: "trade-form" } do |form| %>
|
||||
|
||||
<%= form.hidden_field :account_id %>
|
||||
|
||||
|
@ -22,7 +22,7 @@
|
|||
{ label: t(".type"), selected: type },
|
||||
{ data: {
|
||||
action: "trade-form#changeType",
|
||||
trade_form_url_param: new_account_trade_path(account_id: entry.account&.id || entry.account_id),
|
||||
trade_form_url_param: new_trade_path(account_id: entry.account&.id || entry.account_id),
|
||||
trade_form_key_param: "type",
|
||||
}} %>
|
||||
|
||||
|
@ -31,7 +31,7 @@
|
|||
<div class="form-field combobox">
|
||||
<%= form.combobox :ticker,
|
||||
securities_path(country_code: Current.family.country),
|
||||
name_when_new: "account_entry[manual_ticker]",
|
||||
name_when_new: "entry[manual_ticker]",
|
||||
label: t(".holding"),
|
||||
placeholder: t(".ticker_placeholder"),
|
||||
required: true %>
|
|
@ -23,7 +23,7 @@
|
|||
</span>
|
||||
<% end %>
|
||||
|
||||
<% trade = entry.account_trade %>
|
||||
<% trade = entry.trade %>
|
||||
|
||||
<div class="mb-2">
|
||||
<%= disclosure t(".overview") do %>
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
<div class="truncate">
|
||||
<%= link_to entry.display_name,
|
||||
account_entry_path(entry),
|
||||
entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
</div>
|
|
@ -1,3 +1,3 @@
|
|||
<%= modal_form_wrapper title: t(".title") do %>
|
||||
<%= render "account/trades/form", entry: @entry %>
|
||||
<%= render "trades/form", entry: @entry %>
|
||||
<% end %>
|
|
@ -1,14 +1,14 @@
|
|||
<%= drawer(reload_on_close: true) do %>
|
||||
<%= render "account/trades/header", entry: @entry %>
|
||||
<%= render "trades/header", entry: @entry %>
|
||||
|
||||
<% trade = @entry.account_trade %>
|
||||
<% trade = @entry.trade %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Details Section -->
|
||||
<%= disclosure t(".details") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_trade_path(@entry),
|
||||
url: trade_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.date_field :date,
|
||||
|
@ -46,7 +46,7 @@
|
|||
<%= disclosure t(".additional") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_trade_path(@entry),
|
||||
url: trade_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.text_area :notes,
|
||||
|
@ -63,7 +63,7 @@
|
|||
<div class="pb-4">
|
||||
<!-- Exclude Trade Form -->
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_trade_path(@entry),
|
||||
url: trade_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||
|
@ -76,7 +76,7 @@
|
|||
<%= f.check_box :excluded,
|
||||
class: "sr-only peer",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<label for="account_entry_excluded"
|
||||
<label for="entry_excluded"
|
||||
class="switch"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -90,7 +90,7 @@
|
|||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_entry_path(@entry),
|
||||
entry_path(@entry),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm
|
||||
font-medium border border-secondary",
|
|
@ -1,4 +1,4 @@
|
|||
<%= styled_form_with model: @entry, url: account_transactions_path, class: "space-y-4" do |f| %>
|
||||
<%= styled_form_with model: @entry, url: transactions_path, class: "space-y-4" do |f| %>
|
||||
|
||||
<% if entry.errors.any? %>
|
||||
<%= render "shared/form_errors", model: entry %>
|
||||
|
@ -18,6 +18,8 @@
|
|||
<section class="space-y-2 overflow-hidden">
|
||||
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
|
||||
|
||||
<%= f.hidden_field :entryable_type, value: "Transaction" %>
|
||||
|
||||
<% if @entry.account_id %>
|
||||
<%= f.hidden_field :account_id %>
|
||||
<% else %>
|
||||
|
@ -28,7 +30,7 @@
|
|||
<%= 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 %>
|
||||
<%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.current, value: Date.current %>
|
||||
<%= f.date_field :date, label: t(".date"), required: true, min: Entry.min_supported_date, max: Date.current, value: Date.current %>
|
||||
</section>
|
||||
|
||||
<%= disclosure t(".details"), default_open: false do %>
|
|
@ -1,23 +1,23 @@
|
|||
<header class="flex justify-between items-center text-primary font-medium">
|
||||
<h1 class="text-xl">Transactions</h1>
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= contextual_menu do %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %>
|
||||
<% end %>
|
||||
<%# locals: (entry:) %>
|
||||
|
||||
<%= link_to new_import_path, class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("download", class: "text-secondary w-4 h-4") %>
|
||||
<p class="text-sm font-medium text-primary"><%= t(".import") %></p>
|
||||
<% end %>
|
||||
<%= 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>
|
||||
|
||||
<%= link_to new_account_transaction_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<p class="text-sm font-medium">New transaction</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="text-lg text-secondary">
|
||||
<%= entry.currency %>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<% if entry.transaction.transfer? %>
|
||||
<%= lucide_icon "arrow-left-right", class: "text-secondary mt-1 w-5 h-5" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<span class="text-sm text-secondary">
|
||||
<%= I18n.l(entry.date, format: :long) %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
|
|
@ -7,15 +7,14 @@
|
|||
|
||||
<div class="flex items-center gap-1 text-secondary">
|
||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
|
||||
|
||||
<%= link_to bulk_edit_account_transactions_path,
|
||||
<%= link_to new_transactions_bulk_update_path,
|
||||
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
|
||||
title: "Edit",
|
||||
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
|
||||
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
||||
<%= form_with url: transactions_bulk_deletion_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>
|
|
@ -36,7 +36,7 @@
|
|||
<div class="flex items-center gap-1">
|
||||
<%= link_to(
|
||||
transaction.transfer? ? transaction.transfer.name : entry.display_name,
|
||||
transaction.transfer? ? transfer_path(transaction.transfer) : account_entry_path(entry),
|
||||
transaction.transfer? ? transfer_path(transaction.transfer) : entry_path(entry),
|
||||
data: {
|
||||
turbo_frame: "drawer",
|
||||
turbo_prefetch: false
|
||||
|
@ -51,7 +51,7 @@
|
|||
<% end %>
|
||||
|
||||
<% if transaction.transfer? %>
|
||||
<%= render "account/transactions/transfer_match", transaction: transaction %>
|
||||
<%= render "transactions/transfer_match", transaction: transaction %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
@ -74,7 +74,7 @@
|
|||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 col-span-2">
|
||||
<%= render "account/transactions/transaction_category", transaction: transaction %>
|
||||
<%= render "transactions/transaction_category", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
|
@ -2,7 +2,7 @@
|
|||
<dialog data-controller="modal"
|
||||
data-action="mousedown->modal#clickOutside"
|
||||
class="bg-container shadow-border-xs rounded-2xl max-h-[calc(100vh-32px)] h-full max-w-[480px] w-full mt-4 mr-4 ml-auto">
|
||||
<%= styled_form_with url: bulk_update_account_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
|
||||
<%= styled_form_with url: transactions_bulk_update_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
|
||||
<div class="flex h-full flex-col justify-between p-4 gap-4">
|
||||
<div>
|
||||
<div class="flex h-9 items-center justify-end">
|
||||
|
@ -22,26 +22,26 @@
|
|||
<div class="space-y-2">
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-secondary bg-gray-25 focus-visible:outline-hidden">
|
||||
<h4><%= t(".overview") %></h4>
|
||||
<h4>Overview</h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pb-6 space-y-2">
|
||||
<%= form.date_field :date, label: t(".date_label"), max: Date.current %>
|
||||
<%= form.date_field :date, label: "Date", max: Date.current %>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-secondary bg-gray-25 focus-visible:outline-hidden">
|
||||
<h4><%= t(".details") %></h4>
|
||||
<h4>Details</h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="space-y-2">
|
||||
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-subdued" } %>
|
||||
<%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-subdued" } %>
|
||||
<%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: t(".none"), multiple: true, label: t(".tag_label"), container_class: "h-40" } %>
|
||||
<%= form.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), rows: 5 %>
|
||||
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %>
|
||||
<%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
|
||||
<%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", container_class: "h-40" } %>
|
||||
<%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
@ -50,9 +50,9 @@
|
|||
</div>
|
||||
|
||||
<div class="flex justify-end items-center gap-2">
|
||||
<%= link_to t(".cancel"), transactions_path, class: "btn btn--ghost" %>
|
||||
<%= link_to "Cancel", transactions_path, class: "btn btn--ghost" %>
|
||||
|
||||
<%= tag.button t(".save"),
|
||||
<%= tag.button "Save",
|
||||
type: "button",
|
||||
data: { "bulk-select-scope-param": "bulk_update", action: "bulk-select#submitBulkRequest" },
|
||||
class: "btn btn--primary" %>
|
|
@ -1,5 +1,27 @@
|
|||
<div class="space-y-4 pb-20 flex flex-col" data-controller="focus-record" data-focus-record-id-value="<%= @focused_record ? dom_id(@focused_record) : nil %>">
|
||||
<%= render "header" %>
|
||||
<header class="flex justify-between items-center text-primary font-medium">
|
||||
<h1 class="text-xl">Transactions</h1>
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= contextual_menu do %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_import_path, class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("download", class: "text-secondary w-4 h-4") %>
|
||||
<p class="text-sm font-medium text-primary"><%= t(".import") %></p>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_transaction_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<p class="text-sm font-medium">New transaction</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<%= render "summary", totals: @totals %>
|
||||
|
||||
|
@ -11,7 +33,7 @@
|
|||
<%= render "transactions/searches/search" %>
|
||||
|
||||
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
|
||||
<%= render "account/transactions/selection_bar" %>
|
||||
<%= render "transactions/selection_bar" %>
|
||||
</div>
|
||||
|
||||
<% if @pagy.count > 0 %>
|
||||
|
@ -34,7 +56,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= render "account/entries/empty" %>
|
||||
<%= render "entries/empty" %>
|
||||
<% end %>
|
||||
|
||||
<div class="pt-4">
|
||||
|
|
3
app/views/transactions/new.html.erb
Normal file
3
app/views/transactions/new.html.erb
Normal file
|
@ -0,0 +1,3 @@
|
|||
<%= modal_form_wrapper title: "New transaction" do %>
|
||||
<%= render "form", entry: @entry %>
|
||||
<% end %>
|
|
@ -1,12 +1,12 @@
|
|||
<%= drawer(reload_on_close: true) do %>
|
||||
<%= render "account/transactions/header", entry: @entry %>
|
||||
<%= render "transactions/header", entry: @entry %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Overview Section -->
|
||||
<%= disclosure t(".overview") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
url: transaction_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
|
||||
|
@ -19,7 +19,7 @@
|
|||
max: Date.current,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<% unless @entry.account_transaction.transfer? %>
|
||||
<% unless @entry.transaction.transfer? %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= f.select :nature,
|
||||
[["Expense", "outflow"], ["Income", "inflow"]],
|
||||
|
@ -51,10 +51,10 @@
|
|||
<%= disclosure t(".details"), default_open: false do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
url: transaction_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<% unless @entry.account_transaction.transfer? %>
|
||||
<% unless @entry.transaction.transfer? %>
|
||||
<%= f.select :account,
|
||||
options_for_select(
|
||||
Current.family.accounts.alphabetically.pluck(:name, :id),
|
||||
|
@ -100,7 +100,7 @@
|
|||
<div class="pb-4">
|
||||
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
url: transaction_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
|
@ -113,7 +113,7 @@
|
|||
<%= f.check_box :excluded,
|
||||
class: "sr-only peer",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<label for="account_entry_excluded"
|
||||
<label for="entry_excluded"
|
||||
class="switch"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -125,7 +125,7 @@
|
|||
<p class="text-secondary">Transfers and payments are special types of transactions that indicate money movement between 2 accounts.</p>
|
||||
</div>
|
||||
|
||||
<%= link_to new_account_transaction_transfer_match_path(@entry), class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: :modal } do %>
|
||||
<%= link_to new_transaction_transfer_match_path(@entry), class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "arrow-left-right", class: "w-4 h-4 shrink-0" %>
|
||||
<span class="whitespace-nowrap">Open matcher</span>
|
||||
<% end %>
|
||||
|
@ -139,7 +139,7 @@
|
|||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_entry_path(@entry),
|
||||
entry_path(@entry),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm
|
||||
font-medium border border-secondary",
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue