mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
First sketch of budgeting module
This commit is contained in:
parent
5d1a2937bb
commit
9a2a7b31d4
45 changed files with 342 additions and 84 deletions
|
@ -22,10 +22,9 @@ class Account::TransactionsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_transfers
|
def mark_transfers
|
||||||
Current.family
|
selected_entries = Current.family.entries.account_transactions.where(id: bulk_update_params[:entry_ids])
|
||||||
.entries
|
|
||||||
.where(id: bulk_update_params[:entry_ids])
|
TransferMatcher.new(Current.family).match!(selected_entries)
|
||||||
.mark_transfers!
|
|
||||||
|
|
||||||
redirect_back_or_to transactions_url, notice: t(".success")
|
redirect_back_or_to transactions_url, notice: t(".success")
|
||||||
end
|
end
|
||||||
|
@ -33,8 +32,12 @@ class Account::TransactionsController < ApplicationController
|
||||||
def unmark_transfers
|
def unmark_transfers
|
||||||
Current.family
|
Current.family
|
||||||
.entries
|
.entries
|
||||||
|
.account_transactions
|
||||||
|
.includes(:entryable)
|
||||||
.where(id: bulk_update_params[:entry_ids])
|
.where(id: bulk_update_params[:entry_ids])
|
||||||
.update_all marked_as_transfer: false
|
.each do |entry|
|
||||||
|
entry.entryable.update!(category_id: nil)
|
||||||
|
end
|
||||||
|
|
||||||
redirect_back_or_to transactions_url, notice: t(".success")
|
redirect_back_or_to transactions_url, notice: t(".success")
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,7 @@ module Account::EntriesHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def unconfirmed_transfer?(entry)
|
def unconfirmed_transfer?(entry)
|
||||||
entry.marked_as_transfer? && entry.transfer.nil?
|
entry.transfer.nil? && entry.entryable.category&.classification == "transfer"
|
||||||
end
|
end
|
||||||
|
|
||||||
def transfer_entries(entries)
|
def transfer_entries(entries)
|
||||||
|
|
|
@ -30,7 +30,20 @@ class Account::Entry < ApplicationRecord
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
scope :incomes_and_expenses, -> {
|
||||||
|
joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id")
|
||||||
|
.joins(:account)
|
||||||
|
.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
||||||
|
# All transfers excluded from income/expenses, outflow payments are expenses, inflow payments are NOT income
|
||||||
|
.where(<<~SQL.squish)
|
||||||
|
categories.id IS NULL OR
|
||||||
|
(
|
||||||
|
categories.classification != 'transfer' AND
|
||||||
|
(categories.classification != 'payment' OR account_entries.amount > 0)
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
@ -98,13 +111,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,8 +134,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 = 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,8 +142,7 @@ class Account::Entry < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def expense_total(currency = "USD")
|
def expense_total(currency = "USD")
|
||||||
total = without_transfers.account_transactions.includes(:entryable)
|
total = 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
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -67,8 +67,9 @@ 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
|
category: account.family.default_transfer_category
|
||||||
|
)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -59,8 +59,9 @@ class Account::Transfer < ApplicationRecord
|
||||||
currency: from_account.currency,
|
currency: from_account.currency,
|
||||||
date: date,
|
date: date,
|
||||||
name: "Transfer to #{to_account.name}",
|
name: "Transfer to #{to_account.name}",
|
||||||
marked_as_transfer: true,
|
entryable: Account::Transaction.new(
|
||||||
entryable: Account::Transaction.new
|
category: from_account.family.default_transfer_category
|
||||||
|
)
|
||||||
|
|
||||||
# Attempt to convert the amount to the to_account's currency. If the conversion fails,
|
# Attempt to convert the amount to the to_account's currency. If the conversion fails,
|
||||||
# use the original amount.
|
# use the original amount.
|
||||||
|
@ -75,8 +76,9 @@ class Account::Transfer < ApplicationRecord
|
||||||
currency: converted_amount.currency.iso_code,
|
currency: converted_amount.currency.iso_code,
|
||||||
date: date,
|
date: date,
|
||||||
name: "Transfer from #{from_account.name}",
|
name: "Transfer from #{from_account.name}",
|
||||||
marked_as_transfer: true,
|
entryable: Account::Transaction.new(
|
||||||
entryable: Account::Transaction.new
|
category: to_account.family.default_transfer_category
|
||||||
|
)
|
||||||
|
|
||||||
new entries: [ outflow, inflow ]
|
new entries: [ outflow, inflow ]
|
||||||
end
|
end
|
||||||
|
@ -106,8 +108,8 @@ class Account::Transfer < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def all_transactions_marked
|
def all_transactions_marked
|
||||||
unless entries.all?(&:marked_as_transfer)
|
unless entries.all? { |e| e.entryable.category == from_account.family.default_transfer_category }
|
||||||
errors.add :entries, :must_be_marked_as_transfer
|
errors.add :entries, :must_have_transfer_category
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
4
app/models/budget.rb
Normal file
4
app/models/budget.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class Budget < ApplicationRecord
|
||||||
|
belongs_to :family
|
||||||
|
has_many :budget_categories, dependent: :destroy
|
||||||
|
end
|
4
app/models/budget_category.rb
Normal file
4
app/models/budget_category.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class BudgetCategory < ApplicationRecord
|
||||||
|
belongs_to :budget
|
||||||
|
belongs_to :category
|
||||||
|
end
|
|
@ -4,9 +4,12 @@ class Category < ApplicationRecord
|
||||||
|
|
||||||
belongs_to :family
|
belongs_to :family
|
||||||
|
|
||||||
|
has_many :budget_categories, dependent: :destroy
|
||||||
has_many :subcategories, class_name: "Category", foreign_key: :parent_id
|
has_many :subcategories, class_name: "Category", foreign_key: :parent_id
|
||||||
belongs_to :parent, class_name: "Category", optional: true
|
belongs_to :parent, class_name: "Category", optional: true
|
||||||
|
|
||||||
|
enum :classification, { expense: "expense", income: "income", transfer: "transfer", payment: "payment" }
|
||||||
|
|
||||||
validates :name, :color, :family, presence: true
|
validates :name, :color, :family, presence: true
|
||||||
validates :name, uniqueness: { scope: :family_id }
|
validates :name, uniqueness: { scope: :family_id }
|
||||||
|
|
||||||
|
|
|
@ -88,7 +88,7 @@ class Demo::Generator
|
||||||
"Rent & Utilities", "Home Improvement", "Shopping" ]
|
"Rent & Utilities", "Home Improvement", "Shopping" ]
|
||||||
|
|
||||||
categories.each do |category|
|
categories.each do |category|
|
||||||
family.categories.create!(name: category, color: COLORS.sample)
|
family.categories.create!(name: category, color: COLORS.sample, classification: category == "Income" ? "income" : "expense")
|
||||||
end
|
end
|
||||||
|
|
||||||
food = family.categories.find_by(name: "Food & Drink")
|
food = family.categories.find_by(name: "Food & Drink")
|
||||||
|
|
|
@ -21,6 +21,18 @@ class Family < ApplicationRecord
|
||||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||||
validates :date_format, inclusion: { in: DATE_FORMATS }
|
validates :date_format, inclusion: { in: DATE_FORMATS }
|
||||||
|
|
||||||
|
def default_transfer_category
|
||||||
|
@default_transfer_category ||= categories.find_or_create_by!(classification: "transfer") do |c|
|
||||||
|
c.name = "Transfer"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_payment_category
|
||||||
|
@default_payment_category ||= categories.find_or_create_by!(classification: "payment") do |c|
|
||||||
|
c.name = "Payment"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def sync_data(start_date: nil)
|
def sync_data(start_date: nil)
|
||||||
update!(last_synced_at: Time.current)
|
update!(last_synced_at: Time.current)
|
||||||
|
|
||||||
|
@ -82,7 +94,10 @@ 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("INNER JOIN account_entries ON account_entries.account_id = accounts.id")
|
||||||
|
.joins("INNER JOIN account_transactions ON account_entries.entryable_id = account_transactions.id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||||
|
.joins("LEFT JOIN categories ON account_transactions.category_id = categories.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 +105,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("categories.classification IS NULL OR categories.classification != ?", "transfer")
|
||||||
.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 +124,7 @@ class Family < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot_transactions
|
def snapshot_transactions
|
||||||
candidate_entries = entries.account_transactions.without_transfers.excluding(
|
candidate_entries = entries.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 = []
|
||||||
|
|
5
app/models/goal.rb
Normal file
5
app/models/goal.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class Goal < ApplicationRecord
|
||||||
|
belongs_to :family
|
||||||
|
|
||||||
|
enum :type, { saving: "saving" }
|
||||||
|
end
|
|
@ -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)
|
||||||
|
@ -136,9 +135,9 @@ class PlaidAccount < ApplicationRecord
|
||||||
|
|
||||||
# See https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
|
# See https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
|
||||||
def get_category(plaid_category)
|
def get_category(plaid_category)
|
||||||
ignored_categories = [ "BANK_FEES", "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS", "OTHER" ]
|
return family.default_transfer_category if [ "TRANSFER_IN", "TRANSFER_OUT" ].include?(plaid_category)
|
||||||
|
return family.default_payment_category if [ "LOAN_PAYMENTS" ].include?(plaid_category)
|
||||||
return nil if ignored_categories.include?(plaid_category)
|
return nil if [ "BANK_FEES", "OTHER" ].include?(plaid_category)
|
||||||
|
|
||||||
family.categories.find_or_create_by!(name: plaid_category.titleize)
|
family.categories.find_or_create_by!(name: plaid_category.titleize)
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,13 +26,13 @@ class PlaidInvestmentSync
|
||||||
next if security.nil? && plaid_security.nil?
|
next if security.nil? && plaid_security.nil?
|
||||||
|
|
||||||
if transaction.type == "cash" || plaid_security.ticker_symbol == "CUR:USD"
|
if transaction.type == "cash" || plaid_security.ticker_symbol == "CUR:USD"
|
||||||
|
category = plaid_account.account.family.default_transfer_category if transaction.subtype.in?(%w[deposit withdrawal])
|
||||||
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|
|
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|
|
||||||
t.name = transaction.name
|
t.name = transaction.name
|
||||||
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(category: category)
|
||||||
t.entryable = Account::Transaction.new
|
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|
|
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|
|
||||||
|
|
2
app/models/saving_goal.rb
Normal file
2
app/models/saving_goal.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
class SavingGoal < Goal
|
||||||
|
end
|
36
app/models/transfer_matcher.rb
Normal file
36
app/models/transfer_matcher.rb
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
class TransferMatcher
|
||||||
|
attr_reader :family
|
||||||
|
|
||||||
|
def initialize(family)
|
||||||
|
@family = family
|
||||||
|
end
|
||||||
|
|
||||||
|
def match!(transaction_entries)
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
transaction_entries.each do |entry|
|
||||||
|
entry.entryable.update!(category_id: transfer_category.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
create_transfers(transaction_entries)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def create_transfers(entries)
|
||||||
|
matches = entries.to_a.combination(2).select do |entry1, entry2|
|
||||||
|
entry1.amount == -entry2.amount &&
|
||||||
|
entry1.account_id != entry2.account_id &&
|
||||||
|
(entry1.date - entry2.date).abs <= 4
|
||||||
|
end
|
||||||
|
|
||||||
|
matches.each do |match|
|
||||||
|
Account::Transfer.create!(entries: match)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfer_category
|
||||||
|
@transfer_category ||= family.categories.find_or_create_by!(classification: "transfer") do |category|
|
||||||
|
category.name = "Transfer"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -12,7 +12,7 @@
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<% if entry.marked_as_transfer? %>
|
<% if entry.entryable.category&.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>
|
||||||
|
|
|
@ -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.entryable.category&.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"]],
|
||||||
|
@ -52,7 +52,7 @@
|
||||||
url: account_transaction_path(@entry),
|
url: account_transaction_path(@entry),
|
||||||
class: "space-y-2",
|
class: "space-y-2",
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<% unless @entry.marked_as_transfer? %>
|
<% unless @entry.entryable.category&.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,
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
|
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
|
||||||
|
<%= f.select :classification, Category.classifications.keys.map { |c| [c.humanize, c] }, required: true, label: true %>
|
||||||
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" } %>
|
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" } %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -13,7 +13,6 @@
|
||||||
<%= combobox_style_tag %>
|
<%= combobox_style_tag %>
|
||||||
|
|
||||||
<%= 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"
|
<meta name="viewport"
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -23,15 +23,4 @@
|
||||||
nil %>
|
nil %>
|
||||||
<%= form.label :types, t(".expense"), value: "expense", class: "text-sm text-gray-900" %>
|
<%= form.label :types, t(".expense"), value: "expense", class: "text-sm text-gray-900" %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3" data-filter-name="transfer">
|
|
||||||
<%= form.check_box :types,
|
|
||||||
{
|
|
||||||
multiple: true,
|
|
||||||
checked: @q[:types]&.include?("transfer"),
|
|
||||||
class: "maybe-checkbox maybe-checkbox--light"
|
|
||||||
},
|
|
||||||
"transfer",
|
|
||||||
nil %>
|
|
||||||
<%= form.label :types, t(".transfer"), value: "transfer", class: "text-sm text-gray-900" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,7 +11,8 @@ en:
|
||||||
attributes:
|
attributes:
|
||||||
entries:
|
entries:
|
||||||
must_be_from_different_accounts: must be from different accounts
|
must_be_from_different_accounts: must be from different accounts
|
||||||
must_be_marked_as_transfer: must be marked as transfer
|
must_have_transfer_category: must have transfer category
|
||||||
must_have_an_inflow_and_outflow_that_net_to_zero: must have an inflow
|
must_have_an_inflow_and_outflow_that_net_to_zero: must have an inflow
|
||||||
and outflow that net to zero
|
and outflow that net to zero
|
||||||
must_have_exactly_2_entries: must have exactly 2 entries
|
must_have_exactly_2_entries: must have exactly 2 entries
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,6 @@ en:
|
||||||
type_filter:
|
type_filter:
|
||||||
expense: Expense
|
expense: Expense
|
||||||
income: Income
|
income: Income
|
||||||
transfer: Transfer
|
|
||||||
menu:
|
menu:
|
||||||
account_filter: Account
|
account_filter: Account
|
||||||
amount_filter: Amount
|
amount_filter: Amount
|
||||||
|
|
13
db/migrate/20241230154019_create_budgets.rb
Normal file
13
db/migrate/20241230154019_create_budgets.rb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
class CreateBudgets < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :budgets, id: :uuid do |t|
|
||||||
|
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||||
|
t.date :start_date, null: false
|
||||||
|
t.date :end_date, null: false
|
||||||
|
t.decimal :budgeted_amount, null: false, precision: 19, scale: 4
|
||||||
|
t.decimal :expected_income, null: false, precision: 19, scale: 4
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
11
db/migrate/20241230155132_create_budget_categories.rb
Normal file
11
db/migrate/20241230155132_create_budget_categories.rb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
class CreateBudgetCategories < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :budget_categories, id: :uuid do |t|
|
||||||
|
t.references :budget, null: false, foreign_key: true, type: :uuid
|
||||||
|
t.references :category, null: false, foreign_key: true, type: :uuid
|
||||||
|
t.decimal :budgeted_amount, null: false, precision: 19, scale: 4
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
14
db/migrate/20241230162744_create_goals.rb
Normal file
14
db/migrate/20241230162744_create_goals.rb
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
class CreateGoals < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :goals, id: :uuid do |t|
|
||||||
|
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||||
|
t.string :name, null: false
|
||||||
|
t.string :type, null: false
|
||||||
|
t.decimal :target_amount, null: false, precision: 19, scale: 4
|
||||||
|
t.decimal :starting_amount, null: false, precision: 19, scale: 4
|
||||||
|
t.date :start_date, null: false
|
||||||
|
t.date :target_date, null: false
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
34
db/migrate/20241230164615_income_category.rb
Normal file
34
db/migrate/20241230164615_income_category.rb
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
class IncomeCategory < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :categories, :classification, :string, null: false, default: "expense"
|
||||||
|
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE categories
|
||||||
|
SET classification = 'income'
|
||||||
|
WHERE LOWER(name) = 'income'
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Assign the transfer classification for any entries marked as transfer
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE categories
|
||||||
|
SET classification = 'transfer'
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT DISTINCT t.category_id
|
||||||
|
FROM account_entries e
|
||||||
|
INNER JOIN account_transactions t ON t.id = e.entryable_id AND e.entryable_type = 'Account::Transaction'
|
||||||
|
WHERE e.marked_as_transfer = true AND t.category_id IS NOT NULL
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# We will now use categories to identify one-way transfers, and Account::Transfer for two-way transfers
|
||||||
|
remove_column :account_entries, :marked_as_transfer
|
||||||
|
end
|
||||||
|
|
||||||
|
dir.down do
|
||||||
|
add_column :account_entries, :marked_as_transfer, :boolean, null: false, default: false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
42
db/schema.rb
generated
42
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_30_164615) 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"
|
||||||
|
@ -43,7 +43,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
||||||
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.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
|
||||||
|
@ -168,6 +167,27 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
||||||
t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable"
|
t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "budget_id", null: false
|
||||||
|
t.uuid "category_id", null: false
|
||||||
|
t.decimal "budgeted_amount", precision: 19, scale: 4, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["budget_id"], name: "index_budget_categories_on_budget_id"
|
||||||
|
t.index ["category_id"], name: "index_budget_categories_on_category_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "budgets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "family_id", null: false
|
||||||
|
t.date "start_date", null: false
|
||||||
|
t.date "end_date", null: false
|
||||||
|
t.decimal "budgeted_amount", precision: 19, scale: 4, null: false
|
||||||
|
t.decimal "expected_income", precision: 19, scale: 4, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["family_id"], name: "index_budgets_on_family_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "color", default: "#6172F3", null: false
|
t.string "color", default: "#6172F3", null: false
|
||||||
|
@ -175,6 +195,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
||||||
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 "parent_id"
|
t.uuid "parent_id"
|
||||||
|
t.string "classification", default: "expense", null: false
|
||||||
t.index ["family_id"], name: "index_categories_on_family_id"
|
t.index ["family_id"], name: "index_categories_on_family_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -226,6 +247,19 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
||||||
t.boolean "data_enrichment_enabled", default: false
|
t.boolean "data_enrichment_enabled", default: false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "family_id", null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "type", null: false
|
||||||
|
t.decimal "target_amount", precision: 19, scale: 4, null: false
|
||||||
|
t.decimal "starting_amount", precision: 19, scale: 4, null: false
|
||||||
|
t.date "start_date", null: false
|
||||||
|
t.date "target_date", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["family_id"], name: "index_goals_on_family_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "good_job_batches", 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
|
||||||
|
@ -647,7 +681,11 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
||||||
add_foreign_key "accounts", "plaid_accounts"
|
add_foreign_key "accounts", "plaid_accounts"
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "budget_categories", "budgets"
|
||||||
|
add_foreign_key "budget_categories", "categories"
|
||||||
|
add_foreign_key "budgets", "families"
|
||||||
add_foreign_key "categories", "families"
|
add_foreign_key "categories", "families"
|
||||||
|
add_foreign_key "goals", "families"
|
||||||
add_foreign_key "impersonation_session_logs", "impersonation_sessions"
|
add_foreign_key "impersonation_session_logs", "impersonation_sessions"
|
||||||
add_foreign_key "impersonation_sessions", "users", column: "impersonated_id"
|
add_foreign_key "impersonation_sessions", "users", column: "impersonated_id"
|
||||||
add_foreign_key "impersonation_sessions", "users", column: "impersonator_id"
|
add_foreign_key "impersonation_sessions", "users", column: "impersonator_id"
|
||||||
|
|
|
@ -93,7 +93,7 @@ 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 created_entry.entryable.category.transfer?
|
||||||
assert_redirected_to @entry.account
|
assert_redirected_to @entry.account
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
2
test/fixtures/account/entries.yml
vendored
2
test/fixtures/account/entries.yml
vendored
|
@ -31,7 +31,6 @@ transfer_out:
|
||||||
amount: 100
|
amount: 100
|
||||||
currency: USD
|
currency: USD
|
||||||
account: depository
|
account: depository
|
||||||
marked_as_transfer: true
|
|
||||||
transfer: one
|
transfer: one
|
||||||
entryable_type: Account::Transaction
|
entryable_type: Account::Transaction
|
||||||
entryable: transfer_out
|
entryable: transfer_out
|
||||||
|
@ -42,7 +41,6 @@ transfer_in:
|
||||||
amount: -100
|
amount: -100
|
||||||
currency: USD
|
currency: USD
|
||||||
account: credit_card
|
account: credit_card
|
||||||
marked_as_transfer: true
|
|
||||||
transfer: one
|
transfer: one
|
||||||
entryable_type: Account::Transaction
|
entryable_type: Account::Transaction
|
||||||
entryable: transfer_in
|
entryable: transfer_in
|
||||||
|
|
7
test/fixtures/account/transactions.yml
vendored
7
test/fixtures/account/transactions.yml
vendored
|
@ -2,5 +2,8 @@ one:
|
||||||
category: food_and_drink
|
category: food_and_drink
|
||||||
merchant: amazon
|
merchant: amazon
|
||||||
|
|
||||||
transfer_out: { }
|
transfer_out:
|
||||||
transfer_in: { }
|
category: transfer
|
||||||
|
|
||||||
|
transfer_in:
|
||||||
|
category: transfer
|
4
test/fixtures/budget_categories.yml
vendored
Normal file
4
test/fixtures/budget_categories.yml
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
food_and_drink:
|
||||||
|
budget: one
|
||||||
|
category: food_and_drink
|
||||||
|
budgeted_amount: 800
|
6
test/fixtures/budgets.yml
vendored
Normal file
6
test/fixtures/budgets.yml
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
one:
|
||||||
|
start_date: <%= 1.month.ago.to_date %>
|
||||||
|
end_date: <%= Date.current %>
|
||||||
|
family: dylan_family
|
||||||
|
budgeted_amount: 5000
|
||||||
|
expected_income: 8000
|
16
test/fixtures/categories.yml
vendored
16
test/fixtures/categories.yml
vendored
|
@ -1,17 +1,33 @@
|
||||||
one:
|
one:
|
||||||
name: Test
|
name: Test
|
||||||
|
classification: expense
|
||||||
family: empty
|
family: empty
|
||||||
|
|
||||||
income:
|
income:
|
||||||
name: Income
|
name: Income
|
||||||
|
classification: income
|
||||||
|
color: "#fd7f6f"
|
||||||
|
family: dylan_family
|
||||||
|
|
||||||
|
transfer:
|
||||||
|
name: Transfer
|
||||||
|
classification: transfer
|
||||||
|
color: "#fd7f6f"
|
||||||
|
family: dylan_family
|
||||||
|
|
||||||
|
payment:
|
||||||
|
name: Payment
|
||||||
|
classification: payment
|
||||||
color: "#fd7f6f"
|
color: "#fd7f6f"
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
|
|
||||||
food_and_drink:
|
food_and_drink:
|
||||||
name: Food & Drink
|
name: Food & Drink
|
||||||
|
classification: expense
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
|
|
||||||
subcategory:
|
subcategory:
|
||||||
name: Restaurants
|
name: Restaurants
|
||||||
|
classification: expense
|
||||||
parent: food_and_drink
|
parent: food_and_drink
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
|
|
8
test/fixtures/goals.yml
vendored
Normal file
8
test/fixtures/goals.yml
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
saving:
|
||||||
|
name: Vacation savings
|
||||||
|
type: saving
|
||||||
|
start_date: <%= 1.month.ago.to_date %>
|
||||||
|
target_date: <%= 1.year.from_now.to_date %>
|
||||||
|
target_amount: 10000
|
||||||
|
starting_amount: 2000
|
||||||
|
family: dylan_family
|
|
@ -74,7 +74,7 @@ class Account::EntryTest < ActiveSupport::TestCase
|
||||||
create_transaction(account: account, amount: 100)
|
create_transaction(account: account, amount: 100)
|
||||||
create_transaction(account: account, amount: -500) # income, will be ignored
|
create_transaction(account: account, amount: -500) # income, will be ignored
|
||||||
|
|
||||||
assert_equal Money.new(200), family.entries.expense_total("USD")
|
assert_equal Money.new(200), account.entries.expense_total("USD")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can calculate total income for a group of transactions" do
|
test "can calculate total income for a group of transactions" do
|
||||||
|
@ -82,8 +82,8 @@ class Account::EntryTest < ActiveSupport::TestCase
|
||||||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||||
create_transaction(account: account, amount: -100)
|
create_transaction(account: account, amount: -100)
|
||||||
create_transaction(account: account, amount: -100)
|
create_transaction(account: account, amount: -100)
|
||||||
create_transaction(account: account, amount: 500) # income, will be ignored
|
create_transaction(account: account, amount: 500) # expense, will be ignored
|
||||||
|
|
||||||
assert_equal Money.new(-200), family.entries.income_total("USD")
|
assert_equal Money.new(-200), account.entries.income_total("USD")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -28,28 +28,31 @@ class Account::TransferTest < ActiveSupport::TestCase
|
||||||
name: "Inflow",
|
name: "Inflow",
|
||||||
amount: -100,
|
amount: -100,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
marked_as_transfer: true,
|
entryable: Account::Transaction.new(
|
||||||
entryable: Account::Transaction.new
|
category: account.family.default_transfer_category
|
||||||
|
)
|
||||||
|
|
||||||
outflow = account.entries.create! \
|
outflow = account.entries.create! \
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
name: "Outflow",
|
name: "Outflow",
|
||||||
amount: 100,
|
amount: 100,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
marked_as_transfer: true,
|
entryable: Account::Transaction.new(
|
||||||
entryable: Account::Transaction.new
|
category: account.family.default_transfer_category
|
||||||
|
)
|
||||||
|
|
||||||
assert_raise ActiveRecord::RecordInvalid do
|
assert_raise ActiveRecord::RecordInvalid do
|
||||||
Account::Transfer.create! entries: [ inflow, outflow ]
|
Account::Transfer.create! entries: [ inflow, outflow ]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "all transfer transactions must be marked as transfers" do
|
test "all transfer transactions must have transfer category" do
|
||||||
@inflow.update! marked_as_transfer: false
|
@inflow.entryable.update! category: nil
|
||||||
|
|
||||||
assert_raise ActiveRecord::RecordInvalid do
|
transfer = Account::Transfer.new entries: [ @inflow, @outflow ]
|
||||||
Account::Transfer.create! entries: [ @inflow, @outflow ]
|
|
||||||
end
|
assert_not transfer.valid?
|
||||||
|
assert_equal "Entries must have transfer category", transfer.errors.full_messages.first
|
||||||
end
|
end
|
||||||
|
|
||||||
test "single-currency transfer transactions must net to zero" do
|
test "single-currency transfer transactions must net to zero" do
|
||||||
|
|
7
test/models/budget_category_test.rb
Normal file
7
test/models/budget_category_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class BudgetCategoryTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
7
test/models/budget_test.rb
Normal file
7
test/models/budget_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class BudgetTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
|
@ -124,7 +124,7 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
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)
|
create_transaction(account: liability_account, date: 2.days.ago.to_date, amount: -333, category: categories(:payment))
|
||||||
|
|
||||||
snapshot = @family.snapshot_transactions
|
snapshot = @family.snapshot_transactions
|
||||||
|
|
||||||
|
|
7
test/models/goal_test.rb
Normal file
7
test/models/goal_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class GoalTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
28
test/models/transfer_matcher_test.rb
Normal file
28
test/models/transfer_matcher_test.rb
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TransferMatcherTest < ActiveSupport::TestCase
|
||||||
|
include Account::EntriesTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@family = families(:dylan_family)
|
||||||
|
@matcher = TransferMatcher.new(@family)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "matches entries with opposite amounts and different accounts within 4 days" do
|
||||||
|
entry1 = create_transaction(account: accounts(:depository), amount: 100, date: Date.current)
|
||||||
|
entry2 = create_transaction(account: accounts(:credit_card), amount: -100, date: 2.days.ago.to_date)
|
||||||
|
|
||||||
|
assert_difference "Account::Transfer.count", 1 do
|
||||||
|
@matcher.match!([ entry1, entry2 ])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't match entries more than 4 days apart" do
|
||||||
|
entry1 = create_transaction(account: accounts(:depository), amount: 100, date: Date.current)
|
||||||
|
entry2 = create_transaction(account: accounts(:credit_card), amount: -100, date: Date.current + 5.days)
|
||||||
|
|
||||||
|
assert_no_difference "Account::Transfer.count" do
|
||||||
|
@matcher.match!([ entry1, entry2 ])
|
||||||
|
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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue