1
0
Fork 0
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:
Zach Gollwitzer 2025-01-07 09:41:24 -05:00 committed by GitHub
parent 46e129308f
commit 307a3687e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 1161 additions and 682 deletions

View file

@ -121,7 +121,7 @@ GEM
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.4) bootsnap (1.18.4)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (6.2.2) brakeman (7.0.0)
racc racc
builder (3.3.0) builder (3.3.0)
capybara (3.40.0) capybara (3.40.0)

View file

@ -29,6 +29,11 @@
@apply focus:opacity-100 focus:outline-none focus:ring-0; @apply focus:opacity-100 focus:outline-none focus:ring-0;
@apply placeholder-shown:opacity-50; @apply placeholder-shown:opacity-50;
@apply disabled:text-gray-400; @apply disabled:text-gray-400;
@apply text-ellipsis overflow-hidden whitespace-nowrap;
}
select.form-field__input {
@apply pr-8;
} }
.form-field__radio { .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; @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 { [type='checkbox'].maybe-checkbox--dark {
@apply ring-gray-900 checked:text-white; @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 { [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"); 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");
} }

View file

@ -21,24 +21,6 @@ class Account::TransactionsController < ApplicationController
redirect_back_or_to transactions_url, notice: t(".success", count: updated) redirect_back_or_to transactions_url, notice: t(".success", count: updated)
end 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 private
def bulk_delete_params def bulk_delete_params
params.require(:bulk_delete).permit(entry_ids: []) params.require(:bulk_delete).permit(entry_ids: [])

View 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

View file

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

View file

@ -52,11 +52,14 @@ module EntryableResource
respond_to do |format| respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") } format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
format.turbo_stream do format.turbo_stream do
render turbo_stream: turbo_stream.replace( render turbo_stream: [
turbo_stream.replace(
"header_account_entry_#{@entry.id}", "header_account_entry_#{@entry.id}",
partial: "#{entryable_type.name.underscore.pluralize}/header", partial: "#{entryable_type.name.underscore.pluralize}/header",
locals: { entry: @entry } locals: { entry: @entry }
) ),
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
]
end end
end end
else else

View file

@ -6,10 +6,15 @@ class TransactionsController < ApplicationController
search_query = Current.family.transactions.search(@q).includes(:entryable).reverse_chronological search_query = Current.family.transactions.search(@q).includes(:entryable).reverse_chronological
@pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50") @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 = { @totals = {
count: search_query.select { |t| t.currency == Current.family.currency }.count, count: ((count_with_transfers - count_without_transfers) / 2) + count_without_transfers,
income: search_query.income_total(Current.family.currency).abs, income: totals_query.income_total(family_currency).abs,
expense: search_query.expense_total(Current.family.currency) expense: totals_query.expense_total(family_currency)
} }
end end

View 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

View file

@ -3,10 +3,6 @@ module Account::EntriesHelper
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}" "account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
end end
def unconfirmed_transfer?(entry)
entry.marked_as_transfer? && entry.transfer.nil?
end
def transfer_entries(entries) def transfer_entries(entries)
transfers = entries.select { |e| e.transfer_id.present? } transfers = entries.select { |e| e.transfer_id.present? }
transfers.map(&:transfer).uniq transfers.map(&:transfer).uniq
@ -18,8 +14,19 @@ module Account::EntriesHelper
yield grouped_entries yield grouped_entries
end end
next if content.blank?
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: } 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 end
private private

View file

@ -1,2 +0,0 @@
module Account::TransfersHelper
end

View file

@ -67,9 +67,9 @@ module ApplicationHelper
render partial: "shared/drawer", locals: { content:, reload_on_close: } render partial: "shared/drawer", locals: { content:, reload_on_close: }
end end
def disclosure(title, &block) def disclosure(title, default_open: true, &block)
content = capture &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 end
def sidebar_link_to(name, path, options = {}) def sidebar_link_to(name, path, options = {})

View file

@ -5,6 +5,24 @@ module CategoriesHelper
color: Category::UNCATEGORIZED_COLOR color: Category::UNCATEGORIZED_COLOR
end 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 def family_categories
[ null_category ].concat(Current.family.categories.alphabetically) [ null_category ].concat(Current.family.categories.alphabetically)
end end

View file

@ -99,7 +99,9 @@ export default class extends Controller {
} }
_rowsForGroup(group) { _rowsForGroup(group) {
return this.rowTargets.filter((row) => group.contains(row)); return this.rowTargets.filter(
(row) => group.contains(row) && !row.disabled,
);
} }
_addToSelection(idToAdd) { _addToSelection(idToAdd) {
@ -115,7 +117,9 @@ export default class extends Controller {
} }
_selectAll() { _selectAll() {
this.selectedIdsValue = this.rowTargets.map((t) => t.dataset.id); this.selectedIdsValue = this.rowTargets
.filter((t) => !t.disabled)
.map((t) => t.dataset.id);
} }
_updateView = () => { _updateView = () => {

View 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");
}
}
}

View file

@ -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) { scope :with_converted_amount, ->(currency) {
# Join with exchange rates to convert the amount to the given currency # Join with exchange rates to convert the amount to the given currency
# If no rate is available, exclude the transaction from the results # If no rate is available, exclude the transaction from the results
@ -59,6 +67,15 @@ class Account::Entry < ApplicationRecord
enriched_name.presence || name enriched_name.presence || name
end 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 class << self
def search(params) def search(params)
Account::EntrySearch.new(params).build_query(all) 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) select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
end 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) def bulk_update!(bulk_update_params)
bulk_attributes = { bulk_attributes = {
date: bulk_update_params[:date], date: bulk_update_params[:date],
@ -128,7 +138,7 @@ class Account::Entry < ApplicationRecord
end end
def income_total(currency = "USD") 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") .where("account_entries.amount <= 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) } .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum .sum
@ -137,29 +147,12 @@ class Account::Entry < ApplicationRecord
end end
def expense_total(currency = "USD") 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") .where("account_entries.amount > 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) } .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum .sum
Money.new(total, currency) Money.new(total, currency)
end 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
end end

View file

@ -6,8 +6,8 @@ class Account::EntrySearch
attribute :amount, :string attribute :amount, :string
attribute :amount_operator, :string attribute :amount_operator, :string
attribute :types, :string attribute :types, :string
attribute :accounts, :string attribute :accounts, array: true
attribute :account_ids, :string attribute :account_ids, array: true
attribute :start_date, :string attribute :start_date, :string
attribute :end_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? query = query.where("account_entries.date <= ?", end_date) if end_date.present?
if types.present? if types.present?
query = query.where(marked_as_transfer: false) unless types.include?("transfer")
if types.include?("income") && !types.include?("expense") if types.include?("income") && !types.include?("expense")
query = query.where("account_entries.amount < 0") query = query.where("account_entries.amount < 0")
elsif types.include?("expense") && !types.include?("income") elsif types.include?("expense") && !types.include?("income")

View file

@ -5,6 +5,8 @@ class Account::Syncer
end end
def run def run
Transfer.auto_match_for_account(account)
holdings = sync_holdings holdings = sync_holdings
balances = sync_balances(holdings) balances = sync_balances(holdings)
account.reload account.reload

View file

@ -4,6 +4,13 @@ class Account::TradeBuilder
attr_accessor :account, :date, :amount, :currency, :qty, attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :type, :transfer_account_id :price, :ticker, :type, :transfer_account_id
attr_reader :buildable
def initialize(attributes = {})
super
@buildable = set_buildable
end
def save def save
buildable.save buildable.save
end end
@ -17,7 +24,7 @@ class Account::TradeBuilder
end end
private private
def buildable def set_buildable
case type case type
when "buy", "sell" when "buy", "sell"
build_trade build_trade
@ -55,9 +62,9 @@ class Account::TradeBuilder
from_account = type == "withdrawal" ? account : transfer_account from_account = type == "withdrawal" ? account : transfer_account
to_account = type == "withdrawal" ? transfer_account : account to_account = type == "withdrawal" ? transfer_account : account
Account::Transfer.build_from_accounts( Transfer.from_accounts(
from_account, from_account: from_account,
to_account, to_account: to_account,
date: date, date: date,
amount: signed_amount amount: signed_amount
) )
@ -67,7 +74,6 @@ class Account::TradeBuilder
date: date, date: date,
amount: signed_amount, amount: signed_amount,
currency: currency, currency: currency,
marked_as_transfer: true,
entryable: Account::Transaction.new entryable: Account::Transaction.new
) )
end end

View file

@ -6,6 +6,9 @@ class Account::Transaction < ApplicationRecord
has_many :taggings, as: :taggable, dependent: :destroy has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings 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 accepts_nested_attributes_for :taggings, allow_destroy: true
scope :active, -> { where(excluded: false) } scope :active, -> { where(excluded: false) }
@ -15,4 +18,12 @@ class Account::Transaction < ApplicationRecord
Account::TransactionSearch.new(params).build_query(all) Account::TransactionSearch.new(params).build_query(all)
end end
end end
def transfer
transfer_as_inflow || transfer_as_outflow
end
def transfer?
transfer.present? && transfer.status != "rejected"
end
end end

