mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Transfer and Payment auto-matching, model and UI improvements (#1585)
* Transfer data model migration * Transfers and payment modeling and UI improvements * Fix CI * Transfer matching flow * Better UI for transfers * Auto transfer matching, approve, reject flow * Mark transfers created from form as confirmed * Account filtering * Excluded rejected transfers from calculations * Calculation tweaks with transfer exclusions * Clean up migration
This commit is contained in:
parent
46e129308f
commit
307a3687e8
78 changed files with 1161 additions and 682 deletions
|
@ -121,7 +121,7 @@ GEM
|
||||||
bindex (0.8.1)
|
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)
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: [])
|
||||||
|
|
56
app/controllers/account/transfer_matches_controller.rb
Normal file
56
app/controllers/account/transfer_matches_controller.rb
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
class Account::TransferMatchesController < ApplicationController
|
||||||
|
before_action :set_entry
|
||||||
|
|
||||||
|
def new
|
||||||
|
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||||
|
@transfer_match_candidates = @entry.transfer_match_candidates
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@transfer = build_transfer
|
||||||
|
@transfer.save!
|
||||||
|
@transfer.sync_account_later
|
||||||
|
|
||||||
|
redirect_back_or_to transactions_path, notice: t(".success")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_entry
|
||||||
|
@entry = Current.family.entries.find(params[:transaction_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfer_match_params
|
||||||
|
params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_transfer
|
||||||
|
if transfer_match_params[:method] == "new"
|
||||||
|
target_account = Current.family.accounts.find(transfer_match_params[:target_account_id])
|
||||||
|
|
||||||
|
missing_transaction = Account::Transaction.new(
|
||||||
|
entry: target_account.entries.build(
|
||||||
|
amount: @entry.amount * -1,
|
||||||
|
currency: @entry.currency,
|
||||||
|
date: @entry.date,
|
||||||
|
name: "Transfer to #{@entry.amount.negative? ? @entry.account.name : target_account.name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer = Transfer.find_or_initialize_by(
|
||||||
|
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction,
|
||||||
|
outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction
|
||||||
|
)
|
||||||
|
transfer.status = "confirmed"
|
||||||
|
transfer
|
||||||
|
else
|
||||||
|
target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id])
|
||||||
|
|
||||||
|
transfer = Transfer.find_or_initialize_by(
|
||||||
|
inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction,
|
||||||
|
outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction
|
||||||
|
)
|
||||||
|
transfer.status = "confirmed"
|
||||||
|
transfer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,61 +0,0 @@
|
||||||
class Account::TransfersController < ApplicationController
|
|
||||||
layout :with_sidebar
|
|
||||||
|
|
||||||
before_action :set_transfer, only: %i[destroy show update]
|
|
||||||
|
|
||||||
def new
|
|
||||||
@transfer = Account::Transfer.new
|
|
||||||
end
|
|
||||||
|
|
||||||
def show
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
|
||||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
|
||||||
|
|
||||||
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
|
|
||||||
date: transfer_params[:date],
|
|
||||||
amount: transfer_params[:amount].to_d
|
|
||||||
|
|
||||||
if @transfer.save
|
|
||||||
@transfer.entries.each(&:sync_account_later)
|
|
||||||
redirect_to transactions_path, notice: t(".success")
|
|
||||||
else
|
|
||||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
|
||||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
|
||||||
flash[:alert] = @transfer.errors.full_messages.to_sentence
|
|
||||||
redirect_to transactions_path
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
@transfer.update_entries!(transfer_update_params)
|
|
||||||
redirect_back_or_to transactions_url, notice: t(".success")
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy
|
|
||||||
@transfer.destroy!
|
|
||||||
redirect_back_or_to transactions_url, notice: t(".success")
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_transfer
|
|
||||||
record = Account::Transfer.find(params[:id])
|
|
||||||
|
|
||||||
unless record.entries.all? { |entry| Current.family.accounts.include?(entry.account) }
|
|
||||||
raise ActiveRecord::RecordNotFound
|
|
||||||
end
|
|
||||||
|
|
||||||
@transfer = record
|
|
||||||
end
|
|
||||||
|
|
||||||
def transfer_params
|
|
||||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
|
||||||
end
|
|
||||||
|
|
||||||
def transfer_update_params
|
|
||||||
params.require(:account_transfer).permit(:excluded, :notes)
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -52,11 +52,14 @@ module EntryableResource
|
||||||
respond_to do |format|
|
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: [
|
||||||
"header_account_entry_#{@entry.id}",
|
turbo_stream.replace(
|
||||||
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
"header_account_entry_#{@entry.id}",
|
||||||
locals: { entry: @entry }
|
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
||||||
)
|
locals: { entry: @entry }
|
||||||
|
),
|
||||||
|
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
|
||||||
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
66
app/controllers/transfers_controller.rb
Normal file
66
app/controllers/transfers_controller.rb
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
class TransfersController < ApplicationController
|
||||||
|
layout :with_sidebar
|
||||||
|
|
||||||
|
before_action :set_transfer, only: %i[destroy show update]
|
||||||
|
|
||||||
|
def new
|
||||||
|
@transfer = Transfer.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||||
|
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||||
|
|
||||||
|
@transfer = Transfer.from_accounts(
|
||||||
|
from_account: from_account,
|
||||||
|
to_account: to_account,
|
||||||
|
date: transfer_params[:date],
|
||||||
|
amount: transfer_params[:amount].to_d
|
||||||
|
)
|
||||||
|
|
||||||
|
if @transfer.save
|
||||||
|
@transfer.sync_account_later
|
||||||
|
|
||||||
|
flash[:notice] = t(".success")
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_back_or_to transactions_path }
|
||||||
|
redirect_target_url = request.referer || transactions_path
|
||||||
|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@transfer.update!(transfer_update_params)
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_back_or_to transactions_url, notice: t(".success") }
|
||||||
|
format.turbo_stream
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@transfer.destroy!
|
||||||
|
redirect_back_or_to transactions_url, notice: t(".success")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_transfer
|
||||||
|
@transfer = Transfer.find(params[:id])
|
||||||
|
|
||||||
|
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfer_params
|
||||||
|
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfer_update_params
|
||||||
|
params.require(:transfer).permit(:notes, :status)
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,10 +3,6 @@ module Account::EntriesHelper
|
||||||
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
|
"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
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
module Account::TransfersHelper
|
|
||||||
end
|
|
|
@ -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 = {})
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = () => {
|
||||||
|
|
16
app/javascript/controllers/transfer_match_controller.js
Normal file
16
app/javascript/controllers/transfer_match_controller.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
// Connects to data-controller="transfer-match"
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["newSelect", "existingSelect"];
|
||||||
|
|
||||||
|
update(event) {
|
||||||
|
if (event.target.value === "new") {
|
||||||
|
this.newSelectTarget.classList.remove("hidden");
|
||||||
|
this.existingSelectTarget.classList.add("hidden");
|
||||||
|
} else {
|
||||||
|
this.newSelectTarget.classList.add("hidden");
|
||||||
|
this.existingSelectTarget.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,7 +30,15 @@ class Account::Entry < ApplicationRecord
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
# All entries that are not part of a pending/approved transfer (rejected transfers count as normal entries, so are included)
|
||||||
|
scope :incomes_and_expenses, -> {
|
||||||
|
joins(
|
||||||
|
'LEFT JOIN transfers AS inflow_transfers ON inflow_transfers.inflow_transaction_id = account_entries.entryable_id
|
||||||
|
LEFT JOIN transfers AS outflow_transfers ON outflow_transfers.outflow_transaction_id = account_entries.entryable_id'
|
||||||
|
)
|
||||||
|
.where("(inflow_transfers.id IS NULL AND outflow_transfers.id IS NULL) OR inflow_transfers.status = 'rejected' OR outflow_transfers.status = 'rejected'")
|
||||||
|
}
|
||||||
|
|
||||||
scope :with_converted_amount, ->(currency) {
|
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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,113 +0,0 @@
|
||||||
class Account::Transfer < ApplicationRecord
|
|
||||||
has_many :entries, dependent: :destroy
|
|
||||||
|
|
||||||
validate :net_zero_flows, if: :single_currency_transfer?
|
|
||||||
validate :transaction_count, :from_different_accounts, :all_transactions_marked
|
|
||||||
|
|
||||||
def date
|
|
||||||
outflow_transaction&.date
|
|
||||||
end
|
|
||||||
|
|
||||||
def amount_money
|
|
||||||
entries.first&.amount_money&.abs || Money.new(0)
|
|
||||||
end
|
|
||||||
|
|
||||||
def from_name
|
|
||||||
from_account&.name || I18n.t("account/transfer.from_fallback_name")
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_name
|
|
||||||
to_account&.name || I18n.t("account/transfer.to_fallback_name")
|
|
||||||
end
|
|
||||||
|
|
||||||
def name
|
|
||||||
I18n.t("account/transfer.name", from_account: from_name, to_account: to_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
def from_account
|
|
||||||
outflow_transaction&.account
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_account
|
|
||||||
inflow_transaction&.account
|
|
||||||
end
|
|
||||||
|
|
||||||
def inflow_transaction
|
|
||||||
entries.find { |e| e.amount.negative? }
|
|
||||||
end
|
|
||||||
|
|
||||||
def outflow_transaction
|
|
||||||
entries.find { |e| e.amount.positive? }
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_entries!(params)
|
|
||||||
transaction do
|
|
||||||
entries.each do |entry|
|
|
||||||
entry.update!(params)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sync_account_later
|
|
||||||
entries.each(&:sync_account_later)
|
|
||||||
end
|
|
||||||
|
|
||||||
class << self
|
|
||||||
def build_from_accounts(from_account, to_account, date:, amount:)
|
|
||||||
outflow = from_account.entries.build \
|
|
||||||
amount: amount.abs,
|
|
||||||
currency: from_account.currency,
|
|
||||||
date: date,
|
|
||||||
name: "Transfer to #{to_account.name}",
|
|
||||||
marked_as_transfer: true,
|
|
||||||
entryable: Account::Transaction.new
|
|
||||||
|
|
||||||
# Attempt to convert the amount to the to_account's currency. If the conversion fails,
|
|
||||||
# use the original amount.
|
|
||||||
converted_amount = begin
|
|
||||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
|
||||||
rescue Money::ConversionError
|
|
||||||
Money.new(amount.abs, from_account.currency)
|
|
||||||
end
|
|
||||||
|
|
||||||
inflow = to_account.entries.build \
|
|
||||||
amount: converted_amount.amount * -1,
|
|
||||||
currency: converted_amount.currency.iso_code,
|
|
||||||
date: date,
|
|
||||||
name: "Transfer from #{from_account.name}",
|
|
||||||
marked_as_transfer: true,
|
|
||||||
entryable: Account::Transaction.new
|
|
||||||
|
|
||||||
new entries: [ outflow, inflow ]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def single_currency_transfer?
|
|
||||||
entries.map { |e| e.currency }.uniq.size == 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def transaction_count
|
|
||||||
unless entries.size == 2
|
|
||||||
errors.add :entries, :must_have_exactly_2_entries
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def from_different_accounts
|
|
||||||
accounts = entries.map { |e| e.account_id }.uniq
|
|
||||||
errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size
|
|
||||||
end
|
|
||||||
|
|
||||||
def net_zero_flows
|
|
||||||
unless entries.sum(&:amount).zero?
|
|
||||||
errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def all_transactions_marked
|
|
||||||
unless entries.all?(&:marked_as_transfer)
|
|
||||||
errors.add :entries, :must_be_marked_as_transfer
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -17,6 +17,9 @@ class Category < ApplicationRecord
|
||||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 = []
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
139
app/models/transfer.rb
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
class Transfer < ApplicationRecord
|
||||||
|
belongs_to :inflow_transaction, class_name: "Account::Transaction"
|
||||||
|
belongs_to :outflow_transaction, class_name: "Account::Transaction"
|
||||||
|
|
||||||
|
enum :status, { pending: "pending", confirmed: "confirmed", rejected: "rejected" }
|
||||||
|
|
||||||
|
validate :transfer_has_different_accounts
|
||||||
|
validate :transfer_has_opposite_amounts
|
||||||
|
validate :transfer_within_date_range
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def from_accounts(from_account:, to_account:, date:, amount:)
|
||||||
|
# Attempt to convert the amount to the to_account's currency.
|
||||||
|
# If the conversion fails, use the original amount.
|
||||||
|
converted_amount = begin
|
||||||
|
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
||||||
|
rescue Money::ConversionError
|
||||||
|
Money.new(amount.abs, from_account.currency)
|
||||||
|
end
|
||||||
|
|
||||||
|
new(
|
||||||
|
inflow_transaction: Account::Transaction.new(
|
||||||
|
entry: to_account.entries.build(
|
||||||
|
amount: converted_amount.amount.abs * -1,
|
||||||
|
currency: converted_amount.currency.iso_code,
|
||||||
|
date: date,
|
||||||
|
name: "Transfer from #{from_account.name}",
|
||||||
|
entryable: Account::Transaction.new
|
||||||
|
)
|
||||||
|
),
|
||||||
|
outflow_transaction: Account::Transaction.new(
|
||||||
|
entry: from_account.entries.build(
|
||||||
|
amount: amount.abs,
|
||||||
|
currency: from_account.currency,
|
||||||
|
date: date,
|
||||||
|
name: "Transfer to #{to_account.name}",
|
||||||
|
entryable: Account::Transaction.new
|
||||||
|
)
|
||||||
|
),
|
||||||
|
status: "confirmed"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def auto_match_for_account(account)
|
||||||
|
matches = account.entries.account_transactions.joins("
|
||||||
|
JOIN account_entries ae2 ON
|
||||||
|
account_entries.amount = -ae2.amount AND
|
||||||
|
account_entries.currency = ae2.currency AND
|
||||||
|
account_entries.account_id <> ae2.account_id AND
|
||||||
|
ABS(account_entries.date - ae2.date) <= 4
|
||||||
|
").select(
|
||||||
|
"account_entries.id",
|
||||||
|
"account_entries.entryable_id AS e1_entryable_id",
|
||||||
|
"ae2.entryable_id AS e2_entryable_id",
|
||||||
|
"account_entries.amount AS e1_amount",
|
||||||
|
"ae2.amount AS e2_amount"
|
||||||
|
)
|
||||||
|
|
||||||
|
Transfer.transaction do
|
||||||
|
matches.each do |match|
|
||||||
|
inflow = match.e1_amount.negative? ? match.e1_entryable_id : match.e2_entryable_id
|
||||||
|
outflow = match.e1_amount.negative? ? match.e2_entryable_id : match.e1_entryable_id
|
||||||
|
|
||||||
|
# Skip all rejected, or already matched transfers
|
||||||
|
next if Transfer.exists?(
|
||||||
|
inflow_transaction_id: inflow,
|
||||||
|
outflow_transaction_id: outflow
|
||||||
|
)
|
||||||
|
|
||||||
|
Transfer.create!(
|
||||||
|
inflow_transaction_id: inflow,
|
||||||
|
outflow_transaction_id: outflow
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_account_later
|
||||||
|
inflow_transaction.entry.sync_account_later
|
||||||
|
outflow_transaction.entry.sync_account_later
|
||||||
|
end
|
||||||
|
|
||||||
|
def belongs_to_family?(family)
|
||||||
|
family.transactions.include?(inflow_transaction)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_account
|
||||||
|
inflow_transaction.entry.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def from_account
|
||||||
|
outflow_transaction.entry.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def amount_abs
|
||||||
|
inflow_transaction.entry.amount_money.abs
|
||||||
|
end
|
||||||
|
|
||||||
|
def name
|
||||||
|
if payment?
|
||||||
|
I18n.t("transfer.payment_name", to_account: to_account.name)
|
||||||
|
else
|
||||||
|
I18n.t("transfer.name", to_account: to_account.name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def payment?
|
||||||
|
to_account.liability?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def transfer_has_different_accounts
|
||||||
|
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||||
|
errors.add(:base, :must_be_from_different_accounts) if inflow_transaction.entry.account == outflow_transaction.entry.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfer_has_opposite_amounts
|
||||||
|
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||||
|
|
||||||
|
inflow_amount = inflow_transaction.entry.amount
|
||||||
|
outflow_amount = outflow_transaction.entry.amount
|
||||||
|
|
||||||
|
if inflow_transaction.entry.currency == outflow_transaction.entry.currency
|
||||||
|
# For same currency, amounts must be exactly opposite
|
||||||
|
errors.add(:base, :must_have_opposite_amounts) if inflow_amount + outflow_amount != 0
|
||||||
|
else
|
||||||
|
# For different currencies, just check the signs are opposite
|
||||||
|
errors.add(:base, :must_have_opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfer_within_date_range
|
||||||
|
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||||
|
|
||||||
|
date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs
|
||||||
|
errors.add(:base, :must_be_within_date_range) if date_diff > 4
|
||||||
|
end
|
||||||
|
end
|
|
@ -84,7 +84,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 %>
|
||||||
|
|
|
@ -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",
|
||||||
}} %>
|
}} %>
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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 %>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,49 +19,44 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="truncate">
|
<div class="truncate">
|
||||||
<% if entry.new_record? %>
|
<div class="space-y-0.5">
|
||||||
<%= content_tag :p, entry.display_name %>
|
<div class="flex items-center gap-1">
|
||||||
<% else %>
|
<% if entry.new_record? %>
|
||||||
<%= link_to entry.display_name,
|
<%= content_tag :p, entry.display_name %>
|
||||||
entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry),
|
<% else %>
|
||||||
|
<%= link_to entry.display_name,
|
||||||
|
entry.account_transaction.transfer? ? transfer_path(entry.account_transaction.transfer) : account_entry_path(entry),
|
||||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||||
class: "hover:underline hover:text-gray-800" %>
|
class: "hover:underline hover:text-gray-800" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if entry.excluded %>
|
||||||
|
<span title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
|
||||||
|
<%= lucide_icon "asterisk", class: "w-4 h-4 shrink-0 text-orange-500" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if entry.account_transaction.transfer? %>
|
||||||
|
<%= render "account/transactions/transfer_match", entry: entry %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-gray-500 text-xs font-normal">
|
||||||
|
<% if entry.account_transaction.transfer? %>
|
||||||
|
<%= render "transfers/account_links", transfer: entry.account_transaction.transfer, is_inflow: entry.account_transaction.transfer_as_inflow.present? %>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to entry.account.name, account_path(entry.account, tab: "transactions"), data: { turbo_frame: "_top" }, class: "hover:underline" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if unconfirmed_transfer?(entry) %>
|
|
||||||
<%= render "account/transfers/transfer_toggle", entry: entry %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if entry.transfer.present? %>
|
<div class="flex items-center gap-1 col-span-2">
|
||||||
<% unless balance_trend %>
|
<%= render "account/transactions/transaction_category", entry: entry %>
|
||||||
<div class="col-span-2"></div>
|
</div>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<%= render "account/transfers/account_logos", transfer: entry.transfer, outflow: entry.amount.positive? %>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="flex items-center gap-1 col-span-2">
|
|
||||||
<%= render "categories/menu", transaction: transaction %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% unless balance_trend %>
|
|
||||||
<%= tag.div class: "col-span-2 overflow-hidden truncate" do %>
|
|
||||||
<% if entry.new_record? %>
|
|
||||||
<%= tag.p account.name %>
|
|
||||||
<% else %>
|
|
||||||
<%= link_to account.name,
|
|
||||||
account_path(account, tab: "transactions"),
|
|
||||||
data: { turbo_frame: "_top" },
|
|
||||||
class: "hover:underline" %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="col-span-2 ml-auto">
|
<div class="col-span-2 ml-auto">
|
||||||
<%= content_tag :p,
|
<%= content_tag :p,
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
<%# locals: (entry:) %>
|
||||||
|
|
||||||
|
<div id="<%= dom_id(entry, "category_menu") %>">
|
||||||
|
<% if entry.account_transaction.transfer? %>
|
||||||
|
<%= render "categories/badge", category: entry.account_transaction.transfer.payment? ? payment_category : transfer_category %>
|
||||||
|
<% else %>
|
||||||
|
<%= render "categories/menu", transaction: entry.account_transaction %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
27
app/views/account/transactions/_transfer_match.html.erb
Normal file
27
app/views/account/transactions/_transfer_match.html.erb
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<%# locals: (entry:) %>
|
||||||
|
|
||||||
|
<div id="<%= dom_id(entry, "transfer_match") %>" class="flex items-center gap-1">
|
||||||
|
<% if entry.account_transaction.transfer.confirmed? %>
|
||||||
|
<span title="<%= entry.account_transaction.transfer.payment? ? "Payment" : "Transfer" %> is confirmed">
|
||||||
|
<%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %>
|
||||||
|
</span>
|
||||||
|
<% elsif entry.account_transaction.transfer.pending? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
||||||
|
Auto-matched
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "confirmed" }),
|
||||||
|
method: :patch,
|
||||||
|
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
|
||||||
|
title: "Confirm match" do %>
|
||||||
|
<%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "rejected" }),
|
||||||
|
method: :patch,
|
||||||
|
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
|
||||||
|
title: "Reject match" do %>
|
||||||
|
<%= lucide_icon "x", class: "w-4 h-4 text-gray-400 hover:text-gray-600" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
|
@ -19,7 +19,7 @@
|
||||||
max: Date.current,
|
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">
|
||||||
|
|
44
app/views/account/transfer_matches/_matching_fields.html.erb
Normal file
44
app/views/account/transfer_matches/_matching_fields.html.erb
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<%# locals: (form:, entry:, candidates:, accounts:) %>
|
||||||
|
|
||||||
|
<% if candidates.any? %>
|
||||||
|
<div data-controller="transfer-match" class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Select a method for matching your transactions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= form.select :method,
|
||||||
|
[
|
||||||
|
["Match existing transaction (recommended)", "existing"],
|
||||||
|
["Create new transaction", "new"]
|
||||||
|
],
|
||||||
|
{ selected: "existing", label: "Matching method" },
|
||||||
|
data: { action: "change->transfer-match#update" } %>
|
||||||
|
|
||||||
|
<div data-transfer-match-target="existingSelect">
|
||||||
|
<%= form.select :matched_entry_id,
|
||||||
|
candidates.map { |entry|
|
||||||
|
[entry_name_detailed(entry), entry.id]
|
||||||
|
},
|
||||||
|
{ label: "Matching transaction" } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-transfer-match-target="newSelect" class="hidden">
|
||||||
|
<%= form.select :target_account_id,
|
||||||
|
accounts.map { |account| [account.name, account.id] },
|
||||||
|
{ label: "Target account" } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
We couldn't find any transactions to match from your other accounts.
|
||||||
|
Please select an account and we will create a new inflow transaction for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= form.hidden_field :method, value: "new" %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.select :target_account_id,
|
||||||
|
accounts.map { |account| [account.name, account.id] },
|
||||||
|
{ label: "Target account" } %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
60
app/views/account/transfer_matches/new.html.erb
Normal file
60
app/views/account/transfer_matches/new.html.erb
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<%= modal_form_wrapper title: "Match transfer or payment" do %>
|
||||||
|
<%= styled_form_with(
|
||||||
|
url: account_transaction_transfer_match_path(@entry),
|
||||||
|
scope: :transfer_match,
|
||||||
|
class: "space-y-8",
|
||||||
|
data: { turbo_frame: :_top }
|
||||||
|
) do |f| %>
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h2 class="text-sm font-medium text-gray-700">
|
||||||
|
<%= @entry.amount.positive? ? "From account: #{@entry.account.name}" : "From account" %>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<% if @entry.amount.positive? %>
|
||||||
|
<%= f.select(
|
||||||
|
:entry_id,
|
||||||
|
[[entry_name_detailed(@entry), @entry.id]],
|
||||||
|
{
|
||||||
|
label: "Outflow transaction",
|
||||||
|
selected: @entry.id,
|
||||||
|
},
|
||||||
|
disabled: true
|
||||||
|
) %>
|
||||||
|
<% else %>
|
||||||
|
<%= render "account/transfer_matches/matching_fields",
|
||||||
|
form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex justify-center py-2">
|
||||||
|
<%= lucide_icon "arrow-down", class: "w-5 h-5" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h2 class="text-sm font-medium text-gray-700">
|
||||||
|
<%= @entry.amount.negative? ? "To account: #{@entry.account.name}" : "To account" %>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<% if @entry.amount.negative? %>
|
||||||
|
<%= f.select(
|
||||||
|
:entry_id,
|
||||||
|
[[entry_name_detailed(@entry), @entry.id]],
|
||||||
|
{
|
||||||
|
label: "Inflow transaction",
|
||||||
|
selected: @entry.id,
|
||||||
|
},
|
||||||
|
disabled: true
|
||||||
|
) %>
|
||||||
|
<% else %>
|
||||||
|
<%= render "account/transfer_matches/matching_fields",
|
||||||
|
form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<%= f.submit "Create transfer match", data: { turbo_submits_with: "Saving..."} %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
|
@ -1,25 +0,0 @@
|
||||||
<%# locals: (transfer:, outflow: false) %>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<% if outflow %>
|
|
||||||
<%= link_to transfer.from_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
|
||||||
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= lucide_icon "arrow-right", class: "text-gray-500 w-4 h-4" %>
|
|
||||||
|
|
||||||
<%= link_to transfer.to_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
|
||||||
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
|
|
||||||
<% end %>
|
|
||||||
<% else %>
|
|
||||||
<%= link_to transfer.to_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
|
||||||
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= lucide_icon "arrow-left", class: "text-gray-500 w-4 h-4" %>
|
|
||||||
|
|
||||||
<%= link_to transfer.from_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
|
|
||||||
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
|
@ -1,16 +0,0 @@
|
||||||
<%# locals: (entry:) %>
|
|
||||||
|
|
||||||
<%= form_with url: unmark_transfers_account_transactions_path, class: "flex items-center", data: {
|
|
||||||
turbo_confirm: {
|
|
||||||
title: t(".remove_transfer"),
|
|
||||||
body: t(".remove_transfer_body"),
|
|
||||||
accept: t(".remove_transfer_confirm"),
|
|
||||||
},
|
|
||||||
turbo_frame: "_top"
|
|
||||||
} do |f| %>
|
|
||||||
<%= f.hidden_field "bulk_update[entry_ids][]", value: entry.id %>
|
|
||||||
<%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %>
|
|
||||||
<%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %>
|
|
||||||
<%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
|
@ -12,7 +12,7 @@
|
||||||
<% end %>
|
<% 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 %>
|
||||||
|
|
||||||
|
|
|
@ -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 %>
|
||||||
|
|
|
@ -9,4 +9,5 @@
|
||||||
color: <%= category.color %>;">
|
color: <%= category.color %>;">
|
||||||
<%= category.name %>
|
<%= category.name %>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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 %>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
7
app/views/transfers/_account_links.html.erb
Normal file
7
app/views/transfers/_account_links.html.erb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<%# locals: (transfer:, is_inflow: false) %>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<% first_account, second_account = is_inflow ? [transfer.to_account, transfer.from_account] : [transfer.from_account, transfer.to_account] %>
|
||||||
|
<%= link_to first_account.name, account_path(first_account, tab: "activity"), class: "hover:underline", data: { turbo_frame: "_top" } %>
|
||||||
|
<%= lucide_icon is_inflow ? "arrow-left" : "arrow-right", class: "w-4 h-4 shrink-0" %>
|
||||||
|
<%= link_to second_account.name, account_path(second_account, tab: "activity"), class: "hover:underline", data: { turbo_frame: "_top" } %>
|
||||||
|
</div>
|
|
@ -29,7 +29,7 @@
|
||||||
<%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
|
<%= f.collection_select :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>
|
77
app/views/transfers/_transfer.html.erb
Normal file
77
app/views/transfers/_transfer.html.erb
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
<%# locals: (transfer:) %>
|
||||||
|
|
||||||
|
<%= turbo_frame_tag dom_id(transfer) do %>
|
||||||
|
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||||
|
<div class="pr-10 flex items-center gap-4 col-span-8">
|
||||||
|
<%= check_box_tag dom_id(transfer),
|
||||||
|
disabled: true,
|
||||||
|
class: "maybe-checkbox maybe-checkbox--light" %>
|
||||||
|
|
||||||
|
<div class="max-w-full">
|
||||||
|
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
|
||||||
|
<%= render "shared/circle_logo", name: transfer.name, size: "sm" %>
|
||||||
|
|
||||||
|
<div class="truncate">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= link_to transfer.name,
|
||||||
|
transfer_path(transfer),
|
||||||
|
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||||
|
class: "hover:underline hover:text-gray-800" %>
|
||||||
|
|
||||||
|
<% if transfer.status == "confirmed" %>
|
||||||
|
<span title="<%= transfer.payment? ? "Payment" : "Transfer" %> is confirmed">
|
||||||
|
<%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %>
|
||||||
|
</span>
|
||||||
|
<% elsif transfer.status == "rejected" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
|
||||||
|
Rejected
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
||||||
|
Auto-matched
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<%= button_to transfer_path(transfer, transfer: { status: "confirmed" }),
|
||||||
|
method: :patch,
|
||||||
|
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
|
||||||
|
title: "Confirm match" do %>
|
||||||
|
<%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= button_to transfer_path(transfer, transfer: { status: "rejected" }),
|
||||||
|
method: :patch,
|
||||||
|
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
|
||||||
|
title: "Reject match" do %>
|
||||||
|
<%= lucide_icon "x", class: "w-4 h-4 text-gray-400 hover:text-gray-600" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-gray-500 text-xs font-normal">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<%= link_to transfer.from_account.name, transfer.from_account, class: "hover:underline", data: { turbo_frame: "_top" } %>
|
||||||
|
<%= lucide_icon "arrow-left-right", class: "w-4 h-4" %>
|
||||||
|
<%= link_to transfer.to_account.name, transfer.to_account, class: "hover:underline", data: { turbo_frame: "_top" } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 col-span-2">
|
||||||
|
<%= render "categories/badge", category: transfer.payment? ? payment_category : transfer_category %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-2 ml-auto">
|
||||||
|
<p class="flex items-center gap-1">
|
||||||
|
<span>
|
||||||
|
+/- <%= format_money(transfer.amount_abs) %>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
|
@ -3,11 +3,11 @@
|
||||||
<div class="flex items-center gap-4">
|
<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>
|
17
app/views/transfers/update.turbo_stream.erb
Normal file
17
app/views/transfers/update.turbo_stream.erb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<%= turbo_stream.replace @transfer %>
|
||||||
|
|
||||||
|
<%= turbo_stream.replace "category_menu_account_entry_#{@transfer.inflow_transaction.entry.id}",
|
||||||
|
partial: "account/transactions/transaction_category",
|
||||||
|
locals: { entry: @transfer.inflow_transaction.entry } %>
|
||||||
|
|
||||||
|
<%= turbo_stream.replace "category_menu_account_entry_#{@transfer.outflow_transaction.entry.id}",
|
||||||
|
partial: "account/transactions/transaction_category",
|
||||||
|
locals: { entry: @transfer.outflow_transaction.entry } %>
|
||||||
|
|
||||||
|
<%= turbo_stream.replace "transfer_match_account_entry_#{@transfer.inflow_transaction.entry.id}",
|
||||||
|
partial: "account/transactions/transfer_match",
|
||||||
|
locals: { entry: @transfer.inflow_transaction.entry } %>
|
||||||
|
|
||||||
|
<%= turbo_stream.replace "transfer_match_account_entry_#{@transfer.outflow_transaction.entry.id}",
|
||||||
|
partial: "account/transactions/transfer_match",
|
||||||
|
locals: { entry: @transfer.outflow_transaction.entry } %>
|
|
@ -103,6 +103,30 @@
|
||||||
],
|
],
|
||||||
"note": ""
|
"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"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
en:
|
|
||||||
account/transfer:
|
|
||||||
from_fallback_name: Originator
|
|
||||||
name: Transfer from %{from_account} to %{to_account}
|
|
||||||
to_fallback_name: Receiver
|
|
||||||
activerecord:
|
|
||||||
errors:
|
|
||||||
models:
|
|
||||||
account/transfer:
|
|
||||||
attributes:
|
|
||||||
entries:
|
|
||||||
must_be_from_different_accounts: must be from different accounts
|
|
||||||
must_be_marked_as_transfer: must be marked as transfer
|
|
||||||
must_have_an_inflow_and_outflow_that_net_to_zero: must have an inflow
|
|
||||||
and outflow that net to zero
|
|
||||||
must_have_exactly_2_entries: must have exactly 2 entries
|
|
17
config/locales/models/transfer/en.yml
Normal file
17
config/locales/models/transfer/en.yml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
en:
|
||||||
|
activerecord:
|
||||||
|
errors:
|
||||||
|
models:
|
||||||
|
transfer:
|
||||||
|
attributes:
|
||||||
|
base:
|
||||||
|
must_be_from_different_accounts: Transfer must have different accounts
|
||||||
|
must_be_within_date_range: Transfer transaction dates must be within
|
||||||
|
4 days of each other
|
||||||
|
must_have_opposite_amounts: Transfer transactions must have opposite
|
||||||
|
amounts
|
||||||
|
must_have_single_currency: Transfer must have a single currency
|
||||||
|
transfer:
|
||||||
|
name: Transfer to %{to_account}
|
||||||
|
payment_name: Payment to %{to_account}
|
|
@ -36,15 +36,8 @@ en:
|
||||||
no_transactions: No transactions for this account yet.
|
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
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
---
|
|
||||||
en:
|
|
||||||
account:
|
|
||||||
transfers:
|
|
||||||
create:
|
|
||||||
success: Transfer created
|
|
||||||
destroy:
|
|
||||||
success: Transfer removed
|
|
||||||
form:
|
|
||||||
amount: Amount
|
|
||||||
date: Date
|
|
||||||
expense: Expense
|
|
||||||
from: From
|
|
||||||
income: Income
|
|
||||||
select_account: Select account
|
|
||||||
submit: Create transfer
|
|
||||||
to: To
|
|
||||||
transfer: Transfer
|
|
||||||
new:
|
|
||||||
title: New transfer
|
|
||||||
show:
|
|
||||||
delete: Delete
|
|
||||||
delete_subtitle: This permanently deletes both of the transactions related
|
|
||||||
to the transfer. This cannot be undone.
|
|
||||||
delete_title: Delete transfer?
|
|
||||||
details: Details
|
|
||||||
exclude_subtitle: This excludes the transfer from any in-app features or analytics.
|
|
||||||
exclude_title: Exclude transfer
|
|
||||||
note_label: Notes
|
|
||||||
note_placeholder: Add a note to this transfer
|
|
||||||
overview: Overview
|
|
||||||
settings: Settings
|
|
||||||
transfer_toggle:
|
|
||||||
remove_transfer: Remove transfer
|
|
||||||
remove_transfer_body: This will remove the transfer from this transaction
|
|
||||||
remove_transfer_confirm: Confirm
|
|
||||||
update:
|
|
||||||
success: Transfer updated
|
|
|
@ -1,11 +1,8 @@
|
||||||
---
|
---
|
||||||
en:
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
31
config/locales/views/transfers/en.yml
Normal file
31
config/locales/views/transfers/en.yml
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
en:
|
||||||
|
transfers:
|
||||||
|
create:
|
||||||
|
success: Transfer created
|
||||||
|
destroy:
|
||||||
|
success: Transfer removed
|
||||||
|
form:
|
||||||
|
amount: Amount
|
||||||
|
date: Date
|
||||||
|
expense: Expense
|
||||||
|
from: From
|
||||||
|
income: Income
|
||||||
|
select_account: Select account
|
||||||
|
submit: Create transfer
|
||||||
|
to: To
|
||||||
|
transfer: Transfer
|
||||||
|
new:
|
||||||
|
title: New transfer
|
||||||
|
show:
|
||||||
|
delete: Remove transfer
|
||||||
|
delete_subtitle: This removes the transfer. It will not delete the underlying
|
||||||
|
transactions.
|
||||||
|
delete_title: Remove transfer?
|
||||||
|
details: Details
|
||||||
|
note_label: Notes
|
||||||
|
note_placeholder: Add a note to this transfer
|
||||||
|
overview: Overview
|
||||||
|
settings: Settings
|
||||||
|
update:
|
||||||
|
success: Transfer updated
|
|
@ -46,9 +46,7 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
resources :merchants, only: %i[index new create edit update destroy]
|
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
|
||||||
|
|
75
db/migrate/20241231140709_reverse_transfer_relations.rb
Normal file
75
db/migrate/20241231140709_reverse_transfer_relations.rb
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
class ReverseTransferRelations < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :transfers, id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.references :inflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid
|
||||||
|
t.references :outflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid
|
||||||
|
t.string :status, null: false, default: "pending"
|
||||||
|
t.text :notes
|
||||||
|
|
||||||
|
t.index [ :inflow_transaction_id, :outflow_transaction_id ], unique: true
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
execute <<~SQL
|
||||||
|
INSERT INTO transfers (inflow_transaction_id, outflow_transaction_id, status, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
CASE WHEN e1.amount <= 0 THEN e1.entryable_id ELSE e2.entryable_id END as inflow_transaction_id,
|
||||||
|
CASE WHEN e1.amount <= 0 THEN e2.entryable_id ELSE e1.entryable_id END as outflow_transaction_id,
|
||||||
|
'confirmed' as status,
|
||||||
|
e1.created_at,
|
||||||
|
e1.updated_at
|
||||||
|
FROM account_entries e1
|
||||||
|
JOIN account_entries e2 ON
|
||||||
|
e1.transfer_id = e2.transfer_id AND
|
||||||
|
e1.id != e2.id AND
|
||||||
|
e1.id < e2.id -- Ensures we don't duplicate transfers from both sides
|
||||||
|
JOIN accounts a1 ON e1.account_id = a1.id
|
||||||
|
JOIN accounts a2 ON e2.account_id = a2.id
|
||||||
|
WHERE
|
||||||
|
e1.entryable_type = 'Account::Transaction' AND
|
||||||
|
e2.entryable_type = 'Account::Transaction' AND
|
||||||
|
e1.transfer_id IS NOT NULL AND
|
||||||
|
a1.family_id = a2.family_id;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
dir.down do
|
||||||
|
execute <<~SQL
|
||||||
|
WITH new_transfers AS (
|
||||||
|
INSERT INTO account_transfers (created_at, updated_at)
|
||||||
|
SELECT created_at, updated_at
|
||||||
|
FROM transfers
|
||||||
|
RETURNING id, created_at
|
||||||
|
),
|
||||||
|
transfer_pairs AS (
|
||||||
|
SELECT
|
||||||
|
nt.id as transfer_id,
|
||||||
|
ae_in.id as inflow_entry_id,
|
||||||
|
ae_out.id as outflow_entry_id
|
||||||
|
FROM transfers t
|
||||||
|
JOIN new_transfers nt ON nt.created_at = t.created_at
|
||||||
|
JOIN account_entries ae_in ON ae_in.entryable_id = t.inflow_transaction_id
|
||||||
|
JOIN account_entries ae_out ON ae_out.entryable_id = t.outflow_transaction_id
|
||||||
|
WHERE
|
||||||
|
ae_in.entryable_type = 'Account::Transaction' AND
|
||||||
|
ae_out.entryable_type = 'Account::Transaction'
|
||||||
|
)
|
||||||
|
UPDATE account_entries ae
|
||||||
|
SET transfer_id = tp.transfer_id
|
||||||
|
FROM transfer_pairs tp
|
||||||
|
WHERE ae.id IN (tp.inflow_entry_id, tp.outflow_entry_id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
remove_foreign_key :account_entries, :account_transfers, column: :transfer_id
|
||||||
|
remove_column :account_entries, :transfer_id, :uuid
|
||||||
|
remove_column :account_entries, :marked_as_transfer, :boolean
|
||||||
|
|
||||||
|
drop_table :account_transfers, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
25
db/schema.rb
generated
25
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
42
test/controllers/account/transfer_matches_controller_test.rb
Normal file
42
test/controllers/account/transfer_matches_controller_test.rb
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Account::TransferMatchesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
include Account::EntriesTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "matches existing transaction and creates transfer" do
|
||||||
|
inflow_transaction = create_transaction(amount: 100, account: accounts(:depository))
|
||||||
|
outflow_transaction = create_transaction(amount: -100, account: accounts(:investment))
|
||||||
|
|
||||||
|
assert_difference "Transfer.count", 1 do
|
||||||
|
post account_transaction_transfer_match_path(inflow_transaction), params: {
|
||||||
|
transfer_match: {
|
||||||
|
method: "existing",
|
||||||
|
matched_entry_id: outflow_transaction.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to transactions_url
|
||||||
|
assert_equal "Transfer created", flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates transfer for target account" do
|
||||||
|
inflow_transaction = create_transaction(amount: 100, account: accounts(:depository))
|
||||||
|
|
||||||
|
assert_difference [ "Transfer.count", "Account::Entry.count", "Account::Transaction.count" ], 1 do
|
||||||
|
post account_transaction_transfer_match_path(inflow_transaction), params: {
|
||||||
|
transfer_match: {
|
||||||
|
method: "new",
|
||||||
|
target_account_id: accounts(:investment).id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to transactions_url
|
||||||
|
assert_equal "Transfer created", flash[:notice]
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,19 +1,19 @@
|
||||||
require "test_helper"
|
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
|
4
test/fixtures/account/entries.yml
vendored
4
test/fixtures/account/entries.yml
vendored
|
@ -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
|
||||||
|
|
1
test/fixtures/account/transfers.yml
vendored
1
test/fixtures/account/transfers.yml
vendored
|
@ -1 +0,0 @@
|
||||||
one: { }
|
|
3
test/fixtures/transfers.yml
vendored
Normal file
3
test/fixtures/transfers.yml
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
one:
|
||||||
|
inflow_transaction: transfer_in
|
||||||
|
outflow_transaction: transfer_out
|
|
@ -1,69 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class Account::TransferTest < ActiveSupport::TestCase
|
|
||||||
setup do
|
|
||||||
@outflow = account_entries(:transfer_out)
|
|
||||||
@inflow = account_entries(:transfer_in)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "transfer valid if it has inflow and outflow from different accounts for the same amount" do
|
|
||||||
transfer = Account::Transfer.create! entries: [ @inflow, @outflow ]
|
|
||||||
|
|
||||||
assert transfer.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "transfer must have 2 transactions" do
|
|
||||||
invalid_transfer_1 = Account::Transfer.new entries: [ @outflow ]
|
|
||||||
invalid_transfer_2 = Account::Transfer.new entries: [ @inflow, @outflow, account_entries(:transaction) ]
|
|
||||||
|
|
||||||
assert invalid_transfer_1.invalid?
|
|
||||||
assert invalid_transfer_2.invalid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "transfer cannot have 2 transactions from the same account" do
|
|
||||||
account = accounts(:depository)
|
|
||||||
|
|
||||||
inflow = account.entries.create! \
|
|
||||||
date: Date.current,
|
|
||||||
name: "Inflow",
|
|
||||||
amount: -100,
|
|
||||||
currency: "USD",
|
|
||||||
marked_as_transfer: true,
|
|
||||||
entryable: Account::Transaction.new
|
|
||||||
|
|
||||||
outflow = account.entries.create! \
|
|
||||||
date: Date.current,
|
|
||||||
name: "Outflow",
|
|
||||||
amount: 100,
|
|
||||||
currency: "USD",
|
|
||||||
marked_as_transfer: true,
|
|
||||||
entryable: Account::Transaction.new
|
|
||||||
|
|
||||||
assert_raise ActiveRecord::RecordInvalid do
|
|
||||||
Account::Transfer.create! entries: [ inflow, outflow ]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "all transfer transactions must be marked as transfers" do
|
|
||||||
@inflow.update! marked_as_transfer: false
|
|
||||||
|
|
||||||
assert_raise ActiveRecord::RecordInvalid do
|
|
||||||
Account::Transfer.create! entries: [ @inflow, @outflow ]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "single-currency transfer transactions must net to zero" do
|
|
||||||
@outflow.update! amount: 105
|
|
||||||
|
|
||||||
assert_raises ActiveRecord::RecordInvalid do
|
|
||||||
Account::Transfer.create! entries: [ @inflow, @outflow ]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "multi-currency transfer transactions do not have to net to zero" do
|
|
||||||
@outflow.update! amount: 105, currency: "EUR"
|
|
||||||
transfer = Account::Transfer.create! entries: [ @inflow, @outflow ]
|
|
||||||
|
|
||||||
assert transfer.valid?
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -120,11 +120,9 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "calculates rolling transaction totals" do
|
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
|
||||||
|
|
||||||
|
|
100
test/models/transfer_test.rb
Normal file
100
test/models/transfer_test.rb
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TransferTest < ActiveSupport::TestCase
|
||||||
|
include Account::EntriesTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@outflow = account_transactions(:transfer_out)
|
||||||
|
@inflow = account_transactions(:transfer_in)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transfer has different accounts, opposing amounts, and within 4 days of each other" do
|
||||||
|
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
|
||||||
|
inflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:credit_card), amount: -500)
|
||||||
|
|
||||||
|
assert_difference -> { Transfer.count } => 1 do
|
||||||
|
Transfer.create!(
|
||||||
|
inflow_transaction: inflow_entry.account_transaction,
|
||||||
|
outflow_transaction: outflow_entry.account_transaction,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transfer cannot have 2 transactions from the same account" do
|
||||||
|
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
|
||||||
|
inflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:depository), amount: -500)
|
||||||
|
|
||||||
|
transfer = Transfer.new(
|
||||||
|
inflow_transaction: inflow_entry.account_transaction,
|
||||||
|
outflow_transaction: outflow_entry.account_transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_no_difference -> { Transfer.count } do
|
||||||
|
transfer.save
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal "Transfer must have different accounts", transfer.errors.full_messages.first
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Transfer transactions must have opposite amounts" do
|
||||||
|
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
|
||||||
|
inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -400)
|
||||||
|
|
||||||
|
transfer = Transfer.new(
|
||||||
|
inflow_transaction: inflow_entry.account_transaction,
|
||||||
|
outflow_transaction: outflow_entry.account_transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_no_difference -> { Transfer.count } do
|
||||||
|
transfer.save
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal "Transfer transactions must have opposite amounts", transfer.errors.full_messages.first
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transfer dates must be within 4 days of each other" do
|
||||||
|
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
|
||||||
|
inflow_entry = create_transaction(date: 5.days.ago.to_date, account: accounts(:credit_card), amount: -500)
|
||||||
|
|
||||||
|
transfer = Transfer.new(
|
||||||
|
inflow_transaction: inflow_entry.account_transaction,
|
||||||
|
outflow_transaction: outflow_entry.account_transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_no_difference -> { Transfer.count } do
|
||||||
|
transfer.save
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal "Transfer transaction dates must be within 4 days of each other", transfer.errors.full_messages.first
|
||||||
|
end
|
||||||
|
|
||||||
|
test "from_accounts converts amounts to the to_account's currency" do
|
||||||
|
accounts(:depository).update!(currency: "EUR")
|
||||||
|
|
||||||
|
eur_account = accounts(:depository).reload
|
||||||
|
usd_account = accounts(:credit_card)
|
||||||
|
|
||||||
|
ExchangeRate.create!(
|
||||||
|
from_currency: "EUR",
|
||||||
|
to_currency: "USD",
|
||||||
|
rate: 1.1,
|
||||||
|
date: Date.current,
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer = Transfer.from_accounts(
|
||||||
|
from_account: eur_account,
|
||||||
|
to_account: usd_account,
|
||||||
|
date: Date.current,
|
||||||
|
amount: 500,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 500, transfer.outflow_transaction.entry.amount
|
||||||
|
assert_equal "EUR", transfer.outflow_transaction.entry.currency
|
||||||
|
assert_equal -550, transfer.inflow_transaction.entry.amount
|
||||||
|
assert_equal "USD", transfer.inflow_transaction.entry.currency
|
||||||
|
|
||||||
|
assert_difference -> { Transfer.count } => 1 do
|
||||||
|
transfer.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -210,7 +210,7 @@ class TransactionsTest < ApplicationSystemTestCase
|
||||||
end
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue