1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +02:00

Improve account transaction, trade, and valuation editing and sync experience (#1506)
Some checks failed
Publish Docker image / ci (push) Has been cancelled
Publish Docker image / Build docker image (push) Has been cancelled

* Consolidate entry controller logic

* Transaction builder

* Update trades controller to use new params

* Load account charts in turbo frames, fix PG overflow

* Consolidate tests

* Tests passing

* Remove unused code

* Add client side trade form validations
This commit is contained in:
Zach Gollwitzer 2024-11-27 16:01:50 -05:00 committed by GitHub
parent 76f2714006
commit c3248cd796
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 1103 additions and 1159 deletions

View file

@ -1,14 +1,7 @@
class Account::CashesController < ApplicationController
layout :with_sidebar
before_action :set_account
def index
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
end

View file

@ -2,56 +2,21 @@ class Account::EntriesController < ApplicationController
layout :with_sidebar
before_action :set_account
before_action :set_entry, only: %i[edit update show destroy]
def index
@q = search_params
@pagy, @entries = pagy(@account.entries.search(@q).reverse_chronological, limit: params[:per_page] || "10")
end
def edit
render entryable_view_path(:edit)
end
def update
prev_amount = @entry.amount
prev_date = @entry.date
@entry.update!(entry_params)
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
end
end
def show
render entryable_view_path(:show)
end
def destroy
@entry.destroy!
@entry.sync_account_later
redirect_to account_url(@entry.account), notice: t(".success")
@pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10")
end
private
def entryable_view_path(action)
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
end
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_entry
@entry = @account.entries.find(params[:id])
end
def entry_params
params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes)
def entries_scope
scope = Current.family.entries
scope = scope.where(account: @account) if @account
scope
end
def search_params

View file

@ -1,11 +1,12 @@
class Account::HoldingsController < ApplicationController
layout :with_sidebar
before_action :set_account
before_action :set_holding, only: %i[show destroy]
def index
@holdings = @account.holdings.current
@account = Current.family.accounts.find(params[:account_id])
@holdings = Current.family.holdings.current
@holdings = @holdings.where(account: @account) if @account
end
def show
@ -13,16 +14,17 @@ class Account::HoldingsController < ApplicationController
def destroy
@holding.destroy_holding_and_entries!
redirect_back_or_to account_holdings_path(@account)
flash[:notice] = t(".success")
respond_to do |format|
format.html { redirect_back_or_to account_path(@holding.account) }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account)) }
end
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_holding
@holding = @account.holdings.current.find(params[:id])
@holding = Current.family.holdings.current.find(params[:id])
end
end

View file

@ -1,69 +1,37 @@
class Account::TradesController < ApplicationController
layout :with_sidebar
include EntryableResource
before_action :set_account
before_action :set_entry, only: :update
def new
@entry = @account.entries.account_trades.new(
currency: @account.currency,
entryable_attributes: {}
)
end
def index
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[Account::Trade Account::Transaction])
end
def create
@builder = Account::EntryBuilder.new(entry_params)
if entry = @builder.save
entry.sync_account_later
redirect_to @account, notice: t(".success")
else
flash[:alert] = t(".failure")
redirect_back_or_to @account
end
end
def update
@entry.update!(entry_params)
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
end
end
def securities
query = params[:q]
return render json: [] if query.blank? || query.length < 2 || query.length > 100
@securities = Security::SynthComboboxOption.find_in_synth(query)
end
permitted_entryable_attributes :id, :qty, :price
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
def build_entry
Account::TradeBuilder.new(create_entry_params)
end
def set_entry
@entry = @account.entries.find(params[:id])
def create_entry_params
params.require(:account_entry).permit(
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
).tap do |params|
account_id = params.delete(:account_id)
params[:account] = Current.family.accounts.find(account_id)
end
end
def entry_params
params.require(:account_entry)
.permit(
:type, :date, :qty, :ticker, :price, :amount, :notes, :excluded, :currency, :transfer_account_id, :entryable_type,
entryable_attributes: [
:id,
:qty,
:ticker,
:price
]
)
.merge(account: @account)
def update_entry_params
return entry_params unless entry_params[:entryable_attributes].present?
update_params = entry_params
update_params = update_params.merge(entryable_type: "Account::Trade")
qty = update_params[:entryable_attributes][:qty]
price = update_params[:entryable_attributes][:price]
if qty.present? && price.present?
qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d
update_params[:entryable_attributes][:qty] = qty
update_params[:amount] = qty * price.to_d
end
update_params.except(:nature)
end
end

View file

@ -0,0 +1,22 @@
class Account::TransactionCategoriesController < ApplicationController
def update
@entry = Current.family.entries.account_transactions.find(params[:transaction_id])
@entry.update!(entry_params)
respond_to do |format|
format.html { redirect_back_or_to account_transaction_path(@entry) }
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"category_menu_account_transaction_#{@entry.account_transaction_id}",
partial: "categories/menu",
locals: { transaction: @entry.account_transaction }
)
end
end
end
private
def entry_params
params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])
end
end

View file

@ -1,74 +1,55 @@
class Account::TransactionsController < ApplicationController
layout :with_sidebar
include EntryableResource
before_action :set_account
before_action :set_entry, only: :update
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
def index
@pagy, @entries = pagy(
@account.entries.account_transactions.reverse_chronological,
limit: params[:per_page] || "10"
)
def bulk_delete
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
destroyed.map(&:account).uniq.each(&:sync_later)
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
end
def update
prev_amount = @entry.amount
prev_date = @entry.date
@entry.update!(entry_params.except(:origin))
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
@entry,
partial: "account/entries/entry",
locals: entry_locals.merge(entry: @entry)
)
def bulk_edit
end
def bulk_update
updated = Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.bulk_update!(bulk_update_params)
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
end
def mark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.mark_transfers!
redirect_back_or_to transactions_url, notice: t(".success")
end
def unmark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.update_all marked_as_transfer: false
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
def bulk_delete_params
params.require(:bulk_delete).permit(entry_ids: [])
end
def set_entry
@entry = @account.entries.find(params[:id])
def bulk_update_params
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
end
def entry_locals
{
selectable: entry_params[:origin].present?,
show_balance: entry_params[:origin] == "account",
origin: entry_params[:origin]
}
end
def entry_params
params.require(:account_entry)
.permit(
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature, :origin,
entryable_attributes: [
:id,
:category_id,
:merchant_id,
{ tag_ids: [] }
]
).tap do |permitted_params|
nature = permitted_params.delete(:nature)
if permitted_params[:amount]
amount_value = permitted_params[:amount].to_d
if nature == "income"
amount_value *= -1
end
permitted_params[:amount] = amount_value
end
end
def search_params
params.fetch(:q, {})
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
end
end

View file

@ -1,38 +1,3 @@
class Account::ValuationsController < ApplicationController
layout :with_sidebar
before_action :set_account
def new
@entry = @account.entries.account_valuations.new(
currency: @account.currency,
entryable_attributes: {}
)
end
def create
@entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {}))
if @entry.save
@entry.sync_account_later
redirect_back_or_to account_valuations_path(@account), notice: t(".success")
else
flash[:alert] = @entry.errors.full_messages.to_sentence
redirect_to @account
end
end
def index
@entries = @account.entries.account_valuations.reverse_chronological
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def entry_params
params.require(:account_entry).permit(:name, :date, :amount, :currency)
end
include EntryableResource
end

View file

@ -31,6 +31,11 @@ class AccountsController < ApplicationController
redirect_to account_path(@account)
end
def chart
@account = Current.family.accounts.find(params[:id])
render layout: "application"
end
def sync_all
unless Current.family.syncing?
Current.family.sync_later

View file

@ -0,0 +1,126 @@
module EntryableResource
extend ActiveSupport::Concern
included do
layout :with_sidebar
before_action :set_entry, only: %i[show update destroy]
end
class_methods do
def permitted_entryable_attributes(*attrs)
@permitted_entryable_attributes = attrs if attrs.any?
@permitted_entryable_attributes ||= [ :id ]
end
end
def show
end
def new
account = Current.family.accounts.find_by(id: params[:account_id])
@entry = Current.family.entries.new(
account: account,
currency: account ? account.currency : Current.family.currency,
entryable: entryable_type.new
)
end
def create
@entry = build_entry
if @entry.save
@entry.sync_account_later
flash[:notice] = t("account.entries.create.success")
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account) }
redirect_target_url = request.referer || account_path(@entry.account)
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
end
else
render :new, status: :unprocessable_entity
end
end
def update
if @entry.update(update_entry_params)
@entry.sync_account_later
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"header_account_entry_#{@entry.id}",
partial: "#{entryable_type.name.underscore.pluralize}/header",
locals: { entry: @entry }
)
end
end
else
render :show, status: :unprocessable_entity
end
end
def destroy
account = @entry.account
@entry.destroy!
@entry.sync_account_later
flash[:notice] = t("account.entries.destroy.success")
respond_to do |format|
format.html { redirect_back_or_to account_path(account) }
redirect_target_url = request.referer || account_path(@entry.account)
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
end
end
private
def entryable_type
permitted_entryable_types = %w[Account::Transaction Account::Valuation Account::Trade]
klass = params[:entryable_type] || "Account::#{controller_name.classify}"
klass.constantize if permitted_entryable_types.include?(klass)
end
def set_entry
@entry = Current.family.entries.find(params[:id])
end
def build_entry
Current.family.entries.new(create_entry_params)
end
def update_entry_params
prepared_entry_params
end
def create_entry_params
prepared_entry_params.merge({
entryable_type: entryable_type.name,
entryable_attributes: entry_params[:entryable_attributes] || {}
})
end
def prepared_entry_params
default_params = entry_params.except(:nature)
default_params = default_params.merge(entryable_type: entryable_type.name) if entry_params[:entryable_attributes].present?
if entry_params[:nature].present? && entry_params[:amount].present?
signed_amount = entry_params[:nature] == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
default_params = default_params.merge(amount: signed_amount)
end
default_params
end
def entry_params
params.require(:account_entry).permit(
:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature,
entryable_attributes: self.class.permitted_entryable_attributes
)
end
end