View file

@ -18,6 +18,11 @@ class Account::TransactionSearch
def build_query(scope) def build_query(scope)
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.present?
if categories.exclude?("Uncategorized") if categories.exclude?("Uncategorized")
query = query query = query

View file

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

View file

@ -17,6 +17,9 @@ class Category < ApplicationRecord
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
UNCATEGORIZED_COLOR = "#737373" UNCATEGORIZED_COLOR = "#737373"
TRANSFER_COLOR = "#444CE7"
PAYMENT_COLOR = "#db5a54"
TRADE_COLOR = "#e99537"
class Group class Group
attr_reader :category, :subcategories attr_reader :category, :subcategories

View file

@ -36,6 +36,8 @@ class Demo::Generator
create_car_and_loan! create_car_and_loan!
create_other_accounts! create_other_accounts!
create_transfer_transactions!
puts "accounts created" puts "accounts created"
puts "Demo data loaded successfully!" puts "Demo data loaded successfully!"
end end
@ -49,12 +51,14 @@ class Demo::Generator
family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id
family = Family.find_by(id: family_id) family = Family.find_by(id: family_id)
Transfer.destroy_all
family.destroy! if family family.destroy! if family
Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload) Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload)
end end
def clear_data! def clear_data!
Transfer.destroy_all
InviteCode.destroy_all InviteCode.destroy_all
User.find_by_email("user@maybe.local")&.destroy User.find_by_email("user@maybe.local")&.destroy
ExchangeRate.destroy_all ExchangeRate.destroy_all
@ -177,6 +181,40 @@ class Demo::Generator
end end
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! def load_securities!
# Create an unknown security to simulate edge cases # Create an unknown security to simulate edge cases
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock", exchange_mic: "UNKNOWN" Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock", exchange_mic: "UNKNOWN"

View file

@ -82,7 +82,9 @@ class Family < ApplicationRecord
def snapshot_account_transactions def snapshot_account_transactions
period = Period.last_30_days 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( .select(
"accounts.*", "accounts.*",
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending", "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.begin)
.where("account_entries.date <= ?", period.date_range.end) .where("account_entries.date <= ?", period.date_range.end)
.where("account_entries.marked_as_transfer = ?", false) .where("transfers.id IS NULL")
.where("account_entries.entryable_type = ?", "Account::Transaction")
.group("accounts.id") .group("accounts.id")
.having("SUM(ABS(account_entries.amount)) > 0") .having("SUM(ABS(account_entries.amount)) > 0")
.to_a .to_a
@ -110,9 +111,7 @@ class Family < ApplicationRecord
end end
def snapshot_transactions def snapshot_transactions
candidate_entries = entries.account_transactions.without_transfers.excluding( candidate_entries = entries.account_transactions.incomes_and_expenses
entries.joins(:account).where(amount: ..0, accounts: { classification: Account.classifications[:liability] })
)
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days) rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
spending = [] spending = []

View file

@ -89,7 +89,6 @@ class PlaidAccount < ApplicationRecord
t.amount = plaid_txn.amount t.amount = plaid_txn.amount
t.currency = plaid_txn.iso_currency_code t.currency = plaid_txn.iso_currency_code
t.date = plaid_txn.date t.date = plaid_txn.date
t.marked_as_transfer = transfer?(plaid_txn)
t.entryable = Account::Transaction.new( t.entryable = Account::Transaction.new(
category: get_category(plaid_txn.personal_finance_category.primary), category: get_category(plaid_txn.personal_finance_category.primary),
merchant: get_merchant(plaid_txn.merchant_name) merchant: get_merchant(plaid_txn.merchant_name)

View file

@ -31,7 +31,6 @@ class PlaidInvestmentSync
t.amount = transaction.amount t.amount = transaction.amount
t.currency = transaction.iso_currency_code t.currency = transaction.iso_currency_code
t.date = transaction.date t.date = transaction.date
t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal])
t.entryable = Account::Transaction.new t.entryable = Account::Transaction.new
end end
else else

139
app/models/transfer.rb Normal file
View 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

View file

@ -84,7 +84,7 @@
</div> </div>
<div class="p-4 bg-white rounded-bl-lg rounded-br-lg"> <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>
</div> </div>
<% end %> <% end %>

View file

