mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Transfer and Payment auto-matching, model and UI improvements (#1585)
* Transfer data model migration * Transfers and payment modeling and UI improvements * Fix CI * Transfer matching flow * Better UI for transfers * Auto transfer matching, approve, reject flow * Mark transfers created from form as confirmed * Account filtering * Excluded rejected transfers from calculations * Calculation tweaks with transfer exclusions * Clean up migration
This commit is contained in:
parent
46e129308f
commit
307a3687e8
78 changed files with 1161 additions and 682 deletions
|
@ -121,7 +121,7 @@ GEM
|
|||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.2.2)
|
||||
brakeman (7.0.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
|
|
|
@ -29,6 +29,11 @@
|
|||
@apply focus:opacity-100 focus:outline-none focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
}
|
||||
|
||||
select.form-field__input {
|
||||
@apply pr-8;
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
|
@ -51,10 +56,18 @@
|
|||
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--light:disabled {
|
||||
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark:disabled {
|
||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
|
|
@ -21,24 +21,6 @@ class Account::TransactionsController < ApplicationController
|
|||
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 bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
|
|
56
app/controllers/account/transfer_matches_controller.rb
Normal file
56
app/controllers/account/transfer_matches_controller.rb
Normal file
|
@ -0,0 +1,56 @@
|
|||
class Account::TransferMatchesController < ApplicationController
|
||||
before_action :set_entry
|
||||
|
||||
def new
|
||||
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||
@transfer_match_candidates = @entry.transfer_match_candidates
|
||||
end
|
||||
|
||||
def create
|
||||
@transfer = build_transfer
|
||||
@transfer.save!
|
||||
@transfer.sync_account_later
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_entry
|
||||
@entry = Current.family.entries.find(params[:transaction_id])
|
||||
end
|
||||
|
||||
def transfer_match_params
|
||||
params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id)
|
||||
end
|
||||
|
||||
def build_transfer
|
||||
if transfer_match_params[:method] == "new"
|
||||
target_account = Current.family.accounts.find(transfer_match_params[:target_account_id])
|
||||
|
||||
missing_transaction = Account::Transaction.new(
|
||||
entry: target_account.entries.build(
|
||||
amount: @entry.amount * -1,
|
||||
currency: @entry.currency,
|
||||
date: @entry.date,
|
||||
name: "Transfer to #{@entry.amount.negative? ? @entry.account.name : target_account.name}",
|
||||
)
|
||||
)
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction,
|
||||
outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
else
|
||||
target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id])
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction,
|
||||
outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,61 +0,0 @@
|
|||
class Account::TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
@transfer = Account::Transfer.new
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d
|
||||
|
||||
if @transfer.save
|
||||
@transfer.entries.each(&:sync_account_later)
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:alert] = @transfer.errors.full_messages.to_sentence
|
||||
redirect_to transactions_path
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@transfer.update_entries!(transfer_update_params)
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transfer
|
||||
record = Account::Transfer.find(params[:id])
|
||||
|
||||
unless record.entries.all? { |entry| Current.family.accounts.include?(entry.account) }
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
|
||||
@transfer = record
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
||||
end
|
||||
|
||||
def transfer_update_params
|
||||
params.require(:account_transfer).permit(:excluded, :notes)
|
||||
end
|
||||
end
|
|
@ -52,11 +52,14 @@ module EntryableResource
|
|||
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 }
|
||||
)
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"header_account_entry_#{@entry.id}",
|
||||
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
|
|
|
@ -6,10 +6,15 @@ class TransactionsController < ApplicationController
|
|||
search_query = Current.family.transactions.search(@q).includes(:entryable).reverse_chronological
|
||||
@pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50")
|
||||
|
||||
totals_query = search_query.incomes_and_expenses
|
||||
family_currency = Current.family.currency
|
||||
count_with_transfers = search_query.count
|
||||
count_without_transfers = totals_query.count
|
||||
|
||||
@totals = {
|
||||
count: search_query.select { |t| t.currency == Current.family.currency }.count,
|
||||
income: search_query.income_total(Current.family.currency).abs,
|
||||
expense: search_query.expense_total(Current.family.currency)
|
||||
count: ((count_with_transfers - count_without_transfers) / 2) + count_without_transfers,
|
||||
income: totals_query.income_total(family_currency).abs,
|
||||
expense: totals_query.expense_total(family_currency)
|
||||
}
|
||||
end
|
||||
|
||||
|
|
66
app/controllers/transfers_controller.rb
Normal file
66
app/controllers/transfers_controller.rb
Normal file
|
@ -0,0 +1,66 @@
|
|||
class TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
@transfer = Transfer.new
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d
|
||||
)
|
||||
|
||||
if @transfer.save
|
||||
@transfer.sync_account_later
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_path }
|
||||
redirect_target_url = request.referer || transactions_path
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@transfer.update!(transfer_update_params)
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_url, notice: t(".success") }
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_transfer
|
||||
@transfer = Transfer.find(params[:id])
|
||||
|
||||
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
||||
end
|
||||
|
||||
def transfer_update_params
|
||||
params.require(:transfer).permit(:notes, :status)
|
||||
end
|
||||
end
|
|
@ -3,10 +3,6 @@ module Account::EntriesHelper
|
|||
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
|
||||
end
|
||||
|
||||
def unconfirmed_transfer?(entry)
|
||||
entry.marked_as_transfer? && entry.transfer.nil?
|
||||
end
|
||||
|
||||
def transfer_entries(entries)
|
||||
transfers = entries.select { |e| e.transfer_id.present? }
|
||||
transfers.map(&:transfer).uniq
|
||||
|
@ -18,8 +14,19 @@ module Account::EntriesHelper
|
|||
yield grouped_entries
|
||||
end
|
||||
|
||||
next if content.blank?
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: }
|
||||
end.join.html_safe
|
||||
end.compact.join.html_safe
|
||||
end
|
||||
|
||||
def entry_name_detailed(entry)
|
||||
[
|
||||
entry.date,
|
||||
format_money(entry.amount_money),
|
||||
entry.account.name,
|
||||
entry.display_name
|
||||
].join(" • ")
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
module Account::TransfersHelper
|
||||
end
|
|
@ -67,9 +67,9 @@ module ApplicationHelper
|
|||
render partial: "shared/drawer", locals: { content:, reload_on_close: }
|
||||
end
|
||||
|
||||
def disclosure(title, &block)
|
||||
def disclosure(title, default_open: true, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/disclosure", locals: { title: title, content: content }
|
||||
render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open }
|
||||
end
|
||||
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
|
|
|
@ -5,6 +5,24 @@ module CategoriesHelper
|
|||
color: Category::UNCATEGORIZED_COLOR
|
||||
end
|
||||
|
||||
def transfer_category
|
||||
Category.new \
|
||||
name: "⇄ Transfer",
|
||||
color: Category::TRANSFER_COLOR
|
||||
end
|
||||
|
||||
def payment_category
|
||||
Category.new \
|
||||
name: "→ Payment",
|
||||
color: Category::PAYMENT_COLOR
|
||||
end
|
||||
|
||||
def trade_category
|
||||
Category.new \
|
||||
name: "Trade",
|
||||
color: Category::TRADE_COLOR
|
||||
end
|
||||
|
||||
def family_categories
|
||||
[ null_category ].concat(Current.family.categories.alphabetically)
|
||||
end
|
||||
|
|
|
@ -99,7 +99,9 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
_rowsForGroup(group) {
|
||||
return this.rowTargets.filter((row) => group.contains(row));
|
||||
return this.rowTargets.filter(
|
||||
(row) => group.contains(row) && !row.disabled,
|
||||
);
|
||||
}
|
||||
|
||||
_addToSelection(idToAdd) {
|
||||
|
@ -115,7 +117,9 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
_selectAll() {
|
||||
this.selectedIdsValue = this.rowTargets.map((t) => t.dataset.id);
|
||||
this.selectedIdsValue = this.rowTargets
|
||||
.filter((t) => !t.disabled)
|
||||
.map((t) => t.dataset.id);
|
||||
}
|
||||
|
||||
_updateView = () => {
|
||||
|
|
16
app/javascript/controllers/transfer_match_controller.js
Normal file
16
app/javascript/controllers/transfer_match_controller.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="transfer-match"
|
||||
export default class extends Controller {
|
||||
static targets = ["newSelect", "existingSelect"];
|
||||
|
||||
update(event) {
|
||||
if (event.target.value === "new") {
|
||||
this.newSelectTarget.classList.remove("hidden");
|
||||
this.existingSelectTarget.classList.add("hidden");
|
||||
} else {
|
||||
this.newSelectTarget.classList.add("hidden");
|
||||
this.existingSelectTarget.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,7 +30,15 @@ class Account::Entry < ApplicationRecord
|
|||
)
|
||||
}
|
||||
|
||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
||||
# All entries that are not part of a pending/approved transfer (rejected transfers count as normal entries, so are included)
|
||||
scope :incomes_and_expenses, -> {
|
||||
joins(
|
||||
'LEFT JOIN transfers AS inflow_transfers ON inflow_transfers.inflow_transaction_id = account_entries.entryable_id
|
||||
LEFT JOIN transfers AS outflow_transfers ON outflow_transfers.outflow_transaction_id = account_entries.entryable_id'
|
||||
)
|
||||
.where("(inflow_transfers.id IS NULL AND outflow_transfers.id IS NULL) OR inflow_transfers.status = 'rejected' OR outflow_transfers.status = 'rejected'")
|
||||
}
|
||||
|
||||
scope :with_converted_amount, ->(currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
# If no rate is available, exclude the transaction from the results
|
||||
|
@ -59,6 +67,15 @@ class Account::Entry < ApplicationRecord
|
|||
enriched_name.presence || name
|
||||
end
|
||||
|
||||
def transfer_match_candidates
|
||||
account.family.entries
|
||||
.where.not(account_id: account_id)
|
||||
.where.not(id: id)
|
||||
.where(amount: -amount)
|
||||
.where(currency: currency)
|
||||
.where(date: (date - 4.days)..(date + 4.days))
|
||||
end
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
Account::EntrySearch.new(params).build_query(all)
|
||||
|
@ -98,13 +115,6 @@ class Account::Entry < ApplicationRecord
|
|||
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
|
||||
end
|
||||
|
||||
def mark_transfers!
|
||||
update_all marked_as_transfer: true
|
||||
|
||||
# Attempt to "auto match" and save a transfer if 2 transactions selected
|
||||
Account::Transfer.new(entries: all).save if all.count == 2
|
||||
end
|
||||
|
||||
def bulk_update!(bulk_update_params)
|
||||
bulk_attributes = {
|
||||
date: bulk_update_params[:date],
|
||||
|
@ -128,7 +138,7 @@ class Account::Entry < ApplicationRecord
|
|||
end
|
||||
|
||||
def income_total(currency = "USD")
|
||||
total = without_transfers.account_transactions.includes(:entryable)
|
||||
total = account_transactions.includes(:entryable).incomes_and_expenses
|
||||
.where("account_entries.amount <= 0")
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
|
@ -137,29 +147,12 @@ class Account::Entry < ApplicationRecord
|
|||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
total = without_transfers.account_transactions.includes(:entryable)
|
||||
total = account_transactions.includes(:entryable).incomes_and_expenses
|
||||
.where("account_entries.amount > 0")
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
|
||||
Money.new(total, currency)
|
||||
end
|
||||
|
||||
private
|
||||
def entryable_search(params)
|
||||
entryable_ids = []
|
||||
entryable_search_performed = false
|
||||
|
||||
Account::Entryable::TYPES.map(&:constantize).each do |entryable|
|
||||
next unless entryable.requires_search?(params)
|
||||
|
||||
entryable_search_performed = true
|
||||
entryable_ids += entryable.search(params).pluck(:id)
|
||||
end
|
||||
|
||||
return nil unless entryable_search_performed
|
||||
|
||||
entryable_ids
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,8 +6,8 @@ class Account::EntrySearch
|
|||
attribute :amount, :string
|
||||
attribute :amount_operator, :string
|
||||
attribute :types, :string
|
||||
attribute :accounts, :string
|
||||
attribute :account_ids, :string
|
||||
attribute :accounts, array: true
|
||||
attribute :account_ids, array: true
|
||||
attribute :start_date, :string
|
||||
attribute :end_date, :string
|
||||
|
||||
|
@ -27,8 +27,6 @@ class Account::EntrySearch
|
|||
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
|
||||
|
||||
if types.present?
|
||||
query = query.where(marked_as_transfer: false) unless types.include?("transfer")
|
||||
|
||||
if types.include?("income") && !types.include?("expense")
|
||||
query = query.where("account_entries.amount < 0")
|
||||
elsif types.include?("expense") && !types.include?("income")
|
||||
|
|
|
@ -5,6 +5,8 @@ class Account::Syncer
|
|||
end
|
||||
|
||||
def run
|
||||
Transfer.auto_match_for_account(account)
|
||||
|
||||
holdings = sync_holdings
|
||||
balances = sync_balances(holdings)
|
||||
account.reload
|
||||
|
|
|
@ -4,6 +4,13 @@ class Account::TradeBuilder
|
|||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :type, :transfer_account_id
|
||||
|
||||
attr_reader :buildable
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
@buildable = set_buildable
|
||||
end
|
||||
|
||||
def save
|
||||
buildable.save
|
||||
end
|
||||
|
@ -17,7 +24,7 @@ class Account::TradeBuilder
|
|||
end
|
||||
|
||||
private
|
||||
def buildable
|
||||
def set_buildable
|
||||
case type
|
||||
when "buy", "sell"
|
||||
build_trade
|
||||
|
@ -55,9 +62,9 @@ class Account::TradeBuilder
|
|||
from_account = type == "withdrawal" ? account : transfer_account
|
||||
to_account = type == "withdrawal" ? transfer_account : account
|
||||
|
||||
Account::Transfer.build_from_accounts(
|
||||
from_account,
|
||||
to_account,
|
||||
Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: date,
|
||||
amount: signed_amount
|
||||
)
|
||||
|
@ -67,7 +74,6 @@ class Account::TradeBuilder
|
|||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
end
|
||||
|
|
|
@ -6,6 +6,9 @@ class Account::Transaction < ApplicationRecord
|
|||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
|
||||
has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :restrict_with_exception
|
||||
has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :restrict_with_exception
|
||||
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
scope :active, -> { where(excluded: false) }
|
||||
|
@ -15,4 +18,12 @@ class Account::Transaction < ApplicationRecord
|
|||
Account::TransactionSearch.new(params).build_query(all)
|
||||
end
|
||||
end
|
||||
|
||||
def transfer
|
||||
transfer_as_inflow || transfer_as_outflow
|
||||
end
|
||||
|
||||
def transfer?
|
||||
transfer.present? && transfer.status != "rejected"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,6 +18,11 @@ class Account::TransactionSearch
|
|||
def build_query(scope)
|
||||
query = scope
|
||||
|
||||
if types.present? && types.exclude?("transfer")
|
||||
query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id")
|
||||
.where("transfers.id IS NULL")
|
||||
end
|
||||
|
||||
if categories.present?
|
||||
if categories.exclude?("Uncategorized")
|
||||
query = query
|
||||
|
|
|
@ -1,113 +0,0 @@
|
|||
class Account::Transfer < ApplicationRecord
|
||||
has_many :entries, dependent: :destroy
|
||||
|
||||
validate :net_zero_flows, if: :single_currency_transfer?
|
||||
validate :transaction_count, :from_different_accounts, :all_transactions_marked
|
||||
|
||||
def date
|
||||
outflow_transaction&.date
|
||||
end
|
||||
|
||||
def amount_money
|
||||
entries.first&.amount_money&.abs || Money.new(0)
|
||||
end
|
||||
|
||||
def from_name
|
||||
from_account&.name || I18n.t("account/transfer.from_fallback_name")
|
||||
end
|
||||
|
||||
def to_name
|
||||
to_account&.name || I18n.t("account/transfer.to_fallback_name")
|
||||
end
|
||||
|
||||
def name
|
||||
I18n.t("account/transfer.name", from_account: from_name, to_account: to_name)
|
||||
end
|
||||
|
||||
def from_account
|
||||
outflow_transaction&.account
|
||||
end
|
||||
|
||||
def to_account
|
||||
inflow_transaction&.account
|
||||
end
|
||||
|
||||
def inflow_transaction
|
||||
entries.find { |e| e.amount.negative? }
|
||||
end
|
||||
|
||||
def outflow_transaction
|
||||
entries.find { |e| e.amount.positive? }
|
||||
end
|
||||
|
||||
def update_entries!(params)
|
||||
transaction do
|
||||
entries.each do |entry|
|
||||
entry.update!(params)
|
||||
end
|
||||
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 \
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
# Attempt to convert the amount to the to_account's currency. If the conversion fails,
|
||||
# use the original amount.
|
||||
converted_amount = begin
|
||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
||||
rescue Money::ConversionError
|
||||
Money.new(amount.abs, from_account.currency)
|
||||
end
|
||||
|
||||
inflow = to_account.entries.build \
|
||||
amount: converted_amount.amount * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: "Transfer from #{from_account.name}",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
new entries: [ outflow, inflow ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def single_currency_transfer?
|
||||
entries.map { |e| e.currency }.uniq.size == 1
|
||||
end
|
||||
|
||||
def transaction_count
|
||||
unless entries.size == 2
|
||||
errors.add :entries, :must_have_exactly_2_entries
|
||||
end
|
||||
end
|
||||
|
||||
def from_different_accounts
|
||||
accounts = entries.map { |e| e.account_id }.uniq
|
||||
errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size
|
||||
end
|
||||
|
||||
def net_zero_flows
|
||||
unless entries.sum(&:amount).zero?
|
||||
errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
|
||||
end
|
||||
end
|
||||
|
||||
def all_transactions_marked
|
||||
unless entries.all?(&:marked_as_transfer)
|
||||
errors.add :entries, :must_be_marked_as_transfer
|
||||
end
|
||||
end
|
||||
end
|
|
@ -17,6 +17,9 @@ class Category < ApplicationRecord
|
|||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
UNCATEGORIZED_COLOR = "#737373"
|
||||
TRANSFER_COLOR = "#444CE7"
|
||||
PAYMENT_COLOR = "#db5a54"
|
||||
TRADE_COLOR = "#e99537"
|
||||
|
||||
class Group
|
||||
attr_reader :category, :subcategories
|
||||
|
|
|
@ -36,6 +36,8 @@ class Demo::Generator
|
|||
create_car_and_loan!
|
||||
create_other_accounts!
|
||||
|
||||
create_transfer_transactions!
|
||||
|
||||
puts "accounts created"
|
||||
puts "Demo data loaded successfully!"
|
||||
end
|
||||
|
@ -49,12 +51,14 @@ class Demo::Generator
|
|||
family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id
|
||||
|
||||
family = Family.find_by(id: family_id)
|
||||
Transfer.destroy_all
|
||||
family.destroy! if family
|
||||
|
||||
Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload)
|
||||
end
|
||||
|
||||
def clear_data!
|
||||
Transfer.destroy_all
|
||||
InviteCode.destroy_all
|
||||
User.find_by_email("user@maybe.local")&.destroy
|
||||
ExchangeRate.destroy_all
|
||||
|
@ -177,6 +181,40 @@ class Demo::Generator
|
|||
end
|
||||
end
|
||||
|
||||
def create_transfer_transactions!
|
||||
checking = family.accounts.find_by(name: "Chase Checking")
|
||||
credit_card = family.accounts.find_by(name: "Chase Credit Card")
|
||||
investment = family.accounts.find_by(name: "Robinhood")
|
||||
|
||||
create_transaction!(
|
||||
account: checking,
|
||||
date: 1.day.ago.to_date,
|
||||
amount: 100,
|
||||
name: "Credit Card Payment"
|
||||
)
|
||||
|
||||
create_transaction!(
|
||||
account: credit_card,
|
||||
date: 1.day.ago.to_date,
|
||||
amount: -100,
|
||||
name: "Credit Card Payment"
|
||||
)
|
||||
|
||||
create_transaction!(
|
||||
account: checking,
|
||||
date: 3.days.ago.to_date,
|
||||
amount: 500,
|
||||
name: "Transfer to investment"
|
||||
)
|
||||
|
||||
create_transaction!(
|
||||
account: investment,
|
||||
date: 2.days.ago.to_date,
|
||||
amount: -500,
|
||||
name: "Transfer from checking"
|
||||
)
|
||||
end
|
||||
|
||||
def load_securities!
|
||||
# Create an unknown security to simulate edge cases
|
||||
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock", exchange_mic: "UNKNOWN"
|
||||
|
|
|
@ -82,7 +82,9 @@ class Family < ApplicationRecord
|
|||
|
||||
def snapshot_account_transactions
|
||||
period = Period.last_30_days
|
||||
results = accounts.active.joins(:entries)
|
||||
results = accounts.active
|
||||
.joins(:entries)
|
||||
.joins("LEFT JOIN transfers ON (transfers.inflow_transaction_id = account_entries.entryable_id OR transfers.outflow_transaction_id = account_entries.entryable_id)")
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
|
||||
|
@ -90,8 +92,7 @@ class Family < ApplicationRecord
|
|||
)
|
||||
.where("account_entries.date >= ?", period.date_range.begin)
|
||||
.where("account_entries.date <= ?", period.date_range.end)
|
||||
.where("account_entries.marked_as_transfer = ?", false)
|
||||
.where("account_entries.entryable_type = ?", "Account::Transaction")
|
||||
.where("transfers.id IS NULL")
|
||||
.group("accounts.id")
|
||||
.having("SUM(ABS(account_entries.amount)) > 0")
|
||||
.to_a
|
||||
|
@ -110,9 +111,7 @@ class Family < ApplicationRecord
|
|||
end
|
||||
|
||||
def snapshot_transactions
|
||||
candidate_entries = entries.account_transactions.without_transfers.excluding(
|
||||
entries.joins(:account).where(amount: ..0, accounts: { classification: Account.classifications[:liability] })
|
||||
)
|
||||
candidate_entries = entries.account_transactions.incomes_and_expenses
|
||||
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
|
||||
|
||||
spending = []
|
||||
|
|
|
@ -89,7 +89,6 @@ class PlaidAccount < ApplicationRecord
|
|||
t.amount = plaid_txn.amount
|
||||
t.currency = plaid_txn.iso_currency_code
|
||||
t.date = plaid_txn.date
|
||||
t.marked_as_transfer = transfer?(plaid_txn)
|
||||
t.entryable = Account::Transaction.new(
|
||||
category: get_category(plaid_txn.personal_finance_category.primary),
|
||||
merchant: get_merchant(plaid_txn.merchant_name)
|
||||
|
|
|
@ -31,7 +31,6 @@ class PlaidInvestmentSync
|
|||
t.amount = transaction.amount
|
||||
t.currency = transaction.iso_currency_code
|
||||
t.date = transaction.date
|
||||
t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal])
|
||||
t.entryable = Account::Transaction.new
|
||||
end
|
||||
else
|
||||
|
|
139
app/models/transfer.rb
Normal file
139
app/models/transfer.rb
Normal file
|
@ -0,0 +1,139 @@
|
|||
class Transfer < ApplicationRecord
|
||||
belongs_to :inflow_transaction, class_name: "Account::Transaction"
|
||||
belongs_to :outflow_transaction, class_name: "Account::Transaction"
|
||||
|
||||
enum :status, { pending: "pending", confirmed: "confirmed", rejected: "rejected" }
|
||||
|
||||
validate :transfer_has_different_accounts
|
||||
validate :transfer_has_opposite_amounts
|
||||
validate :transfer_within_date_range
|
||||
|
||||
class << self
|
||||
def from_accounts(from_account:, to_account:, date:, amount:)
|
||||
# Attempt to convert the amount to the to_account's currency.
|
||||
# If the conversion fails, use the original amount.
|
||||
converted_amount = begin
|
||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
||||
rescue Money::ConversionError
|
||||
Money.new(amount.abs, from_account.currency)
|
||||
end
|
||||
|
||||
new(
|
||||
inflow_transaction: Account::Transaction.new(
|
||||
entry: to_account.entries.build(
|
||||
amount: converted_amount.amount.abs * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: "Transfer from #{from_account.name}",
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
),
|
||||
outflow_transaction: Account::Transaction.new(
|
||||
entry: from_account.entries.build(
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
),
|
||||
status: "confirmed"
|
||||
)
|
||||
end
|
||||
|
||||
def auto_match_for_account(account)
|
||||
matches = account.entries.account_transactions.joins("
|
||||
JOIN account_entries ae2 ON
|
||||
account_entries.amount = -ae2.amount AND
|
||||
account_entries.currency = ae2.currency AND
|
||||
account_entries.account_id <> ae2.account_id AND
|
||||
ABS(account_entries.date - ae2.date) <= 4
|
||||
").select(
|
||||
"account_entries.id",
|
||||
"account_entries.entryable_id AS e1_entryable_id",
|
||||
"ae2.entryable_id AS e2_entryable_id",
|
||||
"account_entries.amount AS e1_amount",
|
||||
"ae2.amount AS e2_amount"
|
||||
)
|
||||
|
||||
Transfer.transaction do
|
||||
matches.each do |match|
|
||||
inflow = match.e1_amount.negative? ? match.e1_entryable_id : match.e2_entryable_id
|
||||
outflow = match.e1_amount.negative? ? match.e2_entryable_id : match.e1_entryable_id
|
||||
|
||||
# Skip all rejected, or already matched transfers
|
||||
next if Transfer.exists?(
|
||||
inflow_transaction_id: inflow,
|
||||
outflow_transaction_id: outflow
|
||||
)
|
||||
|
||||
Transfer.create!(
|
||||
inflow_transaction_id: inflow,
|
||||
outflow_transaction_id: outflow
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
inflow_transaction.entry.sync_account_later
|
||||
outflow_transaction.entry.sync_account_later
|
||||
end
|
||||
|
||||
def belongs_to_family?(family)
|
||||
family.transactions.include?(inflow_transaction)
|
||||
end
|
||||
|
||||
def to_account
|
||||
inflow_transaction.entry.account
|
||||
end
|
||||
|
||||
def from_account
|
||||
outflow_transaction.entry.account
|
||||
end
|
||||
|
||||
def amount_abs
|
||||
inflow_transaction.entry.amount_money.abs
|
||||
end
|
||||
|
||||
def name
|
||||
if payment?
|
||||
I18n.t("transfer.payment_name", to_account: to_account.name)
|
||||
else
|
||||
I18n.t("transfer.name", to_account: to_account.name)
|
||||
end
|
||||
end
|
||||
|
||||
def payment?
|
||||
to_account.liability?
|
||||
end
|
||||
|
||||
private
|
||||
def transfer_has_different_accounts
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
errors.add(:base, :must_be_from_different_accounts) if inflow_transaction.entry.account == outflow_transaction.entry.account
|
||||
end
|
||||
|
||||
def transfer_has_opposite_amounts
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
|
||||
inflow_amount = inflow_transaction.entry.amount
|
||||
outflow_amount = outflow_transaction.entry.amount
|
||||
|
||||
if inflow_transaction.entry.currency == outflow_transaction.entry.currency
|
||||
# For same currency, amounts must be exactly opposite
|
||||
errors.add(:base, :must_have_opposite_amounts) if inflow_amount + outflow_amount != 0
|
||||
else
|
||||
# For different currencies, just check the signs are opposite
|
||||
errors.add(:base, :must_have_opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive?
|
||||
end
|
||||
end
|
||||
|
||||
def transfer_within_date_range
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
|
||||
date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs
|
||||
errors.add(:base, :must_be_within_date_range) if date_diff > 4
|
||||
end
|
||||
end
|
|
@ -84,7 +84,7 @@
|
|||
</div>
|
||||
|
||||
<div class="p-4 bg-white rounded-bl-lg rounded-br-lg">
|
||||
<%= render "pagination", pagy: @pagy, current_path: account_path(@account, page: params[:page]) %>
|
||||
<%= render "pagination", pagy: @pagy, current_path: account_path(@account, page: params[:page], tab: params[:tab]) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
{ label: t(".type"), selected: type },
|
||||
{ data: {
|
||||
action: "trade-form#changeType",
|
||||
trade_form_url_param: new_account_trade_path(account_id: entry.account_id),
|
||||
trade_form_url_param: new_account_trade_path(account_id: entry.account&.id || entry.account_id),
|
||||
trade_form_key_param: "type",
|
||||
}} %>
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<% trade, account = entry.account_trade, entry.account %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
|
||||
<div class="col-span-8 flex items-center gap-4">
|
||||
<div class="col-span-6 flex items-center gap-4">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
|
@ -30,6 +30,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 flex items-center">
|
||||
<%= render "categories/badge", category: trade_category %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 justify-self-end font-medium text-sm">
|
||||
<%= content_tag :p,
|
||||
format_money(-entry.amount_money),
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<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: :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 %>
|
||||
<%= link_to new_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") %>
|
||||
<% end %>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
</span>
|
||||
</h3>
|
||||
|
||||
<% if entry.marked_as_transfer? %>
|
||||
<% if entry.account_transaction.transfer? %>
|
||||
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -8,26 +8,6 @@
|
|||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
|
||||
|
||||
<%= form_with url: mark_transfers_account_transactions_path,
|
||||
scope: "bulk_update",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
turbo_confirm: {
|
||||
title: t(".mark_transfers"),
|
||||
body: t(".mark_transfers_message"),
|
||||
accept: t(".mark_transfers_confirm"),
|
||||
}
|
||||
} do |f| %>
|
||||
<button id="bulk-transfer-btn"
|
||||
type="button"
|
||||
data-bulk-select-scope-param="bulk_update"
|
||||
data-action="bulk-select#submitBulkRequest"
|
||||
class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md"
|
||||
title="Mark as transfer">
|
||||
<%= lucide_icon "arrow-right-left", class: "w-5 group-hover:text-white" %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= 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",
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
|
||||
<% transaction, account = entry.account_transaction, entry.account %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
|
||||
<div class="pr-10 flex items-center gap-4 col-span-6">
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="pr-10 flex items-center gap-4 <%= balance_trend ? "col-span-6" : "col-span-8" %>">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
disabled: entry.account_transaction.transfer?,
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
||||
<% end %>
|
||||
|
@ -18,49 +19,44 @@
|
|||
<% end %>
|
||||
|
||||
<div class="truncate">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, entry.display_name %>
|
||||
<% else %>
|
||||
<%= link_to entry.display_name,
|
||||
entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry),
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, entry.display_name %>
|
||||
<% else %>
|
||||
<%= link_to entry.display_name,
|
||||
entry.account_transaction.transfer? ? transfer_path(entry.account_transaction.transfer) : account_entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if entry.excluded %>
|
||||
<span title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
|
||||
<%= lucide_icon "asterisk", class: "w-4 h-4 shrink-0 text-orange-500" %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<% if entry.account_transaction.transfer? %>
|
||||
<%= render "account/transactions/transfer_match", entry: entry %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-500 text-xs font-normal">
|
||||
<% if entry.account_transaction.transfer? %>
|
||||
<%= render "transfers/account_links", transfer: entry.account_transaction.transfer, is_inflow: entry.account_transaction.transfer_as_inflow.present? %>
|
||||
<% else %>
|
||||
<%= link_to entry.account.name, account_path(entry.account, tab: "transactions"), data: { turbo_frame: "_top" }, class: "hover:underline" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if unconfirmed_transfer?(entry) %>
|
||||
<%= render "account/transfers/transfer_toggle", entry: entry %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if entry.transfer.present? %>
|
||||
<% unless balance_trend %>
|
||||
<div class="col-span-2"></div>
|
||||
<% end %>
|
||||
|
||||
<div class="col-span-2">
|
||||
<%= render "account/transfers/account_logos", transfer: entry.transfer, outflow: entry.amount.positive? %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-1 col-span-2">
|
||||
<%= render "categories/menu", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<% unless balance_trend %>
|
||||
<%= tag.div class: "col-span-2 overflow-hidden truncate" do %>
|
||||
<% if entry.new_record? %>
|
||||
<%= tag.p account.name %>
|
||||
<% else %>
|
||||
<%= link_to account.name,
|
||||
account_path(account, tab: "transactions"),
|
||||
data: { turbo_frame: "_top" },
|
||||
class: "hover:underline" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="flex items-center gap-1 col-span-2">
|
||||
<%= render "account/transactions/transaction_category", entry: entry %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
||||
<%= content_tag :p,
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<%# locals: (entry:) %>
|
||||
|
||||
<div id="<%= dom_id(entry, "category_menu") %>">
|
||||
<% if entry.account_transaction.transfer? %>
|
||||
<%= render "categories/badge", category: entry.account_transaction.transfer.payment? ? payment_category : transfer_category %>
|
||||
<% else %>
|
||||
<%= render "categories/menu", transaction: entry.account_transaction %>
|
||||
<% end %>
|
||||
</div>
|
27
app/views/account/transactions/_transfer_match.html.erb
Normal file
27
app/views/account/transactions/_transfer_match.html.erb
Normal file
|
@ -0,0 +1,27 @@
|
|||
<%# locals: (entry:) %>
|
||||
|
||||
<div id="<%= dom_id(entry, "transfer_match") %>" class="flex items-center gap-1">
|
||||
<% if entry.account_transaction.transfer.confirmed? %>
|
||||
<span title="<%= entry.account_transaction.transfer.payment? ? "Payment" : "Transfer" %> is confirmed">
|
||||
<%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %>
|
||||
</span>
|
||||
<% elsif entry.account_transaction.transfer.pending? %>
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
||||
Auto-matched
|
||||
</span>
|
||||
|
||||
<%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "confirmed" }),
|
||||
method: :patch,
|
||||
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
|
||||
title: "Confirm match" do %>
|
||||
<%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %>
|
||||
<% end %>
|
||||
|
||||
<%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "rejected" }),
|
||||
method: :patch,
|
||||
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
|
||||
title: "Reject match" do %>
|
||||
<%= lucide_icon "x", class: "w-4 h-4 text-gray-400 hover:text-gray-600" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
|
@ -19,7 +19,7 @@
|
|||
max: Date.current,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<% unless @entry.marked_as_transfer? %>
|
||||
<% unless @entry.account_transaction.transfer? %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= f.select :nature,
|
||||
[["Expense", "outflow"], ["Income", "inflow"]],
|
||||
|
@ -32,27 +32,7 @@
|
|||
min: 0,
|
||||
value: @entry.amount.abs %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= f.select :account,
|
||||
options_for_select(
|
||||
Current.family.accounts.alphabetically.pluck(:name, :id),
|
||||
@entry.account_id
|
||||
),
|
||||
{ label: t(".account_label") },
|
||||
{ disabled: true } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Details Section -->
|
||||
<%= disclosure t(".details") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<% unless @entry.marked_as_transfer? %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.collection_select :category_id,
|
||||
Current.family.categories.alphabetically,
|
||||
|
@ -60,6 +40,30 @@
|
|||
{ label: t(".category_label"),
|
||||
class: "text-gray-400", include_blank: t(".uncategorized") },
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Details Section -->
|
||||
<%= disclosure t(".details"), default_open: false do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<% unless @entry.account_transaction.transfer? %>
|
||||
<%= f.select :account,
|
||||
options_for_select(
|
||||
Current.family.accounts.alphabetically.pluck(:name, :id),
|
||||
@entry.account_id
|
||||
),
|
||||
{ label: t(".account_label") },
|
||||
{ disabled: true } %>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
|
||||
<%= ef.collection_select :merchant_id,
|
||||
Current.family.merchants.alphabetically,
|
||||
|
@ -94,15 +98,15 @@
|
|||
<!-- Settings Section -->
|
||||
<%= disclosure t(".settings") do %>
|
||||
<div class="pb-4">
|
||||
<!-- Exclude Transaction Form -->
|
||||
|
||||
<%= styled_form_with model: @entry,
|
||||
url: account_transaction_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
|
||||
<h4 class="text-gray-900">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
|
||||
<p class="text-gray-500">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-block select-none">
|
||||
|
@ -115,6 +119,18 @@
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900">Transfer or Debt Payment?</h4>
|
||||
<p class="text-gray-500">Transfers and payments are special types of transactions that indicate money movement between 2 accounts.</p>
|
||||
</div>
|
||||
|
||||
<%= link_to new_account_transaction_transfer_match_path(@entry), class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "arrow-left-right", class: "w-4 h-4 shrink-0" %>
|
||||
<span class="whitespace-nowrap">Open matcher</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Delete Transaction Form -->
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
|
|
44
app/views/account/transfer_matches/_matching_fields.html.erb
Normal file
44
app/views/account/transfer_matches/_matching_fields.html.erb
Normal file
|
@ -0,0 +1,44 @@
|
|||
<%# locals: (form:, entry:, candidates:, accounts:) %>
|
||||
|
||||
<% if candidates.any? %>
|
||||
<div data-controller="transfer-match" class="space-y-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
Select a method for matching your transactions.
|
||||
</p>
|
||||
|
||||
<%= form.select :method,
|
||||
[
|
||||
["Match existing transaction (recommended)", "existing"],
|
||||
["Create new transaction", "new"]
|
||||
],
|
||||
{ selected: "existing", label: "Matching method" },
|
||||
data: { action: "change->transfer-match#update" } %>
|
||||
|
||||
<div data-transfer-match-target="existingSelect">
|
||||
<%= form.select :matched_entry_id,
|
||||
candidates.map { |entry|
|
||||
[entry_name_detailed(entry), entry.id]
|
||||
},
|
||||
{ label: "Matching transaction" } %>
|
||||
</div>
|
||||
|
||||
<div data-transfer-match-target="newSelect" class="hidden">
|
||||
<%= form.select :target_account_id,
|
||||
accounts.map { |account| [account.name, account.id] },
|
||||
{ label: "Target account" } %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">
|
||||
We couldn't find any transactions to match from your other accounts.
|
||||
Please select an account and we will create a new inflow transaction for you.
|
||||
</p>
|
||||
|
||||
<%= form.hidden_field :method, value: "new" %>
|
||||
|
||||
<div>
|
||||
<%= form.select :target_account_id,
|
||||
accounts.map { |account| [account.name, account.id] },
|
||||
{ label: "Target account" } %>
|
||||
</div>
|
||||
<% end %>
|
60
app/views/account/transfer_matches/new.html.erb
Normal file
60
app/views/account/transfer_matches/new.html.erb
Normal file
|
@ -0,0 +1,60 @@
|
|||
<%= modal_form_wrapper title: "Match transfer or payment" do %>
|
||||
<%= styled_form_with(
|
||||
url: account_transaction_transfer_match_path(@entry),
|
||||
scope: :transfer_match,
|
||||
class: "space-y-8",
|
||||
data: { turbo_frame: :_top }
|
||||
) do |f| %>
|
||||
<section class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-sm font-medium text-gray-700">
|
||||
<%= @entry.amount.positive? ? "From account: #{@entry.account.name}" : "From account" %>
|
||||
</h2>
|
||||
|
||||
<% if @entry.amount.positive? %>
|
||||
<%= f.select(
|
||||
:entry_id,
|
||||
[[entry_name_detailed(@entry), @entry.id]],
|
||||
{
|
||||
label: "Outflow transaction",
|
||||
selected: @entry.id,
|
||||
},
|
||||
disabled: true
|
||||
) %>
|
||||
<% else %>
|
||||
<%= render "account/transfer_matches/matching_fields",
|
||||
form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-center py-2">
|
||||
<%= lucide_icon "arrow-down", class: "w-5 h-5" %>
|
||||
</div>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-sm font-medium text-gray-700">
|
||||
<%= @entry.amount.negative? ? "To account: #{@entry.account.name}" : "To account" %>
|
||||
</h2>
|
||||
|
||||
<% if @entry.amount.negative? %>
|
||||
<%= f.select(
|
||||
:entry_id,
|
||||
[[entry_name_detailed(@entry), @entry.id]],
|
||||
{
|
||||
label: "Inflow transaction",
|
||||
selected: @entry.id,
|
||||
},
|
||||
disabled: true
|
||||
) %>
|
||||
<% else %>
|
||||
<%= render "account/transfer_matches/matching_fields",
|
||||
form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%= f.submit "Create transfer match", data: { turbo_submits_with: "Saving..."} %>
|
||||
<% end %>
|
||||
<% end %>
|
|
@ -1,25 +0,0 @@
|
|||
<%# locals: (transfer:, outflow: false) %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<% if outflow %>
|
||||
<%= link_to transfer.from_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
||||
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
|
||||
<% end %>
|
||||
|
||||
<%= lucide_icon "arrow-right", class: "text-gray-500 w-4 h-4" %>
|
||||
|
||||
<%= link_to transfer.to_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
||||
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= link_to transfer.to_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
||||
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
|
||||
<% end %>
|
||||
|
||||
<%= lucide_icon "arrow-left", class: "text-gray-500 w-4 h-4" %>
|
||||
|
||||
<%= link_to transfer.from_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
||||
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
|
@ -1,16 +0,0 @@
|
|||
<%# locals: (entry:) %>
|
||||
|
||||
<%= form_with url: unmark_transfers_account_transactions_path, class: "flex items-center", data: {
|
||||
turbo_confirm: {
|
||||
title: t(".remove_transfer"),
|
||||
body: t(".remove_transfer_body"),
|
||||
accept: t(".remove_transfer_confirm"),
|
||||
},
|
||||
turbo_frame: "_top"
|
||||
} do |f| %>
|
||||
<%= f.hidden_field "bulk_update[entry_ids][]", value: entry.id %>
|
||||
<%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %>
|
||||
<%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %>
|
||||
<%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %>
|
||||
<% end %>
|
||||
<% end %>
|
|
@ -12,7 +12,7 @@
|
|||
<% end %>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %>
|
||||
<%= tag.div class: "w-6 h-6 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %>
|
||||
<%= lucide_icon icon, class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<%# locals: (account:) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id, page: params[:page]) do %>
|
||||
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id, page: params[:page], tab: params[:tab]) do %>
|
||||
<%= render "account/entries/loading" %>
|
||||
<% end %>
|
||||
|
|
|
@ -9,4 +9,5 @@
|
|||
color: <%= category.color %>;">
|
||||
<%= category.name %>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
|
|
@ -29,16 +29,8 @@
|
|||
</div>
|
||||
<hr>
|
||||
<div class="relative p-1.5 w-full">
|
||||
<%= link_to new_category_path(transaction_id: @transaction),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
|
||||
<%= t(".add_new") %>
|
||||
<% end %>
|
||||
|
||||
<% if @transaction.category %>
|
||||
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry),
|
||||
<%= button_to account_transaction_path(@transaction.entry),
|
||||
method: :patch,
|
||||
data: { turbo_frame: dom_id(@transaction.entry) },
|
||||
params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
||||
|
@ -48,6 +40,32 @@
|
|||
<%= t(".clear") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_account_transaction_transfer_match_path(@transaction.entry),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
|
||||
|
||||
<p>Match transfer/payment</p>
|
||||
<% end %>
|
||||
|
||||
<div class="flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form_with url: account_transaction_path(@transaction.entry),
|
||||
method: :patch,
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.hidden_field "account_entry[excluded]", value: !@transaction.entry.excluded %>
|
||||
<%= f.check_box "account_entry[excluded]",
|
||||
checked: @transaction.entry.excluded,
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
|
||||
|
||||
<%= lucide_icon "asterisk", class: "w-5 h-5 shrink-0 text-orange-500 ml-auto" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
|
||||
<%= javascript_importmap_tags %>
|
||||
<%= hotwire_livereload_tags if Rails.env.development? %>
|
||||
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
||||
|
||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<%# locals: (title:, content:, subtitle: nil) %>
|
||||
|
||||
<%= modal do %>
|
||||
<article class="mx-auto w-full p-4 space-y-4 min-w-[450px] max-w-xl">
|
||||
<article class="mx-auto w-full p-4 space-y-4 min-w-[450px]">
|
||||
<div class="space-y-2">
|
||||
<header class="flex justify-between items-center">
|
||||
<h2 class="font-medium"><%= title %></h2>
|
||||
|
|
|
@ -17,20 +17,24 @@
|
|||
<% if @transaction_entries.present? %>
|
||||
<div class="grow overflow-y-auto">
|
||||
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">
|
||||
<div class="pl-0.5 col-span-6 flex items-center gap-4">
|
||||
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag "selection_entry",
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { action: "bulk-select#togglePageSelection" } %>
|
||||
<p class="col-span-4">transaction</p>
|
||||
<p>transaction</p>
|
||||
</div>
|
||||
|
||||
<p class="col-span-2">category</p>
|
||||
<p class="col-span-2">account</p>
|
||||
<p class="col-span-2 justify-self-end">amount</p>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<%= entries_by_date(@transaction_entries, totals: true) do |entries| %>
|
||||
<%= render entries %>
|
||||
<%# Render transfers by selecting one side of the transfer (to prevent double-rendering the same transfer across date groups) %>
|
||||
<%= render partial: "transfers/transfer",
|
||||
collection: entries.select { |e| e.account_transaction.transfer? && e.account_transaction.transfer_as_outflow.present? }.map { |e| e.account_transaction.transfer_as_outflow } %>
|
||||
|
||||
<%# Render regular entries %>
|
||||
<%= render partial: "account/entries/entry", collection: entries.reject { |e| e.account_transaction.transfer? } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
7
app/views/transfers/_account_links.html.erb
Normal file
7
app/views/transfers/_account_links.html.erb
Normal file
|
@ -0,0 +1,7 @@
|
|||
<%# locals: (transfer:, is_inflow: false) %>
|
||||
<div class="flex items-center gap-1">
|
||||
<% first_account, second_account = is_inflow ? [transfer.to_account, transfer.from_account] : [transfer.from_account, transfer.to_account] %>
|
||||
<%= link_to first_account.name, account_path(first_account, tab: "activity"), class: "hover:underline", data: { turbo_frame: "_top" } %>
|
||||
<%= lucide_icon is_inflow ? "arrow-left" : "arrow-right", class: "w-4 h-4 shrink-0" %>
|
||||
<%= link_to second_account.name, account_path(second_account, tab: "activity"), class: "hover:underline", data: { turbo_frame: "_top" } %>
|
||||
</div>
|
|
@ -29,7 +29,7 @@
|
|||
<%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
|
||||
<%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
|
||||
<%= f.number_field :amount, label: t(".amount"), required: true, min: 0, placeholder: "100", step: 0.00000001 %>
|
||||
<%= f.date_field :date, value: transfer.date || Date.current, label: t(".date"), required: true, max: Date.current %>
|
||||
<%= f.date_field :date, value: transfer.inflow_transaction&.entry&.date || Date.current, label: t(".date"), required: true, max: Date.current %>
|
||||
</section>
|
||||
|
||||
<section>
|
77
app/views/transfers/_transfer.html.erb
Normal file
77
app/views/transfers/_transfer.html.erb
Normal file
|
@ -0,0 +1,77 @@
|
|||
<%# locals: (transfer:) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(transfer) do %>
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="pr-10 flex items-center gap-4 col-span-8">
|
||||
<%= check_box_tag dom_id(transfer),
|
||||
disabled: true,
|
||||
class: "maybe-checkbox maybe-checkbox--light" %>
|
||||
|
||||
<div class="max-w-full">
|
||||
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
|
||||
<%= render "shared/circle_logo", name: transfer.name, size: "sm" %>
|
||||
|
||||
<div class="truncate">
|
||||
<div class="space-y-0.5">
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to transfer.name,
|
||||
transfer_path(transfer),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
|
||||
<% if transfer.status == "confirmed" %>
|
||||
<span title="<%= transfer.payment? ? "Payment" : "Transfer" %> is confirmed">
|
||||
<%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %>
|
||||
</span>
|
||||
<% elsif transfer.status == "rejected" %>
|
||||
<span class="inline-flex items-center rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
|
||||
Rejected
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
||||
Auto-matched
|
||||
</span>
|
||||
|
||||
<%= button_to transfer_path(transfer, transfer: { status: "confirmed" }),
|
||||
method: :patch,
|
||||
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
|
||||
title: "Confirm match" do %>
|
||||
<%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %>
|
||||
<% end %>
|
||||
|
||||
<%= button_to transfer_path(transfer, transfer: { status: "rejected" }),
|
||||
method: :patch,
|
||||
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
|
||||
title: "Reject match" do %>
|
||||
<%= lucide_icon "x", class: "w-4 h-4 text-gray-400 hover:text-gray-600" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-500 text-xs font-normal">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= link_to transfer.from_account.name, transfer.from_account, class: "hover:underline", data: { turbo_frame: "_top" } %>
|
||||
<%= lucide_icon "arrow-left-right", class: "w-4 h-4" %>
|
||||
<%= link_to transfer.to_account.name, transfer.to_account, class: "hover:underline", data: { turbo_frame: "_top" } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 col-span-2">
|
||||
<%= render "categories/badge", category: transfer.payment? ? payment_category : transfer_category %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
||||
<p class="flex items-center gap-1">
|
||||
<span>
|
||||
+/- <%= format_money(transfer.amount_abs) %>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
|
@ -3,11 +3,11 @@
|
|||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium">
|
||||
<span class="text-2xl">
|
||||
<%= format_money @transfer.amount_money %>
|
||||
<%= format_money @transfer.amount_abs %>
|
||||
</span>
|
||||
|
||||
<span class="text-lg text-gray-500">
|
||||
<%= @transfer.amount_money.currency.iso_code %>
|
||||
<%= @transfer.amount_abs.currency.iso_code %>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
|
@ -25,21 +25,21 @@
|
|||
<div class="pb-4 px-3 pt-2 text-sm space-y-3 text-gray-900">
|
||||
<div class="space-y-3">
|
||||
<dl class="flex items-center gap-2 justify-between">
|
||||
<dt class="text-gray-500">To</dt>
|
||||
<dt class="text-gray-500">From</dt>
|
||||
<dd class="flex items-center gap-2 font-medium">
|
||||
<%= render "accounts/logo", account: @transfer.inflow_transaction.account, size: "sm" %>
|
||||
<%= @transfer.to_name %>
|
||||
<%= render "accounts/logo", account: @transfer.from_account, size: "sm" %>
|
||||
<%= link_to @transfer.from_account.name, account_path(@transfer.from_account), data: { turbo_frame: "_top" } %>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<dl class="flex items-center gap-2 justify-between">
|
||||
<dt class="text-gray-500">Date</dt>
|
||||
<dd class="font-medium"><%= l(@transfer.date, format: :long) %></dd>
|
||||
<dd class="font-medium"><%= l(@transfer.outflow_transaction.entry.date, format: :long) %></dd>
|
||||
</dl>
|
||||
|
||||
<dl class="flex items-center gap-2 justify-between">
|
||||
<dt class="text-gray-500">Amount</dt>
|
||||
<dd class="font-medium text-red-500"><%= format_money -@transfer.amount_money %></dd>
|
||||
<dd class="font-medium text-red-500"><%= format_money -@transfer.amount_abs %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
|
@ -47,21 +47,21 @@
|
|||
|
||||
<div class="space-y-3">
|
||||
<dl class="flex items-center gap-2 justify-between">
|
||||
<dt class="text-gray-500">From</dt>
|
||||
<dt class="text-gray-500">To</dt>
|
||||
<dd class="flex items-center gap-2 font-medium">
|
||||
<%= render "accounts/logo", account: @transfer.outflow_transaction.account, size: "sm" %>
|
||||
<%= @transfer.from_name %>
|
||||
<%= render "accounts/logo", account: @transfer.to_account, size: "sm" %>
|
||||
<%= link_to @transfer.to_account.name, account_path(@transfer.to_account), data: { turbo_frame: "_top" } %>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<dl class="flex items-center gap-2 justify-between">
|
||||
<dt class="text-gray-500">Date</dt>
|
||||
<dd class="font-medium"><%= l(@transfer.date, format: :long) %></dd>
|
||||
<dd class="font-medium"><%= l(@transfer.inflow_transaction.entry.date, format: :long) %></dd>
|
||||
</dl>
|
||||
|
||||
<dl class="flex items-center gap-2 justify-between">
|
||||
<dt class="text-gray-500">Amount</dt>
|
||||
<dd class="font-medium text-green-500">+<%= format_money @transfer.amount_money %></dd>
|
||||
<dd class="font-medium text-green-500">+<%= format_money @transfer.amount_abs %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -74,7 +74,6 @@
|
|||
<%= f.text_area :notes,
|
||||
label: t(".note_label"),
|
||||
placeholder: t(".note_placeholder"),
|
||||
value: @transfer.outflow_transaction.notes,
|
||||
rows: 5,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
|
@ -83,25 +82,6 @@
|
|||
<!-- Settings Section -->
|
||||
<%= disclosure t(".settings") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @transfer,
|
||||
class: "p-3", data: { controller: "auto-submit-form" } do |f| %>
|
||||
<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>
|
||||
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-block select-none">
|
||||
<%= f.check_box :excluded,
|
||||
checked: @transfer.inflow_transaction.excluded,
|
||||
class: "sr-only peer",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<label for="account_transfer_excluded"
|
||||
class="maybe-switch"></label>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
|
||||
|
@ -109,9 +89,9 @@
|
|||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_transfer_path(@transfer),
|
||||
transfer_path(@transfer),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm
|
||||
class: "rounded-lg px-3 py-2 whitespace-nowrap text-red-500 text-sm
|
||||
font-medium border border-alpha-black-200",
|
||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
||||
</div>
|
17
app/views/transfers/update.turbo_stream.erb
Normal file
17
app/views/transfers/update.turbo_stream.erb
Normal file
|
@ -0,0 +1,17 @@
|
|||
<%= turbo_stream.replace @transfer %>
|
||||
|
||||
<%= turbo_stream.replace "category_menu_account_entry_#{@transfer.inflow_transaction.entry.id}",
|
||||
partial: "account/transactions/transaction_category",
|
||||
locals: { entry: @transfer.inflow_transaction.entry } %>
|
||||
|
||||
<%= turbo_stream.replace "category_menu_account_entry_#{@transfer.outflow_transaction.entry.id}",
|
||||
partial: "account/transactions/transaction_category",
|
||||
locals: { entry: @transfer.outflow_transaction.entry } %>
|
||||
|
||||
<%= turbo_stream.replace "transfer_match_account_entry_#{@transfer.inflow_transaction.entry.id}",
|
||||
partial: "account/transactions/transfer_match",
|
||||
locals: { entry: @transfer.inflow_transaction.entry } %>
|
||||
|
||||
<%= turbo_stream.replace "transfer_match_account_entry_#{@transfer.outflow_transaction.entry.id}",
|
||||
partial: "account/transactions/transfer_match",
|
||||
locals: { entry: @transfer.outflow_transaction.entry } %>
|
|
@ -103,6 +103,30 @@
|
|||
],
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "Dangerous Eval",
|
||||
"warning_code": 13,
|
||||
"fingerprint": "c193307bb82f931950d3bf2855f82f9a7f50d94c5bd950ee2803cb8a8abe5253",
|
||||
"check_name": "Evaluation",
|
||||
"message": "Dynamic string evaluated as code",
|
||||
"file": "app/helpers/styled_form_builder.rb",
|
||||
"line": 7,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/",
|
||||
"code": "class_eval(\" def #{selector}(method, options = {})\\n merged_options = { class: \\\"form-field__input\\\" }.merge(options)\\n label = build_label(method, options)\\n field = super(method, merged_options)\\n\\n build_styled_field(label, field, merged_options)\\n end\\n\", \"app/helpers/styled_form_builder.rb\", (7 + 1))",
|
||||
"render_path": null,
|
||||
"location": {
|
||||
"type": "method",
|
||||
"class": "StyledFormBuilder",
|
||||
"method": null
|
||||
},
|
||||
"user_input": null,
|
||||
"confidence": "Weak",
|
||||
"cwe_id": [
|
||||
913,
|
||||
95
|
||||
],
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "Dynamic Render Path",
|
||||
"warning_code": 15,
|
||||
|
@ -138,6 +162,5 @@
|
|||
"note": ""
|
||||
}
|
||||
],
|
||||
"updated": "2024-12-18 17:46:13 -0500",
|
||||
"brakeman_version": "6.2.2"
|
||||
"brakeman_version": "7.0.0"
|
||||
}
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
en:
|
||||
account/transfer:
|
||||
from_fallback_name: Originator
|
||||
name: Transfer from %{from_account} to %{to_account}
|
||||
to_fallback_name: Receiver
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
account/transfer:
|
||||
attributes:
|
||||
entries:
|
||||
must_be_from_different_accounts: must be from different accounts
|
||||
must_be_marked_as_transfer: must be marked as transfer
|
||||
must_have_an_inflow_and_outflow_that_net_to_zero: must have an inflow
|
||||
and outflow that net to zero
|
||||
must_have_exactly_2_entries: must have exactly 2 entries
|
17
config/locales/models/transfer/en.yml
Normal file
17
config/locales/models/transfer/en.yml
Normal file
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
en:
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
transfer:
|
||||
attributes:
|
||||
base:
|
||||
must_be_from_different_accounts: Transfer must have different accounts
|
||||
must_be_within_date_range: Transfer transaction dates must be within
|
||||
4 days of each other
|
||||
must_have_opposite_amounts: Transfer transactions must have opposite
|
||||
amounts
|
||||
must_have_single_currency: Transfer must have a single currency
|
||||
transfer:
|
||||
name: Transfer to %{to_account}
|
||||
payment_name: Payment to %{to_account}
|
|
@ -36,15 +36,8 @@ en:
|
|||
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
|
||||
mark_transfers_message: By marking transactions as transfers, they will no
|
||||
longer be included in income or spending calculations.
|
||||
show:
|
||||
account_label: Account
|
||||
amount: Amount
|
||||
|
@ -55,9 +48,6 @@ en:
|
|||
balances, and cannot be undone.
|
||||
delete_title: Delete transaction
|
||||
details: Details
|
||||
exclude_subtitle: This excludes the transaction from any in-app features or
|
||||
analytics.
|
||||
exclude_title: Exclude transaction
|
||||
merchant_label: Merchant
|
||||
name_label: Name
|
||||
nature: Type
|
||||
|
@ -68,5 +58,6 @@ en:
|
|||
settings: Settings
|
||||
tags_label: Tags
|
||||
uncategorized: "(uncategorized)"
|
||||
unmark_transfers:
|
||||
success: Transfer removed
|
||||
transfer_matches:
|
||||
create:
|
||||
success: Transfer created
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
---
|
||||
en:
|
||||
account:
|
||||
transfers:
|
||||
create:
|
||||
success: Transfer created
|
||||
destroy:
|
||||
success: Transfer removed
|
||||
form:
|
||||
amount: Amount
|
||||
date: Date
|
||||
expense: Expense
|
||||
from: From
|
||||
income: Income
|
||||
select_account: Select account
|
||||
submit: Create transfer
|
||||
to: To
|
||||
transfer: Transfer
|
||||
new:
|
||||
title: New transfer
|
||||
show:
|
||||
delete: Delete
|
||||
delete_subtitle: This permanently deletes both of the transactions related
|
||||
to the transfer. This cannot be undone.
|
||||
delete_title: Delete transfer?
|
||||
details: Details
|
||||
exclude_subtitle: This excludes the transfer from any in-app features or analytics.
|
||||
exclude_title: Exclude transfer
|
||||
note_label: Notes
|
||||
note_placeholder: Add a note to this transfer
|
||||
overview: Overview
|
||||
settings: Settings
|
||||
transfer_toggle:
|
||||
remove_transfer: Remove transfer
|
||||
remove_transfer_body: This will remove the transfer from this transaction
|
||||
remove_transfer_confirm: Confirm
|
||||
update:
|
||||
success: Transfer updated
|
|
@ -1,11 +1,8 @@
|
|||
---
|
||||
en:
|
||||
category:
|
||||
dropdowns:
|
||||
show:
|
||||
empty: No categories found
|
||||
bootstrap: Generate default categories
|
||||
categories:
|
||||
bootstrap:
|
||||
success: Default categories created successfully
|
||||
category:
|
||||
delete: Delete category
|
||||
edit: Edit category
|
||||
|
@ -18,15 +15,18 @@ en:
|
|||
form:
|
||||
placeholder: Category name
|
||||
index:
|
||||
bootstrap: Use default categories
|
||||
categories: Categories
|
||||
empty: No categories found
|
||||
new: New category
|
||||
bootstrap: Use default categories
|
||||
bootstrap:
|
||||
success: Default categories created successfully
|
||||
menu:
|
||||
loading: Loading...
|
||||
new:
|
||||
new_category: New category
|
||||
update:
|
||||
success: Category updated successfully
|
||||
category:
|
||||
dropdowns:
|
||||
show:
|
||||
bootstrap: Generate default categories
|
||||
empty: No categories found
|
||||
|
|
|
@ -6,7 +6,6 @@ en:
|
|||
delete: Delete category
|
||||
edit: Edit category
|
||||
show:
|
||||
add_new: Add new
|
||||
clear: Clear
|
||||
clear: Clear category
|
||||
no_categories: No categories found
|
||||
search_placeholder: Search
|
||||
|
|
31
config/locales/views/transfers/en.yml
Normal file
31
config/locales/views/transfers/en.yml
Normal file
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
en:
|
||||
transfers:
|
||||
create:
|
||||
success: Transfer created
|
||||
destroy:
|
||||
success: Transfer removed
|
||||
form:
|
||||
amount: Amount
|
||||
date: Date
|
||||
expense: Expense
|
||||
from: From
|
||||
income: Income
|
||||
select_account: Select account
|
||||
submit: Create transfer
|
||||
to: To
|
||||
transfer: Transfer
|
||||
new:
|
||||
title: New transfer
|
||||
show:
|
||||
delete: Remove transfer
|
||||
delete_subtitle: This removes the transfer. It will not delete the underlying
|
||||
transactions.
|
||||
delete_title: Remove transfer?
|
||||
details: Details
|
||||
note_label: Notes
|
||||
note_placeholder: Add a note to this transfer
|
||||
overview: Overview
|
||||
settings: Settings
|
||||
update:
|
||||
success: Transfer updated
|
|
@ -46,9 +46,7 @@ Rails.application.routes.draw do
|
|||
|
||||
resources :merchants, only: %i[index new create edit update destroy]
|
||||
|
||||
namespace :account do
|
||||
resources :transfers, only: %i[new create destroy show update]
|
||||
end
|
||||
resources :transfers, only: %i[new create destroy show update]
|
||||
|
||||
resources :imports, only: %i[index new show create destroy] do
|
||||
post :publish, on: :member
|
||||
|
@ -81,6 +79,7 @@ Rails.application.routes.draw do
|
|||
resources :entries, only: :index
|
||||
|
||||
resources :transactions, only: %i[show new create update destroy] do
|
||||
resource :transfer_match, only: %i[new create]
|
||||
resource :category, only: :update, controller: :transaction_categories
|
||||
|
||||
collection do
|
||||
|
|
75
db/migrate/20241231140709_reverse_transfer_relations.rb
Normal file
75
db/migrate/20241231140709_reverse_transfer_relations.rb
Normal file
|
@ -0,0 +1,75 @@
|
|||
class ReverseTransferRelations < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :transfers, id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.references :inflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid
|
||||
t.references :outflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid
|
||||
t.string :status, null: false, default: "pending"
|
||||
t.text :notes
|
||||
|
||||
t.index [ :inflow_transaction_id, :outflow_transaction_id ], unique: true
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
execute <<~SQL
|
||||
INSERT INTO transfers (inflow_transaction_id, outflow_transaction_id, status, created_at, updated_at)
|
||||
SELECT
|
||||
CASE WHEN e1.amount <= 0 THEN e1.entryable_id ELSE e2.entryable_id END as inflow_transaction_id,
|
||||
CASE WHEN e1.amount <= 0 THEN e2.entryable_id ELSE e1.entryable_id END as outflow_transaction_id,
|
||||
'confirmed' as status,
|
||||
e1.created_at,
|
||||
e1.updated_at
|
||||
FROM account_entries e1
|
||||
JOIN account_entries e2 ON
|
||||
e1.transfer_id = e2.transfer_id AND
|
||||
e1.id != e2.id AND
|
||||
e1.id < e2.id -- Ensures we don't duplicate transfers from both sides
|
||||
JOIN accounts a1 ON e1.account_id = a1.id
|
||||
JOIN accounts a2 ON e2.account_id = a2.id
|
||||
WHERE
|
||||
e1.entryable_type = 'Account::Transaction' AND
|
||||
e2.entryable_type = 'Account::Transaction' AND
|
||||
e1.transfer_id IS NOT NULL AND
|
||||
a1.family_id = a2.family_id;
|
||||
SQL
|
||||
end
|
||||
|
||||
dir.down do
|
||||
execute <<~SQL
|
||||
WITH new_transfers AS (
|
||||
INSERT INTO account_transfers (created_at, updated_at)
|
||||
SELECT created_at, updated_at
|
||||
FROM transfers
|
||||
RETURNING id, created_at
|
||||
),
|
||||
transfer_pairs AS (
|
||||
SELECT
|
||||
nt.id as transfer_id,
|
||||
ae_in.id as inflow_entry_id,
|
||||
ae_out.id as outflow_entry_id
|
||||
FROM transfers t
|
||||
JOIN new_transfers nt ON nt.created_at = t.created_at
|
||||
JOIN account_entries ae_in ON ae_in.entryable_id = t.inflow_transaction_id
|
||||
JOIN account_entries ae_out ON ae_out.entryable_id = t.outflow_transaction_id
|
||||
WHERE
|
||||
ae_in.entryable_type = 'Account::Transaction' AND
|
||||
ae_out.entryable_type = 'Account::Transaction'
|
||||
)
|
||||
UPDATE account_entries ae
|
||||
SET transfer_id = tp.transfer_id
|
||||
FROM transfer_pairs tp
|
||||
WHERE ae.id IN (tp.inflow_entry_id, tp.outflow_entry_id);
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
remove_foreign_key :account_entries, :account_transfers, column: :transfer_id
|
||||
remove_column :account_entries, :transfer_id, :uuid
|
||||
remove_column :account_entries, :marked_as_transfer, :boolean
|
||||
|
||||
drop_table :account_transfers, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
25
db/schema.rb
generated
25
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_12_31_140709) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -42,8 +42,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
|||
t.string "name", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.uuid "transfer_id"
|
||||
t.boolean "marked_as_transfer", default: false, null: false
|
||||
t.uuid "import_id"
|
||||
t.text "notes"
|
||||
t.boolean "excluded", default: false
|
||||
|
@ -52,7 +50,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
|||
t.string "enriched_name"
|
||||
t.index ["account_id"], name: "index_account_entries_on_account_id"
|
||||
t.index ["import_id"], name: "index_account_entries_on_import_id"
|
||||
t.index ["transfer_id"], name: "index_account_entries_on_transfer_id"
|
||||
end
|
||||
|
||||
create_table "account_holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
@ -89,11 +86,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
|||
t.index ["merchant_id"], name: "index_account_transactions_on_merchant_id"
|
||||
end
|
||||
|
||||
create_table "account_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "account_valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
@ -606,6 +598,18 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
|||
t.index ["family_id"], name: "index_tags_on_family_id"
|
||||
end
|
||||
|
||||
create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "inflow_transaction_id", null: false
|
||||
t.uuid "outflow_transaction_id", null: false
|
||||
t.string "status", default: "pending", null: false
|
||||
t.text "notes"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_8cd07a28bd", unique: true
|
||||
t.index ["inflow_transaction_id"], name: "index_transfers_on_inflow_transaction_id"
|
||||
t.index ["outflow_transaction_id"], name: "index_transfers_on_outflow_transaction_id"
|
||||
end
|
||||
|
||||
create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "first_name"
|
||||
|
@ -634,7 +638,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
|||
end
|
||||
|
||||
add_foreign_key "account_balances", "accounts", on_delete: :cascade
|
||||
add_foreign_key "account_entries", "account_transfers", column: "transfer_id"
|
||||
add_foreign_key "account_entries", "accounts"
|
||||
add_foreign_key "account_entries", "imports"
|
||||
add_foreign_key "account_holdings", "accounts"
|
||||
|
@ -663,5 +666,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
|||
add_foreign_key "sessions", "users"
|
||||
add_foreign_key "taggings", "tags"
|
||||
add_foreign_key "tags", "families"
|
||||
add_foreign_key "transfers", "account_transactions", column: "inflow_transaction_id"
|
||||
add_foreign_key "transfers", "account_transactions", column: "outflow_transaction_id"
|
||||
add_foreign_key "users", "families"
|
||||
end
|
||||
|
|
|
@ -38,7 +38,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
assert_difference -> { Account::Entry.count } => 2,
|
||||
-> { Account::Transaction.count } => 2,
|
||||
-> { Account::Transfer.count } => 1 do
|
||||
-> { Transfer.count } => 1 do
|
||||
post account_trades_url, params: {
|
||||
account_entry: {
|
||||
account_id: @entry.account_id,
|
||||
|
@ -59,7 +59,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
assert_difference -> { Account::Entry.count } => 2,
|
||||
-> { Account::Transaction.count } => 2,
|
||||
-> { Account::Transfer.count } => 1 do
|
||||
-> { Transfer.count } => 1 do
|
||||
post account_trades_url, params: {
|
||||
account_entry: {
|
||||
account_id: @entry.account_id,
|
||||
|
@ -78,7 +78,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
test "deposit and withdrawal has optional transfer account" do
|
||||
assert_difference -> { Account::Entry.count } => 1,
|
||||
-> { Account::Transaction.count } => 1,
|
||||
-> { Account::Transfer.count } => 0 do
|
||||
-> { Transfer.count } => 0 do
|
||||
post account_trades_url, params: {
|
||||
account_entry: {
|
||||
account_id: @entry.account_id,
|
||||
|
@ -93,7 +93,6 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
created_entry = Account::Entry.order(created_at: :desc).first
|
||||
|
||||
assert created_entry.amount.positive?
|
||||
assert created_entry.marked_as_transfer
|
||||
assert_redirected_to @entry.account
|
||||
end
|
||||
|
||||
|
|
|
@ -74,7 +74,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
|||
end
|
||||
|
||||
test "can destroy many transactions at once" do
|
||||
transactions = @user.family.entries.account_transactions
|
||||
transactions = @user.family.entries.account_transactions.incomes_and_expenses
|
||||
delete_count = transactions.size
|
||||
|
||||
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do
|
||||
|
|
42
test/controllers/account/transfer_matches_controller_test.rb
Normal file
42
test/controllers/account/transfer_matches_controller_test.rb
Normal file
|
@ -0,0 +1,42 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::TransferMatchesControllerTest < ActionDispatch::IntegrationTest
|
||||
include Account::EntriesTestHelper
|
||||
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
end
|
||||
|
||||
test "matches existing transaction and creates transfer" do
|
||||
inflow_transaction = create_transaction(amount: 100, account: accounts(:depository))
|
||||
outflow_transaction = create_transaction(amount: -100, account: accounts(:investment))
|
||||
|
||||
assert_difference "Transfer.count", 1 do
|
||||
post account_transaction_transfer_match_path(inflow_transaction), params: {
|
||||
transfer_match: {
|
||||
method: "existing",
|
||||
matched_entry_id: outflow_transaction.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to transactions_url
|
||||
assert_equal "Transfer created", flash[:notice]
|
||||
end
|
||||
|
||||
test "creates transfer for target account" do
|
||||
inflow_transaction = create_transaction(amount: 100, account: accounts(:depository))
|
||||
|
||||
assert_difference [ "Transfer.count", "Account::Entry.count", "Account::Transaction.count" ], 1 do
|
||||
post account_transaction_transfer_match_path(inflow_transaction), params: {
|
||||
transfer_match: {
|
||||
method: "new",
|
||||
target_account_id: accounts(:investment).id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to transactions_url
|
||||
assert_equal "Transfer created", flash[:notice]
|
||||
end
|
||||
end
|
|
@ -1,19 +1,19 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::TransfersControllerTest < ActionDispatch::IntegrationTest
|
||||
class TransfersControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
end
|
||||
|
||||
test "should get new" do
|
||||
get new_account_transfer_url
|
||||
get new_transfer_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "can create transfers" do
|
||||
assert_difference "Account::Transfer.count", 1 do
|
||||
post account_transfers_url, params: {
|
||||
account_transfer: {
|
||||
assert_difference "Transfer.count", 1 do
|
||||
post transfers_url, params: {
|
||||
transfer: {
|
||||
from_account_id: accounts(:depository).id,
|
||||
to_account_id: accounts(:credit_card).id,
|
||||
date: Date.current,
|
||||
|
@ -26,8 +26,8 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest
|
|||
end
|
||||
|
||||
test "can destroy transfer" do
|
||||
assert_difference -> { Account::Transfer.count } => -1, -> { Account::Transaction.count } => -2 do
|
||||
delete account_transfer_url(account_transfers(:one))
|
||||
assert_difference -> { Transfer.count } => -1, -> { Account::Transaction.count } => 0 do
|
||||
delete transfer_url(transfers(:one))
|
||||
end
|
||||
end
|
||||
end
|
4
test/fixtures/account/entries.yml
vendored
4
test/fixtures/account/entries.yml
vendored
|
@ -31,8 +31,6 @@ transfer_out:
|
|||
amount: 100
|
||||
currency: USD
|
||||
account: depository
|
||||
marked_as_transfer: true
|
||||
transfer: one
|
||||
entryable_type: Account::Transaction
|
||||
entryable: transfer_out
|
||||
|
||||
|
@ -42,7 +40,5 @@ transfer_in:
|
|||
amount: -100
|
||||
currency: USD
|
||||
account: credit_card
|
||||
marked_as_transfer: true
|
||||
transfer: one
|
||||
entryable_type: Account::Transaction
|
||||
entryable: transfer_in
|
||||
|
|
1
test/fixtures/account/transfers.yml
vendored
1
test/fixtures/account/transfers.yml
vendored
|
@ -1 +0,0 @@
|
|||
one: { }
|
3
test/fixtures/transfers.yml
vendored
Normal file
3
test/fixtures/transfers.yml
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
one:
|
||||
inflow_transaction: transfer_in
|
||||
outflow_transaction: transfer_out
|
|
@ -1,69 +0,0 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::TransferTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@outflow = account_entries(:transfer_out)
|
||||
@inflow = account_entries(:transfer_in)
|
||||
end
|
||||
|
||||
test "transfer valid if it has inflow and outflow from different accounts for the same amount" do
|
||||
transfer = Account::Transfer.create! entries: [ @inflow, @outflow ]
|
||||
|
||||
assert transfer.valid?
|
||||
end
|
||||
|
||||
test "transfer must have 2 transactions" do
|
||||
invalid_transfer_1 = Account::Transfer.new entries: [ @outflow ]
|
||||
invalid_transfer_2 = Account::Transfer.new entries: [ @inflow, @outflow, account_entries(:transaction) ]
|
||||
|
||||
assert invalid_transfer_1.invalid?
|
||||
assert invalid_transfer_2.invalid?
|
||||
end
|
||||
|
||||
test "transfer cannot have 2 transactions from the same account" do
|
||||
account = accounts(:depository)
|
||||
|
||||
inflow = account.entries.create! \
|
||||
date: Date.current,
|
||||
name: "Inflow",
|
||||
amount: -100,
|
||||
currency: "USD",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
outflow = account.entries.create! \
|
||||
date: Date.current,
|
||||
name: "Outflow",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
assert_raise ActiveRecord::RecordInvalid do
|
||||
Account::Transfer.create! entries: [ inflow, outflow ]
|
||||
end
|
||||
end
|
||||
|
||||
test "all transfer transactions must be marked as transfers" do
|
||||
@inflow.update! marked_as_transfer: false
|
||||
|
||||
assert_raise ActiveRecord::RecordInvalid do
|
||||
Account::Transfer.create! entries: [ @inflow, @outflow ]
|
||||
end
|
||||
end
|
||||
|
||||
test "single-currency transfer transactions must net to zero" do
|
||||
@outflow.update! amount: 105
|
||||
|
||||
assert_raises ActiveRecord::RecordInvalid do
|
||||
Account::Transfer.create! entries: [ @inflow, @outflow ]
|
||||
end
|
||||
end
|
||||
|
||||
test "multi-currency transfer transactions do not have to net to zero" do
|
||||
@outflow.update! amount: 105, currency: "EUR"
|
||||
transfer = Account::Transfer.create! entries: [ @inflow, @outflow ]
|
||||
|
||||
assert transfer.valid?
|
||||
end
|
||||
end
|
|
@ -120,11 +120,9 @@ class FamilyTest < ActiveSupport::TestCase
|
|||
|
||||
test "calculates rolling transaction totals" do
|
||||
account = create_account(balance: 1000, accountable: Depository.new)
|
||||
liability_account = create_account(balance: 1000, accountable: Loan.new)
|
||||
create_transaction(account: account, date: 2.days.ago.to_date, amount: -500)
|
||||
create_transaction(account: account, date: 1.day.ago.to_date, amount: 100)
|
||||
create_transaction(account: account, date: Date.current, amount: 20)
|
||||
create_transaction(account: liability_account, date: 2.days.ago.to_date, amount: -333)
|
||||
|
||||
snapshot = @family.snapshot_transactions
|
||||
|
||||
|
|
100
test/models/transfer_test.rb
Normal file
100
test/models/transfer_test.rb
Normal file
|
@ -0,0 +1,100 @@
|
|||
require "test_helper"
|
||||
|
||||
class TransferTest < ActiveSupport::TestCase
|
||||
include Account::EntriesTestHelper
|
||||
|
||||
setup do
|
||||
@outflow = account_transactions(:transfer_out)
|
||||
@inflow = account_transactions(:transfer_in)
|
||||
end
|
||||
|
||||
test "transfer has different accounts, opposing amounts, and within 4 days of each other" do
|
||||
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
|
||||
inflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:credit_card), amount: -500)
|
||||
|
||||
assert_difference -> { Transfer.count } => 1 do
|
||||
Transfer.create!(
|
||||
inflow_transaction: inflow_entry.account_transaction,
|
||||
outflow_transaction: outflow_entry.account_transaction,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "transfer cannot have 2 transactions from the same account" do
|
||||
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
|
||||
inflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:depository), amount: -500)
|
||||
|
||||
transfer = Transfer.new(
|
||||
inflow_transaction: inflow_entry.account_transaction,
|
||||
outflow_transaction: outflow_entry.account_transaction,
|
||||
)
|
||||
|
||||
assert_no_difference -> { Transfer.count } do
|
||||
transfer.save
|
||||
end
|
||||
|
||||
assert_equal "Transfer must have different accounts", transfer.errors.full_messages.first
|
||||
end
|
||||
|
||||
test "Transfer transactions must have opposite amounts" do
|
||||
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
|
||||
inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -400)
|
||||
|
||||
transfer = Transfer.new(
|
||||
inflow_transaction: inflow_entry.account_transaction,
|
||||
outflow_transaction: outflow_entry.account_transaction,
|
||||
)
|
||||
|
||||
assert_no_difference -> { Transfer.count } do
|
||||
transfer.save
|
||||
end
|
||||
|
||||
assert_equal "Transfer transactions must have opposite amounts", transfer.errors.full_messages.first
|
||||
end
|
||||
|
||||
test "transfer dates must be within 4 days of each other" do
|
||||
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
|
||||
inflow_entry = create_transaction(date: 5.days.ago.to_date, account: accounts(:credit_card), amount: -500)
|
||||
|
||||
transfer = Transfer.new(
|
||||
inflow_transaction: inflow_entry.account_transaction,
|
||||
outflow_transaction: outflow_entry.account_transaction,
|
||||
)
|
||||
|
||||
assert_no_difference -> { Transfer.count } do
|
||||
transfer.save
|
||||
end
|
||||
|
||||
assert_equal "Transfer transaction dates must be within 4 days of each other", transfer.errors.full_messages.first
|
||||
end
|
||||
|
||||
test "from_accounts converts amounts to the to_account's currency" do
|
||||
accounts(:depository).update!(currency: "EUR")
|
||||
|
||||
eur_account = accounts(:depository).reload
|
||||
usd_account = accounts(:credit_card)
|
||||
|
||||
ExchangeRate.create!(
|
||||
from_currency: "EUR",
|
||||
to_currency: "USD",
|
||||
rate: 1.1,
|
||||
date: Date.current,
|
||||
)
|
||||
|
||||
transfer = Transfer.from_accounts(
|
||||
from_account: eur_account,
|
||||
to_account: usd_account,
|
||||
date: Date.current,
|
||||
amount: 500,
|
||||
)
|
||||
|
||||
assert_equal 500, transfer.outflow_transaction.entry.amount
|
||||
assert_equal "EUR", transfer.outflow_transaction.entry.currency
|
||||
assert_equal -550, transfer.inflow_transaction.entry.amount
|
||||
assert_equal "USD", transfer.inflow_transaction.entry.currency
|
||||
|
||||
assert_difference -> { Transfer.count } => 1 do
|
||||
transfer.save!
|
||||
end
|
||||
end
|
||||
end
|
|
@ -210,7 +210,7 @@ class TransactionsTest < ApplicationSystemTestCase
|
|||
end
|
||||
|
||||
def number_of_transactions_on_page
|
||||
[ @user.family.entries.without_transfers.count, @page_size ].min
|
||||
[ @user.family.entries.count, @page_size ].min
|
||||
end
|
||||
|
||||
def all_transactions_checkbox
|
||||
|
|
|
@ -19,71 +19,13 @@ class TransfersTest < ApplicationSystemTestCase
|
|||
|
||||
select checking_name, from: "From"
|
||||
select savings_name, from: "To"
|
||||
fill_in "account_transfer[amount]", with: 500
|
||||
fill_in "transfer[amount]", with: 500
|
||||
fill_in "Date", with: transfer_date
|
||||
|
||||
click_button "Create transfer"
|
||||
|
||||
within "#entry-group-" + transfer_date.to_s do
|
||||
assert_text "Transfer from"
|
||||
assert_text "Payment to"
|
||||
end
|
||||
end
|
||||
|
||||
test "can match 2 transactions and create a transfer" do
|
||||
transfer_date = Date.current
|
||||
outflow = accounts(:depository).entries.create! \
|
||||
name: "Outflow from checking account",
|
||||
date: transfer_date,
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
inflow = accounts(:credit_card).entries.create! \
|
||||
name: "Inflow to cc account",
|
||||
date: transfer_date,
|
||||
amount: -100,
|
||||
currency: "USD",
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
visit transactions_url
|
||||
|
||||
transaction_entry_checkbox(inflow).check
|
||||
transaction_entry_checkbox(outflow).check
|
||||
|
||||
bulk_transfer_action_button.click
|
||||
|
||||
click_on "Mark as transfers"
|
||||
|
||||
within "#entry-group-" + transfer_date.to_s do
|
||||
assert_text "Outflow"
|
||||
assert_text "Inflow"
|
||||
end
|
||||
end
|
||||
|
||||
test "can mark a single transaction as a transfer" do
|
||||
txn = @user.family.entries.account_transactions.reverse_chronological.first
|
||||
|
||||
within "#" + dom_id(txn) do
|
||||
assert_text txn.account_transaction.category.name || "Uncategorized"
|
||||
end
|
||||
|
||||
transaction_entry_checkbox(txn).check
|
||||
|
||||
bulk_transfer_action_button.click
|
||||
click_on "Mark as transfers"
|
||||
|
||||
within "#" + dom_id(txn) do
|
||||
assert_no_text "Uncategorized"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transaction_entry_checkbox(transaction_entry)
|
||||
find("#" + dom_id(transaction_entry, "selection"))
|
||||
end
|
||||
|
||||
def bulk_transfer_action_button
|
||||
find("#bulk-transfer-btn")
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue