1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09: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:
Zach Gollwitzer 2024-06-19 06:52:08 -04:00 committed by GitHub
parent b462bc8f8c
commit ca39b26070
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 991 additions and 427 deletions

View file

@ -7,6 +7,10 @@
details > summary::-webkit-details-marker {
@apply hidden;
}
details > summary {
@apply list-none;
}
}
@layer components {

View file

@ -9,9 +9,9 @@ class TransactionsController < ApplicationController
@pagy, @transactions = pagy(result, items: 50)
@totals = {
count: result.count,
income: result.inflows.sum(&:amount_money).abs,
expense: result.outflows.sum(&:amount_money).abs
count: result.select { |t| t.currency == Current.family.currency }.count,
income: result.income_total(Current.family.currency).abs,
expense: result.expense_total(Current.family.currency)
}
end
@ -54,7 +54,7 @@ class TransactionsController < ApplicationController
def bulk_delete
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
def bulk_edit
@ -63,13 +63,31 @@ class TransactionsController < ApplicationController
def bulk_update
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!)
redirect_to transactions_url, notice: t(".success", count: transactions.count)
redirect_back_or_to transactions_url, notice: t(".success", count: transactions.count)
else
flash.now[:error] = t(".failure")
render :index, status: :unprocessable_entity
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
def set_transaction

View 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

View file

@ -25,7 +25,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
# 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
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?
money_amount_method = method.to_s.chomp("_money").to_sym

View file

@ -17,4 +17,27 @@ module TransactionsHelper
content: content
}
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

View file

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

View file

@ -113,7 +113,7 @@ export default class extends Controller {
#updateGroups() {
this.groupTargets.forEach(group => {
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
})
}

View file

@ -42,6 +42,7 @@ class Family < ApplicationRecord
)
.where("transactions.date >= ?", period.date_range.begin)
.where("transactions.date <= ?", period.date_range.end)
.where("transactions.marked_as_transfer = ?", false)
.group("id")
.to_a

View file

@ -4,6 +4,7 @@ class Transaction < ApplicationRecord
monetize :amount
belongs_to :account
belongs_to :transfer, optional: true
belongs_to :category, optional: true
belongs_to :merchant, optional: true
has_many :taggings, as: :taggable, dependent: :destroy
@ -42,6 +43,10 @@ class Transaction < ApplicationRecord
amount > 0
end
def transfer?
marked_as_transfer
end
def sync_account_later
if destroyed?
sync_start_date = previous_transaction_date
@ -53,6 +58,21 @@ class Transaction < ApplicationRecord
end
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)
# Sum spending and income for each day in the period with the given currency
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 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 ]))
.group("gs.date")
end
@ -83,7 +103,7 @@ class Transaction < ApplicationRecord
end
def search(params)
query = all
query = all.includes(:transfer)
query = query.by_name(params[:search]) if params[:search].present?
query = query.with_categories(params[:categories]) if params[:categories].present?
query = query.with_accounts(params[:accounts]) if params[:accounts].present?
@ -96,7 +116,6 @@ class Transaction < ApplicationRecord
end
private
def previous_transaction_date
self.account
.transactions

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

View file

@ -4,7 +4,14 @@
</div>
<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 %>
<% end %>
</div>
<%= link_to transaction.account.name,

View file

@ -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="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">
<%= 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}",
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>
<% 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 "|", class: "mx-2" if idx < transactions_by_currency.count - 1 %>
<% end %>
</div>
</div>
<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>

View file

@ -1,9 +1,12 @@
<%= form_with model: @transaction, data: { turbo: false } do |f| %>
<%= form_with model: @transaction, data: { turbo_frame: "_top" } do |f| %>
<section>
<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: :income, label: t(".income"), icon: "plus-circle" %>
<%= radio_tab_tag form: f, name: :nature, value: :transfer, label: t(".transfer"), icon: "arrow-right-left", disabled: 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", checked: params[:nature] == "income" %>
<%= 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>
</section>
@ -11,7 +14,7 @@
<%= 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.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 %>
</section>

View file

@ -9,7 +9,7 @@
<% else %>
<%= link_to transaction.name,
transaction_path(transaction),
data: { turbo_frame: "drawer" },
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
</div>

View file

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

View file

@ -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 %>
<div class="col-span-4 flex items-center gap-4">
<%= 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="pr-10 flex items-center gap-4 <%= unconfirmed_transfer?(transaction) ? "col-span-10" : "col-span-4" %>">
<%= check_box_tag dom_id(transaction, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
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 %>
</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>
<% unless unconfirmed_transfer?(transaction) %>
<div class="col-span-3">
<%= render "transactions/categories/menu", transaction: transaction %>
</div>
@ -17,6 +35,7 @@
account_path(transaction.account),
data: { turbo_frame: "_top" },
class: ["col-span-3 hover:underline"] %>
<% end %>
<div class="col-span-2 ml-auto">
<%= render "transactions/amount", transaction: transaction %>

View file

@ -25,8 +25,8 @@
<p class="col-span-2 justify-self-end">amount</p>
</div>
<div class="space-y-6">
<% @transactions.group_by(&:date).each do |date, transactions| %>
<%= render partial: "date_group", locals: { date:, transactions: } %>
<% group_transactions_by_date(@transactions).each do |date, group| %>
<%= render partial: "date_group", locals: { date:, group: } %>
<% end %>
</div>
<% else %>

View file

@ -1,11 +1,17 @@
<%= drawer do %>
<div>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<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>
</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>
</header>
@ -19,28 +25,20 @@
<div class="pb-6">
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
<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" %>
<% 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 :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" } %>
</div>
<% end %>
</div>
</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>
<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>
@ -70,8 +68,8 @@
<div class="pb-6">
<%= form_with model: @transaction, html: { class: "p-3", data: { controller: "auto-submit-form" } } do |f| %>
<div class="flex cursor-pointer items-center justify-between">
<%= 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 gap-2 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
@ -84,6 +82,7 @@
</div>
<% end %>
<% unless @transaction.transfer? %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
@ -96,6 +95,7 @@
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" } %>
</div>
<% end %>
</div>
</details>
</div>

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

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

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

View file

@ -89,6 +89,8 @@ en:
import: Import
index:
transaction: transaction
mark_transfers:
success: Marked as transfer
merchants:
create:
success: New merchant created successfully
@ -116,6 +118,11 @@ en:
title: New merchant
update:
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:
account_label: Account
account_placeholder: Select an account
@ -127,7 +134,6 @@ en:
delete_subtitle: This permanently deletes the transaction, affects your historical
balances, and cannot be undone.
delete_title: Delete transaction
description: Description
exclude_subtitle: This excludes the transaction from any in-app features or
analytics.
exclude_title: Exclude transaction
@ -139,5 +145,11 @@ en:
overview: Overview
settings: Settings
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:
success: Transaction updated successfully

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

View file

@ -46,6 +46,8 @@ Rails.application.routes.draw do
post "bulk_delete"
get "bulk_edit"
post "bulk_update"
post "mark_transfers"
post "unmark_transfers"
scope module: :transactions, as: :transaction do
resources :rows, only: %i[ show update ]
@ -63,6 +65,8 @@ Rails.application.routes.draw do
end
end
resources :transfers, only: %i[ new create destroy ]
resources :accounts, shallow: true do
get :summary, on: :collection
get :list, on: :collection

View 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

View file

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

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_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
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -87,7 +87,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_12_164944) do
t.uuid "accountable_id"
t.decimal "balance", precision: 19, scale: 4, default: "0.0"
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.enum "status", default: "ok", null: false, enum_type: "account_status"
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.text "notes"
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 ["category_id"], name: "index_transactions_on_category_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
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", "transaction_categories", column: "category_id", on_delete: :nullify
add_foreign_key "transactions", "transaction_merchants", column: "merchant_id"
add_foreign_key "transactions", "transfers"
add_foreign_key "users", "families"
add_foreign_key "valuations", "accounts", on_delete: :cascade
end

View file

@ -4,7 +4,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@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
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
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! \
name: "Transaction to search for",
@ -42,7 +42,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end
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)
assert_response :success

View 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

View file

@ -1,2 +1 @@
one:
id: "123e4567-e89b-12d3-a456-426614174003"
credit_one: { }

View file

@ -1,11 +1 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# 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
one: { }

View file

@ -1,8 +1,4 @@
checking:
id: "123e4567-e89b-12d3-a456-426614174000"
savings:
id: "123e4567-e89b-12d3-a456-426614174001"
eur_checking:
id: "123e4567-e89b-12d3-a456-426614174004"
multi_currency:
id: "123e4567-e89b-12d3-a456-426614174005"
depository_checking: { }
depository_savings: { }
depository_eur_checking: { }
depository_multi_currency: { }

View file

@ -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 date_offset collectable checking savings_with_valuation_overrides credit_card eur_checking_eur eur_checking_usd multi_currency
2 -30 400 4000 21250 1040 11850 12947.31 10721.26
3 -29 400 3985 21750 940 12050 13182.7 10921.26
4 -28 400 3985 21750 940 12050 13194.75 10921.26
5 -27 400 3985 21750 940 12050 13132.09 10921.26
6 -26 400 3985 21750 940 12050 13083.89 10921.26
7 -25 400 3985 21000 940 12050 13081.48 10921.26
8 -24 400 3985 21000 940 12050 13062.2 10921.26
9 -23 400 3985 21000 940 12050 13022.435 10921.26
10 -22 400 5060 21000 940 12050 13060.995 10921.26
11 -21 400 5060 21000 940 12050 13068.225 10921.26
12 -20 400 5060 21000 940 12050 13079.07 10921.26
13 -19 400 5060 21000 940 11950 12932.29 10813.04
14 -18 400 5060 19000 940 11950 12934.68 10813.04
15 -17 400 5060 19000 940 11950 12927.51 10813.04
16 -16 400 5060 19000 940 11950 12916.755 10813.04
17 -15 400 5040 19000 960 11950 12882.1 10813.04
18 -14 400 5040 19000 960 11950 12879.71 10813.04
19 -13 400 5040 19000 960 11950 12873.735 10813.04
20 -12 700 5010 19500 990 11950 12821.155 10813.04
21 -11 700 5010 19500 990 11950 12797.255 10813.04
22 -10 700 5010 19500 990 11950 12873.735 10813.04
23 -9 700 5010 19500 990 12000 12939.6 10863.04
24 -8 700 5010 19500 990 12000 12933.6 10863.04
25 -7 700 5010 19500 990 12000 12928.8 10863.04
26 -6 700 5010 19500 990 12000 12906 10863.04
27 -5 700 5000 19700 1000 12000 12891.6 10863.04
28 -4 550 5000 19700 1000 12000 12945.6 10000
29 -3 550 5000 20500 1000 12000 13046.4 10000
30 -2 550 5000 20500 1000 12000 12982.8 10000
31 -1 550 5000 20500 1000 12000 13014 10000
32 0 550 5000 20500 1000 12000 13000.8 10000

View file

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

View file

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

View file

@ -1,2 +1,3 @@
one:
id: "123e4567-e89b-12d3-a456-426614174002"
other_asset_collectable: { }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,36 +1,39 @@
# Account with only valuations
collectable:
family: dylan_family
name: Collectable Account
balance: 550
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:
family: dylan_family
name: Checking Account
balance: 5000
accountable_type: Account::Depository
accountable_id: "123e4567-e89b-12d3-a456-426614174000"
accountable: depository_checking
institution: chase
# Account with both transactions and valuations
savings_with_valuation_overrides:
savings:
family: dylan_family
name: Savings account with valuation overrides
balance: 20000
balance: 19700
accountable_type: Account::Depository
accountable_id: "123e4567-e89b-12d3-a456-426614174001"
accountable: depository_savings
institution: chase
# Liability account
credit_card:
family: dylan_family
name: Credit Card
balance: 1000
accountable_type: Account::Credit
accountable_id: "123e4567-e89b-12d3-a456-426614174003"
accountable: credit_one
institution: chase
eur_checking:
@ -39,7 +42,7 @@ eur_checking:
currency: EUR
balance: 12000
accountable_type: Account::Depository
accountable_id: "123e4567-e89b-12d3-a456-426614174004"
accountable: depository_eur_checking
institution: revolut
# Multi-currency account (e.g. Wise, Revolut, etc.)
@ -47,7 +50,39 @@ multi_currency:
family: dylan_family
name: Multi Currency Account
currency: USD # multi-currency accounts still have a "primary" currency
balance: 10000
balance: 9467
accountable_type: Account::Depository
accountable_id: "123e4567-e89b-12d3-a456-426614174005"
accountable: depository_multi_currency
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

View file

@ -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:
base_currency: EUR
converted_currency: USD
rate: 1.0926
date: <%= 30.days.ago.to_date %>
day_29_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.094
date: <%= 29.days.ago.to_date %>
day_28_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.095
date: <%= 28.days.ago.to_date %>
day_27_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0898
date: <%= 27.days.ago.to_date %>
day_26_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0858
date: <%= 26.days.ago.to_date %>
day_25_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0856
date: <%= 25.days.ago.to_date %>
day_24_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.084
date: <%= 24.days.ago.to_date %>
day_23_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0807
date: <%= 23.days.ago.to_date %>
day_22_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0839
date: <%= 22.days.ago.to_date %>
day_21_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0845
date: <%= 21.days.ago.to_date %>
day_20_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0854
date: <%= 20.days.ago.to_date %>
day_19_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0822
date: <%= 19.days.ago.to_date %>
day_18_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0824
date: <%= 18.days.ago.to_date %>
day_17_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0818
date: <%= 17.days.ago.to_date %>
day_16_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0809
date: <%= 16.days.ago.to_date %>
day_15_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.078
date: <%= 15.days.ago.to_date %>
day_14_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0778
date: <%= 14.days.ago.to_date %>
day_13_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0773
date: <%= 13.days.ago.to_date %>
day_12_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0729
date: <%= 12.days.ago.to_date %>
day_11_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0709
date: <%= 11.days.ago.to_date %>
day_10_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0773
date: <%= 10.days.ago.to_date %>
day_9_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0783
date: <%= 9.days.ago.to_date %>
day_8_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0778
date: <%= 8.days.ago.to_date %>
day_7_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0774
date: <%= 7.days.ago.to_date %>
day_6_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0755
date: <%= 6.days.ago.to_date %>
day_5_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0743
date: <%= 5.days.ago.to_date %>
day_4_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0788
date: <%= 4.days.ago.to_date %>
day_3_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0872
date: <%= 3.days.ago.to_date %>
day_2_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0819
date: <%= 2.days.ago.to_date %>
day_1_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0845
date: <%= 1.days.ago.to_date %>
today_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0834
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:
base_currency: USD
converted_currency: EUR
rate: 0.9179
date: <%= 30.days.ago.to_date %>
day_29_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9154
date: <%= 29.days.ago.to_date %>
day_28_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9107
date: <%= 28.days.ago.to_date %>
day_27_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9139
date: <%= 27.days.ago.to_date %>
day_26_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9082
date: <%= 26.days.ago.to_date %>
day_25_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9077
date: <%= 25.days.ago.to_date %>
day_24_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9054
date: <%= 24.days.ago.to_date %>
day_23_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9004
date: <%= 23.days.ago.to_date %>
day_22_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9040
date: <%= 22.days.ago.to_date %>
day_21_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9060
date: <%= 21.days.ago.to_date %>
day_20_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9052
date: <%= 20.days.ago.to_date %>
day_19_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9139
date: <%= 19.days.ago.to_date %>
day_18_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9155
date: <%= 18.days.ago.to_date %>
day_17_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9135
date: <%= 17.days.ago.to_date %>
day_16_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9141
date: <%= 16.days.ago.to_date %>
day_15_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9131
date: <%= 15.days.ago.to_date %>
day_14_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9147
date: <%= 14.days.ago.to_date %>
day_13_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9112
date: <%= 13.days.ago.to_date %>
day_12_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9115
date: <%= 12.days.ago.to_date %>
day_11_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9132
date: <%= 11.days.ago.to_date %>
day_10_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9130
date: <%= 10.days.ago.to_date %>
day_9_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9192
date: <%= 9.days.ago.to_date %>
day_8_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9188
date: <%= 8.days.ago.to_date %>
day_7_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9194
date: <%= 7.days.ago.to_date %>
day_6_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9177
date: <%= 6.days.ago.to_date %>
day_5_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9187
date: <%= 5.days.ago.to_date %>
day_4_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9213
date: <%= 4.days.ago.to_date %>
day_3_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9186
date: <%= 3.days.ago.to_date %>
day_2_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9218
date: <%= 2.days.ago.to_date %>
day_1_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9213
date: <%= 1.days.ago.to_date %>
today_usd_to_eur:
base_currency: USD
converted_currency: EUR

View file

@ -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
1 date_offset net_worth assets liabilities depositories investments loans credits properties vehicles other_assets other_liabilities spending income rolling_spend rolling_income savings_rate
2 -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
3 -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
4 -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
5 -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
6 -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
7 -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
8 -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
9 -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
10 -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
11 -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
12 -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
13 -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
14 -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
15 -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 -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
17 -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
18 -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
19 -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
20 -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
21 -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
22 -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
23 -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
24 -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
25 -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
26 -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
27 -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
28 -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
29 -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
30 -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
31 -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
32 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

View file

View 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
1 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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
25 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
26 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
27 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
28 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
29 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
30 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
31 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
32 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
33 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

View file

@ -39,12 +39,46 @@ checking_five:
currency: USD
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:
name: Interest Received
date: <%= 5.days.ago.to_date %>
amount: -200
account: savings_with_valuation_overrides
account: savings
category: income
currency: USD
@ -52,7 +86,7 @@ savings_two:
name: Check Deposit
date: <%= 12.days.ago.to_date %>
amount: -50
account: savings_with_valuation_overrides
account: savings
category: income
currency: USD
@ -60,17 +94,26 @@ savings_three:
name: Withdrawal
date: <%= 18.days.ago.to_date %>
amount: 2000
account: savings_with_valuation_overrides
account: savings
currency: USD
savings_four:
name: Check Deposit
date: <%= 29.days.ago.to_date %>
amount: -500
account: savings_with_valuation_overrides
account: savings
category: income
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_one:
name: Starbucks
@ -96,12 +139,14 @@ credit_card_three:
currency: USD
merchant: amazon
credit_card_four:
name: CC Payment
date: <%= 29.days.ago.to_date %>
credit_card_four_payment:
name: Received CC Payment from Checking Account
date: <%= 30.days.ago.to_date %>
amount: -100
account: credit_card
currency: USD
marked_as_transfer: true
transfer: credit_card_payment
# eur_checking transactions
eur_checking_one:
@ -120,7 +165,7 @@ eur_checking_two:
eur_checking_three:
name: Check
date: <%= 29.days.ago.to_date %>
date: <%= 30.days.ago.to_date %>
amount: -200
currency: EUR
account: eur_checking
@ -143,7 +188,7 @@ multi_currency_two:
multi_currency_three:
name: Outflow 2
date: <%= 19.days.ago.to_date %>
amount: 100
amount: 110.85
currency: EUR
account: multi_currency

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

@ -0,0 +1,2 @@
credit_card_payment: { }
savings_transfer: { }

View file

@ -11,21 +11,45 @@ collectable_two:
collectable_three:
value: 400
date: <%= 30.days.ago.to_date %>
date: <%= 31.days.ago.to_date %>
account: collectable
# For checking account that has valuations and transactions
savings_one:
value: 20500
date: <%= 3.days.ago.to_date %>
account: savings_with_valuation_overrides
iou_one:
value: 200
date: <%= 31.days.ago.to_date %>
account: iou
savings_two:
multi_currency_one:
value: 10200
date: <%= 31.days.ago.to_date %>
account: multi_currency
savings_one:
value: 19500
date: <%= 12.days.ago.to_date %>
account: savings_with_valuation_overrides
account: savings
savings_three:
savings_two:
value: 21000
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

View file

@ -2,111 +2,82 @@ require "test_helper"
require "csv"
class Account::Balance::CalculatorTest < ActiveSupport::TestCase
# See: https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
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"],
include FamilySnapshotTestHelper
# Balances should be calculated for all currencies of an account
"eur_checking_eur" => row["eur_checking_eur"],
"eur_checking_usd" => row["eur_checking_usd"]
}
test "syncs other asset balances" do
expected_balances = get_expected_balances_for(:collectable)
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
test "syncs account with only valuations" do
account = accounts(:collectable)
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
def calculated_balances_for(account_key)
Account::Balance::Calculator.new(accounts(account_key)).calculate.daily_balances
end
end

View file

@ -4,7 +4,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
setup do
@account = accounts(:savings_with_valuation_overrides)
@account = accounts(:savings)
end
test "triggers sync job" do
@ -14,27 +14,27 @@ class Account::SyncableTest < ActiveSupport::TestCase
end
test "account has no balances until synced" do
account = accounts(:savings_with_valuation_overrides)
account = accounts(:savings)
assert_equal 0, account.balances.count
end
test "account has balances after syncing" do
account = accounts(:savings_with_valuation_overrides)
account = accounts(:savings)
account.sync
assert_equal 31, account.balances.count
assert_equal 32, account.balances.count
end
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
assert_equal 31, account.balances.count
assert_equal 32, account.balances.count
end
test "balances are updated after syncing" do
account = accounts(:savings_with_valuation_overrides)
account = accounts(:savings)
balance_date = 10.days.ago
account.balances.create!(date: balance_date, balance: 1000)
account.sync
@ -43,7 +43,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
end
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
account.balances.create!(date: balance_date, balance: 1000)
account.sync 5.days.ago.to_date
@ -52,7 +52,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
end
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
account.balances.create!(date: balance_date, balance: 1000)
account.sync 20.days.ago.to_date
@ -61,7 +61,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
end
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
account.balances.create!(date: balance_date, balance: 1000)
account.sync balance_date.to_date
@ -73,13 +73,13 @@ class Account::SyncableTest < ActiveSupport::TestCase
account = accounts(:eur_checking)
account.sync
assert_equal 62, account.balances.count
assert_equal 31, account.balances.where(currency: "EUR").count
assert_equal 31, account.balances.where(currency: "USD").count
assert_equal 64, account.balances.count
assert_equal 32, account.balances.where(currency: "EUR").count
assert_equal 32, account.balances.where(currency: "USD").count
end
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)
account.balances.create!(date: 1.year.ago, balance: 1000)
@ -88,14 +88,6 @@ class Account::SyncableTest < ActiveSupport::TestCase
account.sync
assert_equal 31, 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
assert_equal 32, account.balances.count
end
end

View file

@ -5,16 +5,6 @@ class AccountTest < ActiveSupport::TestCase
def setup
@account = accounts(:checking)
@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
test "new account should be valid" do
@ -47,26 +37,23 @@ class AccountTest < ActiveSupport::TestCase
test "syncs regular account" do
@account.sync
assert_equal "ok", @account.status
assert_equal 31, @account.balances.count
assert_equal 32, @account.balances.count
end
test "syncs foreign currency account" do
account = accounts(:eur_checking)
account.sync
assert_equal "ok", account.status
assert_equal 31, account.balances.where(currency: "USD").count
assert_equal 31, account.balances.where(currency: "EUR").count
assert_equal 32, account.balances.where(currency: "USD").count
assert_equal 32, account.balances.where(currency: "EUR").count
end
test "groups accounts by type" do
@family.accounts.each do |account|
account.sync
end
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]
liabilities = result[:liabilities]
@ -84,14 +71,14 @@ class AccountTest < ActiveSupport::TestCase
other_liabilities = liabilities.children.find { |group| group.name == "Account::OtherLiability" }
assert_equal 4, depositories.children.count
assert_equal 0, properties.children.count
assert_equal 0, vehicles.children.count
assert_equal 0, investments.children.count
assert_equal 1, properties.children.count
assert_equal 1, vehicles.children.count
assert_equal 1, investments.children.count
assert_equal 1, other_assets.children.count
assert_equal 1, credits.children.count
assert_equal 0, loans.children.count
assert_equal 0, other_liabilities.children.count
assert_equal 1, loans.children.count
assert_equal 1, other_liabilities.children.count
end
test "generates series with last balance equal to current account balance" do

View file

@ -2,26 +2,13 @@ require "test_helper"
require "csv"
class FamilyTest < ActiveSupport::TestCase
include FamilySnapshotTestHelper
def setup
@family = families(:dylan_family)
@family.accounts.each do |account|
account.sync
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
test "should have many users" do
@ -58,57 +45,62 @@ class FamilyTest < ActiveSupport::TestCase
end
test "should calculate total assets" do
expected = @expected_snapshots.last["assets"].to_d
assert_equal Money.new(expected), @family.assets
expected = get_today_snapshot_value_for :assets
assert_in_delta expected, @family.assets.amount, 0.01
end
test "should calculate total liabilities" do
expected = @expected_snapshots.last["liabilities"].to_d
assert_equal Money.new(expected), @family.liabilities
expected = get_today_snapshot_value_for :liabilities
assert_in_delta expected, @family.liabilities.amount, 0.01
end
test "should calculate net worth" do
expected = @expected_snapshots.last["net_worth"].to_d
assert_equal Money.new(expected), @family.net_worth
expected = get_today_snapshot_value_for :net_worth
assert_in_delta expected, @family.net_worth.amount, 0.01
end
test "should calculate snapshot correctly" do
asset_series = @family.snapshot[:asset_series]
liability_series = @family.snapshot[:liability_series]
net_worth_series = @family.snapshot[:net_worth_series]
test "calculates asset time series" do
series = @family.snapshot[:asset_series]
expected_series = get_expected_balances_for :assets
assert_equal @expected_snapshots.count, asset_series.values.count
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
assert_time_series_balances series, expected_series
end
test "should calculate transaction snapshot correctly" do
spending_series = @family.snapshot_transactions[:spending_series]
income_series = @family.snapshot_transactions[:income_series]
savings_rate_series = @family.snapshot_transactions[:savings_rate_series]
test "calculates liability time series" do
series = @family.snapshot[:liability_series]
expected_series = get_expected_balances_for :liabilities
assert_equal @expected_snapshots.count, spending_series.values.count
assert_equal @expected_snapshots.count, income_series.values.count
assert_equal @expected_snapshots.count, savings_rate_series.values.count
assert_time_series_balances series, expected_series
end
@expected_snapshots.each_with_index do |row, index|
expected_spending = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_spend"].to_d))
expected_income = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_income"].to_d))
expected_savings_rate = TimeSeries::Value.new(date: row["date"], value: Money.new(row["savings_rate"].to_d))
test "calculates net worth time series" do
series = @family.snapshot[:net_worth_series]
expected_series = get_expected_balances_for :net_worth
assert_in_delta expected_spending.value.amount, Money.new(spending_series.values[index].value).amount, 0.01
assert_in_delta expected_income.value.amount, Money.new(income_series.values[index].value).amount, 0.01
assert_in_delta expected_savings_rate.value.amount, savings_rate_series.values[index].value, 0.01
assert_time_series_balances series, expected_series
end
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
@ -127,4 +119,15 @@ class FamilyTest < ActiveSupport::TestCase
assert_equal liabilities_before - disabled_cc.balance, @family.liabilities
assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth
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

View file

@ -3,6 +3,7 @@ require "test_helper"
class TransactionTest < ActiveSupport::TestCase
setup do
@transaction = transactions(:checking_one)
@family = families(:dylan_family)
end
# 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.sync_account_later
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

View 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

View file

@ -3,7 +3,7 @@ require "ostruct"
class ValueGroupTest < ActiveSupport::TestCase
setup do
checking = accounts(:checking)
savings = accounts(:savings_with_valuation_overrides)
savings = accounts(:savings)
collectable = accounts(:collectable)
# Level 1

View 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

View file

@ -134,7 +134,7 @@ class TransactionsTest < ApplicationSystemTestCase
def number_of_transactions_on_page
page_size = 50
[ @user.family.transactions.count, page_size ].min
[ @user.family.transactions.where(transfer_id: nil).count, page_size ].min
end
def all_transactions_checkbox