@ -22,7 +22,7 @@
{ label: t(".type"), selected: type }, { label: t(".type"), selected: type },
{ data: { { data: {
action: "trade-form#changeType", 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", trade_form_key_param: "type",
}} %> }} %>

View file

@ -3,7 +3,7 @@
<% trade, account = entry.account_trade, entry.account %> <% 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="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 %> <% if selectable %>
<%= check_box_tag dom_id(entry, "selection"), <%= check_box_tag dom_id(entry, "selection"),
class: "maybe-checkbox maybe-checkbox--light", class: "maybe-checkbox maybe-checkbox--light",
@ -30,6 +30,10 @@
</div> </div>
</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"> <div class="col-span-2 justify-self-end font-medium text-sm">
<%= content_tag :p, <%= content_tag :p,
format_money(-entry.amount_money), format_money(-entry.amount_money),

View file

@ -8,7 +8,7 @@
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2"> <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: :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" %> <%= 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" %> <%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
<%= tag.span t(".transfer") %> <%= tag.span t(".transfer") %>
<% end %> <% end %>

View file

@ -12,7 +12,7 @@
</span> </span>
</h3> </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" %> <%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
<% end %> <% end %>
</div> </div>

View file

@ -8,26 +8,6 @@
<div class="flex items-center gap-1 text-gray-500"> <div class="flex items-center gap-1 text-gray-500">
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %> <%= 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, <%= link_to bulk_edit_account_transactions_path,
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md", class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
title: "Edit", title: "Edit",

View file

@ -1,10 +1,11 @@
<%# locals: (entry:, selectable: true, balance_trend: nil) %> <%# locals: (entry:, selectable: true, balance_trend: nil) %>
<% transaction, account = entry.account_transaction, entry.account %> <% 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="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-6"> <div class="pr-10 flex items-center gap-4 <%= balance_trend ? "col-span-6" : "col-span-8" %>">
<% if selectable %> <% if selectable %>
<%= check_box_tag dom_id(entry, "selection"), <%= check_box_tag dom_id(entry, "selection"),
disabled: entry.account_transaction.transfer?,
class: "maybe-checkbox maybe-checkbox--light", class: "maybe-checkbox maybe-checkbox--light",
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %> data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
<% end %> <% end %>
@ -18,50 +19,45 @@
<% end %> <% end %>
<div class="truncate"> <div class="truncate">
<div class="space-y-0.5">
<div class="flex items-center gap-1">
<% if entry.new_record? %> <% if entry.new_record? %>
<%= content_tag :p, entry.display_name %> <%= content_tag :p, entry.display_name %>
<% else %> <% else %>
<%= link_to entry.display_name, <%= link_to entry.display_name,
entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry), entry.account_transaction.transfer? ? transfer_path(entry.account_transaction.transfer) : account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false }, data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %> class: "hover:underline hover:text-gray-800" %>
<% end %> <% end %>
</div>
<% 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 %> <% end %>
</div> </div>
<% if unconfirmed_transfer?(entry) %> <div class="text-gray-500 text-xs font-normal">
<%= render "account/transfers/transfer_toggle", entry: entry %> <% if entry.account_transaction.transfer? %>
<% end %> <%= render "transfers/account_links", transfer: entry.account_transaction.transfer, is_inflow: entry.account_transaction.transfer_as_inflow.present? %>
</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 %> <% 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>
</div>
<div class="flex items-center gap-1 col-span-2"> <div class="flex items-center gap-1 col-span-2">
<%= render "categories/menu", transaction: transaction %> <%= render "account/transactions/transaction_category", entry: entry %>
</div> </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="col-span-2 ml-auto"> <div class="col-span-2 ml-auto">
<%= content_tag :p, <%= content_tag :p,
format_money(-entry.amount_money), format_money(-entry.amount_money),

View file

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

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

View file

@ -19,7 +19,7 @@
max: Date.current, max: Date.current,
"data-auto-submit-form-target": "auto" %> "data-auto-submit-form-target": "auto" %>
<% unless @entry.marked_as_transfer? %> <% unless @entry.account_transaction.transfer? %>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<%= f.select :nature, <%= f.select :nature,
[["Expense", "outflow"], ["Income", "inflow"]], [["Expense", "outflow"], ["Income", "inflow"]],
@ -32,27 +32,7 @@
min: 0, min: 0,
value: @entry.amount.abs %> value: @entry.amount.abs %>
</div> </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| %> <%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id, <%= ef.collection_select :category_id,
Current.family.categories.alphabetically, Current.family.categories.alphabetically,
@ -60,6 +40,30 @@
{ label: t(".category_label"), { label: t(".category_label"),
class: "text-gray-400", include_blank: t(".uncategorized") }, class: "text-gray-400", include_blank: t(".uncategorized") },
"data-auto-submit-form-target": "auto" %> "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, <%= ef.collection_select :merchant_id,
Current.family.merchants.alphabetically, Current.family.merchants.alphabetically,
@ -94,15 +98,15 @@
<!-- Settings Section --> <!-- Settings Section -->
<%= disclosure t(".settings") do %> <%= disclosure t(".settings") do %>
<div class="pb-4"> <div class="pb-4">
<!-- Exclude Transaction Form -->
<%= styled_form_with model: @entry, <%= styled_form_with model: @entry,
url: account_transaction_path(@entry), url: account_transaction_path(@entry),
class: "p-3", class: "p-3",
data: { controller: "auto-submit-form" } do |f| %> 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"> <div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4> <h4 class="text-gray-900">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p> <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>
<div class="relative inline-block select-none"> <div class="relative inline-block select-none">
@ -115,6 +119,18 @@
</div> </div>
<% end %> <% 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 --> <!-- Delete Transaction Form -->
<div class="flex items-center justify-between gap-2 p-3"> <div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1"> <div class="text-sm space-y-1">

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

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@
<% end %> <% end %>
<div class="flex items-center gap-3"> <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" %> <%= lucide_icon icon, class: "w-4 h-4" %>
<% end %> <% end %>

View file

@ -1,5 +1,5 @@
<%# locals: (account:) %> <%# 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" %> <%= render "account/entries/loading" %>
<% end %> <% end %>

View file

@ -9,4 +9,5 @@
color: <%= category.color %>;"> color: <%= category.color %>;">
<%= category.name %> <%= category.name %>
</span> </span>
</div> </div>

View file

@ -29,16 +29,8 @@
</div> </div>
<hr> <hr>
<div class="relative p-1.5 w-full"> <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 %> <% if @transaction.category %>
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry), <%= button_to account_transaction_path(@transaction.entry),
method: :patch, method: :patch,
data: { turbo_frame: dom_id(@transaction.entry) }, data: { turbo_frame: dom_id(@transaction.entry) },
params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } }, params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
@ -48,6 +40,32 @@
<%= t(".clear") %> <%= t(".clear") %>
<% end %> <% end %>
<% 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>
</div> </div>
<% end %> <% end %>

View file

@ -10,7 +10,6 @@
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %> <%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %> <%= 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"> <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">

View file

@ -1,7 +1,7 @@
<%# locals: (title:, content:, subtitle: nil) %> <%# locals: (title:, content:, subtitle: nil) %>
<%= modal do %> <%= 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"> <div class="space-y-2">
<header class="flex justify-between items-center"> <header class="flex justify-between items-center">
<h2 class="font-medium"><%= title %></h2> <h2 class="font-medium"><%= title %></h2>

View file

@ -17,20 +17,24 @@
<% if @transaction_entries.present? %> <% if @transaction_entries.present? %>
<div class="grow overflow-y-auto"> <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="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", <%= check_box_tag "selection_entry",
class: "maybe-checkbox maybe-checkbox--light", class: "maybe-checkbox maybe-checkbox--light",
data: { action: "bulk-select#togglePageSelection" } %> data: { action: "bulk-select#togglePageSelection" } %>
<p class="col-span-4">transaction</p> <p>transaction</p>
</div> </div>
<p class="col-span-2">category</p> <p class="col-span-2">category</p>
<p class="col-span-2">account</p>
<p class="col-span-2 justify-self-end">amount</p> <p class="col-span-2 justify-self-end">amount</p>
</div> </div>
<div class="space-y-6"> <div class="space-y-6">
<%= entries_by_date(@transaction_entries, totals: true) do |entries| %> <%= 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 %> <% end %>
</div> </div>
</div> </div>

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

View file

@ -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 :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.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.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>
<section> <section>

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

View file

@ -3,11 +3,11 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<h3 class="font-medium"> <h3 class="font-medium">
<span class="text-2xl"> <span class="text-2xl">
<%= format_money @transfer.amount_money %> <%= format_money @transfer.amount_abs %>
</span> </span>
<span class="text-lg text-gray-500"> <span class="text-lg text-gray-500">
<%= @transfer.amount_money.currency.iso_code %> <%= @transfer.amount_abs.currency.iso_code %>
</span> </span>
</h3> </h3>
@ -25,21 +25,21 @@
<div class="pb-4 px-3 pt-2 text-sm space-y-3 text-gray-900"> <div class="pb-4 px-3 pt-2 text-sm space-y-3 text-gray-900">
<div class="space-y-3"> <div class="space-y-3">
<dl class="flex items-center gap-2 justify-between"> <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"> <dd class="flex items-center gap-2 font-medium">
<%= render "accounts/logo", account: @transfer.inflow_transaction.account, size: "sm" %> <%= render "accounts/logo", account: @transfer.from_account, size: "sm" %>
<%= @transfer.to_name %> <%= link_to @transfer.from_account.name, account_path(@transfer.from_account), data: { turbo_frame: "_top" } %>
</dd> </dd>
</dl> </dl>
<dl class="flex items-center gap-2 justify-between"> <dl class="flex items-center gap-2 justify-between">
<dt class="text-gray-500">Date</dt> <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>
<dl class="flex items-center gap-2 justify-between"> <dl class="flex items-center gap-2 justify-between">
<dt class="text-gray-500">Amount</dt> <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> </dl>
</div> </div>
@ -47,21 +47,21 @@
<div class="space-y-3"> <div class="space-y-3">
<dl class="flex items-center gap-2 justify-between"> <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"> <dd class="flex items-center gap-2 font-medium">
<%= render "accounts/logo", account: @transfer.outflow_transaction.account, size: "sm" %> <%= render "accounts/logo", account: @transfer.to_account, size: "sm" %>
<%= @transfer.from_name %> <%= link_to @transfer.to_account.name, account_path(@transfer.to_account), data: { turbo_frame: "_top" } %>
</dd> </dd>
</dl> </dl>
<dl class="flex items-center gap-2 justify-between"> <dl class="flex items-center gap-2 justify-between">
<dt class="text-gray-500">Date</dt> <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>
<dl class="flex items-center gap-2 justify-between"> <dl class="flex items-center gap-2 justify-between">
<dt class="text-gray-500">Amount</dt> <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> </dl>
</div> </div>
</div> </div>
@ -74,7 +74,6 @@
<%= f.text_area :notes, <%= f.text_area :notes,
label: t(".note_label"), label: t(".note_label"),
placeholder: t(".note_placeholder"), placeholder: t(".note_placeholder"),
value: @transfer.outflow_transaction.notes,
rows: 5, rows: 5,
"data-auto-submit-form-target": "auto" %> "data-auto-submit-form-target": "auto" %>
<% end %> <% end %>
@ -83,25 +82,6 @@
<!-- Settings Section --> <!-- Settings Section -->
<%= disclosure t(".settings") do %> <%= disclosure t(".settings") do %>
<div class="pb-4"> <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="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1"> <div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".delete_title") %></h4> <h4 class="text-gray-900"><%= t(".delete_title") %></h4>
@ -109,9 +89,9 @@
</div> </div>
<%= button_to t(".delete"), <%= button_to t(".delete"),
account_transfer_path(@transfer), transfer_path(@transfer),
method: :delete, 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", font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %> data: { turbo_confirm: true, turbo_frame: "_top" } %>
</div> </div>

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

View file

@ -103,6 +103,30 @@
], ],
"note": "" "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_type": "Dynamic Render Path",
"warning_code": 15, "warning_code": 15,
@ -138,6 +162,5 @@
"note": "" "note": ""
} }
], ],
"updated": "2024-12-18 17:46:13 -0500", "brakeman_version": "7.0.0"
"brakeman_version": "6.2.2"
} }

View file

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

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

View file

@ -36,15 +36,8 @@ en:
no_transactions: No transactions for this account yet. no_transactions: No transactions for this account yet.
transaction: transaction transaction: transaction
transactions: Transactions transactions: Transactions
mark_transfers:
success: Marked as transfers
new: new:
new_transaction: New transaction 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: show:
account_label: Account account_label: Account
amount: Amount amount: Amount
@ -55,9 +48,6 @@ en:
balances, and cannot be undone. balances, and cannot be undone.
delete_title: Delete transaction delete_title: Delete transaction
details: Details details: Details
exclude_subtitle: This excludes the transaction from any in-app features or
analytics.
exclude_title: Exclude transaction
merchant_label: Merchant merchant_label: Merchant
name_label: Name name_label: Name
nature: Type nature: Type
@ -68,5 +58,6 @@ en:
settings: Settings settings: Settings
tags_label: Tags tags_label: Tags
uncategorized: "(uncategorized)" uncategorized: "(uncategorized)"
unmark_transfers: transfer_matches:
success: Transfer removed create:
success: Transfer created