View file

@ -1,5 +1,18 @@
class SecuritiesController < ApplicationController
def import
SecuritiesImportJob.perform_later(params[:exchange_mic])
def index
query = params[:q]
return render json: [] if query.blank? || query.length < 2 || query.length > 100
@securities = Security.search({
search: query,
country: country_code_filter
})
end
private
def country_code_filter
filter = params[:country_code]
filter = "#{filter},US" unless filter == "US"
filter
end
end

View file

@ -13,94 +13,13 @@ class TransactionsController < ApplicationController
}
end
def new
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
if params[:account_id]
e.account = Current.family.accounts.find(params[:account_id])
e.currency = e.account.currency
else
e.currency = Current.family.currency
end
end
end
def create
@entry = Current.family
.accounts
.find(params[:account_entry][:account_id])
.entries
.create!(transaction_entry_params.merge(amount: amount))
@entry.sync_account_later
redirect_back_or_to @entry.account, notice: t(".success")
end
def bulk_delete
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
destroyed.map(&:account).uniq.each(&:sync_later)
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
end
def bulk_edit
end
def bulk_update
updated = Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.bulk_update!(bulk_update_params)
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
end
def mark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.mark_transfers!
redirect_back_or_to transactions_url, notice: t(".success")
end
def unmark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.update_all marked_as_transfer: false
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def amount
if nature.income?
transaction_entry_params[:amount].to_d * -1
else
transaction_entry_params[:amount].to_d
end
end
def nature
params[:account_entry][:nature].to_s.inquiry
end
def bulk_delete_params
params.require(:bulk_delete).permit(entry_ids: [])
end
def bulk_update_params
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
end
def search_params
params.fetch(:q, {})
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
end
def transaction_entry_params
params.require(:account_entry)
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
.permit(
:start_date, :end_date, :search, :amount,
:amount_operator, accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: []
)
end
end

View file

@ -62,9 +62,9 @@ module ApplicationHelper
# <div>Content here</div>
# <% end %>
#
def drawer(&block)
def drawer(reload_on_close: false, &block)
content = capture &block
render partial: "shared/drawer", locals: { content: content }
render partial: "shared/drawer", locals: { content:, reload_on_close: }
end
def disclosure(title, &block)

View file

@ -1,3 +1,7 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails";
import "controllers";
Turbo.StreamActions.redirect = function () {
Turbo.visit(this.target);
};

View file

@ -6,7 +6,7 @@ const application = Application.start();
application.debug = false;
window.Stimulus = application;
Turbo.setConfirmMethod((message) => {
Turbo.config.forms.confirm = (message) => {
const dialog = document.getElementById("turbo-confirm");
try {
@ -52,6 +52,6 @@ Turbo.setConfirmMethod((message) => {
{ once: true },
);
});
});
};
export { application };

View file

@ -2,6 +2,10 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="modal"
export default class extends Controller {
static values = {
reloadOnClose: { type: Boolean, default: false },
};
connect() {
if (this.element.open) return;
this.element.showModal();
@ -10,11 +14,15 @@ export default class extends Controller {
// Hide the dialog when the user clicks outside of it
clickOutside(e) {
if (e.target === this.element) {
this.element.close();
this.close();
}
}
close() {
this.element.close();
if (this.reloadOnCloseValue) {
window.location.reload();
}
}
}

View file

@ -1,71 +1,11 @@
import { Controller } from "@hotwired/stimulus";
const TRADE_TYPES = {
BUY: "buy",
SELL: "sell",
TRANSFER_IN: "transfer_in",
TRANSFER_OUT: "transfer_out",
INTEREST: "interest",
};
const FIELD_VISIBILITY = {
[TRADE_TYPES.BUY]: { ticker: true, qty: true, price: true },
[TRADE_TYPES.SELL]: { ticker: true, qty: true, price: true },
[TRADE_TYPES.TRANSFER_IN]: { amount: true, transferAccount: true },
[TRADE_TYPES.TRANSFER_OUT]: { amount: true, transferAccount: true },
[TRADE_TYPES.INTEREST]: { amount: true },
};
// Connects to data-controller="trade-form"
export default class extends Controller {
static targets = [
"typeInput",
"tickerInput",
"amountInput",
"transferAccountInput",
"qtyInput",
"priceInput",
];
connect() {
this.handleTypeChange = this.handleTypeChange.bind(this);
this.typeInputTarget.addEventListener("change", this.handleTypeChange);
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY);
}
disconnect() {
this.typeInputTarget.removeEventListener("change", this.handleTypeChange);
}
handleTypeChange(event) {
this.updateFields(event.target.value);
}
updateFields(type) {
const visibleFields = FIELD_VISIBILITY[type] || {};
Object.entries(this.fieldTargets).forEach(([field, target]) => {
const isVisible = visibleFields[field] || false;
// Update visibility
target.hidden = !isVisible;
// Update required status based on visibility
if (isVisible) {
target.setAttribute("required", "");
} else {
target.removeAttribute("required");
}
});
}
get fieldTargets() {
return {
ticker: this.tickerInputTarget,
amount: this.amountInputTarget,
transferAccount: this.transferAccountInputTarget,
qty: this.qtyInputTarget,
price: this.priceInputTarget,
};
// Reloads the page with a new type without closing the modal
async changeType(event) {
const url = new URL(event.params.url, window.location.origin);
url.searchParams.set(event.params.key, event.target.value);
Turbo.visit(url, { frame: "modal" });
}
}

View file

@ -12,7 +12,7 @@ class Account < ApplicationRecord
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
has_many :holdings, dependent: :destroy
has_many :holdings, dependent: :destroy, class_name: "Account::Holding"
has_many :balances, dependent: :destroy
has_many :issues, as: :issuable, dependent: :destroy

View file

@ -30,10 +30,10 @@ class Account::Entry < ApplicationRecord
}
def sync_account_later
if destroyed?
sync_start_date = previous_entry&.date
sync_start_date = if destroyed?
previous_entry&.date
else
sync_start_date = [ date_previously_was, date ].compact.min
[ date_previously_was, date ].compact.min
end
account.sync_later(start_date: sync_start_date)

View file

@ -1,46 +0,0 @@
class Account::EntryBuilder
include ActiveModel::Model
TYPES = %w[income expense buy sell interest transfer_in transfer_out].freeze
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id
validates :type, inclusion: { in: TYPES }
def save
if valid?
create_builder.save
end
end
private
def create_builder
case type
when "buy", "sell"
create_trade_builder
else
create_transaction_builder
end
end
def create_trade_builder
Account::TradeBuilder.new \
type: type,
date: date,
qty: qty,
ticker: ticker,
price: price,
account: account
end
def create_transaction_builder
Account::TransactionBuilder.new \
type: type,
date: date,
amount: amount,
account: account,
currency: currency,
transfer_account_id: transfer_account_id
end
end

View file

@ -28,8 +28,7 @@ class Account::Trade < ApplicationRecord
def name
prefix = sell? ? "Sell " : "Buy "
generated = prefix + "#{qty.abs} shares of #{security.ticker}"
entry.name || generated
prefix + "#{qty.abs} shares of #{security.ticker}"
end
def unrealized_gain_loss

View file

@ -1,33 +1,103 @@
class Account::TradeBuilder < Account::EntryBuilder
class Account::TradeBuilder
include ActiveModel::Model
TYPES = %w[buy sell].freeze
attr_accessor :type, :qty, :price, :ticker, :date, :account
validates :type, :qty, :price, :ticker, :date, presence: true
validates :price, numericality: { greater_than: 0 }
validates :type, inclusion: { in: TYPES }
attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :type, :transfer_account_id
def save
if valid?
create_entry
buildable.save
end
def errors
buildable.errors
end
def sync_account_later
buildable.sync_account_later
end
private
def buildable
case type
when "buy", "sell"
build_trade
when "deposit", "withdrawal"
build_transfer
when "interest"
build_interest
else
raise "Unknown trade type: #{type}"
end
end
def create_entry
account.entries.account_trades.create! \
def build_trade
account.entries.new(
date: date,
amount: amount,
currency: account.currency,
amount: signed_amount,
currency: currency,
entryable: Account::Trade.new(
security: security,
qty: signed_qty,
price: price.to_d,
currency: account.currency
price: price,
currency: currency,
security: security
)
)
end
def build_transfer
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
if transfer_account
from_account = type == "withdrawal" ? account : transfer_account
to_account = type == "withdrawal" ? transfer_account : account
Account::Transfer.build_from_accounts(
from_account,
to_account,
date: date,
amount: signed_amount
)
else
account.entries.build(
name: signed_amount < 0 ? "Deposit from #{account.name}" : "Withdrawal to #{account.name}",
date: date,
amount: signed_amount,
currency: currency,
marked_as_transfer: true,
entryable: Account::Transaction.new
)
end
end
def build_interest
account.entries.build(
name: "Interest payment",
date: date,
amount: signed_amount,
currency: currency,
entryable: Account::Transaction.new
)
end
def signed_qty
return nil unless type.in?([ "buy", "sell" ])
type == "sell" ? -qty.to_d : qty.to_d
end
def signed_amount
case type
when "buy", "sell"
signed_qty * price.to_d
when "deposit", "withdrawal"
type == "deposit" ? -amount.to_d : amount.to_d
when "interest"
amount.to_d * -1
end
end
def family
account.family
end
def security
@ -40,14 +110,4 @@ class Account::TradeBuilder < Account::EntryBuilder
security
end
def amount
price.to_d * signed_qty
end
def signed_qty
_qty = qty.to_d
_qty = _qty * -1 if type == "sell"
_qty
end
end

View file

@ -1,64 +0,0 @@
class Account::TransactionBuilder
include ActiveModel::Model
TYPES = %w[income expense interest transfer_in transfer_out].freeze
attr_accessor :type, :amount, :date, :account, :currency, :transfer_account_id
validates :type, :amount, :date, presence: true
validates :type, inclusion: { in: TYPES }
def save
if valid?
transfer? ? create_transfer : create_transaction
end
end
private
def transfer?
%w[transfer_in transfer_out].include?(type)
end
def create_transfer
return create_unlinked_transfer(account.id, signed_amount) if transfer_account_id.blank?
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
to_account_id = type == "transfer_in" ? account.id : transfer_account_id
outflow = create_unlinked_transfer(from_account_id, signed_amount.abs)
inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1)
Account::Transfer.create! entries: [ outflow, inflow ]
inflow
end
def create_unlinked_transfer(account_id, amount)
build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!)
end
def create_transaction
build_entry(account.id, signed_amount).tap(&:save!)
end
def build_entry(account_id, amount, marked_as_transfer: false)
Account::Entry.new \
account_id: account_id,
name: marked_as_transfer ? (amount < 0 ? "Deposit" : "Withdrawal") : "Interest",
amount: amount,
currency: currency,
date: date,
marked_as_transfer: marked_as_transfer,
entryable: Account::Transaction.new
end
def signed_amount
case type
when "expense", "transfer_out"
amount.to_d
else
amount.to_d * -1
end
end
end

