1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +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:
Zach Gollwitzer 2025-04-14 11:40:34 -04:00 committed by GitHub
parent f181ba941f
commit e657c40d19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
172 changed files with 1297 additions and 1258 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,3 +0,0 @@
class Account::ValuationsController < ApplicationController
include EntryableResource
end

View file

@ -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

View file

@ -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

View 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

View file

@ -1,4 +1,4 @@
class Account::HoldingsController < ApplicationController
class HoldingsController < ApplicationController
before_action :set_holding, only: %i[show destroy]
def index

View 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

View 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

View 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

View 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

View file

@ -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, {})

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"
)

View file

@ -1,3 +0,0 @@
class Account::Valuation < ApplicationRecord
include Account::Entryable
end

View file

@ -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

View file

@ -1,4 +1,4 @@
class Account::Balance < ApplicationRecord
class Balance < ApplicationRecord
include Monetizable
belongs_to :account

View file

@ -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,

View file

@ -1,4 +1,4 @@
class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator
class Balance::ForwardCalculator < Balance::BaseCalculator
private
def calculate_balances
current_cash_balance = 0

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: ",")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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!

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 }

View file

@ -1,5 +1,5 @@
class Account::Trade < ApplicationRecord
include Account::Entryable, Monetizable
class Trade < ApplicationRecord
include Entryable, Monetizable
monetize :price

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,4 @@
module Account::Transaction::Provided
module Transaction::Provided
extend ActiveSupport::Concern
def fetch_enrichment_info

View file

@ -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" ]

View file

@ -1,4 +1,4 @@
module Account::Transaction::Transferable
module Transaction::Transferable
extend ActiveSupport::Concern
included do

View file

@ -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

View file

@ -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
View file

@ -0,0 +1,3 @@
class Valuation < ApplicationRecord
include Entryable
end

View file

@ -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

View file

@ -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 %>

View file

@ -1,3 +0,0 @@
<%= modal_form_wrapper title: t(".new_transaction") do %>
<%= render "form", entry: @entry %>
<% end %>

View file

@ -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" %>

View file

@ -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 }
}
),

View file

@ -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" } %>

View file

@ -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>

View file

@ -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" %>

View file

@ -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>

View file

@ -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 } %>

View file

@ -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 %>

View file

@ -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 %>

View file

@ -23,7 +23,7 @@
</span>
<% end %>
<% trade = entry.account_trade %>
<% trade = entry.trade %>
<div class="mb-2">
<%= disclosure t(".overview") do %>

View file

@ -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>

View file

@ -1,3 +1,3 @@
<%= modal_form_wrapper title: t(".title") do %>
<%= render "account/trades/form", entry: @entry %>
<%= render "trades/form", entry: @entry %>
<% end %>

View file

@ -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",

View file

@ -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 %>

View file

@ -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 %>

View file

@ -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>

View file

@ -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">

View file

@ -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" %>

View file

@ -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">

View file

@ -0,0 +1,3 @@
<%= modal_form_wrapper title: "New transaction" do %>
<%= render "form", entry: @entry %>
<% end %>

View file

@ -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