View file

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

View file

@ -1,11 +1,8 @@
--- ---
en: en:
category:
dropdowns:
show:
empty: No categories found
bootstrap: Generate default categories
categories: categories:
bootstrap:
success: Default categories created successfully
category: category:
delete: Delete category delete: Delete category
edit: Edit category edit: Edit category
@ -18,15 +15,18 @@ en:
form: form:
placeholder: Category name placeholder: Category name
index: index:
bootstrap: Use default categories
categories: Categories categories: Categories
empty: No categories found empty: No categories found
new: New category new: New category
bootstrap: Use default categories
bootstrap:
success: Default categories created successfully
menu: menu:
loading: Loading... loading: Loading...
new: new:
new_category: New category new_category: New category
update: update:
success: Category updated successfully success: Category updated successfully
category:
dropdowns:
show:
bootstrap: Generate default categories
empty: No categories found

View file

@ -6,7 +6,6 @@ en:
delete: Delete category delete: Delete category
edit: Edit category edit: Edit category
show: show:
add_new: Add new clear: Clear category
clear: Clear
no_categories: No categories found no_categories: No categories found
search_placeholder: Search search_placeholder: Search

View 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

View file

@ -46,9 +46,7 @@ Rails.application.routes.draw do
resources :merchants, only: %i[index new create edit update destroy] resources :merchants, only: %i[index new create edit update destroy]
namespace :account do
resources :transfers, only: %i[new create destroy show update] resources :transfers, only: %i[new create destroy show update]
end
resources :imports, only: %i[index new show create destroy] do resources :imports, only: %i[index new show create destroy] do
post :publish, on: :member post :publish, on: :member
@ -81,6 +79,7 @@ Rails.application.routes.draw do
resources :entries, only: :index resources :entries, only: :index
resources :transactions, only: %i[show new create update destroy] do resources :transactions, only: %i[show new create update destroy] do
resource :transfer_match, only: %i[new create]
resource :category, only: :update, controller: :transaction_categories resource :category, only: :update, controller: :transaction_categories
collection do collection do

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

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -42,8 +42,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
t.string "name", null: false t.string "name", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_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.uuid "import_id"
t.text "notes" t.text "notes"
t.boolean "excluded", default: false 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.string "enriched_name"
t.index ["account_id"], name: "index_account_entries_on_account_id" t.index ["account_id"], name: "index_account_entries_on_account_id"
t.index ["import_id"], name: "index_account_entries_on_import_id" t.index ["import_id"], name: "index_account_entries_on_import_id"
t.index ["transfer_id"], name: "index_account_entries_on_transfer_id"
end end
create_table "account_holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| 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" t.index ["merchant_id"], name: "index_account_transactions_on_merchant_id"
end 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| create_table "account_valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_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" t.index ["family_id"], name: "index_tags_on_family_id"
end 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| create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false t.uuid "family_id", null: false
t.string "first_name" t.string "first_name"
@ -634,7 +638,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
end end
add_foreign_key "account_balances", "accounts", on_delete: :cascade 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", "accounts"
add_foreign_key "account_entries", "imports" add_foreign_key "account_entries", "imports"
add_foreign_key "account_holdings", "accounts" 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 "sessions", "users"
add_foreign_key "taggings", "tags" add_foreign_key "taggings", "tags"
add_foreign_key "tags", "families" 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" add_foreign_key "users", "families"
end end

View file

