mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-22 14:49:38 +02:00
Transaction transfers, payments, and matching (#883)
* Add transfer model and clean up family snapshot fixtures * Ignore transfers in income and expense snapshots * Add transfer validations * Implement basic transfer matching UI * Fix merge conflicts * Add missing translations * Tweak selection states for transfer types * Add missing i18n translation
This commit is contained in:
parent
b462bc8f8c
commit
ca39b26070
57 changed files with 991 additions and 427 deletions
|
@ -7,6 +7,10 @@
|
||||||
details > summary::-webkit-details-marker {
|
details > summary::-webkit-details-marker {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
details > summary {
|
||||||
|
@apply list-none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
|
|
@ -9,9 +9,9 @@ class TransactionsController < ApplicationController
|
||||||
@pagy, @transactions = pagy(result, items: 50)
|
@pagy, @transactions = pagy(result, items: 50)
|
||||||
|
|
||||||
@totals = {
|
@totals = {
|
||||||
count: result.count,
|
count: result.select { |t| t.currency == Current.family.currency }.count,
|
||||||
income: result.inflows.sum(&:amount_money).abs,
|
income: result.income_total(Current.family.currency).abs,
|
||||||
expense: result.outflows.sum(&:amount_money).abs
|
expense: result.expense_total(Current.family.currency)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ class TransactionsController < ApplicationController
|
||||||
|
|
||||||
def bulk_delete
|
def bulk_delete
|
||||||
destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids])
|
destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids])
|
||||||
redirect_to transactions_url, notice: t(".success", count: destroyed.count)
|
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||||
end
|
end
|
||||||
|
|
||||||
def bulk_edit
|
def bulk_edit
|
||||||
|
@ -63,13 +63,31 @@ class TransactionsController < ApplicationController
|
||||||
def bulk_update
|
def bulk_update
|
||||||
transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids])
|
transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids])
|
||||||
if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h.compact_blank!)
|
if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h.compact_blank!)
|
||||||
redirect_to transactions_url, notice: t(".success", count: transactions.count)
|
redirect_back_or_to transactions_url, notice: t(".success", count: transactions.count)
|
||||||
else
|
else
|
||||||
flash.now[:error] = t(".failure")
|
flash.now[:error] = t(".failure")
|
||||||
render :index, status: :unprocessable_entity
|
render :index, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def mark_transfers
|
||||||
|
Current.family
|
||||||
|
.transactions
|
||||||
|
.where(id: bulk_update_params[:transaction_ids])
|
||||||
|
.mark_transfers!
|
||||||
|
|
||||||
|
redirect_back_or_to transactions_url, notice: t(".success")
|
||||||
|
end
|
||||||
|
|
||||||
|
def unmark_transfers
|
||||||
|
Current.family
|
||||||
|
.transactions
|
||||||
|
.where(id: bulk_update_params[:transaction_ids])
|
||||||
|
.update_all marked_as_transfer: false
|
||||||
|
|
||||||
|
redirect_back_or_to transactions_url, notice: t(".success")
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_transaction
|
def set_transaction
|
||||||
|
|
41
app/controllers/transfers_controller.rb
Normal file
41
app/controllers/transfers_controller.rb
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
class TransfersController < ApplicationController
|
||||||
|
layout "with_sidebar"
|
||||||
|
|
||||||
|
before_action :set_transfer, only: :destroy
|
||||||
|
|
||||||
|
def new
|
||||||
|
@transfer = Transfer.new
|
||||||
|
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.build_from_accounts from_account, to_account, \
|
||||||
|
date: transfer_params[:date],
|
||||||
|
amount: transfer_params[:amount].to_d,
|
||||||
|
currency: transfer_params[:currency],
|
||||||
|
name: transfer_params[:name]
|
||||||
|
|
||||||
|
if @transfer.save
|
||||||
|
redirect_to transactions_path, notice: t(".success")
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@transfer.destroy_and_remove_marks!
|
||||||
|
redirect_back_or_to transactions_url, notice: t(".success")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_transfer
|
||||||
|
@transfer = Transfer.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfer_params
|
||||||
|
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :currency, :date, :name)
|
||||||
|
end
|
||||||
|
end
|
|
@ -25,7 +25,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
||||||
# See `Monetizable` concern, which adds a _money suffix to the attribute name
|
# See `Monetizable` concern, which adds a _money suffix to the attribute name
|
||||||
# For a monetized field, the setter will always be the attribute name without the _money suffix
|
# For a monetized field, the setter will always be the attribute name without the _money suffix
|
||||||
def money_field(method, options = {})
|
def money_field(method, options = {})
|
||||||
money = @object.send(method)
|
money = @object && @object.respond_to?(method) ? @object.send(method) : nil
|
||||||
raise ArgumentError, "The value of #{method} is not a Money object" unless money.is_a?(Money) || money.nil?
|
raise ArgumentError, "The value of #{method} is not a Money object" unless money.is_a?(Money) || money.nil?
|
||||||
|
|
||||||
money_amount_method = method.to_s.chomp("_money").to_sym
|
money_amount_method = method.to_s.chomp("_money").to_sym
|
||||||
|
|
|
@ -17,4 +17,27 @@ module TransactionsHelper
|
||||||
content: content
|
content: content
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def unconfirmed_transfer?(transaction)
|
||||||
|
transaction.marked_as_transfer && transaction.transfer.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_transactions_by_date(transactions)
|
||||||
|
grouped_by_date = {}
|
||||||
|
|
||||||
|
transactions.each do |transaction|
|
||||||
|
if transaction.transfer
|
||||||
|
transfer_date = transaction.transfer.inflow_transaction.date
|
||||||
|
grouped_by_date[transfer_date] ||= { transactions: [], transfers: [] }
|
||||||
|
unless grouped_by_date[transfer_date][:transfers].include?(transaction.transfer)
|
||||||
|
grouped_by_date[transfer_date][:transfers] << transaction.transfer
|
||||||
|
end
|
||||||
|
else
|
||||||
|
grouped_by_date[transaction.date] ||= { transactions: [], transfers: [] }
|
||||||
|
grouped_by_date[transaction.date][:transactions] << transaction
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
grouped_by_date
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
2
app/helpers/transfers_helper.rb
Normal file
2
app/helpers/transfers_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module TransfersHelper
|
||||||
|
end
|
|
@ -113,7 +113,7 @@ export default class extends Controller {
|
||||||
#updateGroups() {
|
#updateGroups() {
|
||||||
this.groupTargets.forEach(group => {
|
this.groupTargets.forEach(group => {
|
||||||
const rows = this.rowTargets.filter(row => group.contains(row))
|
const rows = this.rowTargets.filter(row => group.contains(row))
|
||||||
const groupSelected = rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
|
const groupSelected = rows.length > 0 && rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
|
||||||
group.querySelector("input[type='checkbox']").checked = groupSelected
|
group.querySelector("input[type='checkbox']").checked = groupSelected
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,7 @@ class Family < ApplicationRecord
|
||||||
)
|
)
|
||||||
.where("transactions.date >= ?", period.date_range.begin)
|
.where("transactions.date >= ?", period.date_range.begin)
|
||||||
.where("transactions.date <= ?", period.date_range.end)
|
.where("transactions.date <= ?", period.date_range.end)
|
||||||
|
.where("transactions.marked_as_transfer = ?", false)
|
||||||
.group("id")
|
.group("id")
|
||||||
.to_a
|
.to_a
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ class Transaction < ApplicationRecord
|
||||||
monetize :amount
|
monetize :amount
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
belongs_to :transfer, optional: true
|
||||||
belongs_to :category, optional: true
|
belongs_to :category, optional: true
|
||||||
belongs_to :merchant, optional: true
|
belongs_to :merchant, optional: true
|
||||||
has_many :taggings, as: :taggable, dependent: :destroy
|
has_many :taggings, as: :taggable, dependent: :destroy
|
||||||
|
@ -42,6 +43,10 @@ class Transaction < ApplicationRecord
|
||||||
amount > 0
|
amount > 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def transfer?
|
||||||
|
marked_as_transfer
|
||||||
|
end
|
||||||
|
|
||||||
def sync_account_later
|
def sync_account_later
|
||||||
if destroyed?
|
if destroyed?
|
||||||
sync_start_date = previous_transaction_date
|
sync_start_date = previous_transaction_date
|
||||||
|
@ -53,6 +58,21 @@ class Transaction < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
def income_total(currency = "USD")
|
||||||
|
inflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money)
|
||||||
|
end
|
||||||
|
|
||||||
|
def expense_total(currency = "USD")
|
||||||
|
outflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_transfers!
|
||||||
|
update_all marked_as_transfer: true
|
||||||
|
|
||||||
|
# Attempt to "auto match" and save a transfer if 2 transactions selected
|
||||||
|
Transfer.new(transactions: all).save if all.count == 2
|
||||||
|
end
|
||||||
|
|
||||||
def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||||
# Sum spending and income for each day in the period with the given currency
|
# Sum spending and income for each day in the period with the given currency
|
||||||
select(
|
select(
|
||||||
|
@ -60,7 +80,7 @@ class Transaction < ApplicationRecord
|
||||||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||||
)
|
)
|
||||||
.from(transactions.with_converted_amount(currency), :t)
|
.from(transactions.with_converted_amount(currency).where(marked_as_transfer: false), :t)
|
||||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
|
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||||
.group("gs.date")
|
.group("gs.date")
|
||||||
end
|
end
|
||||||
|
@ -83,7 +103,7 @@ class Transaction < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def search(params)
|
def search(params)
|
||||||
query = all
|
query = all.includes(:transfer)
|
||||||
query = query.by_name(params[:search]) if params[:search].present?
|
query = query.by_name(params[:search]) if params[:search].present?
|
||||||
query = query.with_categories(params[:categories]) if params[:categories].present?
|
query = query.with_categories(params[:categories]) if params[:categories].present?
|
||||||
query = query.with_accounts(params[:accounts]) if params[:accounts].present?
|
query = query.with_accounts(params[:accounts]) if params[:accounts].present?
|
||||||
|
@ -96,7 +116,6 @@ class Transaction < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def previous_transaction_date
|
def previous_transaction_date
|
||||||
self.account
|
self.account
|
||||||
.transactions
|
.transactions
|
||||||
|
|
57
app/models/transfer.rb
Normal file
57
app/models/transfer.rb
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
class Transfer < ApplicationRecord
|
||||||
|
has_many :transactions, dependent: :nullify
|
||||||
|
|
||||||
|
validate :transaction_count, :from_different_accounts, :net_zero_flows, :all_transactions_marked
|
||||||
|
|
||||||
|
def inflow_transaction
|
||||||
|
transactions.find { |t| t.inflow? }
|
||||||
|
end
|
||||||
|
|
||||||
|
def outflow_transaction
|
||||||
|
transactions.find { |t| t.outflow? }
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_and_remove_marks!
|
||||||
|
transaction do
|
||||||
|
transactions.each do |t|
|
||||||
|
t.update! marked_as_transfer: false
|
||||||
|
end
|
||||||
|
|
||||||
|
destroy!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
|
||||||
|
outflow = from_account.transactions.build(amount: amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
|
||||||
|
inflow = to_account.transactions.build(amount: -amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
|
||||||
|
|
||||||
|
new transactions: [ outflow, inflow ]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def transaction_count
|
||||||
|
unless transactions.size == 2
|
||||||
|
errors.add :transactions, "must have exactly 2 transactions"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def from_different_accounts
|
||||||
|
accounts = transactions.map(&:account_id).uniq
|
||||||
|
errors.add :transactions, "must be from different accounts" if accounts.size < transactions.size
|
||||||
|
end
|
||||||
|
|
||||||
|
def net_zero_flows
|
||||||
|
unless transactions.sum(&:amount).zero?
|
||||||
|
errors.add :transactions, "must have an inflow and outflow that net to zero"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def all_transactions_marked
|
||||||
|
unless transactions.all?(&:marked_as_transfer)
|
||||||
|
errors.add :transactions, "must be marked as transfer"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -4,7 +4,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-3">
|
<div class="col-span-3">
|
||||||
|
<% if transaction.marked_as_transfer %>
|
||||||
|
<div class="flex items-center gap-1 text-gray-500 pl-5">
|
||||||
|
<%= lucide_icon "arrow-right-left", class: "w-4 h-4 text-gray-500" %>
|
||||||
|
<p>Transfer</p>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
<%= render "transactions/categories/badge", category: transaction.category %>
|
<%= render "transactions/categories/badge", category: transaction.category %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= link_to transaction.account.name,
|
<%= link_to transaction.account.name,
|
||||||
|
|
|
@ -1,17 +1,26 @@
|
||||||
<%# locals: (date:, transactions:) %>
|
<%# locals: (date:, group:) %>
|
||||||
<div class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
|
<div class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
|
||||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||||
<div class="flex pl-0.5 items-center gap-4">
|
<div class="flex pl-0.5 items-center gap-4">
|
||||||
<%= check_box_tag "#{date}_transactions_selection",
|
<%= check_box_tag "#{date}_transactions_selection",
|
||||||
class: "maybe-checkbox maybe-checkbox--light",
|
class: ["maybe-checkbox maybe-checkbox--light", "hidden": group[:transactions].count == 0],
|
||||||
id: "selection_transaction_#{date}",
|
id: "selection_transaction_#{date}",
|
||||||
data: { action: "bulk-select#toggleGroupSelection" } %>
|
data: { action: "bulk-select#toggleGroupSelection" } %>
|
||||||
<%= tag.span "#{date.strftime('%b %d, %Y')} · #{transactions.size}" %>
|
|
||||||
|
<%= tag.span "#{date.strftime('%b %d, %Y')} · #{group[:transactions].size + (group[:transfers].size * 2)}" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% transactions_by_currency = group[:transactions].group_by(&:currency) %>
|
||||||
|
<% transactions_by_currency.each_with_index do |(_currency, transactions), idx| %>
|
||||||
<%= tag.span format_money(-transactions.sum(&:amount_money)) %>
|
<%= tag.span format_money(-transactions.sum(&:amount_money)) %>
|
||||||
|
<%= tag.span "|", class: "mx-2" if idx < transactions_by_currency.count - 1 %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||||
<%= render transactions %>
|
<%= render group[:transactions] %>
|
||||||
|
<%= render group[:transfers] %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
<%= form_with model: @transaction, data: { turbo: false } do |f| %>
|
<%= form_with model: @transaction, data: { turbo_frame: "_top" } do |f| %>
|
||||||
<section>
|
<section>
|
||||||
<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: :expense, label: t(".expense"), icon: "minus-circle", checked: true %>
|
<%= radio_tab_tag form: f, name: :nature, value: :expense, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "expense" || params[:nature].nil? %>
|
||||||
<%= radio_tab_tag form: f, name: :nature, value: :income, label: t(".income"), icon: "plus-circle" %>
|
<%= radio_tab_tag form: f, name: :nature, value: :income, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "income" %>
|
||||||
<%= radio_tab_tag form: f, name: :nature, value: :transfer, label: t(".transfer"), icon: "arrow-right-left", disabled: true %>
|
<%= link_to new_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %>
|
||||||
|
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
|
||||||
|
<%= tag.span t(".transfer") %>
|
||||||
|
<% end %>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -11,7 +14,7 @@
|
||||||
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
|
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
|
||||||
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true %>
|
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true %>
|
||||||
<%= f.money_field :amount_money, label: t(".amount"), required: true %>
|
<%= f.money_field :amount_money, label: t(".amount"), required: true %>
|
||||||
<%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") }, required: true %>
|
<%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
|
||||||
<%= f.date_field :date, label: t(".date"), required: true, max: Date.today %>
|
<%= f.date_field :date, label: t(".date"), required: true, max: Date.today %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to transaction.name,
|
<%= link_to transaction.name,
|
||||||
transaction_path(transaction),
|
transaction_path(transaction),
|
||||||
data: { turbo_frame: "drawer" },
|
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||||
class: "hover:underline hover:text-gray-800" %>
|
class: "hover:underline hover:text-gray-800" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,6 +8,21 @@
|
||||||
<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_transactions_path,
|
||||||
|
builder: ActionView::Helpers::FormBuilder,
|
||||||
|
scope: "bulk_update",
|
||||||
|
data: {
|
||||||
|
turbo_confirm: {
|
||||||
|
title: t(".mark_transfers"),
|
||||||
|
body: t(".mark_transfers_message"),
|
||||||
|
accept: t(".mark_transfers_confirm"),
|
||||||
|
}
|
||||||
|
} do |f| %>
|
||||||
|
<button 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_transactions_path,
|
<%= link_to bulk_edit_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,14 +1,32 @@
|
||||||
<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 py-4 text-sm font-medium px-4" do %>
|
<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4" do %>
|
||||||
<div class="col-span-4 flex items-center gap-4">
|
<div class="pr-10 flex items-center gap-4 <%= unconfirmed_transfer?(transaction) ? "col-span-10" : "col-span-4" %>">
|
||||||
<%= check_box_tag dom_id(transaction, "selection"),
|
<%= check_box_tag dom_id(transaction, "selection"),
|
||||||
class: "maybe-checkbox maybe-checkbox--light",
|
class: "maybe-checkbox maybe-checkbox--light",
|
||||||
data: { id: transaction.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
data: { id: transaction.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
||||||
|
|
||||||
<div class="max-w-full pr-10">
|
<div class="max-w-full">
|
||||||
<%= render "transactions/name", transaction: transaction %>
|
<%= render "transactions/name", transaction: transaction %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if unconfirmed_transfer?(transaction) %>
|
||||||
|
<%= form_with url: unmark_transfers_transactions_path, builder: ActionView::Helpers::FormBuilder, 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[transaction_ids][]", value: transaction.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 %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% unless unconfirmed_transfer?(transaction) %>
|
||||||
<div class="col-span-3">
|
<div class="col-span-3">
|
||||||
<%= render "transactions/categories/menu", transaction: transaction %>
|
<%= render "transactions/categories/menu", transaction: transaction %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -17,6 +35,7 @@
|
||||||
account_path(transaction.account),
|
account_path(transaction.account),
|
||||||
data: { turbo_frame: "_top" },
|
data: { turbo_frame: "_top" },
|
||||||
class: ["col-span-3 hover:underline"] %>
|
class: ["col-span-3 hover:underline"] %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="col-span-2 ml-auto">
|
<div class="col-span-2 ml-auto">
|
||||||
<%= render "transactions/amount", transaction: transaction %>
|
<%= render "transactions/amount", transaction: transaction %>
|
||||||
|
|
|
@ -25,8 +25,8 @@
|
||||||
<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">
|
||||||
<% @transactions.group_by(&:date).each do |date, transactions| %>
|
<% group_transactions_by_date(@transactions).each do |date, group| %>
|
||||||
<%= render partial: "date_group", locals: { date:, transactions: } %>
|
<%= render partial: "date_group", locals: { date:, group: } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|
|
@ -1,11 +1,17 @@
|
||||||
<%= drawer do %>
|
<%= drawer do %>
|
||||||
<div>
|
<div>
|
||||||
<header class="mb-4 space-y-1">
|
<header class="mb-4 space-y-1">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
<h3 class="font-medium">
|
<h3 class="font-medium">
|
||||||
<span class="text-2xl"><%= format_money @transaction.amount_money %></span>
|
<span class="text-2xl"><%= format_money -@transaction.amount_money %></span>
|
||||||
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
|
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<% if @transaction.marked_as_transfer %>
|
||||||
|
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
|
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
@ -19,28 +25,20 @@
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
<%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %>
|
||||||
<%= f.date_field :date, label: t(".date_label"), max: Date.today, "data-auto-submit-form-target": "auto" %>
|
<%= f.date_field :date, label: t(".date_label"), max: Date.today, "data-auto-submit-form-target": "auto" %>
|
||||||
|
|
||||||
|
<% unless @transaction.marked_as_transfer %>
|
||||||
<%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
<%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||||
<%= f.collection_select :merchant_id, Current.family.transaction_merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
<%= f.collection_select :merchant_id, Current.family.transaction_merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_placeholder"), label: t(".account_label"), class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
|
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_placeholder"), label: t(".account_label"), class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="group space-y-2" open>
|
|
||||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
|
||||||
<h4><%= t(".description") %></h4>
|
|
||||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div class="pb-6">
|
|
||||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
|
||||||
<%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details class="group space-y-2" open>
|
<details class="group space-y-2" open>
|
||||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||||
<h4><%= t(".additional") %></h4>
|
<h4><%= t(".additional") %></h4>
|
||||||
|
@ -70,8 +68,8 @@
|
||||||
|
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
|
|
||||||
<%= form_with model: @transaction, html: { class: "p-3", data: { controller: "auto-submit-form" } } do |f| %>
|
<%= form_with model: @transaction, html: { class: "p-3 space-y-3", data: { controller: "auto-submit-form" } } do |f| %>
|
||||||
<div class="flex cursor-pointer items-center justify-between">
|
<div class="flex cursor-pointer items-center gap-2 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"><%= t(".exclude_title") %></h4>
|
||||||
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
|
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
|
||||||
|
@ -84,6 +82,7 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% unless @transaction.transfer? %>
|
||||||
<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>
|
||||||
|
@ -96,6 +95,7 @@
|
||||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
||||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
32
app/views/transfers/_form.html.erb
Normal file
32
app/views/transfers/_form.html.erb
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<%= form_with model: transfer do |f| %>
|
||||||
|
<section>
|
||||||
|
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
||||||
|
<%= link_to new_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
|
||||||
|
<%= lucide_icon "minus-circle", class: "w-5 h-5" %>
|
||||||
|
<%= tag.span t(".expense") %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to new_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
|
||||||
|
<%= lucide_icon "plus-circle", class: "w-5 h-5" %>
|
||||||
|
<%= tag.span t(".income") %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= tag.div class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 bg-white text-gray-800 shadow-sm" do %>
|
||||||
|
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
|
||||||
|
<%= tag.span t(".transfer") %>
|
||||||
|
<% end %>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-2">
|
||||||
|
<%= f.text_field :name, value: transfer.transactions.first&.name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
|
||||||
|
<%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
|
||||||
|
<%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
|
||||||
|
<%= f.money_field :amount_money, label: t(".amount"), required: true %>
|
||||||
|
<%= f.date_field :date, value: transfer.transactions.first&.date, label: t(".date"), required: true, max: Date.current %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<%= f.submit t(".submit") %>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
37
app/views/transfers/_transfer.html.erb
Normal file
37
app/views/transfers/_transfer.html.erb
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<%= turbo_frame_tag dom_id(transfer), class: "block" do %>
|
||||||
|
<details class="group flex items-center text-gray-900 p-4 text-sm font-medium">
|
||||||
|
<summary class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<%= button_to transfer_path(transfer),
|
||||||
|
method: :delete,
|
||||||
|
class: "flex items-center group/transfer",
|
||||||
|
data: {
|
||||||
|
turbo_frame: "_top",
|
||||||
|
turbo_confirm: {
|
||||||
|
title: t(".remove_title"),
|
||||||
|
body: t(".remove_body"),
|
||||||
|
confirm: t(".remove_confirm")
|
||||||
|
}
|
||||||
|
} do %>
|
||||||
|
<%= lucide_icon "arrow-left-right", class: "group-hover/transfer:hidden w-5 h-5 text-gray-500" %>
|
||||||
|
<%= lucide_icon "unlink", class: "group-hover/transfer:inline-block hidden w-5 h-5 text-gray-500" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="max-w-full pr-10 select-none">
|
||||||
|
<%= tag.p t(".transfer_name", from_account: transfer.outflow_transaction&.account&.name, to_account: transfer.inflow_transaction&.account&.name) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="pt-2 divide-y divide-alpha-black-200">
|
||||||
|
<% transfer.transactions.each do |transaction| %>
|
||||||
|
<div class="py-3 flex items-center justify-between">
|
||||||
|
<%= render "transactions/name", transaction: transaction %>
|
||||||
|
<%= render "transactions/amount", transaction: transaction %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
17
app/views/transfers/new.html.erb
Normal file
17
app/views/transfers/new.html.erb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<%= modal do %>
|
||||||
|
<article class="mx-auto p-4 space-y-4 w-screen max-w-xl">
|
||||||
|
<header class="flex justify-between">
|
||||||
|
<%= tag.h2 t(".title"), class: "font-medium text-xl" %>
|
||||||
|
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<% if @transfer.errors.present? %>
|
||||||
|
<div class="text-red-600 flex items-center gap-2">
|
||||||
|
<%= lucide_icon "circle-alert", class: "w-5 h-5" %>
|
||||||
|
<p class="text-sm"><%= @transfer.errors.full_messages.to_sentence %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render "form", transfer: @transfer %>
|
||||||
|
</article>
|
||||||
|
<% end %>
|
|
@ -89,6 +89,8 @@ en:
|
||||||
import: Import
|
import: Import
|
||||||
index:
|
index:
|
||||||
transaction: transaction
|
transaction: transaction
|
||||||
|
mark_transfers:
|
||||||
|
success: Marked as transfer
|
||||||
merchants:
|
merchants:
|
||||||
create:
|
create:
|
||||||
success: New merchant created successfully
|
success: New merchant created successfully
|
||||||
|
@ -116,6 +118,11 @@ en:
|
||||||
title: New merchant
|
title: New merchant
|
||||||
update:
|
update:
|
||||||
success: Merchant updated successfully
|
success: Merchant updated successfully
|
||||||
|
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
|
||||||
account_placeholder: Select an account
|
account_placeholder: Select an account
|
||||||
|
@ -127,7 +134,6 @@ en:
|
||||||
delete_subtitle: This permanently deletes the transaction, affects your historical
|
delete_subtitle: This permanently deletes the transaction, affects your historical
|
||||||
balances, and cannot be undone.
|
balances, and cannot be undone.
|
||||||
delete_title: Delete transaction
|
delete_title: Delete transaction
|
||||||
description: Description
|
|
||||||
exclude_subtitle: This excludes the transaction from any in-app features or
|
exclude_subtitle: This excludes the transaction from any in-app features or
|
||||||
analytics.
|
analytics.
|
||||||
exclude_title: Exclude transaction
|
exclude_title: Exclude transaction
|
||||||
|
@ -139,5 +145,11 @@ en:
|
||||||
overview: Overview
|
overview: Overview
|
||||||
settings: Settings
|
settings: Settings
|
||||||
tags_label: Select one or more tags
|
tags_label: Select one or more tags
|
||||||
|
transaction:
|
||||||
|
remove_transfer: Remove transfer
|
||||||
|
remove_transfer_body: This will remove the transfer from this transaction
|
||||||
|
remove_transfer_confirm: Confirm
|
||||||
|
unmark_transfers:
|
||||||
|
success: Transfer removed
|
||||||
update:
|
update:
|
||||||
success: Transaction updated successfully
|
success: Transaction updated successfully
|
||||||
|
|
27
config/locales/views/transfers/en.yml
Normal file
27
config/locales/views/transfers/en.yml
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
en:
|
||||||
|
transfers:
|
||||||
|
create:
|
||||||
|
success: Transfer created
|
||||||
|
destroy:
|
||||||
|
success: Transfer removed
|
||||||
|
form:
|
||||||
|
amount: Amount
|
||||||
|
date: Date
|
||||||
|
description: Description
|
||||||
|
description_placeholder: Transfer from Checking to Savings
|
||||||
|
expense: Expense
|
||||||
|
from: From
|
||||||
|
income: Income
|
||||||
|
select_account: Select account
|
||||||
|
submit: Create transfer
|
||||||
|
to: To
|
||||||
|
transfer: Transfer
|
||||||
|
new:
|
||||||
|
title: New transfer
|
||||||
|
transfer:
|
||||||
|
remove_body: This will NOT delete the underlying transactions. It will just
|
||||||
|
remove the transfer.
|
||||||
|
remove_confirm: Confirm
|
||||||
|
remove_title: Remove transfer?
|
||||||
|
transfer_name: Transfer from %{from_account} to %{to_account}
|
|
@ -46,6 +46,8 @@ Rails.application.routes.draw do
|
||||||
post "bulk_delete"
|
post "bulk_delete"
|
||||||
get "bulk_edit"
|
get "bulk_edit"
|
||||||
post "bulk_update"
|
post "bulk_update"
|
||||||
|
post "mark_transfers"
|
||||||
|
post "unmark_transfers"
|
||||||
|
|
||||||
scope module: :transactions, as: :transaction do
|
scope module: :transactions, as: :transaction do
|
||||||
resources :rows, only: %i[ show update ]
|
resources :rows, only: %i[ show update ]
|
||||||
|
@ -63,6 +65,8 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :transfers, only: %i[ new create destroy ]
|
||||||
|
|
||||||
resources :accounts, shallow: true do
|
resources :accounts, shallow: true do
|
||||||
get :summary, on: :collection
|
get :summary, on: :collection
|
||||||
get :list, on: :collection
|
get :list, on: :collection
|
||||||
|
|
7
db/migrate/20240614120946_create_transfers.rb
Normal file
7
db/migrate/20240614120946_create_transfers.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
class CreateTransfers < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :transfers, id: :uuid do |t|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,8 @@
|
||||||
|
class AddTransferFieldsToTransaction < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
change_table :transactions do |t|
|
||||||
|
t.references :transfer, foreign_key: true, type: :uuid
|
||||||
|
t.boolean :marked_as_transfer, default: false, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
13
db/schema.rb
generated
13
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_06_12_164944) do
|
ActiveRecord::Schema[7.2].define(version: 2024_06_14_121110) 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"
|
||||||
|
@ -87,7 +87,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_12_164944) do
|
||||||
t.uuid "accountable_id"
|
t.uuid "accountable_id"
|
||||||
t.decimal "balance", precision: 19, scale: 4, default: "0.0"
|
t.decimal "balance", precision: 19, scale: 4, default: "0.0"
|
||||||
t.string "currency", default: "USD"
|
t.string "currency", default: "USD"
|
||||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Account::Loan'::character varying)::text, ('Account::Credit'::character varying)::text, ('Account::OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Account::Loan'::character varying, 'Account::Credit'::character varying, 'Account::OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||||
t.boolean "is_active", default: true, null: false
|
t.boolean "is_active", default: true, null: false
|
||||||
t.enum "status", default: "ok", null: false, enum_type: "account_status"
|
t.enum "status", default: "ok", null: false, enum_type: "account_status"
|
||||||
t.jsonb "sync_warnings", default: [], null: false
|
t.jsonb "sync_warnings", default: [], null: false
|
||||||
|
@ -310,9 +310,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_12_164944) do
|
||||||
t.boolean "excluded", default: false
|
t.boolean "excluded", default: false
|
||||||
t.text "notes"
|
t.text "notes"
|
||||||
t.uuid "merchant_id"
|
t.uuid "merchant_id"
|
||||||
|
t.uuid "transfer_id"
|
||||||
|
t.boolean "marked_as_transfer", default: false, null: false
|
||||||
t.index ["account_id"], name: "index_transactions_on_account_id"
|
t.index ["account_id"], name: "index_transactions_on_account_id"
|
||||||
t.index ["category_id"], name: "index_transactions_on_category_id"
|
t.index ["category_id"], name: "index_transactions_on_category_id"
|
||||||
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
|
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
|
||||||
|
t.index ["transfer_id"], name: "index_transactions_on_transfer_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
end
|
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|
|
||||||
|
@ -357,6 +365,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_12_164944) do
|
||||||
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "transactions", "transaction_categories", column: "category_id", on_delete: :nullify
|
add_foreign_key "transactions", "transaction_categories", column: "category_id", on_delete: :nullify
|
||||||
add_foreign_key "transactions", "transaction_merchants", column: "merchant_id"
|
add_foreign_key "transactions", "transaction_merchants", column: "merchant_id"
|
||||||
|
add_foreign_key "transactions", "transfers"
|
||||||
add_foreign_key "users", "families"
|
add_foreign_key "users", "families"
|
||||||
add_foreign_key "valuations", "accounts", on_delete: :cascade
|
add_foreign_key "valuations", "accounts", on_delete: :cascade
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
setup do
|
setup do
|
||||||
sign_in @user = users(:family_admin)
|
sign_in @user = users(:family_admin)
|
||||||
@transaction = transactions(:checking_one)
|
@transaction = transactions(:checking_one)
|
||||||
@recent_transactions = @user.family.transactions.ordered.limit(20).to_a
|
@recent_transactions = @user.family.transactions.ordered.where(transfer_id: nil).limit(20).to_a
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should get paginated index with most recent transactions first" do
|
test "should get paginated index with most recent transactions first" do
|
||||||
|
@ -18,7 +18,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
test "transaction count represents filtered total" do
|
test "transaction count represents filtered total" do
|
||||||
get transactions_url
|
get transactions_url
|
||||||
assert_dom "#total-transactions", count: 1, text: @user.family.transactions.count.to_s
|
assert_dom "#total-transactions", count: 1, text: @user.family.transactions.select { |t| t.currency == "USD" }.count.to_s
|
||||||
|
|
||||||
new_transaction = @user.family.accounts.first.transactions.create! \
|
new_transaction = @user.family.accounts.first.transactions.create! \
|
||||||
name: "Transaction to search for",
|
name: "Transaction to search for",
|
||||||
|
@ -42,7 +42,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "loads last page when page is out of range" do
|
test "loads last page when page is out of range" do
|
||||||
user_oldest_transaction = @user.family.transactions.ordered.last
|
user_oldest_transaction = @user.family.transactions.ordered.reject(&:transfer?).last
|
||||||
get transactions_url(page: 9999999999)
|
get transactions_url(page: 9999999999)
|
||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
|
|
33
test/controllers/transfers_controller_test.rb
Normal file
33
test/controllers/transfers_controller_test.rb
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TransfersControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in users(:family_admin)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get new" do
|
||||||
|
get new_transfer_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create transfers" do
|
||||||
|
assert_difference "Transfer.count", 1 do
|
||||||
|
post transfers_url, params: {
|
||||||
|
transfer: {
|
||||||
|
from_account_id: accounts(:checking).id,
|
||||||
|
to_account_id: accounts(:savings).id,
|
||||||
|
date: Date.current,
|
||||||
|
amount: 100,
|
||||||
|
currency: "USD",
|
||||||
|
name: "Test Transfer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can destroy transfer" do
|
||||||
|
assert_difference -> { Transfer.count } => -1, -> { Transaction.count } => 0 do
|
||||||
|
delete transfer_url(transfers(:credit_card_payment))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
3
test/fixtures/account/credits.yml
vendored
3
test/fixtures/account/credits.yml
vendored
|
@ -1,2 +1 @@
|
||||||
one:
|
credit_one: { }
|
||||||
id: "123e4567-e89b-12d3-a456-426614174003"
|
|
||||||
|
|
12
test/fixtures/account/cryptos.yml
vendored
12
test/fixtures/account/cryptos.yml
vendored
|
@ -1,11 +1 @@
|
||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
one: { }
|
||||||
|
|
||||||
# This model initially had no columns defined. If you add columns to the
|
|
||||||
# model remove the "{}" from the fixture names and add the columns immediately
|
|
||||||
# below each fixture, per the syntax in the comments below
|
|
||||||
#
|
|
||||||
# one: {}
|
|
||||||
# column: value
|
|
||||||
#
|
|
||||||
# two: {}
|
|
||||||
# column: value
|
|
12
test/fixtures/account/depositories.yml
vendored
12
test/fixtures/account/depositories.yml
vendored
|
@ -1,8 +1,4 @@
|
||||||
checking:
|
depository_checking: { }
|
||||||
id: "123e4567-e89b-12d3-a456-426614174000"
|
depository_savings: { }
|
||||||
savings:
|
depository_eur_checking: { }
|
||||||
id: "123e4567-e89b-12d3-a456-426614174001"
|
depository_multi_currency: { }
|
||||||
eur_checking:
|
|
||||||
id: "123e4567-e89b-12d3-a456-426614174004"
|
|
||||||
multi_currency:
|
|
||||||
id: "123e4567-e89b-12d3-a456-426614174005"
|
|
||||||
|
|
32
test/fixtures/account/expected_balances.csv
vendored
32
test/fixtures/account/expected_balances.csv
vendored
|
@ -1,32 +0,0 @@
|
||||||
date_offset,collectable,checking,savings_with_valuation_overrides,credit_card,eur_checking_eur,eur_checking_usd,multi_currency
|
|
||||||
-30,400,4000,21250,1040,11850,12947.31,10721.26
|
|
||||||
-29,400,3985,21750,940,12050,13182.7,10921.26
|
|
||||||
-28,400,3985,21750,940,12050,13194.75,10921.26
|
|
||||||
-27,400,3985,21750,940,12050,13132.09,10921.26
|
|
||||||
-26,400,3985,21750,940,12050,13083.89,10921.26
|
|
||||||
-25,400,3985,21000,940,12050,13081.48,10921.26
|
|
||||||
-24,400,3985,21000,940,12050,13062.2,10921.26
|
|
||||||
-23,400,3985,21000,940,12050,13022.435,10921.26
|
|
||||||
-22,400,5060,21000,940,12050,13060.995,10921.26
|
|
||||||
-21,400,5060,21000,940,12050,13068.225,10921.26
|
|
||||||
-20,400,5060,21000,940,12050,13079.07,10921.26
|
|
||||||
-19,400,5060,21000,940,11950,12932.29,10813.04
|
|
||||||
-18,400,5060,19000,940,11950,12934.68,10813.04
|
|
||||||
-17,400,5060,19000,940,11950,12927.51,10813.04
|
|
||||||
-16,400,5060,19000,940,11950,12916.755,10813.04
|
|
||||||
-15,400,5040,19000,960,11950,12882.1,10813.04
|
|
||||||
-14,400,5040,19000,960,11950,12879.71,10813.04
|
|
||||||
-13,400,5040,19000,960,11950,12873.735,10813.04
|
|
||||||
-12,700,5010,19500,990,11950,12821.155,10813.04
|
|
||||||
-11,700,5010,19500,990,11950,12797.255,10813.04
|
|
||||||
-10,700,5010,19500,990,11950,12873.735,10813.04
|
|
||||||
-9,700,5010,19500,990,12000,12939.6,10863.04
|
|
||||||
-8,700,5010,19500,990,12000,12933.6,10863.04
|
|
||||||
-7,700,5010,19500,990,12000,12928.8,10863.04
|
|
||||||
-6,700,5010,19500,990,12000,12906,10863.04
|
|
||||||
-5,700,5000,19700,1000,12000,12891.6,10863.04
|
|
||||||
-4,550,5000,19700,1000,12000,12945.6,10000
|
|
||||||
-3,550,5000,20500,1000,12000,13046.4,10000
|
|
||||||
-2,550,5000,20500,1000,12000,12982.8,10000
|
|
||||||
-1,550,5000,20500,1000,12000,13014,10000
|
|
||||||
0,550,5000,20500,1000,12000,13000.8,10000
|
|
|
1
test/fixtures/account/investments.yml
vendored
1
test/fixtures/account/investments.yml
vendored
|
@ -0,0 +1 @@
|
||||||
|
investment_brokerage: { }
|
1
test/fixtures/account/loans.yml
vendored
1
test/fixtures/account/loans.yml
vendored
|
@ -0,0 +1 @@
|
||||||
|
loan_mortgage: { }
|
5
test/fixtures/account/other_assets.yml
vendored
5
test/fixtures/account/other_assets.yml
vendored
|
@ -1,2 +1,3 @@
|
||||||
one:
|
other_asset_collectable: { }
|
||||||
id: "123e4567-e89b-12d3-a456-426614174002"
|
|
||||||
|
|
||||||
|
|
1
test/fixtures/account/other_liabilities.yml
vendored
1
test/fixtures/account/other_liabilities.yml
vendored
|
@ -0,0 +1 @@
|
||||||
|
other_asset_iou: { }
|
1
test/fixtures/account/properties.yml
vendored
1
test/fixtures/account/properties.yml
vendored
|
@ -0,0 +1 @@
|
||||||
|
property_house: { }
|
1
test/fixtures/account/vehicles.yml
vendored
1
test/fixtures/account/vehicles.yml
vendored
|
@ -0,0 +1 @@
|
||||||
|
vehicle_honda_accord: { }
|
13
test/fixtures/account_balances.yml
vendored
13
test/fixtures/account_balances.yml
vendored
|
@ -1,13 +0,0 @@
|
||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
|
||||||
|
|
||||||
# one:
|
|
||||||
# account: generic
|
|
||||||
# date: 2024-02-12
|
|
||||||
# balance: 9.99
|
|
||||||
# currency: MyString
|
|
||||||
|
|
||||||
# two:
|
|
||||||
# account: generic
|
|
||||||
# date: 2024-02-13
|
|
||||||
# balance: 9.99
|
|
||||||
# currency: MyString
|
|
61
test/fixtures/accounts.yml
vendored
61
test/fixtures/accounts.yml
vendored
|
@ -1,36 +1,39 @@
|
||||||
# Account with only valuations
|
|
||||||
collectable:
|
collectable:
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
name: Collectable Account
|
name: Collectable Account
|
||||||
balance: 550
|
balance: 550
|
||||||
accountable_type: Account::OtherAsset
|
accountable_type: Account::OtherAsset
|
||||||
accountable_id: "123e4567-e89b-12d3-a456-426614174002"
|
accountable: other_asset_collectable
|
||||||
|
|
||||||
|
iou:
|
||||||
|
family: dylan_family
|
||||||
|
name: IOU (personal debt to friend)
|
||||||
|
balance: 200
|
||||||
|
accountable_type: Account::OtherLiability
|
||||||
|
accountable: other_liability_iou
|
||||||
|
|
||||||
# Account with only transactions
|
|
||||||
checking:
|
checking:
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
name: Checking Account
|
name: Checking Account
|
||||||
balance: 5000
|
balance: 5000
|
||||||
accountable_type: Account::Depository
|
accountable_type: Account::Depository
|
||||||
accountable_id: "123e4567-e89b-12d3-a456-426614174000"
|
accountable: depository_checking
|
||||||
institution: chase
|
institution: chase
|
||||||
|
|
||||||
# Account with both transactions and valuations
|
savings:
|
||||||
savings_with_valuation_overrides:
|
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
name: Savings account with valuation overrides
|
name: Savings account with valuation overrides
|
||||||
balance: 20000
|
balance: 19700
|
||||||
accountable_type: Account::Depository
|
accountable_type: Account::Depository
|
||||||
accountable_id: "123e4567-e89b-12d3-a456-426614174001"
|
accountable: depository_savings
|
||||||
institution: chase
|
institution: chase
|
||||||
|
|
||||||
# Liability account
|
|
||||||
credit_card:
|
credit_card:
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
name: Credit Card
|
name: Credit Card
|
||||||
balance: 1000
|
balance: 1000
|
||||||
accountable_type: Account::Credit
|
accountable_type: Account::Credit
|
||||||
accountable_id: "123e4567-e89b-12d3-a456-426614174003"
|
accountable: credit_one
|
||||||
institution: chase
|
institution: chase
|
||||||
|
|
||||||
eur_checking:
|
eur_checking:
|
||||||
|
@ -39,7 +42,7 @@ eur_checking:
|
||||||
currency: EUR
|
currency: EUR
|
||||||
balance: 12000
|
balance: 12000
|
||||||
accountable_type: Account::Depository
|
accountable_type: Account::Depository
|
||||||
accountable_id: "123e4567-e89b-12d3-a456-426614174004"
|
accountable: depository_eur_checking
|
||||||
institution: revolut
|
institution: revolut
|
||||||
|
|
||||||
# Multi-currency account (e.g. Wise, Revolut, etc.)
|
# Multi-currency account (e.g. Wise, Revolut, etc.)
|
||||||
|
@ -47,7 +50,39 @@ multi_currency:
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
name: Multi Currency Account
|
name: Multi Currency Account
|
||||||
currency: USD # multi-currency accounts still have a "primary" currency
|
currency: USD # multi-currency accounts still have a "primary" currency
|
||||||
balance: 10000
|
balance: 9467
|
||||||
accountable_type: Account::Depository
|
accountable_type: Account::Depository
|
||||||
accountable_id: "123e4567-e89b-12d3-a456-426614174005"
|
accountable: depository_multi_currency
|
||||||
institution: revolut
|
institution: revolut
|
||||||
|
|
||||||
|
brokerage:
|
||||||
|
family: dylan_family
|
||||||
|
name: Robinhood Brokerage Account
|
||||||
|
currency: USD
|
||||||
|
balance: 10000
|
||||||
|
accountable_type: Account::Investment
|
||||||
|
accountable: investment_brokerage
|
||||||
|
|
||||||
|
mortgage_loan:
|
||||||
|
family: dylan_family
|
||||||
|
name: Mortgage Loan
|
||||||
|
currency: USD
|
||||||
|
balance: 500000
|
||||||
|
accountable_type: Account::Loan
|
||||||
|
accountable: loan_mortgage
|
||||||
|
|
||||||
|
house:
|
||||||
|
family: dylan_family
|
||||||
|
name: 123 Maybe Court
|
||||||
|
currency: USD
|
||||||
|
balance: 550000
|
||||||
|
accountable_type: Account::Property
|
||||||
|
accountable: property_house
|
||||||
|
|
||||||
|
car:
|
||||||
|
family: dylan_family
|
||||||
|
name: Honda Accord
|
||||||
|
currency: USD
|
||||||
|
balance: 18000
|
||||||
|
accountable_type: Account::Vehicle
|
||||||
|
accountable: vehicle_honda_accord
|
||||||
|
|
73
test/fixtures/exchange_rates.yml
vendored
73
test/fixtures/exchange_rates.yml
vendored
|
@ -1,308 +1,381 @@
|
||||||
|
day_31_ago_eur_to_usd:
|
||||||
|
base_currency: EUR
|
||||||
|
converted_currency: USD
|
||||||
|
rate: 1.0986
|
||||||
|
date: <%= 31.days.ago.to_date %>
|
||||||
|
|
||||||
day_30_ago_eur_to_usd:
|
day_30_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0926
|
rate: 1.0926
|
||||||
date: <%= 30.days.ago.to_date %>
|
date: <%= 30.days.ago.to_date %>
|
||||||
|
|
||||||
day_29_ago_eur_to_usd:
|
day_29_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.094
|
rate: 1.094
|
||||||
date: <%= 29.days.ago.to_date %>
|
date: <%= 29.days.ago.to_date %>
|
||||||
|
|
||||||
day_28_ago_eur_to_usd:
|
day_28_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.095
|
rate: 1.095
|
||||||
date: <%= 28.days.ago.to_date %>
|
date: <%= 28.days.ago.to_date %>
|
||||||
|
|
||||||
day_27_ago_eur_to_usd:
|
day_27_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0898
|
rate: 1.0898
|
||||||
date: <%= 27.days.ago.to_date %>
|
date: <%= 27.days.ago.to_date %>
|
||||||
|
|
||||||
day_26_ago_eur_to_usd:
|
day_26_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0858
|
rate: 1.0858
|
||||||
date: <%= 26.days.ago.to_date %>
|
date: <%= 26.days.ago.to_date %>
|
||||||
|
|
||||||
day_25_ago_eur_to_usd:
|
day_25_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0856
|
rate: 1.0856
|
||||||
date: <%= 25.days.ago.to_date %>
|
date: <%= 25.days.ago.to_date %>
|
||||||
|
|
||||||
day_24_ago_eur_to_usd:
|
day_24_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.084
|
rate: 1.084
|
||||||
date: <%= 24.days.ago.to_date %>
|
date: <%= 24.days.ago.to_date %>
|
||||||
|
|
||||||
day_23_ago_eur_to_usd:
|
day_23_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0807
|
rate: 1.0807
|
||||||
date: <%= 23.days.ago.to_date %>
|
date: <%= 23.days.ago.to_date %>
|
||||||
|
|
||||||
day_22_ago_eur_to_usd:
|
day_22_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0839
|
rate: 1.0839
|
||||||
date: <%= 22.days.ago.to_date %>
|
date: <%= 22.days.ago.to_date %>
|
||||||
|
|
||||||
day_21_ago_eur_to_usd:
|
day_21_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0845
|
rate: 1.0845
|
||||||
date: <%= 21.days.ago.to_date %>
|
date: <%= 21.days.ago.to_date %>
|
||||||
|
|
||||||
day_20_ago_eur_to_usd:
|
day_20_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0854
|
rate: 1.0854
|
||||||
date: <%= 20.days.ago.to_date %>
|
date: <%= 20.days.ago.to_date %>
|
||||||
|
|
||||||
day_19_ago_eur_to_usd:
|
day_19_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0822
|
rate: 1.0822
|
||||||
date: <%= 19.days.ago.to_date %>
|
date: <%= 19.days.ago.to_date %>
|
||||||
|
|
||||||
day_18_ago_eur_to_usd:
|
day_18_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0824
|
rate: 1.0824
|
||||||
date: <%= 18.days.ago.to_date %>
|
date: <%= 18.days.ago.to_date %>
|
||||||
|
|
||||||
day_17_ago_eur_to_usd:
|
day_17_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0818
|
rate: 1.0818
|
||||||
date: <%= 17.days.ago.to_date %>
|
date: <%= 17.days.ago.to_date %>
|
||||||
|
|
||||||
day_16_ago_eur_to_usd:
|
day_16_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0809
|
rate: 1.0809
|
||||||
date: <%= 16.days.ago.to_date %>
|
date: <%= 16.days.ago.to_date %>
|
||||||
|
|
||||||
day_15_ago_eur_to_usd:
|
day_15_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.078
|
rate: 1.078
|
||||||
date: <%= 15.days.ago.to_date %>
|
date: <%= 15.days.ago.to_date %>
|
||||||
|
|
||||||
day_14_ago_eur_to_usd:
|
day_14_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0778
|
rate: 1.0778
|
||||||
date: <%= 14.days.ago.to_date %>
|
date: <%= 14.days.ago.to_date %>
|
||||||
|
|
||||||
day_13_ago_eur_to_usd:
|
day_13_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0773
|
rate: 1.0773
|
||||||
date: <%= 13.days.ago.to_date %>
|
date: <%= 13.days.ago.to_date %>
|
||||||
|
|
||||||
day_12_ago_eur_to_usd:
|
day_12_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0729
|
rate: 1.0729
|
||||||
date: <%= 12.days.ago.to_date %>
|
date: <%= 12.days.ago.to_date %>
|
||||||
|
|
||||||
day_11_ago_eur_to_usd:
|
day_11_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0709
|
rate: 1.0709
|
||||||
date: <%= 11.days.ago.to_date %>
|
date: <%= 11.days.ago.to_date %>
|
||||||
|
|
||||||
day_10_ago_eur_to_usd:
|
day_10_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0773
|
rate: 1.0773
|
||||||
date: <%= 10.days.ago.to_date %>
|
date: <%= 10.days.ago.to_date %>
|
||||||
|
|
||||||
day_9_ago_eur_to_usd:
|
day_9_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0783
|
rate: 1.0783
|
||||||
date: <%= 9.days.ago.to_date %>
|
date: <%= 9.days.ago.to_date %>
|
||||||
|
|
||||||
day_8_ago_eur_to_usd:
|
day_8_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0778
|
rate: 1.0778
|
||||||
date: <%= 8.days.ago.to_date %>
|
date: <%= 8.days.ago.to_date %>
|
||||||
|
|
||||||
day_7_ago_eur_to_usd:
|
day_7_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0774
|
rate: 1.0774
|
||||||
date: <%= 7.days.ago.to_date %>
|
date: <%= 7.days.ago.to_date %>
|
||||||
|
|
||||||
day_6_ago_eur_to_usd:
|
day_6_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0755
|
rate: 1.0755
|
||||||
date: <%= 6.days.ago.to_date %>
|
date: <%= 6.days.ago.to_date %>
|
||||||
|
|
||||||
day_5_ago_eur_to_usd:
|
day_5_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0743
|
rate: 1.0743
|
||||||
date: <%= 5.days.ago.to_date %>
|
date: <%= 5.days.ago.to_date %>
|
||||||
|
|
||||||
day_4_ago_eur_to_usd:
|
day_4_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0788
|
rate: 1.0788
|
||||||
date: <%= 4.days.ago.to_date %>
|
date: <%= 4.days.ago.to_date %>
|
||||||
|
|
||||||
day_3_ago_eur_to_usd:
|
day_3_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0872
|
rate: 1.0872
|
||||||
date: <%= 3.days.ago.to_date %>
|
date: <%= 3.days.ago.to_date %>
|
||||||
|
|
||||||
day_2_ago_eur_to_usd:
|
day_2_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0819
|
rate: 1.0819
|
||||||
date: <%= 2.days.ago.to_date %>
|
date: <%= 2.days.ago.to_date %>
|
||||||
|
|
||||||
day_1_ago_eur_to_usd:
|
day_1_ago_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0845
|
rate: 1.0845
|
||||||
date: <%= 1.days.ago.to_date %>
|
date: <%= 1.days.ago.to_date %>
|
||||||
|
|
||||||
today_eur_to_usd:
|
today_eur_to_usd:
|
||||||
base_currency: EUR
|
base_currency: EUR
|
||||||
converted_currency: USD
|
converted_currency: USD
|
||||||
rate: 1.0834
|
rate: 1.0834
|
||||||
date: <%= Date.current %>
|
date: <%= Date.current %>
|
||||||
|
|
||||||
|
day_31_ago_usd_to_eur:
|
||||||
|
base_currency: USD
|
||||||
|
converted_currency: EUR
|
||||||
|
rate: 0.9279
|
||||||
|
date: <%= 31.days.ago.to_date %>
|
||||||
|
|
||||||
day_30_ago_usd_to_eur:
|
day_30_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9179
|
rate: 0.9179
|
||||||
date: <%= 30.days.ago.to_date %>
|
date: <%= 30.days.ago.to_date %>
|
||||||
|
|
||||||
day_29_ago_usd_to_eur:
|
day_29_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9154
|
rate: 0.9154
|
||||||
date: <%= 29.days.ago.to_date %>
|
date: <%= 29.days.ago.to_date %>
|
||||||
|
|
||||||
day_28_ago_usd_to_eur:
|
day_28_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9107
|
rate: 0.9107
|
||||||
date: <%= 28.days.ago.to_date %>
|
date: <%= 28.days.ago.to_date %>
|
||||||
|
|
||||||
day_27_ago_usd_to_eur:
|
day_27_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9139
|
rate: 0.9139
|
||||||
date: <%= 27.days.ago.to_date %>
|
date: <%= 27.days.ago.to_date %>
|
||||||
|
|
||||||
day_26_ago_usd_to_eur:
|
day_26_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9082
|
rate: 0.9082
|
||||||
date: <%= 26.days.ago.to_date %>
|
date: <%= 26.days.ago.to_date %>
|
||||||
|
|
||||||
day_25_ago_usd_to_eur:
|
day_25_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9077
|
rate: 0.9077
|
||||||
date: <%= 25.days.ago.to_date %>
|
date: <%= 25.days.ago.to_date %>
|
||||||
|
|
||||||
day_24_ago_usd_to_eur:
|
day_24_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9054
|
rate: 0.9054
|
||||||
date: <%= 24.days.ago.to_date %>
|
date: <%= 24.days.ago.to_date %>
|
||||||
|
|
||||||
day_23_ago_usd_to_eur:
|
day_23_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9004
|
rate: 0.9004
|
||||||
date: <%= 23.days.ago.to_date %>
|
date: <%= 23.days.ago.to_date %>
|
||||||
|
|
||||||
day_22_ago_usd_to_eur:
|
day_22_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9040
|
rate: 0.9040
|
||||||
date: <%= 22.days.ago.to_date %>
|
date: <%= 22.days.ago.to_date %>
|
||||||
|
|
||||||
day_21_ago_usd_to_eur:
|
day_21_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9060
|
rate: 0.9060
|
||||||
date: <%= 21.days.ago.to_date %>
|
date: <%= 21.days.ago.to_date %>
|
||||||
|
|
||||||
day_20_ago_usd_to_eur:
|
day_20_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9052
|
rate: 0.9052
|
||||||
date: <%= 20.days.ago.to_date %>
|
date: <%= 20.days.ago.to_date %>
|
||||||
|
|
||||||
day_19_ago_usd_to_eur:
|
day_19_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9139
|
rate: 0.9139
|
||||||
date: <%= 19.days.ago.to_date %>
|
date: <%= 19.days.ago.to_date %>
|
||||||
|
|
||||||
day_18_ago_usd_to_eur:
|
day_18_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9155
|
rate: 0.9155
|
||||||
date: <%= 18.days.ago.to_date %>
|
date: <%= 18.days.ago.to_date %>
|
||||||
|
|
||||||
day_17_ago_usd_to_eur:
|
day_17_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9135
|
rate: 0.9135
|
||||||
date: <%= 17.days.ago.to_date %>
|
date: <%= 17.days.ago.to_date %>
|
||||||
|
|
||||||
day_16_ago_usd_to_eur:
|
day_16_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9141
|
rate: 0.9141
|
||||||
date: <%= 16.days.ago.to_date %>
|
date: <%= 16.days.ago.to_date %>
|
||||||
|
|
||||||
day_15_ago_usd_to_eur:
|
day_15_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9131
|
rate: 0.9131
|
||||||
date: <%= 15.days.ago.to_date %>
|
date: <%= 15.days.ago.to_date %>
|
||||||
|
|
||||||
day_14_ago_usd_to_eur:
|
day_14_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9147
|
rate: 0.9147
|
||||||
date: <%= 14.days.ago.to_date %>
|
date: <%= 14.days.ago.to_date %>
|
||||||
|
|
||||||
day_13_ago_usd_to_eur:
|
day_13_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9112
|
rate: 0.9112
|
||||||
date: <%= 13.days.ago.to_date %>
|
date: <%= 13.days.ago.to_date %>
|
||||||
|
|
||||||
day_12_ago_usd_to_eur:
|
day_12_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9115
|
rate: 0.9115
|
||||||
date: <%= 12.days.ago.to_date %>
|
date: <%= 12.days.ago.to_date %>
|
||||||
|
|
||||||
day_11_ago_usd_to_eur:
|
day_11_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9132
|
rate: 0.9132
|
||||||
date: <%= 11.days.ago.to_date %>
|
date: <%= 11.days.ago.to_date %>
|
||||||
|
|
||||||
day_10_ago_usd_to_eur:
|
day_10_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9130
|
rate: 0.9130
|
||||||
date: <%= 10.days.ago.to_date %>
|
date: <%= 10.days.ago.to_date %>
|
||||||
|
|
||||||
day_9_ago_usd_to_eur:
|
day_9_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9192
|
rate: 0.9192
|
||||||
date: <%= 9.days.ago.to_date %>
|
date: <%= 9.days.ago.to_date %>
|
||||||
|
|
||||||
day_8_ago_usd_to_eur:
|
day_8_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9188
|
rate: 0.9188
|
||||||
date: <%= 8.days.ago.to_date %>
|
date: <%= 8.days.ago.to_date %>
|
||||||
|
|
||||||
day_7_ago_usd_to_eur:
|
day_7_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9194
|
rate: 0.9194
|
||||||
date: <%= 7.days.ago.to_date %>
|
date: <%= 7.days.ago.to_date %>
|
||||||
|
|
||||||
day_6_ago_usd_to_eur:
|
day_6_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9177
|
rate: 0.9177
|
||||||
date: <%= 6.days.ago.to_date %>
|
date: <%= 6.days.ago.to_date %>
|
||||||
|
|
||||||
day_5_ago_usd_to_eur:
|
day_5_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9187
|
rate: 0.9187
|
||||||
date: <%= 5.days.ago.to_date %>
|
date: <%= 5.days.ago.to_date %>
|
||||||
|
|
||||||
day_4_ago_usd_to_eur:
|
day_4_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9213
|
rate: 0.9213
|
||||||
date: <%= 4.days.ago.to_date %>
|
date: <%= 4.days.ago.to_date %>
|
||||||
|
|
||||||
day_3_ago_usd_to_eur:
|
day_3_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9186
|
rate: 0.9186
|
||||||
date: <%= 3.days.ago.to_date %>
|
date: <%= 3.days.ago.to_date %>
|
||||||
|
|
||||||
day_2_ago_usd_to_eur:
|
day_2_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9218
|
rate: 0.9218
|
||||||
date: <%= 2.days.ago.to_date %>
|
date: <%= 2.days.ago.to_date %>
|
||||||
|
|
||||||
day_1_ago_usd_to_eur:
|
day_1_ago_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
rate: 0.9213
|
rate: 0.9213
|
||||||
date: <%= 1.days.ago.to_date %>
|
date: <%= 1.days.ago.to_date %>
|
||||||
|
|
||||||
today_usd_to_eur:
|
today_usd_to_eur:
|
||||||
base_currency: USD
|
base_currency: USD
|
||||||
converted_currency: EUR
|
converted_currency: EUR
|
||||||
|
|
32
test/fixtures/family/expected_snapshots.csv
vendored
32
test/fixtures/family/expected_snapshots.csv
vendored
|
@ -1,32 +0,0 @@
|
||||||
date_offset,net_worth,assets,liabilities,depositories,investments,loans,credits,properties,vehicles,other_assets,other_liabilities,spending,income,rolling_spend,rolling_income,savings_rate
|
|
||||||
-30,48278.57,49318.57,1040.00,48918.57,0.00,0.00,1040.00,0.00,0.00,400.00,0.00,0,0,0,0,0
|
|
||||||
-29,49298.96,50238.96,940.00,49838.96,0.00,0.00,940.00,0.00,0.00,400.00,0.00,15,1018.8,15,1018.8,0.9852767962
|
|
||||||
-28,49311.01,50251.01,940.00,49851.01,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
|
||||||
-27,49248.35,50188.35,940.00,49788.35,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
|
||||||
-26,49200.15,50140.15,940.00,49740.15,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
|
||||||
-25,48447.74,49387.74,940.00,48987.74,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
|
||||||
-24,48428.46,49368.46,940.00,48968.46,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
|
||||||
-23,48388.70,49328.70,940.00,48928.70,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
|
||||||
-22,49502.26,50442.26,940.00,50042.26,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,1075,15,2093.8,0.992835992
|
|
||||||
-21,49509.49,50449.49,940.00,50049.49,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,2093.8,0.992835992
|
|
||||||
-20,49520.33,50460.33,940.00,50060.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,2093.8,0.992835992
|
|
||||||
-19,49265.33,50205.33,940.00,49805.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00,216.44,0,231.44,2093.8,0.8894641322
|
|
||||||
-18,47267.72,48207.72,940.00,47807.72,0.00,0.00,940.00,0.00,0.00,400.00,0.00,2000,0,2231.44,2093.8,-0.06573693763
|
|
||||||
-17,47260.55,48200.55,940.00,47800.55,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,2231.44,2093.8,-0.06573693763
|
|
||||||
-16,47249.80,48189.80,940.00,47789.80,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,2231.44,2093.8,-0.06573693763
|
|
||||||
-15,47175.14,48135.14,960.00,47735.14,0.00,0.00,960.00,0.00,0.00,400.00,0.00,40,0,2271.44,2093.8,-0.08484095902
|
|
||||||
-14,47172.75,48132.75,960.00,47732.75,0.00,0.00,960.00,0.00,0.00,400.00,0.00,0,0,2271.44,2093.8,-0.08484095902
|
|
||||||
-13,47166.78,48126.78,960.00,47726.78,0.00,0.00,960.00,0.00,0.00,400.00,0.00,0,0,2271.44,2093.8,-0.08484095902
|
|
||||||
-12,47854.20,48844.20,990.00,48144.20,0.00,0.00,990.00,0.00,0.00,700.00,0.00,60,50,2331.44,2143.8,-0.08752682153
|
|
||||||
-11,47830.30,48820.30,990.00,48120.30,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2143.8,-0.08752682153
|
|
||||||
-10,47906.78,48896.78,990.00,48196.78,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2143.8,-0.08752682153
|
|
||||||
-9,48022.64,49012.64,990.00,48312.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,103.915,2331.44,2247.715,-0.03724893948
|
|
||||||
-8,48016.64,49006.64,990.00,48306.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715,-0.03724893948
|
|
||||||
-7,48011.84,49001.84,990.00,48301.84,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715,-0.03724893948
|
|
||||||
-6,47989.04,48979.04,990.00,48279.04,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715,-0.03724893948
|
|
||||||
-5,48154.64,49154.64,1000.00,48454.64,0.00,0.00,1000.00,0.00,0.00,700.00,0.00,20,200,2351.44,2447.715,0.03933260204
|
|
||||||
-4,47195.60,48195.60,1000.00,47645.60,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,863.04,0,3214.48,2447.715,-0.3132574667
|
|
||||||
-3,48096.40,49096.40,1000.00,48546.40,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
|
||||||
-2,48032.80,49032.80,1000.00,48482.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
|
||||||
-1,48064.00,49064.00,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
|
||||||
0,48050.80,49050.80,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
|
|
0
test/fixtures/files/.keep
vendored
0
test/fixtures/files/.keep
vendored
33
test/fixtures/files/expected_family_snapshots.csv
vendored
Normal file
33
test/fixtures/files/expected_family_snapshots.csv
vendored
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
date_offset,collectable,iou,checking,credit_card,savings,eur_checking_eur,eur_checking_usd,multi_currency,brokerage,mortgage_loan,house,car,net_worth,assets,liabilities,depositories,investments,loans,credits,properties,vehicles,other_assets,other_liabilities,spending,income,rolling_spend,rolling_income,savings_rate
|
||||||
|
31,400.00,200.00,4950.00,1040.00,20700.00,11850.00,13018.41,10200.00,10000.00,500000.00,550000.00,18000.00,126028.41,627268.41,501240.00,48868.41,10000.00,500000.00,1040.00,550000.00,18000.00,400.00,200.00,0.00,0.00,0.00,0.00,0.0000
|
||||||
|
30,400.00,200.00,4100.00,940.00,20950.00,12050.00,13165.83,10200.00,10000.00,500000.00,550000.00,18000.00,125675.83,626815.83,501140.00,48415.83,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,218.52,0.00,218.52,1.0000
|
||||||
|
29,400.00,200.00,3985.00,940.00,21450.00,12050.00,13182.70,10400.00,10000.00,500000.00,550000.00,18000.00,126277.70,627417.70,501140.00,49017.70,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,15.00,700.00,15.00,918.52,0.9837
|
||||||
|
28,400.00,200.00,3985.00,940.00,21450.00,12050.00,13194.75,10400.00,10000.00,500000.00,550000.00,18000.00,126289.75,627429.75,501140.00,49029.75,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
|
||||||
|
27,400.00,200.00,3985.00,940.00,21450.00,12050.00,13132.09,10400.00,10000.00,500000.00,550000.00,18000.00,126227.09,627367.09,501140.00,48967.09,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
|
||||||
|
26,400.00,200.00,3985.00,940.00,21450.00,12050.00,13083.89,10400.00,10000.00,500000.00,550000.00,18000.00,126178.89,627318.89,501140.00,48918.89,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
|
||||||
|
25,400.00,200.00,3985.00,940.00,21000.00,12050.00,13081.48,10400.00,10000.00,500000.00,550000.00,18000.00,125726.48,626866.48,501140.00,48466.48,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
|
||||||
|
24,400.00,200.00,3985.00,940.00,21000.00,12050.00,13062.20,10400.00,10000.00,500000.00,550000.00,18000.00,125707.20,626847.20,501140.00,48447.20,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
|
||||||
|
23,400.00,200.00,3985.00,940.00,21000.00,12050.00,13022.44,10400.00,10000.00,500000.00,550000.00,18000.00,125667.44,626807.44,501140.00,48407.44,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
|
||||||
|
22,400.00,200.00,5060.00,940.00,21000.00,12050.00,13061.00,10400.00,10000.00,500000.00,550000.00,18000.00,126781.00,627921.00,501140.00,49521.00,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,1075.00,15.00,1993.52,0.9925
|
||||||
|
21,400.00,200.00,5060.00,940.00,21000.00,12050.00,13068.23,10400.00,10000.00,500000.00,550000.00,18000.00,126788.23,627928.23,501140.00,49528.23,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,1993.52,0.9925
|
||||||
|
20,400.00,200.00,5060.00,940.00,21000.00,12050.00,13079.07,10400.00,10000.00,500000.00,550000.00,18000.00,126799.07,627939.07,501140.00,49539.07,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,1993.52,0.9925
|
||||||
|
19,400.00,200.00,5060.00,940.00,21000.00,11950.00,12932.29,10280.04,10000.00,500000.00,550000.00,18000.00,126532.33,627672.33,501140.00,49272.33,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,228.18,0.00,243.18,1993.52,0.8780
|
||||||
|
18,400.00,200.00,5060.00,940.00,19000.00,11950.00,12934.68,10280.04,10000.00,500000.00,550000.00,18000.00,124534.72,625674.72,501140.00,47274.72,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,2000.00,0.00,2243.18,1993.52,-0.1252
|
||||||
|
17,400.00,200.00,5060.00,940.00,19000.00,11950.00,12927.51,10280.04,10000.00,500000.00,550000.00,18000.00,124527.55,625667.55,501140.00,47267.55,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2243.18,1993.52,-0.1252
|
||||||
|
16,400.00,200.00,5060.00,940.00,19000.00,11950.00,12916.76,10280.04,10000.00,500000.00,550000.00,18000.00,124516.79,625656.79,501140.00,47256.79,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2243.18,1993.52,-0.1252
|
||||||
|
15,400.00,200.00,5040.00,960.00,19000.00,11950.00,12882.10,10280.04,10000.00,500000.00,550000.00,18000.00,124442.14,625602.14,501160.00,47202.14,10000.00,500000.00,960.00,550000.00,18000.00,400.00,200.00,40.00,0.00,2283.18,1993.52,-0.1453
|
||||||
|
14,400.00,200.00,5040.00,960.00,19000.00,11950.00,12879.71,10280.04,10000.00,500000.00,550000.00,18000.00,124439.75,625599.75,501160.00,47199.75,10000.00,500000.00,960.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2283.18,1993.52,-0.1453
|
||||||
|
13,400.00,200.00,5040.00,960.00,19000.00,11950.00,12873.74,10280.04,10000.00,500000.00,550000.00,18000.00,124433.77,625593.77,501160.00,47193.77,10000.00,500000.00,960.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2283.18,1993.52,-0.1453
|
||||||
|
12,700.00,200.00,5010.00,990.00,19500.00,11950.00,12821.16,10280.04,10000.00,500000.00,550000.00,18000.00,125121.19,626311.19,501190.00,47611.19,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,60.00,50.00,2343.18,2043.52,-0.1466
|
||||||
|
11,700.00,200.00,5010.00,990.00,19500.00,11950.00,12797.26,10280.04,10000.00,500000.00,550000.00,18000.00,125097.29,626287.29,501190.00,47587.29,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2043.52,-0.1466
|
||||||
|
10,700.00,200.00,5010.00,990.00,19500.00,11950.00,12873.74,10280.04,10000.00,500000.00,550000.00,18000.00,125173.77,626363.77,501190.00,47663.77,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2043.52,-0.1466
|
||||||
|
9,700.00,200.00,5010.00,990.00,19500.00,12000.00,12939.60,10330.04,10000.00,500000.00,550000.00,18000.00,125289.64,626479.64,501190.00,47779.64,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,103.92,2343.18,2147.44,-0.0912
|
||||||
|
8,700.00,200.00,5010.00,990.00,19500.00,12000.00,12933.60,10330.04,10000.00,500000.00,550000.00,18000.00,125283.64,626473.64,501190.00,47773.64,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2147.44,-0.0912
|
||||||
|
7,700.00,200.00,5010.00,990.00,19500.00,12000.00,12928.80,10330.04,10000.00,500000.00,550000.00,18000.00,125278.84,626468.84,501190.00,47768.84,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2147.44,-0.0912
|
||||||
|
6,700.00,200.00,5010.00,990.00,19500.00,12000.00,12906.00,10330.04,10000.00,500000.00,550000.00,18000.00,125256.04,626446.04,501190.00,47746.04,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2147.44,-0.0912
|
||||||
|
5,700.00,200.00,5000.00,1000.00,19700.00,12000.00,12891.60,10330.04,10000.00,500000.00,550000.00,18000.00,125421.64,626621.64,501200.00,47921.64,10000.00,500000.00,1000.00,550000.00,18000.00,700.00,200.00,20.00,200.00,2363.18,2347.44,-0.0067
|
||||||
|
4,550.00,200.00,5000.00,1000.00,19700.00,12000.00,12945.60,9467.00,10000.00,500000.00,550000.00,18000.00,124462.60,625662.60,501200.00,47112.60,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,863.04,0.00,3226.22,2347.44,-0.3744
|
||||||
|
3,550.00,200.00,5000.00,1000.00,19700.00,12000.00,13046.40,9467.00,10000.00,500000.00,550000.00,18000.00,124563.40,625763.40,501200.00,47213.40,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2347.44,-0.3744
|
||||||
|
2,550.00,200.00,5000.00,1000.00,19700.00,12000.00,12982.80,9467.00,10000.00,500000.00,550000.00,18000.00,124499.80,625699.80,501200.00,47149.80,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2347.44,-0.3744
|
||||||
|
1,550.00,200.00,5000.00,1000.00,19700.00,12000.00,13014.00,9467.00,10000.00,500000.00,550000.00,18000.00,124531.00,625731.00,501200.00,47181.00,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2347.44,-0.3744
|
||||||
|
0,550.00,200.00,5000.00,1000.00,19700.00,12000.00,13000.80,9467.00,10000.00,500000.00,550000.00,18000.00,124517.80,625717.80,501200.00,47167.80,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2347.44,-0.3744
|
|
65
test/fixtures/transactions.yml
vendored
65
test/fixtures/transactions.yml
vendored
|
@ -39,12 +39,46 @@ checking_five:
|
||||||
currency: USD
|
currency: USD
|
||||||
merchant: netflix
|
merchant: netflix
|
||||||
|
|
||||||
# Savings account that has these transactions and valuation overrides
|
checking_six_payment:
|
||||||
|
name: Payment to Credit Card
|
||||||
|
date: <%= 29.days.ago.to_date %>
|
||||||
|
amount: 100
|
||||||
|
account: checking
|
||||||
|
currency: USD
|
||||||
|
marked_as_transfer: true
|
||||||
|
transfer: credit_card_payment
|
||||||
|
|
||||||
|
checking_seven_transfer:
|
||||||
|
name: Transfer to Savings
|
||||||
|
date: <%= 30.days.ago.to_date %>
|
||||||
|
amount: 250
|
||||||
|
account: checking
|
||||||
|
currency: USD
|
||||||
|
marked_as_transfer: true
|
||||||
|
transfer: savings_transfer
|
||||||
|
|
||||||
|
checking_eight_external_payment:
|
||||||
|
name: Transfer TO external CC account (owned by user but not known to app)
|
||||||
|
date: <%= 30.days.ago.to_date %>
|
||||||
|
amount: 800
|
||||||
|
account: checking
|
||||||
|
currency: USD
|
||||||
|
marked_as_transfer: true
|
||||||
|
|
||||||
|
checking_nine_external_transfer:
|
||||||
|
name: Transfer FROM external investing account (owned by user but not known to app)
|
||||||
|
date: <%= 30.days.ago.to_date %>
|
||||||
|
amount: -200
|
||||||
|
account: checking
|
||||||
|
currency: USD
|
||||||
|
marked_as_transfer: true
|
||||||
|
|
||||||
|
# Savings account that has transactions and valuation overrides
|
||||||
savings_one:
|
savings_one:
|
||||||
name: Interest Received
|
name: Interest Received
|
||||||
date: <%= 5.days.ago.to_date %>
|
date: <%= 5.days.ago.to_date %>
|
||||||
amount: -200
|
amount: -200
|
||||||
account: savings_with_valuation_overrides
|
account: savings
|
||||||
category: income
|
category: income
|
||||||
currency: USD
|
currency: USD
|
||||||
|
|
||||||
|
@ -52,7 +86,7 @@ savings_two:
|
||||||
name: Check Deposit
|
name: Check Deposit
|
||||||
date: <%= 12.days.ago.to_date %>
|
date: <%= 12.days.ago.to_date %>
|
||||||
amount: -50
|
amount: -50
|
||||||
account: savings_with_valuation_overrides
|
account: savings
|
||||||
category: income
|
category: income
|
||||||
currency: USD
|
currency: USD
|
||||||
|
|
||||||
|
@ -60,17 +94,26 @@ savings_three:
|
||||||
name: Withdrawal
|
name: Withdrawal
|
||||||
date: <%= 18.days.ago.to_date %>
|
date: <%= 18.days.ago.to_date %>
|
||||||
amount: 2000
|
amount: 2000
|
||||||
account: savings_with_valuation_overrides
|
account: savings
|
||||||
currency: USD
|
currency: USD
|
||||||
|
|
||||||
savings_four:
|
savings_four:
|
||||||
name: Check Deposit
|
name: Check Deposit
|
||||||
date: <%= 29.days.ago.to_date %>
|
date: <%= 29.days.ago.to_date %>
|
||||||
amount: -500
|
amount: -500
|
||||||
account: savings_with_valuation_overrides
|
account: savings
|
||||||
category: income
|
category: income
|
||||||
currency: USD
|
currency: USD
|
||||||
|
|
||||||
|
savings_five_transfer:
|
||||||
|
name: Received Transfer from Checking Account
|
||||||
|
date: <%= 30.days.ago.to_date %>
|
||||||
|
amount: -250
|
||||||
|
account: savings
|
||||||
|
currency: USD
|
||||||
|
marked_as_transfer: true
|
||||||
|
transfer: savings_transfer
|
||||||
|
|
||||||
# Credit card account transactions
|
# Credit card account transactions
|
||||||
credit_card_one:
|
credit_card_one:
|
||||||
name: Starbucks
|
name: Starbucks
|
||||||
|
@ -96,12 +139,14 @@ credit_card_three:
|
||||||
currency: USD
|
currency: USD
|
||||||
merchant: amazon
|
merchant: amazon
|
||||||
|
|
||||||
credit_card_four:
|
credit_card_four_payment:
|
||||||
name: CC Payment
|
name: Received CC Payment from Checking Account
|
||||||
date: <%= 29.days.ago.to_date %>
|
date: <%= 30.days.ago.to_date %>
|
||||||
amount: -100
|
amount: -100
|
||||||
account: credit_card
|
account: credit_card
|
||||||
currency: USD
|
currency: USD
|
||||||
|
marked_as_transfer: true
|
||||||
|
transfer: credit_card_payment
|
||||||
|
|
||||||
# eur_checking transactions
|
# eur_checking transactions
|
||||||
eur_checking_one:
|
eur_checking_one:
|
||||||
|
@ -120,7 +165,7 @@ eur_checking_two:
|
||||||
|
|
||||||
eur_checking_three:
|
eur_checking_three:
|
||||||
name: Check
|
name: Check
|
||||||
date: <%= 29.days.ago.to_date %>
|
date: <%= 30.days.ago.to_date %>
|
||||||
amount: -200
|
amount: -200
|
||||||
currency: EUR
|
currency: EUR
|
||||||
account: eur_checking
|
account: eur_checking
|
||||||
|
@ -143,7 +188,7 @@ multi_currency_two:
|
||||||
multi_currency_three:
|
multi_currency_three:
|
||||||
name: Outflow 2
|
name: Outflow 2
|
||||||
date: <%= 19.days.ago.to_date %>
|
date: <%= 19.days.ago.to_date %>
|
||||||
amount: 100
|
amount: 110.85
|
||||||
currency: EUR
|
currency: EUR
|
||||||
account: multi_currency
|
account: multi_currency
|
||||||
|
|
||||||
|
|
2
test/fixtures/transfers.yml
vendored
Normal file
2
test/fixtures/transfers.yml
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
credit_card_payment: { }
|
||||||
|
savings_transfer: { }
|
44
test/fixtures/valuations.yml
vendored
44
test/fixtures/valuations.yml
vendored
|
@ -11,21 +11,45 @@ collectable_two:
|
||||||
|
|
||||||
collectable_three:
|
collectable_three:
|
||||||
value: 400
|
value: 400
|
||||||
date: <%= 30.days.ago.to_date %>
|
date: <%= 31.days.ago.to_date %>
|
||||||
account: collectable
|
account: collectable
|
||||||
|
|
||||||
# For checking account that has valuations and transactions
|
iou_one:
|
||||||
savings_one:
|
value: 200
|
||||||
value: 20500
|
date: <%= 31.days.ago.to_date %>
|
||||||
date: <%= 3.days.ago.to_date %>
|
account: iou
|
||||||
account: savings_with_valuation_overrides
|
|
||||||
|
|
||||||
savings_two:
|
multi_currency_one:
|
||||||
|
value: 10200
|
||||||
|
date: <%= 31.days.ago.to_date %>
|
||||||
|
account: multi_currency
|
||||||
|
|
||||||
|
savings_one:
|
||||||
value: 19500
|
value: 19500
|
||||||
date: <%= 12.days.ago.to_date %>
|
date: <%= 12.days.ago.to_date %>
|
||||||
account: savings_with_valuation_overrides
|
account: savings
|
||||||
|
|
||||||
savings_three:
|
savings_two:
|
||||||
value: 21000
|
value: 21000
|
||||||
date: <%= 25.days.ago.to_date %>
|
date: <%= 25.days.ago.to_date %>
|
||||||
account: savings_with_valuation_overrides
|
account: savings
|
||||||
|
|
||||||
|
brokerage_one:
|
||||||
|
value: 10000
|
||||||
|
date: <%= 31.days.ago.to_date %>
|
||||||
|
account: brokerage
|
||||||
|
|
||||||
|
mortgage_loan_one:
|
||||||
|
value: 500000
|
||||||
|
date: <%= 31.days.ago.to_date %>
|
||||||
|
account: mortgage_loan
|
||||||
|
|
||||||
|
house_one:
|
||||||
|
value: 550000
|
||||||
|
date: <%= 31.days.ago.to_date %>
|
||||||
|
account: house
|
||||||
|
|
||||||
|
car_one:
|
||||||
|
value: 18000
|
||||||
|
date: <%= 31.days.ago.to_date %>
|
||||||
|
account: car
|
|
@ -2,111 +2,82 @@ require "test_helper"
|
||||||
require "csv"
|
require "csv"
|
||||||
|
|
||||||
class Account::Balance::CalculatorTest < ActiveSupport::TestCase
|
class Account::Balance::CalculatorTest < ActiveSupport::TestCase
|
||||||
# See: https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
|
include FamilySnapshotTestHelper
|
||||||
setup do
|
|
||||||
@expected_balances = CSV.read("test/fixtures/account/expected_balances.csv", headers: true).map do |row|
|
|
||||||
{
|
|
||||||
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
|
|
||||||
"collectable" => row["collectable"],
|
|
||||||
"checking" => row["checking"],
|
|
||||||
"savings_with_valuation_overrides" => row["savings_with_valuation_overrides"],
|
|
||||||
"credit_card" => row["credit_card"],
|
|
||||||
"multi_currency" => row["multi_currency"],
|
|
||||||
|
|
||||||
# Balances should be calculated for all currencies of an account
|
test "syncs other asset balances" do
|
||||||
"eur_checking_eur" => row["eur_checking_eur"],
|
expected_balances = get_expected_balances_for(:collectable)
|
||||||
"eur_checking_usd" => row["eur_checking_usd"]
|
assert_account_balances calculated_balances_for(:collectable), expected_balances
|
||||||
}
|
end
|
||||||
|
|
||||||
|
test "syncs other liability balances" do
|
||||||
|
expected_balances = get_expected_balances_for(:iou)
|
||||||
|
assert_account_balances calculated_balances_for(:iou), expected_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs credit balances" do
|
||||||
|
expected_balances = get_expected_balances_for :credit_card
|
||||||
|
assert_account_balances calculated_balances_for(:credit_card), expected_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs checking account balances" do
|
||||||
|
expected_balances = get_expected_balances_for(:checking)
|
||||||
|
assert_account_balances calculated_balances_for(:checking), expected_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs foreign checking account balances" do
|
||||||
|
# Foreign accounts will generate balances for all currencies
|
||||||
|
expected_usd_balances = get_expected_balances_for(:eur_checking_usd)
|
||||||
|
expected_eur_balances = get_expected_balances_for(:eur_checking_eur)
|
||||||
|
|
||||||
|
calculated_balances = calculated_balances_for(:eur_checking)
|
||||||
|
calculated_usd_balances = calculated_balances.select { |b| b[:currency] == "USD" }
|
||||||
|
calculated_eur_balances = calculated_balances.select { |b| b[:currency] == "EUR" }
|
||||||
|
|
||||||
|
assert_account_balances calculated_usd_balances, expected_usd_balances
|
||||||
|
assert_account_balances calculated_eur_balances, expected_eur_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs multi-currency checking account balances" do
|
||||||
|
expected_balances = get_expected_balances_for(:multi_currency)
|
||||||
|
assert_account_balances calculated_balances_for(:multi_currency), expected_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs savings accounts balances" do
|
||||||
|
expected_balances = get_expected_balances_for(:savings)
|
||||||
|
assert_account_balances calculated_balances_for(:savings), expected_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs investment account balances" do
|
||||||
|
expected_balances = get_expected_balances_for(:brokerage)
|
||||||
|
assert_account_balances calculated_balances_for(:brokerage), expected_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs loan account balances" do
|
||||||
|
expected_balances = get_expected_balances_for(:mortgage_loan)
|
||||||
|
assert_account_balances calculated_balances_for(:mortgage_loan), expected_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs property account balances" do
|
||||||
|
expected_balances = get_expected_balances_for(:house)
|
||||||
|
assert_account_balances calculated_balances_for(:house), expected_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs vehicle account balances" do
|
||||||
|
expected_balances = get_expected_balances_for(:car)
|
||||||
|
assert_account_balances calculated_balances_for(:car), expected_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def assert_account_balances(actual_balances, expected_balances)
|
||||||
|
assert_equal expected_balances.count, actual_balances.count
|
||||||
|
|
||||||
|
actual_balances.each do |ab|
|
||||||
|
expected_balance = expected_balances.find { |eb| eb[:date] == ab[:date] }
|
||||||
|
assert_in_delta expected_balance[:balance], ab[:balance], 0.01, "Balance incorrect on date: #{ab[:date]}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "syncs account with only valuations" do
|
def calculated_balances_for(account_key)
|
||||||
account = accounts(:collectable)
|
Account::Balance::Calculator.new(accounts(account_key)).calculate.daily_balances
|
||||||
|
|
||||||
calculator = Account::Balance::Calculator.new(account)
|
|
||||||
calculator.calculate
|
|
||||||
|
|
||||||
expected = @expected_balances.map { |row| row["collectable"].to_d }
|
|
||||||
actual = calculator.daily_balances.map { |b| b[:balance] }
|
|
||||||
|
|
||||||
assert_equal expected, actual
|
|
||||||
end
|
|
||||||
|
|
||||||
test "syncs account with only transactions" do
|
|
||||||
account = accounts(:checking)
|
|
||||||
|
|
||||||
calculator = Account::Balance::Calculator.new(account)
|
|
||||||
calculator.calculate
|
|
||||||
|
|
||||||
expected = @expected_balances.map { |row| row["checking"].to_d }
|
|
||||||
actual = calculator.daily_balances.map { |b| b[:balance] }
|
|
||||||
|
|
||||||
assert_equal expected, actual
|
|
||||||
end
|
|
||||||
|
|
||||||
test "syncs account with both valuations and transactions" do
|
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
|
||||||
|
|
||||||
calculator = Account::Balance::Calculator.new(account)
|
|
||||||
calculator.calculate
|
|
||||||
|
|
||||||
expected = @expected_balances.map { |row| row["savings_with_valuation_overrides"].to_d }
|
|
||||||
actual = calculator.daily_balances.map { |b| b[:balance] }
|
|
||||||
|
|
||||||
assert_equal expected, actual
|
|
||||||
end
|
|
||||||
|
|
||||||
test "syncs liability account" do
|
|
||||||
account = accounts(:credit_card)
|
|
||||||
|
|
||||||
calculator = Account::Balance::Calculator.new(account)
|
|
||||||
calculator.calculate
|
|
||||||
|
|
||||||
expected = @expected_balances.map { |row| row["credit_card"].to_d }
|
|
||||||
actual = calculator.daily_balances.map { |b| b[:balance] }
|
|
||||||
|
|
||||||
assert_equal expected, actual
|
|
||||||
end
|
|
||||||
|
|
||||||
test "syncs foreign currency account" do
|
|
||||||
account = accounts(:eur_checking)
|
|
||||||
calculator = Account::Balance::Calculator.new(account)
|
|
||||||
calculator.calculate
|
|
||||||
|
|
||||||
# Calculator should calculate balances in both account and family currency
|
|
||||||
expected_eur_balances = @expected_balances.map { |row| row["eur_checking_eur"].to_d }
|
|
||||||
expected_usd_balances = @expected_balances.map { |row| row["eur_checking_usd"].to_d }
|
|
||||||
|
|
||||||
actual_eur_balances = calculator.daily_balances.select { |b| b[:currency] == "EUR" }.sort_by { |b| b[:date] }.map { |b| b[:balance] }
|
|
||||||
actual_usd_balances = calculator.daily_balances.select { |b| b[:currency] == "USD" }.sort_by { |b| b[:date] }.map { |b| b[:balance] }
|
|
||||||
|
|
||||||
assert_equal expected_eur_balances, actual_eur_balances
|
|
||||||
assert_equal expected_usd_balances, actual_usd_balances
|
|
||||||
end
|
|
||||||
|
|
||||||
test "syncs multi currency account" do
|
|
||||||
account = accounts(:multi_currency)
|
|
||||||
calculator = Account::Balance::Calculator.new(account)
|
|
||||||
calculator.calculate
|
|
||||||
|
|
||||||
expected_balances = @expected_balances.map { |row| row["multi_currency"].to_d }
|
|
||||||
|
|
||||||
actual_balances = calculator.daily_balances.map { |b| b[:balance] }
|
|
||||||
|
|
||||||
assert_equal expected_balances, actual_balances
|
|
||||||
end
|
|
||||||
|
|
||||||
test "syncs with overridden start date" do
|
|
||||||
account = accounts(:multi_currency)
|
|
||||||
account.sync
|
|
||||||
calc_start_date = 10.days.ago.to_date
|
|
||||||
calculator = Account::Balance::Calculator.new(account, { calc_start_date: })
|
|
||||||
calculator.calculate
|
|
||||||
|
|
||||||
expected_balances = @expected_balances.filter { |row| row["date"] >= calc_start_date }.map { |row| row["multi_currency"].to_d }
|
|
||||||
|
|
||||||
actual_balances = calculator.daily_balances.map { |b| b[:balance] }
|
|
||||||
|
|
||||||
assert_equal expected_balances, actual_balances
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
|
||||||
include ActiveJob::TestHelper
|
include ActiveJob::TestHelper
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@account = accounts(:savings_with_valuation_overrides)
|
@account = accounts(:savings)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "triggers sync job" do
|
test "triggers sync job" do
|
||||||
|
@ -14,27 +14,27 @@ class Account::SyncableTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "account has no balances until synced" do
|
test "account has no balances until synced" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings)
|
||||||
|
|
||||||
assert_equal 0, account.balances.count
|
assert_equal 0, account.balances.count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "account has balances after syncing" do
|
test "account has balances after syncing" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings)
|
||||||
account.sync
|
account.sync
|
||||||
|
|
||||||
assert_equal 31, account.balances.count
|
assert_equal 32, account.balances.count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "partial sync with missing historical balances performs a full sync" do
|
test "partial sync with missing historical balances performs a full sync" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings)
|
||||||
account.sync 10.days.ago.to_date
|
account.sync 10.days.ago.to_date
|
||||||
|
|
||||||
assert_equal 31, account.balances.count
|
assert_equal 32, account.balances.count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "balances are updated after syncing" do
|
test "balances are updated after syncing" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings)
|
||||||
balance_date = 10.days.ago
|
balance_date = 10.days.ago
|
||||||
account.balances.create!(date: balance_date, balance: 1000)
|
account.balances.create!(date: balance_date, balance: 1000)
|
||||||
account.sync
|
account.sync
|
||||||
|
@ -43,7 +43,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "balances before sync start date are not updated after syncing" do
|
test "balances before sync start date are not updated after syncing" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings)
|
||||||
balance_date = 10.days.ago
|
balance_date = 10.days.ago
|
||||||
account.balances.create!(date: balance_date, balance: 1000)
|
account.balances.create!(date: balance_date, balance: 1000)
|
||||||
account.sync 5.days.ago.to_date
|
account.sync 5.days.ago.to_date
|
||||||
|
@ -52,7 +52,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "balances after sync start date are updated after syncing" do
|
test "balances after sync start date are updated after syncing" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings)
|
||||||
balance_date = 10.days.ago
|
balance_date = 10.days.ago
|
||||||
account.balances.create!(date: balance_date, balance: 1000)
|
account.balances.create!(date: balance_date, balance: 1000)
|
||||||
account.sync 20.days.ago.to_date
|
account.sync 20.days.ago.to_date
|
||||||
|
@ -61,7 +61,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "balance on the sync date is updated after syncing" do
|
test "balance on the sync date is updated after syncing" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings)
|
||||||
balance_date = 5.days.ago
|
balance_date = 5.days.ago
|
||||||
account.balances.create!(date: balance_date, balance: 1000)
|
account.balances.create!(date: balance_date, balance: 1000)
|
||||||
account.sync balance_date.to_date
|
account.sync balance_date.to_date
|
||||||
|
@ -73,13 +73,13 @@ class Account::SyncableTest < ActiveSupport::TestCase
|
||||||
account = accounts(:eur_checking)
|
account = accounts(:eur_checking)
|
||||||
account.sync
|
account.sync
|
||||||
|
|
||||||
assert_equal 62, account.balances.count
|
assert_equal 64, account.balances.count
|
||||||
assert_equal 31, account.balances.where(currency: "EUR").count
|
assert_equal 32, account.balances.where(currency: "EUR").count
|
||||||
assert_equal 31, account.balances.where(currency: "USD").count
|
assert_equal 32, account.balances.where(currency: "USD").count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "stale balances are purged after syncing" do
|
test "stale balances are purged after syncing" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings)
|
||||||
|
|
||||||
# Create old, stale balances that should be purged (since they are before account start date)
|
# Create old, stale balances that should be purged (since they are before account start date)
|
||||||
account.balances.create!(date: 1.year.ago, balance: 1000)
|
account.balances.create!(date: 1.year.ago, balance: 1000)
|
||||||
|
@ -88,14 +88,6 @@ class Account::SyncableTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
account.sync
|
account.sync
|
||||||
|
|
||||||
assert_equal 31, account.balances.count
|
assert_equal 32, account.balances.count
|
||||||
end
|
|
||||||
|
|
||||||
test "account balance is updated after sync" do
|
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
|
||||||
|
|
||||||
assert_changes -> { account.balance }, to: 20500 do
|
|
||||||
account.sync
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,16 +5,6 @@ class AccountTest < ActiveSupport::TestCase
|
||||||
def setup
|
def setup
|
||||||
@account = accounts(:checking)
|
@account = accounts(:checking)
|
||||||
@family = families(:dylan_family)
|
@family = families(:dylan_family)
|
||||||
@snapshots = CSV.read("test/fixtures/family/expected_snapshots.csv", headers: true).map do |row|
|
|
||||||
{
|
|
||||||
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
|
|
||||||
"assets" => row["assets"],
|
|
||||||
"liabilities" => row["liabilities"],
|
|
||||||
"Account::Depository" => row["depositories"],
|
|
||||||
"Account::Credit" => row["credits"],
|
|
||||||
"Account::OtherAsset" => row["other_assets"]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "new account should be valid" do
|
test "new account should be valid" do
|
||||||
|
@ -47,26 +37,23 @@ class AccountTest < ActiveSupport::TestCase
|
||||||
test "syncs regular account" do
|
test "syncs regular account" do
|
||||||
@account.sync
|
@account.sync
|
||||||
assert_equal "ok", @account.status
|
assert_equal "ok", @account.status
|
||||||
assert_equal 31, @account.balances.count
|
assert_equal 32, @account.balances.count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "syncs foreign currency account" do
|
test "syncs foreign currency account" do
|
||||||
account = accounts(:eur_checking)
|
account = accounts(:eur_checking)
|
||||||
account.sync
|
account.sync
|
||||||
assert_equal "ok", account.status
|
assert_equal "ok", account.status
|
||||||
assert_equal 31, account.balances.where(currency: "USD").count
|
assert_equal 32, account.balances.where(currency: "USD").count
|
||||||
assert_equal 31, account.balances.where(currency: "EUR").count
|
assert_equal 32, account.balances.where(currency: "EUR").count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "groups accounts by type" do
|
test "groups accounts by type" do
|
||||||
@family.accounts.each do |account|
|
@family.accounts.each do |account|
|
||||||
account.sync
|
account.sync
|
||||||
end
|
end
|
||||||
|
|
||||||
result = @family.accounts.by_group(period: Period.all)
|
result = @family.accounts.by_group(period: Period.all)
|
||||||
|
|
||||||
expected_assets = @snapshots.last["assets"].to_d
|
|
||||||
expected_liabilities = @snapshots.last["liabilities"].to_d
|
|
||||||
|
|
||||||
assets = result[:assets]
|
assets = result[:assets]
|
||||||
liabilities = result[:liabilities]
|
liabilities = result[:liabilities]
|
||||||
|
|
||||||
|
@ -84,14 +71,14 @@ class AccountTest < ActiveSupport::TestCase
|
||||||
other_liabilities = liabilities.children.find { |group| group.name == "Account::OtherLiability" }
|
other_liabilities = liabilities.children.find { |group| group.name == "Account::OtherLiability" }
|
||||||
|
|
||||||
assert_equal 4, depositories.children.count
|
assert_equal 4, depositories.children.count
|
||||||
assert_equal 0, properties.children.count
|
assert_equal 1, properties.children.count
|
||||||
assert_equal 0, vehicles.children.count
|
assert_equal 1, vehicles.children.count
|
||||||
assert_equal 0, investments.children.count
|
assert_equal 1, investments.children.count
|
||||||
assert_equal 1, other_assets.children.count
|
assert_equal 1, other_assets.children.count
|
||||||
|
|
||||||
assert_equal 1, credits.children.count
|
assert_equal 1, credits.children.count
|
||||||
assert_equal 0, loans.children.count
|
assert_equal 1, loans.children.count
|
||||||
assert_equal 0, other_liabilities.children.count
|
assert_equal 1, other_liabilities.children.count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "generates series with last balance equal to current account balance" do
|
test "generates series with last balance equal to current account balance" do
|
||||||
|
|
|
@ -2,26 +2,13 @@ require "test_helper"
|
||||||
require "csv"
|
require "csv"
|
||||||
|
|
||||||
class FamilyTest < ActiveSupport::TestCase
|
class FamilyTest < ActiveSupport::TestCase
|
||||||
|
include FamilySnapshotTestHelper
|
||||||
|
|
||||||
def setup
|
def setup
|
||||||
@family = families(:dylan_family)
|
@family = families(:dylan_family)
|
||||||
|
|
||||||
@family.accounts.each do |account|
|
@family.accounts.each do |account|
|
||||||
account.sync
|
account.sync
|
||||||
end
|
end
|
||||||
|
|
||||||
# See this Google Sheet for calculations and expected results for dylan_family:
|
|
||||||
# https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
|
|
||||||
@expected_snapshots = CSV.read("test/fixtures/family/expected_snapshots.csv", headers: true).map do |row|
|
|
||||||
{
|
|
||||||
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
|
|
||||||
"net_worth" => row["net_worth"],
|
|
||||||
"assets" => row["assets"],
|
|
||||||
"liabilities" => row["liabilities"],
|
|
||||||
"rolling_spend" => row["rolling_spend"],
|
|
||||||
"rolling_income" => row["rolling_income"],
|
|
||||||
"savings_rate" => row["savings_rate"]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should have many users" do
|
test "should have many users" do
|
||||||
|
@ -58,57 +45,62 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should calculate total assets" do
|
test "should calculate total assets" do
|
||||||
expected = @expected_snapshots.last["assets"].to_d
|
expected = get_today_snapshot_value_for :assets
|
||||||
assert_equal Money.new(expected), @family.assets
|
assert_in_delta expected, @family.assets.amount, 0.01
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should calculate total liabilities" do
|
test "should calculate total liabilities" do
|
||||||
expected = @expected_snapshots.last["liabilities"].to_d
|
expected = get_today_snapshot_value_for :liabilities
|
||||||
assert_equal Money.new(expected), @family.liabilities
|
assert_in_delta expected, @family.liabilities.amount, 0.01
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should calculate net worth" do
|
test "should calculate net worth" do
|
||||||
expected = @expected_snapshots.last["net_worth"].to_d
|
expected = get_today_snapshot_value_for :net_worth
|
||||||
assert_equal Money.new(expected), @family.net_worth
|
assert_in_delta expected, @family.net_worth.amount, 0.01
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should calculate snapshot correctly" do
|
test "calculates asset time series" do
|
||||||
asset_series = @family.snapshot[:asset_series]
|
series = @family.snapshot[:asset_series]
|
||||||
liability_series = @family.snapshot[:liability_series]
|
expected_series = get_expected_balances_for :assets
|
||||||
net_worth_series = @family.snapshot[:net_worth_series]
|
|
||||||
|
|
||||||
assert_equal @expected_snapshots.count, asset_series.values.count
|
assert_time_series_balances series, expected_series
|
||||||
assert_equal @expected_snapshots.count, liability_series.values.count
|
|
||||||
assert_equal @expected_snapshots.count, net_worth_series.values.count
|
|
||||||
|
|
||||||
@expected_snapshots.each_with_index do |row, index|
|
|
||||||
expected_assets = TimeSeries::Value.new(date: row["date"], value: Money.new(row["assets"].to_d))
|
|
||||||
expected_liabilities = TimeSeries::Value.new(date: row["date"], value: Money.new(row["liabilities"].to_d))
|
|
||||||
expected_net_worth = TimeSeries::Value.new(date: row["date"], value: Money.new(row["net_worth"].to_d))
|
|
||||||
|
|
||||||
assert_in_delta expected_assets.value.amount, Money.new(asset_series.values[index].value).amount, 0.01
|
|
||||||
assert_in_delta expected_liabilities.value.amount, Money.new(liability_series.values[index].value).amount, 0.01
|
|
||||||
assert_in_delta expected_net_worth.value.amount, Money.new(net_worth_series.values[index].value).amount, 0.01
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should calculate transaction snapshot correctly" do
|
test "calculates liability time series" do
|
||||||
spending_series = @family.snapshot_transactions[:spending_series]
|
series = @family.snapshot[:liability_series]
|
||||||
income_series = @family.snapshot_transactions[:income_series]
|
expected_series = get_expected_balances_for :liabilities
|
||||||
savings_rate_series = @family.snapshot_transactions[:savings_rate_series]
|
|
||||||
|
|
||||||
assert_equal @expected_snapshots.count, spending_series.values.count
|
assert_time_series_balances series, expected_series
|
||||||
assert_equal @expected_snapshots.count, income_series.values.count
|
end
|
||||||
assert_equal @expected_snapshots.count, savings_rate_series.values.count
|
|
||||||
|
|
||||||
@expected_snapshots.each_with_index do |row, index|
|
test "calculates net worth time series" do
|
||||||
expected_spending = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_spend"].to_d))
|
series = @family.snapshot[:net_worth_series]
|
||||||
expected_income = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_income"].to_d))
|
expected_series = get_expected_balances_for :net_worth
|
||||||
expected_savings_rate = TimeSeries::Value.new(date: row["date"], value: Money.new(row["savings_rate"].to_d))
|
|
||||||
|
|
||||||
assert_in_delta expected_spending.value.amount, Money.new(spending_series.values[index].value).amount, 0.01
|
assert_time_series_balances series, expected_series
|
||||||
assert_in_delta expected_income.value.amount, Money.new(income_series.values[index].value).amount, 0.01
|
end
|
||||||
assert_in_delta expected_savings_rate.value.amount, savings_rate_series.values[index].value, 0.01
|
|
||||||
|
test "calculates rolling expenses" do
|
||||||
|
series = @family.snapshot_transactions[:spending_series]
|
||||||
|
expected_series = get_expected_balances_for :rolling_spend
|
||||||
|
|
||||||
|
assert_time_series_balances series, expected_series, ignore_count: true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates rolling income" do
|
||||||
|
series = @family.snapshot_transactions[:income_series]
|
||||||
|
expected_series = get_expected_balances_for :rolling_income
|
||||||
|
|
||||||
|
assert_time_series_balances series, expected_series, ignore_count: true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates savings rate series" do
|
||||||
|
series = @family.snapshot_transactions[:savings_rate_series]
|
||||||
|
expected_series = get_expected_balances_for :savings_rate
|
||||||
|
|
||||||
|
series.values.each do |tsb|
|
||||||
|
expected_balance = expected_series.find { |eb| eb[:date] == tsb.date }
|
||||||
|
assert_in_delta expected_balance[:balance], tsb.value, 0.0001, "Balance incorrect on date: #{tsb.date}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -127,4 +119,15 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
assert_equal liabilities_before - disabled_cc.balance, @family.liabilities
|
assert_equal liabilities_before - disabled_cc.balance, @family.liabilities
|
||||||
assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth
|
assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def assert_time_series_balances(time_series_balances, expected_balances, ignore_count: false)
|
||||||
|
assert_equal time_series_balances.values.count, expected_balances.count unless ignore_count
|
||||||
|
|
||||||
|
time_series_balances.values.each do |tsb|
|
||||||
|
expected_balance = expected_balances.find { |eb| eb[:date] == tsb.date }
|
||||||
|
assert_in_delta expected_balance[:balance], tsb.value.amount, 0.01, "Balance incorrect on date: #{tsb.date}"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,6 +3,7 @@ require "test_helper"
|
||||||
class TransactionTest < ActiveSupport::TestCase
|
class TransactionTest < ActiveSupport::TestCase
|
||||||
setup do
|
setup do
|
||||||
@transaction = transactions(:checking_one)
|
@transaction = transactions(:checking_one)
|
||||||
|
@family = families(:dylan_family)
|
||||||
end
|
end
|
||||||
|
|
||||||
# See: https://github.com/maybe-finance/maybe/wiki/vision#signage-of-money
|
# See: https://github.com/maybe-finance/maybe/wiki/vision#signage-of-money
|
||||||
|
@ -41,4 +42,14 @@ class TransactionTest < ActiveSupport::TestCase
|
||||||
current_transaction.account.expects(:sync_later).with(prior_transaction.date)
|
current_transaction.account.expects(:sync_later).with(prior_transaction.date)
|
||||||
current_transaction.sync_account_later
|
current_transaction.sync_account_later
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "can calculate total spending for a group of transactions" do
|
||||||
|
assert_equal Money.new(2135), @family.transactions.expense_total("USD")
|
||||||
|
assert_equal Money.new(1010.85, "EUR"), @family.transactions.expense_total("EUR")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can calculate total income for a group of transactions" do
|
||||||
|
assert_equal -Money.new(2075), @family.transactions.income_total("USD")
|
||||||
|
assert_equal -Money.new(250, "EUR"), @family.transactions.income_total("EUR")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
49
test/models/transfer_test.rb
Normal file
49
test/models/transfer_test.rb
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TransferTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
# Transfers can be posted on different dates
|
||||||
|
@outflow = accounts(:checking).transactions.create! date: 1.day.ago.to_date, name: "Transfer to Savings", amount: 100, marked_as_transfer: true
|
||||||
|
@inflow = accounts(:savings).transactions.create! date: Date.current, name: "Transfer from Savings", amount: -100, marked_as_transfer: true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transfer valid if it has inflow and outflow from different accounts for the same amount" do
|
||||||
|
transfer = Transfer.create! transactions: [ @inflow, @outflow ]
|
||||||
|
|
||||||
|
assert transfer.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transfer must have 2 transactions" do
|
||||||
|
invalid_transfer_1 = Transfer.new transactions: [ @outflow ]
|
||||||
|
invalid_transfer_2 = Transfer.new transactions: [ @inflow, @outflow, transactions(:savings_four) ]
|
||||||
|
|
||||||
|
assert invalid_transfer_1.invalid?
|
||||||
|
assert invalid_transfer_2.invalid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transfer cannot have 2 transactions from the same account" do
|
||||||
|
account = accounts(:checking)
|
||||||
|
inflow = account.transactions.create! date: Date.current, name: "Inflow", amount: -100
|
||||||
|
outflow = account.transactions.create! date: Date.current, name: "Outflow", amount: 100
|
||||||
|
|
||||||
|
assert_raise ActiveRecord::RecordInvalid do
|
||||||
|
Transfer.create! transactions: [ 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
|
||||||
|
Transfer.create! transactions: [ @inflow, @outflow ]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "transfer transactions must net to zero" do
|
||||||
|
@outflow.update! amount: 105
|
||||||
|
|
||||||
|
assert_raises ActiveRecord::RecordInvalid do
|
||||||
|
Transfer.create! transactions: [ @inflow, @outflow ]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,7 +3,7 @@ require "ostruct"
|
||||||
class ValueGroupTest < ActiveSupport::TestCase
|
class ValueGroupTest < ActiveSupport::TestCase
|
||||||
setup do
|
setup do
|
||||||
checking = accounts(:checking)
|
checking = accounts(:checking)
|
||||||
savings = accounts(:savings_with_valuation_overrides)
|
savings = accounts(:savings)
|
||||||
collectable = accounts(:collectable)
|
collectable = accounts(:collectable)
|
||||||
|
|
||||||
# Level 1
|
# Level 1
|
||||||
|
|
21
test/support/family_snapshot_test_helper.rb
Normal file
21
test/support/family_snapshot_test_helper.rb
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
module FamilySnapshotTestHelper
|
||||||
|
# See: https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
|
||||||
|
def get_expected_balances_for(key)
|
||||||
|
expected_results_file.map do |row|
|
||||||
|
{
|
||||||
|
date: (Date.current - row["date_offset"].to_i.days).to_date,
|
||||||
|
balance: row[key.to_s].to_d
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_today_snapshot_value_for(metric)
|
||||||
|
expected_results_file[-1][metric.to_s].to_d
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def expected_results_file
|
||||||
|
CSV.read("test/fixtures/files/expected_family_snapshots.csv", headers: true)
|
||||||
|
end
|
||||||
|
end
|
|
@ -134,7 +134,7 @@ class TransactionsTest < ApplicationSystemTestCase
|
||||||
def number_of_transactions_on_page
|
def number_of_transactions_on_page
|
||||||
page_size = 50
|
page_size = 50
|
||||||
|
|
||||||
[ @user.family.transactions.count, page_size ].min
|
[ @user.family.transactions.where(transfer_id: nil).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