View file

@ -48,6 +48,10 @@ class Account::Transfer < ApplicationRecord
end
end
def sync_account_later
entries.each(&:sync_account_later)
end
class << self
def build_from_accounts(from_account, to_account, date:, amount:)
outflow = from_account.entries.build \

View file

@ -35,8 +35,9 @@ module Accountable
end
def post_sync
broadcast_remove_to(account, target: "syncing-notification")
broadcast_remove_to(account.family, target: "syncing-notice")
# Broadcast a simple replace event that the controller can handle
broadcast_replace_to(
account,
target: "chart_account_#{account.id}",

View file

@ -15,6 +15,7 @@ class Family < ApplicationRecord
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
has_many :issues, through: :accounts
has_many :holdings, through: :accounts
has_many :plaid_items, dependent: :destroy
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }

View file

@ -56,7 +56,7 @@ class Investment < ApplicationRecord
end
def post_sync
broadcast_remove_to(account, target: "syncing-notification")
broadcast_remove_to(account, target: "syncing-notice")
broadcast_replace_to(
account,

View file

@ -134,12 +134,12 @@ class Provider::Synth
securities = parsed.dig("data").map do |security|
{
symbol: security.dig("symbol"),
ticker: security.dig("symbol"),
name: security.dig("name"),
logo_url: security.dig("logo_url"),
exchange_acronym: security.dig("exchange", "acronym"),
exchange_mic: security.dig("exchange", "mic_code"),
exchange_country_code: security.dig("exchange", "country_code")
country_code: security.dig("exchange", "country_code")
}
end

View file

@ -8,17 +8,33 @@ class Security < ApplicationRecord
validates :ticker, presence: true
validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false }
class << self
def search(query)
security_prices_provider.search_securities(
query: query[:search],
dataset: "limited",
country_code: query[:country]
).securities.map { |attrs| new(**attrs) }
end
end
def current_price
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
return nil if @current_price.nil?
Money.new(@current_price.price, @current_price.currency)
end
def to_combobox_display
"#{ticker} (#{exchange_acronym})"
def to_combobox_option
SynthComboboxOption.new(
symbol: ticker,
name: name,
logo_url: logo_url,
exchange_acronym: exchange_acronym,
exchange_mic: exchange_mic,
exchange_country_code: country_code
)
end
private
def upcase_ticker

View file

@ -1,22 +1,8 @@
class Security::SynthComboboxOption
include ActiveModel::Model
include Providable
attr_accessor :symbol, :name, :logo_url, :exchange_acronym, :exchange_mic, :exchange_country_code
class << self
def find_in_synth(query)
country = Current.family.country
country = "#{country},US" unless country == "US"
security_prices_provider.search_securities(
query:,
dataset: "limited",
country_code: country
).securities.map { |attrs| new(**attrs) }
end
end
def id
"#{symbol}|#{exchange_mic}|#{exchange_acronym}|#{exchange_country_code}" # submitted by combobox as value
end

View file

@ -1,5 +1,5 @@
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<%# locals: (entry:, selectable: true, show_balance: false) %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance:, origin: } %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance: } %>
<% end %>

View file

@ -6,7 +6,7 @@
</div>
<div class="flex items-center gap-1 text-gray-500">
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
</button>

View file

@ -9,13 +9,13 @@
<%= tag.span t(".new") %>
</button>
<div data-menu-target="content" class="z-10 hidden bg-white rounded-lg border border-alpha-black-25 shadow-xs p-1">
<%= link_to new_account_valuation_path(@account), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= link_to new_account_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("circle-dollar-sign", class: "text-gray-500 w-5 h-5") %>
<%= tag.span t(".new_balance"), class: "text-sm" %>
<% end %>
<% unless @account.crypto? %>
<%= link_to @account.investment? ? new_account_trade_path(@account) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= link_to @account.investment? ? new_account_trade_path(account_id: @account.id) : new_account_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %>
<%= tag.span t(".new_transaction"), class: "text-sm" %>
<% end %>
@ -75,7 +75,7 @@
<div class="space-y-4">
<%= entries_by_date(@entries) do |entries| %>
<%= render entries, show_balance: true, origin: "account" %>
<%= render entries, show_balance: true %>
<% end %>
</div>

View file

@ -6,7 +6,7 @@
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full" %>
<div class="space-y-0.5">
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
<%= link_to holding.name, account_holding_path(holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
<% if holding.amount %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>

View file

@ -101,10 +101,10 @@
</div>
<%= button_to t(".delete"),
account_holding_path(@holding.account, @holding),
account_holding_path(@holding),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
data: { turbo_confirm: true } %>
</div>
</div>
</details>

View file

@ -1,35 +1,51 @@
<%# locals: (entry:) %>
<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" },
model: entry,
scope: :account_entry,
url: account_trades_path(entry.account) do |form| %>
<% type = params[:type] || "buy" %>
<%= styled_form_with model: entry, url: account_trades_path, data: { controller: "trade-form" } do |form| %>
<%= form.hidden_field :account_id %>
<div class="space-y-4">
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% end %>
<div class="space-y-2">
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
<div data-trade-form-target="tickerInput">
<%= form.select :type, [
["Buy", "buy"],
["Sell", "sell"],
["Deposit", "deposit"],
["Withdrawal", "withdrawal"],
["Interest", "interest"]
],
{ label: t(".type"), selected: type },
{ data: {
action: "trade-form#changeType",
trade_form_url_param: new_account_trade_path(account_id: entry.account_id),
trade_form_key_param: "type",
}} %>
<% if %w[buy sell].include?(type) %>
<div class="form-field combobox">
<%= form.combobox :ticker, securities_account_trades_path(entry.account), label: t(".holding"), placeholder: t(".ticker_placeholder") %>
</div>
<%= form.combobox :ticker, securities_path(country_code: Current.family.country), label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %>
</div>
<% end %>
<%= form.date_field :date, label: true, value: Date.today %>
<%= form.date_field :date, label: true, value: Date.today, required: true %>
<div data-trade-form-target="amountInput" hidden>
<%= form.money_field :amount, label: t(".amount") %>
</div>
<% unless %w[buy sell].include?(type) %>
<%= form.money_field :amount, label: t(".amount"), required: true %>
<% end %>
<div data-trade-form-target="transferAccountInput" hidden>
<% if %w[deposit withdrawal].include?(type) %>
<%= form.collection_select :transfer_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %>
</div>
<% end %>
<div data-trade-form-target="qtyInput">
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any" %>
</div>
<div data-trade-form-target="priceInput">
<%= form.money_field :price, label: t(".price"), currency_value_override: "USD", disable_currency: true %>
</div>
<% if %w[buy sell].include?(type) %>
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any", required: true %>
<%= form.money_field :price, label: t(".price"), required: true %>
<% end %>
</div>
<%= form.submit t(".submit") %>

View file

@ -0,0 +1,68 @@
<%# locals: (entry:) %>
<div id="<%= dom_id(entry, :header) %>">
<%= tag.header class: "mb-4 space-y-1" do %>
<span class="text-gray-500 text-sm">
<%= entry.amount.negative? ? t(".sell") : t(".buy") %>
</span>
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money entry.amount_money %>
</span>
<span class="text-lg text-gray-500">
<%= entry.currency %>
</span>
</h3>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
<% end %>
<% trade = entry.account_trade %>
<div class="mb-2">
<%= disclosure t(".overview") do %>
<div class="pb-4">
<dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".symbol_label") %></dt>
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
</div>
<% if trade.buy? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".purchase_price_label") %></dt>
<dd class="text-gray-900"><%= format_money trade.price_money %></dd>
</div>
<% end %>
<% if trade.security.current_price.present? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".current_market_price_label") %></dt>
<dd class="text-gray-900"><%= format_money trade.security.current_price %></dd>
</div>
<% end %>
<% if trade.buy? && trade.unrealized_gain_loss.present? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">
<%= render "shared/trend_change", trend: trade.unrealized_gain_loss %>
</dd>
</div>
<% end %>
</dl>
</div>
<% end %>
</div>
</div>

View file

@ -1,11 +0,0 @@
<div class="flex items-center">
<%= image_tag(security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %>
<div class="flex flex-col">
<span class="text-sm font-medium">
<%= security.name.presence || security.symbol %>
</span>
<span class="text-xs text-gray-500">
<%= "#{security.symbol} (#{security.exchange_acronym})" %>
</span>
</div>
</div>

View file

@ -6,7 +6,7 @@
</div>
<div class="flex items-center gap-1 text-gray-500">
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
</button>

View file

@ -1,8 +1,8 @@
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<%# locals: (entry:, selectable: true, show_balance: false) %>
<% trade, account = entry.account_trade, entry.account %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
<div class="col-span-8 flex items-center gap-4">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
@ -16,12 +16,12 @@
<%= trade.name.first.upcase %>
</div>
<div class="truncate text-gray-900">
<div class="truncate">
<% if entry.new_record? %>
<%= content_tag :p, trade.name %>
<% else %>
<%= link_to trade.name,
account_entry_path(account, entry),
account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
@ -31,7 +31,9 @@
</div>
<div class="col-span-2 justify-self-end font-medium text-sm">
<%= tag.span format_money(entry.amount_money) %>
<%= content_tag :p,
format_money(-entry.amount_money),
class: ["text-green-600": entry.amount.negative?] %>
</div>
<div class="col-span-2 justify-self-end">

View file

@ -1,2 +0,0 @@
<%= async_combobox_options @securities,
render_in: { partial: "account/trades/security" } %>

View file

@ -1,83 +1,37 @@
<% entry, trade, account = @entry, @entry.account_trade, @entry.account %>
<%= drawer(reload_on_close: true) do %>
<%= render "account/trades/header", entry: @entry %>
<%= drawer do %>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money -entry.amount_money %>
</span>
<span class="text-lg text-gray-500">
<%= entry.currency %>
</span>
</h3>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
</header>
<% trade = @entry.account_trade %>
<div class="space-y-2">
<!-- Overview Section -->
<%= disclosure t(".overview") do %>
<div class="pb-4">
<dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".symbol_label") %></dt>
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
</div>
<% if trade.buy? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".purchase_price_label") %></dt>
<dd class="text-gray-900"><%= format_money trade.price_money %></dd>
</div>
<% end %>
<% if trade.security.current_price.present? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".current_market_price_label") %></dt>
<dd class="text-gray-900"><%= format_money trade.security.current_price %></dd>
</div>
<% end %>
<% if trade.buy? && trade.unrealized_gain_loss.present? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">
<%= render "shared/trend_change", trend: trade.unrealized_gain_loss %>
</dd>
</div>
<% end %>
</dl>
</div>
<% end %>
<!-- Details Section -->
<%= disclosure t(".details") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_trade_path(account, entry),
<%= styled_form_with model: @entry,
url: account_trade_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.date_field :date,
label: t(".date_label"),
max: Date.current,
max: Date.today,
"data-auto-submit-form-target": "auto" %>
<div class="flex items-center gap-2">
<%= f.select :nature,
[["Buy", "outflow"], ["Sell", "inflow"]],
{ container_class: "w-1/3", label: "Type", selected: @entry.amount.negative? ? "inflow" : "outflow" },
{ data: { "auto-submit-form-target": "auto" } } %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.number_field :qty,
label: t(".quantity_label"),
step: "any",
value: trade.qty.abs,
"data-auto-submit-form-target": "auto" %>
<% end %>
</div>
<%= f.fields_for :entryable do |ef| %>
<%= ef.money_field :price,
label: t(".cost_per_share_label"),
disable_currency: true,
@ -91,8 +45,8 @@
<!-- Additional Section -->
<%= disclosure t(".additional") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_trade_path(account, entry),
<%= styled_form_with model: @entry,
url: account_trade_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_area :notes,
@ -108,8 +62,8 @@
<%= disclosure t(".settings") do %>
<div class="pb-4">
<!-- Exclude Trade Form -->
<%= styled_form_with model: [account, entry],
url: account_trade_path(account, entry),
<%= styled_form_with model: @entry,
url: account_trade_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<div class="flex cursor-pointer items-center gap-2 justify-between">
@ -136,11 +90,11 @@
</div>
<%= button_to t(".delete"),
account_entry_path(account, entry),
account_entry_path(@entry),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm
font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
data: { turbo_confirm: true } %>
</div>
</div>
<% end %>

View file

@ -1,8 +1,13 @@
<%= styled_form_with model: @entry, url: transactions_path, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
<%= styled_form_with model: @entry, url: account_transactions_path, class: "space-y-4" do |f| %>
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% end %>
<section>
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
<%= radio_tab_tag form: f, name: :nature, value: :expense, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "expense" || params[:nature].nil? %>
<%= radio_tab_tag form: f, name: :nature, value: :income, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "income" %>
<%= radio_tab_tag form: f, name: :nature, value: :outflow, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "outflow" || params[:nature].nil? %>
<%= radio_tab_tag form: f, name: :nature, value: :inflow, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "inflow" %>
<%= link_to new_account_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %>
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
<%= tag.span t(".transfer") %>
@ -12,9 +17,14 @@
<section class="space-y-2 overflow-hidden">
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
<% if @entry.account_id %>
<%= f.hidden_field :account_id %>
<% else %>
<%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
<% end %>
<%= f.money_field :amount, label: t(".amount"), required: true %>
<%= f.hidden_field :entryable_type, value: "Account::Transaction" %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
<% end %>

View file

@ -0,0 +1,23 @@
<%# locals: (entry:) %>
<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %>
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money -entry.amount_money %>
</span>
<span class="text-lg text-gray-500">
<%= entry.currency %>
</span>
</h3>
<% if entry.marked_as_transfer? %>
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
<% end %>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
<% end %>

View file

@ -8,7 +8,7 @@
<div class="flex items-center gap-1 text-gray-500">
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
<%= form_with url: mark_transfers_transactions_path,
<%= form_with url: mark_transfers_account_transactions_path,
scope: "bulk_update",
data: {
turbo_frame: "_top",
@ -28,14 +28,14 @@
</button>
<% end %>
<%= link_to bulk_edit_transactions_path,
<%= link_to bulk_edit_account_transactions_path,
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
title: "Edit",
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
<% end %>
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
</button>

View file

@ -1,4 +1,4 @@
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<%# locals: (entry:, selectable: true, show_balance: false) %>
<% transaction, account = entry.account_transaction, entry.account %>
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
@ -20,7 +20,7 @@
<%= content_tag :p, transaction.name %>
<% else %>
<%= link_to transaction.name,
entry.transfer.present? ? account_transfer_path(entry.transfer, origin:) : account_entry_path(account, entry, origin:),
entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
@ -43,7 +43,7 @@
</div>
<% else %>
<div class="flex items-center gap-1 col-span-2">
<%= render "categories/menu", transaction: transaction, origin: origin %>
<%= render "categories/menu", transaction: transaction %>
</div>
<% unless show_balance %>

View file

@ -2,7 +2,7 @@
<dialog data-controller="modal"
data-action="click->modal#clickOutside"
class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4">
<%= styled_form_with url: bulk_update_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
<%= styled_form_with url: bulk_update_account_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
<div class="flex h-full flex-col justify-between p-4">
<div>
<div class="flex h-9 items-center justify-end">

View file

@ -2,7 +2,7 @@
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex justify-between items-center">
<h3 class="font-medium text-lg"><%= t(".transactions") %></h3>
<%= link_to new_transaction_path(account_id: @account),
<%= link_to new_account_transaction_path(account_id: @account),
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>

View file

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

View file

@ -1,39 +1,14 @@
<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %>
<% origin = params[:origin] %>
<%= drawer do %>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money -entry.amount_money %>
</span>
<span class="text-lg text-gray-500">
<%= entry.currency %>
</span>
</h3>
<% if entry.marked_as_transfer? %>
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
<% end %>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
</header>
<%= drawer(reload_on_close: true) do %>
<%= render "account/transactions/header", entry: @entry %>
<div class="space-y-2">
<!-- Overview Section -->
<%= disclosure t(".overview") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_transaction_path(account, entry),
<%= styled_form_with model: @entry,
url: account_transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<%= f.text_field :name,
label: t(".name_label"),
"data-auto-submit-form-target": "auto" %>
@ -43,25 +18,25 @@
max: Date.current,
"data-auto-submit-form-target": "auto" %>
<% unless entry.marked_as_transfer? %>
<% unless @entry.marked_as_transfer? %>
<div class="flex items-center gap-2">
<%= f.select :nature,
[["Expense", "expense"], ["Income", "income"]],
{ container_class: "w-1/3", label: t(".nature"), selected: entry.amount.negative? ? "income" : "expense" },
[["Expense", "outflow"], ["Income", "inflow"]],
{ container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" },
{ data: { "auto-submit-form-target": "auto" } } %>
<%= f.money_field :amount, label: t(".amount"),
container_class: "w-2/3",
auto_submit: true,
min: 0,
value: entry.amount.abs %>
value: @entry.amount.abs %>
</div>
<% end %>
<%= f.select :account,
options_for_select(
Current.family.accounts.alphabetically.pluck(:name, :id),
entry.account_id
@entry.account_id
),
{ label: t(".account_label") },
{ disabled: true } %>
@ -72,55 +47,45 @@
<!-- Details Section -->
<%= disclosure t(".details") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_transaction_path(account, entry),
<%= styled_form_with model: @entry,
url: account_transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<% unless @entry.marked_as_transfer? %>
<%= f.fields_for :entryable do |ef| %>
<% unless entry.marked_as_transfer? %>
<%= ef.collection_select :category_id,
Current.family.categories.alphabetically,
:id, :name,
{ prompt: t(".category_placeholder"),
label: t(".category_label"),
class: "text-gray-400" },
{ label: t(".category_label"),
class: "text-gray-400", include_blank: t(".uncategorized") },
"data-auto-submit-form-target": "auto" %>
<%= ef.collection_select :merchant_id,
Current.family.merchants.alphabetically,
:id, :name,
{ prompt: t(".merchant_placeholder"),
{ include_blank: t(".none"),
label: t(".merchant_label"),
class: "text-gray-400" },
"data-auto-submit-form-target": "auto" %>
<% end %>
<%= ef.select :tag_ids,
options_for_select(
Current.family.tags.alphabetically.pluck(:name, :id),
transaction.tag_ids
),
{
include_blank: t(".none"),
multiple: true,
label: t(".tags_label"),
container_class: "h-40"
},
{ "data-auto-submit-form-target": "auto" } %>
<% end %>
<% end %>
<%= styled_form_with model: [account, entry],
url: account_transaction_path(account, entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<%= f.text_area :notes,
label: t(".note_label"),
placeholder: t(".note_placeholder"),
rows: 5,
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
</div>
<% end %>
@ -129,11 +94,10 @@
<%= disclosure t(".settings") do %>
<div class="pb-4">
<!-- Exclude Transaction Form -->
<%= styled_form_with model: [account, entry],
url: account_transaction_path(account, entry),
<%= styled_form_with model: @entry,
url: account_transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<div class="flex cursor-pointer items-center gap-2 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
@ -158,7 +122,7 @@
</div>
<%= button_to t(".delete"),
account_entry_path(account, entry),
account_entry_path(@entry),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm
font-medium border border-alpha-black-200",

View file

@ -8,12 +8,12 @@
<section>
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
<%= link_to new_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
<%= link_to new_account_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
<%= lucide_icon "minus-circle", class: "w-5 h-5" %>
<%= tag.span t(".expense") %>
<% end %>
<%= link_to new_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
<%= link_to new_account_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
<%= lucide_icon "plus-circle", class: "w-5 h-5" %>
<%= tag.span t(".income") %>
<% end %>

View file

@ -1,6 +1,6 @@
<%# locals: (entry:) %>
<%= form_with url: unmark_transfers_transactions_path, class: "flex items-center", data: {
<%= form_with url: unmark_transfers_account_transactions_path, class: "flex items-center", data: {
turbo_confirm: {
title: t(".remove_transfer"),
body: t(".remove_transfer_body"),

View file

@ -1,9 +1,12 @@
<%# locals: (entry:) %>
<%= styled_form_with model: [entry.account, entry],
url: entry.new_record? ? account_valuations_path(entry.account) : account_entry_path(entry.account, entry),
class: "space-y-4",
data: { turbo: false } do |form| %>
<%= styled_form_with model: entry, url: account_valuations_path, class: "space-y-4" do |form| %>
<%= form.hidden_field :account_id %>
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% end %>
<div class="space-y-3">
<%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %>
<%= form.money_field :amount, label: t(".amount"), required: true %>

View file

@ -0,0 +1,19 @@
<%# locals: (entry:) %>
<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %>
<span class="text-gray-500 text-sm">
<%= t(".balance") %>
</span>
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money entry.amount_money %>
</span>
</h3>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
<% end %>

View file

@ -1,4 +1,4 @@
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<%# locals: (entry:, selectable: true, show_balance: false) %>
<% account = entry.account %>
<% valuation = entry.account_valuation %>
@ -21,7 +21,7 @@
<%= content_tag :p, entry.name %>
<% else %>
<%= link_to valuation.name,
account_entry_path(account, entry),
account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>

View file

@ -1,30 +1,14 @@
<% entry, account = @entry, @entry.account %>
<%= drawer do %>
<header class="mb-4 space-y-1">
<span class="text-gray-500 text-sm">
<%= t(".balance") %>
</span>
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money entry.amount_money %>
</span>
</h3>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
</header>
<%= drawer(reload_on_close: true) do %>
<%= render "account/valuations/header", entry: %>
<div class="space-y-2">
<!-- Overview Section -->
<%= disclosure t(".overview") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_entry_path(account, entry),
<%= styled_form_with model: entry,
url: account_entry_path(entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_field :name,
@ -48,8 +32,8 @@
<!-- Details Section -->
<%= disclosure t(".details") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_entry_path(account, entry),
<%= styled_form_with model: entry,
url: account_entry_path(entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_area :notes,
@ -72,7 +56,7 @@
</div>
<%= button_to t(".delete"),
account_entry_path(account, entry),
account_entry_path(entry),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>

View file

@ -0,0 +1,5 @@
<div class="h-10">
</div>
<div class="h-64 flex items-center justify-center">
<p class="text-gray-500 animate-pulse text-sm">Loading...</p>
</div>

View file

@ -0,0 +1,32 @@
<% period = Period.from_param(params[:period]) %>
<% series = @account.series(period: period) %>
<% trend = series.trend %>
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<div class="px-4">
<% if trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %>
<%= tag.span "#{trend.value.positive? ? "+" : ""}#{format_money(trend.value)}", style: "color: #{trend.color}" %>
<% unless trend.percent.infinite? %>
<%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %>
<% end %>
<% end %>
<%= tag.span period_label(period), class: "text-gray-500" %>
</div>
<div class="h-64">
<% if series %>
<div
id="lineChart"
class="w-full h-full"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-gray-500">No data available for the selected period.</p>
</div>
<% end %>
</div>
<% end %>

View file

@ -1,5 +1,5 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account) do %>
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id) do %>
<%= render "account/entries/loading" %>
<% end %>

View file

@ -1,12 +1,10 @@
<%# locals: (account:, title: nil, tooltip: nil, **args) %>
<% period = Period.from_param(params[:period]) %>
<% series = account.series(period: period) %>
<% trend = series.trend %>
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg space-y-2">
<div class="flex justify-between px-4 pt-4 mb-2">
<div class="space-y-2">
<div class="flex items-center gap-1">
<%= tag.p title || default_value_title, class: "text-sm font-medium text-gray-500" %>
@ -14,19 +12,6 @@
</div>
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
<div>
<% if trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %>
<%= tag.span "#{trend.value.positive? ? "+" : ""}#{format_money(trend.value)}", style: "color: #{trend.color}" %>
<% unless trend.percent.infinite? %>
<%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %>
<% end %>
<% end %>
<%= tag.span period_label(period), class: "text-gray-500" %>
</div>
</div>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
@ -34,7 +19,7 @@
<% end %>
</div>
<div class="h-64 flex items-center justify-center text-2xl font-bold">
<%= render "shared/line_chart", series: series %>
</div>
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.name) do %>
<%= render "accounts/chart_loader" %>
<% end %>
</div>

View file

@ -2,6 +2,7 @@
<%= link_to key.titleize,
account_path(account, tab: key),
data: { turbo: false },
class: [
"px-2 py-1.5 rounded-md border border-transparent",
"bg-white shadow-xs border-alpha-black-50": is_selected

View file

@ -1,11 +1,12 @@
<%# locals: (transaction:, origin: nil) %>
<div class="relative" data-controller="menu">
<%# locals: (transaction:) %>
<div class="relative" data-controller="menu" id="<%= dom_id(transaction, :category_menu) %>">
<button data-menu-target="button" class="flex cursor-pointer">
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
</button>
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id, origin: origin), loading: :lazy do %>
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
</div>

View file

@ -1,8 +1,17 @@
<%# locals: (category:, origin: nil) %>
<%# locals: (category:) %>
<% is_selected = category.id === @selected_category&.id %>
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry, account_entry: { origin: origin,entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<%= button_to account_transaction_category_path(
@transaction.entry,
account_entry: {
entryable_type: "Account::Transaction",
entryable_attributes: { id: @transaction.id, category_id: category.id }
}
),
method: :patch,
class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<span class="w-5 h-5">
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
</span>

View file

@ -11,7 +11,7 @@
<%= t(".no_categories") %>
</div>
<% @categories.each do |category| %>
<%= render partial: "category/dropdowns/row", locals: { category:, origin: params[:origin] } %>
<%= render partial: "category/dropdowns/row", locals: { category: } %>
<% end %>
</div>
<hr>

View file

@ -1,5 +1,5 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account) do %>
<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account_id: account.id) do %>
<%= render "account/entries/loading" %>
<% end %>

View file

@ -1,9 +1,5 @@
<%# locals: (account:, **args) %>
<% period = Period.from_param(params[:period]) %>
<% series = account.series(period: period) %>
<% trend = series.trend %>
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div class="space-y-2">

View file

@ -1,5 +1,5 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account) do %>
<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account_id: account.id) do %>
<%= render "account/entries/loading" %>
<% end %>

View file

@ -36,7 +36,7 @@
<%= render_flash_notifications %>
<% if Current.family&.syncing? %>
<%= render "shared/notification", id: "syncing-notification", type: :processing, message: t(".syncing") %>
<%= render "shared/syncing_notice" %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,11 @@
<div class="flex items-center">
<%= image_tag(combobox_security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %>
<div class="flex flex-col">
<span class="text-sm font-medium">
<%= combobox_security.name.presence || combobox_security.symbol %>
</span>
<span class="text-xs text-gray-500">
<%= "#{combobox_security.symbol} (#{combobox_security.exchange_acronym})" %>
</span>
</div>
</div>

View file

@ -0,0 +1,2 @@
<%= async_combobox_options @securities.map(&:to_combobox_option),
render_in: { partial: "securities/combobox_security" } %>

View file

@ -1,5 +1,10 @@
<%# locals: (content:, reload_on_close: false) %>
<%= turbo_frame_tag "drawer" do %>
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none" data-controller="modal" data-action="click->modal#clickOutside">
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none"
data-controller="modal"
data-action="click->modal#clickOutside"
data-modal-reload-on-close-value="<%= reload_on_close %>">
<div class="flex flex-col h-full">
<div class="flex justify-end items-center p-4">
<div data-action="click->modal#close" class="cursor-pointer p-2">

View file

@ -0,0 +1,6 @@
<%# locals: (model:) %>
<div class="flex items-center gap-2">
<%= lucide_icon("alert-circle", class: "text-red-500 w-4 h-4 shrink-0") %>
<p class="text-red-500 text-sm"><%= model.errors.full_messages.to_sentence %></p>
</div>

View file

@ -1,10 +1,9 @@
<%# locals: (message:, type: "notice", id: nil, **_opts) %>
<%# locals: (message:, type: "notice", **_opts) %>
<% type = type.to_sym %>
<% action = "animationend->element-removal#remove" if type == :notice %>
<%= tag.div class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25",
id: type == :processing ? "syncing-notification" : id,
data: {
controller: "element-removal",
action: action
@ -20,8 +19,6 @@
<div class="flex h-full items-center justify-center rounded-full bg-error">
<%= lucide_icon "x", class: "w-3 h-3" %>
</div>
<% when :processing %>
<%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %>
<% end %>
</div>

View file

@ -0,0 +1,7 @@
<%= tag.div id: "syncing-notice", class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25" do %>
<div class="h-5 w-5 shrink-0 p-px text-white">
<%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %>
</div>
<%= tag.p t(".syncing"), class: "text-gray-900 text-sm font-medium" %>
<% end %>

View file

@ -16,7 +16,7 @@
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
<% end %>
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= link_to new_account_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p class="text-sm font-medium">New transaction</p>
<% end %>

View file

@ -29,7 +29,7 @@
</div>
<div class="space-y-6">
<%= entries_by_date(@transaction_entries, totals: true) do |entries| %>
<%= render entries, origin: "transactions" %>
<%= render entries %>
<% end %>
</div>
</div>

View file

@ -1,16 +0,0 @@
<% content_for :sidebar do %>
<%= render "settings/nav" %>
<% end %>
<div class="space-y-4">
<h1 class="text-gray-900 text-xl font-medium mb-4">Rules</h1>
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
<div class="flex justify-center items-center py-20">
<p class="text-gray-500">Transaction rules coming soon...</p>
</div>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Merchants", merchants_path) %>
<%= next_setting("Imports", imports_path) %>
</div>
</div>

View file

@ -80,6 +80,29 @@
],
"note": ""
},
{
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "f158202dcc66f2273ddea5e5296bad7146a50ca6667f49c77372b5b234542334",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/concerns/entryable_resource.rb",
"line": 122,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.require(:account_entry).permit(:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_attributes => self.class.permitted_entryable_attributes)",
"render_path": null,
"location": {
"type": "method",
"class": "EntryableResource",
"method": "entry_params"
},
"user_input": ":account_id",
"confidence": "High",
"cwe_id": [
915
],
"note": ""
},
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
@ -115,6 +138,6 @@
"note": ""
}
],
"updated": "2024-11-02 15:02:28 -0400",
"updated": "2024-11-27 15:33:53 -0500",
"brakeman_version": "6.2.2"
}

View file

@ -2,6 +2,8 @@
en:
account:
entries:
create:
success: Entry created
destroy:
success: Entry deleted
empty:

View file

@ -2,6 +2,8 @@
en:
account:
holdings:
destroy:
success: Holding deleted
holding:
per_share: per share
shares: "%{qty} shares"

View file

@ -2,9 +2,6 @@
en:
account:
trades:
create:
failure: Something went wrong
success: Transaction created successfully.
form:
account: Transfer account (optional)
account_prompt: Search account
@ -15,6 +12,15 @@ en:
submit: Add transaction
ticker_placeholder: AAPL
type: Type
header:
buy: Buy
current_market_price_label: Current Market Price
overview: Overview
purchase_price_label: Purchase Price
purchase_qty_label: Purchase Quantity
sell: Sell
symbol_label: Symbol
total_return_label: Unrealized gain/loss
index:
amount: Amount
new: New transaction
@ -27,7 +33,6 @@ en:
show:
additional: Additional
cost_per_share_label: Cost per Share
current_market_price_label: Current Market Price
date_label: Date
delete: Delete
delete_subtitle: This action cannot be undone
@ -37,12 +42,5 @@ en:
exclude_title: Exclude from analytics
note_label: Note
note_placeholder: Add any additional notes here...
overview: Overview
purchase_price_label: Purchase Price
purchase_qty_label: Purchase Quantity
quantity_label: Quantity
settings: Settings
symbol_label: Symbol
total_return_label: Unrealized gain/loss
update:
success: Trade updated successfully.

View file

@ -2,11 +2,44 @@
en:
account:
transactions:
bulk_delete:
success: "%{count} transactions deleted"
bulk_edit:
cancel: Cancel
category_label: Category
category_placeholder: Select a category
date_label: Date
details: Details
merchant_label: Merchant
merchant_placeholder: Select a merchant
note_label: Notes
note_placeholder: Enter a note that will be applied to selected transactions
overview: Overview
save: Save
bulk_update:
success: "%{count} transactions updated"
form:
account: Account
account_prompt: Select an Account
amount: Amount
category: Category
category_prompt: Select a Category
date: Date
description: Description
description_placeholder: Describe transaction
expense: Expense
income: Income
submit: Add transaction
transfer: Transfer
index:
new: New transaction
no_transactions: No transactions for this account yet.
transaction: transaction
transactions: Transactions
mark_transfers:
success: Marked as transfers
new:
new_transaction: New transaction
selection_bar:
mark_transfers: Mark as transfers?
mark_transfers_confirm: Mark as transfers
@ -16,7 +49,6 @@ en:
account_label: Account
amount: Amount
category_label: Category
category_placeholder: Select a category
date_label: Date
delete: Delete
delete_subtitle: This permanently deletes the transaction, affects your historical
@ -27,13 +59,14 @@ en:
analytics.
exclude_title: Exclude transaction
merchant_label: Merchant
merchant_placeholder: Select a merchant
name_label: Name
nature: Type
none: "(none)"
note_label: Notes
note_placeholder: Enter a note
overview: Overview
settings: Settings
tags_label: Tags
update:
success: Transaction updated successfully.
uncategorized: "(uncategorized)"
unmark_transfers:
success: Transfer removed

View file

@ -2,11 +2,11 @@
en:
account:
valuations:
create:
success: Valuation created successfully.
form:
amount: Amount
submit: Add balance update
header:
balance: Balance
index:
change: change
date: date
@ -18,7 +18,6 @@ en:
title: New balance
show:
amount: Amount
balance: Balance
date_label: Date
delete: Delete
delete_subtitle: This action cannot be undone

View file

@ -31,10 +31,11 @@ en:
manual_entry: Enter account balance
title: How would you like to add it?
title: What would you like to add?
chart:
no_change: no change
show:
chart:
balance: Balance
no_change: no change
owed: Amount owed
menu:
confirm_accept: Delete "%{name}"

View file

@ -1,8 +1,6 @@
---
en:
layouts:
application:
syncing: Syncing account data...
auth:
existing_account: Already have an account?
no_account: New to Maybe?

View file

@ -1,6 +1,8 @@
---
en:
shared:
syncing_notice:
syncing: Syncing accounts data...
confirm_modal:
accept: Confirm
body_html: "<p>You will not be able to undo this decision</p>"

View file

@ -1,37 +1,6 @@
---
en:
transactions:
bulk_delete:
success: "%{count} transactions deleted"
bulk_edit:
cancel: Cancel
category_label: Category
category_placeholder: Select a category
date_label: Date
details: Details
merchant_label: Merchant
merchant_placeholder: Select a merchant
note_label: Notes
note_placeholder: Enter a note that will be applied to selected transactions
overview: Overview
save: Save
bulk_update:
success: "%{count} transactions updated"
create:
success: New transaction created successfully
form:
account: Account
account_prompt: Select an Account
amount: Amount
category: Category
category_prompt: Select a Category
date: Date
description: Description
description_placeholder: Describe transaction
expense: Expense
income: Income
submit: Add transaction
transfer: Transfer
header:
edit_categories: Edit categories
edit_imports: Edit imports
@ -41,10 +10,6 @@ en:
index:
transaction: transaction
transactions: transactions
mark_transfers:
success: Marked as transfer
new:
new_transaction: New transaction
searches:
filters:
amount_filter:
@ -77,5 +42,3 @@ en:
equal_to: equal to
greater_than: greater than
less_than: less than
unmark_transfers:
success: Transfer removed

View file

@ -69,22 +69,42 @@ Rails.application.routes.draw do
member do
post :sync
get :chart
end
end
scope module: :account do
namespace :account do
resources :holdings, only: %i[index new show destroy]
resources :cashes, only: :index
resources :transactions, only: %i[index update]
resources :valuations, only: %i[index new create]
resources :trades, only: %i[index new create update] do
get :securities, on: :collection
resources :entries, only: :index
resources :transactions, only: %i[show new create update destroy] do
resource :category, only: :update, controller: :transaction_categories
collection do
post "bulk_delete"
get "bulk_edit"
post "bulk_update"
post "mark_transfers"
post "unmark_transfers"
end
end
resources :entries, only: %i[index edit update show destroy]
resources :valuations, only: %i[show new create update destroy]
resources :trades, only: %i[show new create update destroy]
end
direct :account_entry do |entry, options|
if entry.new_record?
route_for "account_#{entry.entryable_name.pluralize}", options
else
route_for entry.entryable_name, entry, options
end
end
resources :transactions, only: :index
# Convenience routes for polymorphic paths
# Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123
direct :account do |model, options|
@ -104,15 +124,7 @@ Rails.application.routes.draw do
resources :other_assets, except: :index
resources :other_liabilities, except: :index
resources :transactions, only: %i[index new create] do
collection do
post "bulk_delete"
get "bulk_edit"
post "bulk_update"
post "mark_transfers"
post "unmark_transfers"
end
end
resources :securities, only: :index
resources :invite_codes, only: %i[index create]

View file

@ -0,0 +1,5 @@
class AddLogoUrlToSecurity < ActiveRecord::Migration[7.2]
def change
add_column :securities, :logo_url, :string
end
end

3
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_11_22_183828) do
ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -508,6 +508,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_22_183828) do
t.string "country_code"
t.string "exchange_mic"
t.string "exchange_acronym"
t.string "logo_url"
t.index ["country_code"], name: "index_securities_on_country_code"
t.index ["ticker", "exchange_mic"], name: "index_securities_on_ticker_and_exchange_mic", unique: true
end

View file

@ -3,63 +3,11 @@ require "test_helper"
class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@transaction = account_entries :transaction
@valuation = account_entries :valuation
@trade = account_entries :trade
@entry = account_entries(:transaction)
end
# =================
# Shared
# =================
test "should destroy entry" do
[ @transaction, @valuation, @trade ].each do |entry|
assert_difference -> { Account::Entry.count } => -1, -> { entry.entryable_class.count } => -1 do
delete account_entry_url(entry.account, entry)
end
assert_redirected_to account_url(entry.account)
assert_enqueued_with(job: SyncJob)
end
end
test "gets show" do
[ @transaction, @valuation, @trade ].each do |entry|
get account_entry_url(entry.account, entry)
test "gets index" do
get account_entries_path(account_id: @entry.account.id)
assert_response :success
end
end
test "gets edit" do
[ @valuation ].each do |entry|
get edit_account_entry_url(entry.account, entry)
assert_response :success
end
end
test "can update generic entry" do
[ @transaction, @valuation, @trade ].each do |entry|
assert_no_difference_in_entries do
patch account_entry_url(entry.account, entry), params: {
account_entry: {
name: "Name",
date: Date.current,
currency: "USD",
amount: 100
}
}
end
assert_redirected_to account_entry_url(entry.account, entry)
assert_enqueued_with(job: SyncJob)
end
end
private
# Simple guard to verify that nested attributes are passed the record ID to avoid new creation of record
# See `update_only` option of accepts_nested_attributes_for
def assert_no_difference_in_entries(&block)
assert_no_difference [ "Account::Entry.count", "Account::Transaction.count", "Account::Valuation.count" ], &block
end
end

View file

@ -8,12 +8,12 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest
end
test "gets holdings" do
get account_holdings_url(@account)
get account_holdings_url(account_id: @account.id)
assert_response :success
end
test "gets holding" do
get account_holding_path(@account, @holding)
get account_holding_path(@holding)
assert_response :success
end
@ -21,10 +21,10 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest
test "destroys holding and associated entries" do
assert_difference -> { Account::Holding.count } => -1,
-> { Account::Entry.count } => -1 do
delete account_holding_path(@account, @holding)
delete account_holding_path(@holding)
end
assert_redirected_to account_holdings_path(@account)
assert_empty @account.entries.where(entryable: @account.trades.where(security: @holding.security))
assert_redirected_to account_path(@holding.account)
assert_empty @holding.account.entries.where(entryable: @holding.account.trades.where(security: @holding.security))
end
end

View file

@ -1,19 +1,36 @@
require "test_helper"
class Account::TradesControllerTest < ActionDispatch::IntegrationTest
include EntryableResourceInterfaceTest
setup do
sign_in @user = users(:family_admin)
@entry = account_entries :trade
@entry = account_entries(:trade)
end
test "should get index" do
get account_trades_url(@entry.account)
assert_response :success
test "updates trade entry" do
assert_no_difference [ "Account::Entry.count", "Account::Trade.count" ] do
patch account_trade_url(@entry), params: {
account_entry: {
currency: "USD",
entryable_attributes: {
id: @entry.entryable_id,
qty: 20,
price: 20
}
}
}
end
test "should get new" do
get new_account_trade_url(@entry.account)
assert_response :success
@entry.reload
assert_enqueued_with job: SyncJob
assert_equal 20, @entry.account_trade.qty
assert_equal 20, @entry.account_trade.price
assert_equal "USD", @entry.currency
assert_redirected_to account_url(@entry.account)
end
test "creates deposit entry" do
@ -22,9 +39,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
assert_difference -> { Account::Entry.count } => 2,
-> { Account::Transaction.count } => 2,
-> { Account::Transfer.count } => 1 do
post account_trades_url(@entry.account), params: {
post account_trades_url, params: {
account_entry: {
type: "transfer_in",
account_id: @entry.account_id,
type: "deposit",
date: Date.current,
amount: 10,
currency: "USD",
@ -42,9 +60,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
assert_difference -> { Account::Entry.count } => 2,
-> { Account::Transaction.count } => 2,
-> { Account::Transfer.count } => 1 do
post account_trades_url(@entry.account), params: {
post account_trades_url, params: {
account_entry: {
type: "transfer_out",
account_id: @entry.account_id,
type: "withdrawal",
date: Date.current,
amount: 10,
currency: "USD",
@ -60,9 +79,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
assert_difference -> { Account::Entry.count } => 1,
-> { Account::Transaction.count } => 1,
-> { Account::Transfer.count } => 0 do
post account_trades_url(@entry.account), params: {
post account_trades_url, params: {
account_entry: {
type: "transfer_out",
account_id: @entry.account_id,
type: "withdrawal",
date: Date.current,
amount: 10,
currency: "USD"
@ -79,8 +99,9 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
test "creates interest entry" do
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
post account_trades_url(@entry.account), params: {
post account_trades_url, params: {
account_entry: {
account_id: @entry.account_id,
type: "interest",
date: Date.current,
amount: 10,
@ -97,13 +118,15 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
test "creates trade buy entry" do
assert_difference [ "Account::Entry.count", "Account::Trade.count", "Security.count" ], 1 do
post account_trades_url(@entry.account), params: {
post account_trades_url, params: {
account_entry: {
account_id: @entry.account_id,
type: "buy",
date: Date.current,
ticker: "NVDA (NASDAQ)",
qty: 10,
price: 10
price: 10,
currency: "USD"
}
}
end
@ -112,15 +135,16 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
assert created_entry.amount.positive?
assert created_entry.account_trade.qty.positive?
assert_equal "Transaction created successfully.", flash[:notice]
assert_equal "Entry created", flash[:notice]
assert_enqueued_with job: SyncJob
assert_redirected_to @entry.account
assert_redirected_to account_url(created_entry.account)
end
test "creates trade sell entry" do
assert_difference [ "Account::Entry.count", "Account::Trade.count" ], 1 do
post account_trades_url(@entry.account), params: {
post account_trades_url, params: {
account_entry: {
account_id: @entry.account_id,
type: "sell",
ticker: "AAPL (NYSE)",
date: Date.current,
@ -135,8 +159,8 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
assert created_entry.amount.negative?
assert created_entry.account_trade.qty.negative?
assert_equal "Transaction created successfully.", flash[:notice]
assert_equal "Entry created", flash[:notice]
assert_enqueued_with job: SyncJob
assert_redirected_to @entry.account
assert_redirected_to account_url(created_entry.account)
end
end

View file

@ -1,40 +1,117 @@
require "test_helper"
class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
include EntryableResourceInterfaceTest
setup do
sign_in @user = users(:family_admin)
@entry = account_entries :transaction
@entry = account_entries(:transaction)
end
test "should get index" do
get account_transactions_url(@entry.account)
assert_response :success
end
test "update" do
assert_no_difference [ "Account::Entry.count", "Account::Transaction.count" ] do
patch account_transaction_url(@entry.account, @entry), params: {
test "creates with transaction details" do
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
post account_transactions_url, params: {
account_entry: {
name: "Name",
account_id: @entry.account_id,
name: "New transaction",
date: Date.current,
currency: "USD",
amount: 100,
nature: "income",
nature: "inflow",
entryable_attributes: {
tag_ids: [ Tag.first.id, Tag.second.id ],
category_id: Category.first.id,
merchant_id: Merchant.first.id
}
}
}
end
created_entry = Account::Entry.order(:created_at).last
assert_redirected_to account_url(created_entry.account)
assert_equal "Entry created", flash[:notice]
assert_enqueued_with(job: SyncJob)
end
test "updates with transaction details" do
assert_no_difference [ "Account::Entry.count", "Account::Transaction.count" ] do
patch account_transaction_url(@entry), params: {
account_entry: {
name: "Updated name",
date: Date.current,
currency: "USD",
amount: 100,
nature: "inflow",
entryable_type: @entry.entryable_type,
notes: "test notes",
excluded: false,
entryable_attributes: {
id: @entry.entryable_id,
tag_ids: [ Tag.first.id, Tag.second.id ],
category_id: Category.first.id,
merchant_id: Merchant.first.id,
notes: "test notes",
excluded: false
merchant_id: Merchant.first.id
}
}
}
end
assert_equal "Transaction updated successfully.", flash[:notice]
assert_redirected_to account_entry_url(@entry.account, @entry)
@entry.reload
assert_equal "Updated name", @entry.name
assert_equal Date.current, @entry.date
assert_equal "USD", @entry.currency
assert_equal -100, @entry.amount
assert_equal [ Tag.first.id, Tag.second.id ], @entry.entryable.tag_ids.sort
assert_equal Category.first.id, @entry.entryable.category_id
assert_equal Merchant.first.id, @entry.entryable.merchant_id
assert_equal "test notes", @entry.notes
assert_equal false, @entry.excluded
assert_equal "Entry updated", flash[:notice]
assert_redirected_to account_url(@entry.account)
assert_enqueued_with(job: SyncJob)
end
test "can destroy many transactions at once" do
transactions = @user.family.entries.account_transactions
delete_count = transactions.size
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do
post bulk_delete_account_transactions_url, params: {
bulk_delete: {
entry_ids: transactions.pluck(:id)
}
}
end
assert_redirected_to transactions_url
assert_equal "#{delete_count} transactions deleted", flash[:notice]
end
test "can update many transactions at once" do
transactions = @user.family.entries.account_transactions
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do
post bulk_update_account_transactions_url, params: {
bulk_update: {
entry_ids: transactions.map(&:id),
date: 1.day.ago.to_date,
category_id: Category.second.id,
merchant_id: Merchant.second.id,
notes: "Updated note"
}
}
end
assert_redirected_to transactions_url
assert_equal "#{transactions.count} transactions updated", flash[:notice]
transactions.reload.each do |transaction|
assert_equal 1.day.ago.to_date, transaction.date
assert_equal Category.second, transaction.account_transaction.category
assert_equal Merchant.second, transaction.account_transaction.merchant
assert_equal "Updated note", transaction.notes
end
end
end

View file

@ -1,36 +1,11 @@
require "test_helper"
class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest
include EntryableResourceInterfaceTest
setup do
sign_in @user = users(:family_admin)
@entry = account_entries :valuation
end
test "should get index" do
get account_valuations_url(@entry.account)
assert_response :success
end
test "should get new" do
get new_account_valuation_url(@entry.account)
assert_response :success
end
test "create" do
assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do
post account_valuations_url(@entry.account), params: {
account_entry: {
name: "Manual valuation",
amount: 19800,
date: Date.current,
currency: "USD"
}
}
end
assert_equal "Valuation created successfully.", flash[:notice]
assert_enqueued_with job: SyncJob
assert_redirected_to account_valuations_path(@entry.account)
@entry = account_entries(:valuation)
end
test "error when valuation already exists for date" do
@ -44,7 +19,43 @@ class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest
}
end
assert_equal "Date has already been taken", flash[:alert]
assert_redirected_to @entry.account
assert_response :unprocessable_entity
end
test "creates entry with basic attributes" do
assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do
post account_valuations_url, params: {
account_entry: {
name: "New entry",
amount: 10000,
currency: "USD",
date: Date.current,
account_id: @entry.account_id
}
}
end
created_entry = Account::Entry.order(created_at: :desc).first
assert_enqueued_with job: SyncJob
assert_redirected_to account_url(created_entry.account)
end
test "updates entry with basic attributes" do
assert_no_difference [ "Account::Entry.count", "Account::Valuation.count" ] do
patch account_valuation_url(@entry), params: {
account_entry: {
name: "Updated entry",
amount: 20000,
currency: "USD",
date: Date.current
}
}
end
assert_enqueued_with job: SyncJob
assert_redirected_to account_url(@entry.account)
end
end

View file

@ -8,83 +8,6 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
@transaction = account_entries(:transaction)
end
test "should get new" do
get new_transaction_url
assert_response :success
end
test "prefills account_id" do
get new_transaction_url(account_id: @transaction.account.id)
assert_response :success
assert_select "option[selected][value='#{@transaction.account.id}']"
end
test "should create transaction" do
account = @user.family.accounts.first
entry_params = {
account_id: account.id,
amount: 100.45,
currency: "USD",
date: Date.current,
name: "Test transaction",
entryable_type: "Account::Transaction",
entryable_attributes: { category_id: categories(:food_and_drink).id }
}
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
post transactions_url, params: { account_entry: entry_params }
end
assert_equal entry_params[:amount].to_d, Account::Transaction.order(created_at: :desc).first.entry.amount
assert_equal "New transaction created successfully", flash[:notice]
assert_enqueued_with(job: SyncJob)
assert_redirected_to account_url(account)
end
test "expenses are positive" do
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], 1) do
post transactions_url, params: {
account_entry: {
nature: "expense",
account_id: @transaction.account_id,
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name,
entryable_type: "Account::Transaction",
entryable_attributes: {}
}
}
end
created_entry = Account::Entry.order(created_at: :desc).first
assert_redirected_to account_url(@transaction.account)
assert created_entry.amount.positive?, "Amount should be positive"
end
test "incomes are negative" do
assert_difference("Account::Transaction.count") do
post transactions_url, params: {
account_entry: {
nature: "income",
account_id: @transaction.account_id,
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name,
entryable_type: "Account::Transaction",
entryable_attributes: { category_id: categories(:food_and_drink).id }
}
}
end
created_entry = Account::Entry.order(created_at: :desc).first
assert_redirected_to account_url(@transaction.account)
assert created_entry.amount.negative?, "Amount should be negative"
end
test "transaction count represents filtered total" do
family = families(:empty)
sign_in family.users.first
@ -135,46 +58,4 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_dom "#" + dom_id(sorted_transactions.last), count: 1
end
test "can destroy many transactions at once" do
transactions = @user.family.entries.account_transactions
delete_count = transactions.size
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do
post bulk_delete_transactions_url, params: {
bulk_delete: {
entry_ids: transactions.pluck(:id)
}
}
end
assert_redirected_to transactions_url
assert_equal "#{delete_count} transactions deleted", flash[:notice]
end
test "can update many transactions at once" do
transactions = @user.family.entries.account_transactions
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do
post bulk_update_transactions_url, params: {
bulk_update: {
entry_ids: transactions.map(&:id),
date: 1.day.ago.to_date,
category_id: Category.second.id,
merchant_id: Merchant.second.id,
notes: "Updated note"
}
}
end
assert_redirected_to transactions_url
assert_equal "#{transactions.count} transactions updated", flash[:notice]
transactions.reload.each do |transaction|
assert_equal 1.day.ago.to_date, transaction.date
assert_equal Category.second, transaction.account_transaction.category
assert_equal Merchant.second, transaction.account_transaction.merchant
assert_equal "Updated note", transaction.notes
end
end
end

View file

@ -0,0 +1,25 @@
require "test_helper"
module EntryableResourceInterfaceTest
extend ActiveSupport::Testing::Declarative
test "shows new form" do
get new_polymorphic_url(@entry.entryable)
assert_response :success
end
test "shows editing drawer" do
get account_entry_url(@entry)
assert_response :success
end
test "destroys entry" do
assert_difference "Account::Entry.count", -1 do
delete account_entry_url(@entry)
end
assert_enqueued_with job: SyncJob
assert_redirected_to account_url(@entry.account)
end
end

View file

@ -10,9 +10,9 @@ class TradesTest < ApplicationSystemTestCase
visit_account_trades
Security::SynthComboboxOption.stubs(:find_in_synth).returns([
Security::SynthComboboxOption.new(
symbol: "AAPL",
Security.stubs(:search).returns([
Security.new(
ticker: "AAPL",
name: "Apple Inc.",
logo_url: "https://logo.synthfinance.com/ticker/AAPL",
exchange_acronym: "NASDAQ",
@ -37,7 +37,7 @@ class TradesTest < ApplicationSystemTestCase
visit_account_trades
within_trades do
assert_text "Purchase 10 shares of AAPL"
assert_text "Buy 10.0 shares of AAPL"
assert_text "Buy #{shares_qty} shares of AAPL"
end
end