@ -38,7 +38,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
assert_difference -> { Account::Entry.count } => 2, assert_difference -> { Account::Entry.count } => 2,
-> { Account::Transaction.count } => 2, -> { Account::Transaction.count } => 2,
-> { Account::Transfer.count } => 1 do -> { Transfer.count } => 1 do
post account_trades_url, params: { post account_trades_url, params: {
account_entry: { account_entry: {
account_id: @entry.account_id, account_id: @entry.account_id,
@ -59,7 +59,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
assert_difference -> { Account::Entry.count } => 2, assert_difference -> { Account::Entry.count } => 2,
-> { Account::Transaction.count } => 2, -> { Account::Transaction.count } => 2,
-> { Account::Transfer.count } => 1 do -> { Transfer.count } => 1 do
post account_trades_url, params: { post account_trades_url, params: {
account_entry: { account_entry: {
account_id: @entry.account_id, account_id: @entry.account_id,
@ -78,7 +78,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
test "deposit and withdrawal has optional transfer account" do test "deposit and withdrawal has optional transfer account" do
assert_difference -> { Account::Entry.count } => 1, assert_difference -> { Account::Entry.count } => 1,
-> { Account::Transaction.count } => 1, -> { Account::Transaction.count } => 1,
-> { Account::Transfer.count } => 0 do -> { Transfer.count } => 0 do
post account_trades_url, params: { post account_trades_url, params: {
account_entry: { account_entry: {
account_id: @entry.account_id, account_id: @entry.account_id,
@ -93,7 +93,6 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
created_entry = Account::Entry.order(created_at: :desc).first created_entry = Account::Entry.order(created_at: :desc).first
assert created_entry.amount.positive? assert created_entry.amount.positive?
assert created_entry.marked_as_transfer
assert_redirected_to @entry.account assert_redirected_to @entry.account
end end

View file

@ -74,7 +74,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
end end
test "can destroy many transactions at once" do 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 delete_count = transactions.size
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do

View 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

View file

@ -1,19 +1,19 @@
require "test_helper" require "test_helper"
class Account::TransfersControllerTest < ActionDispatch::IntegrationTest class TransfersControllerTest < ActionDispatch::IntegrationTest
setup do setup do
sign_in users(:family_admin) sign_in users(:family_admin)
end end
test "should get new" do test "should get new" do
get new_account_transfer_url get new_transfer_url
assert_response :success assert_response :success
end end
test "can create transfers" do test "can create transfers" do
assert_difference "Account::Transfer.count", 1 do assert_difference "Transfer.count", 1 do
post account_transfers_url, params: { post transfers_url, params: {
account_transfer: { transfer: {
from_account_id: accounts(:depository).id, from_account_id: accounts(:depository).id,
to_account_id: accounts(:credit_card).id, to_account_id: accounts(:credit_card).id,
date: Date.current, date: Date.current,
@ -26,8 +26,8 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest
end end
test "can destroy transfer" do test "can destroy transfer" do
assert_difference -> { Account::Transfer.count } => -1, -> { Account::Transaction.count } => -2 do assert_difference -> { Transfer.count } => -1, -> { Account::Transaction.count } => 0 do
delete account_transfer_url(account_transfers(:one)) delete transfer_url(transfers(:one))
end end
end end
end end

View file

@ -31,8 +31,6 @@ transfer_out:
amount: 100 amount: 100
currency: USD currency: USD
account: depository account: depository
marked_as_transfer: true
transfer: one
entryable_type: Account::Transaction entryable_type: Account::Transaction
entryable: transfer_out entryable: transfer_out
@ -42,7 +40,5 @@ transfer_in:
amount: -100 amount: -100
currency: USD currency: USD
account: credit_card account: credit_card
marked_as_transfer: true
transfer: one
entryable_type: Account::Transaction entryable_type: Account::Transaction
entryable: transfer_in entryable: transfer_in

View file

@ -1 +0,0 @@
one: { }

3
test/fixtures/transfers.yml vendored Normal file
View file

@ -0,0 +1,3 @@
one:
inflow_transaction: transfer_in
outflow_transaction: transfer_out

View file

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

View file

@ -120,11 +120,9 @@ class FamilyTest < ActiveSupport::TestCase
test "calculates rolling transaction totals" do test "calculates rolling transaction totals" do
account = create_account(balance: 1000, accountable: Depository.new) 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: 2.days.ago.to_date, amount: -500)
create_transaction(account: account, date: 1.day.ago.to_date, amount: 100) create_transaction(account: account, date: 1.day.ago.to_date, amount: 100)
create_transaction(account: account, date: Date.current, amount: 20) 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 snapshot = @family.snapshot_transactions

View 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

View file

@ -210,7 +210,7 @@ class TransactionsTest < ApplicationSystemTestCase
end end
def number_of_transactions_on_page def number_of_transactions_on_page
[ @user.family.entries.without_transfers.count, @page_size ].min [ @user.family.entries.count, @page_size ].min
end end
def all_transactions_checkbox def all_transactions_checkbox

View file

@ -19,71 +19,13 @@ class TransfersTest < ApplicationSystemTestCase
select checking_name, from: "From" select checking_name, from: "From"
select savings_name, from: "To" select savings_name, from: "To"
fill_in "account_transfer[amount]", with: 500 fill_in "transfer[amount]", with: 500
fill_in "Date", with: transfer_date fill_in "Date", with: transfer_date
click_button "Create transfer" click_button "Create transfer"
within "#entry-group-" + transfer_date.to_s do within "#entry-group-" + transfer_date.to_s do
assert_text "Transfer from" assert_text "Payment to"
end end
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 end