1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +02:00

Account namespace updates: part 5 (valuations) (#901)

* Move Valuation to Account namespace

* Move account history to controller

* Clean up valuation controller and views

* Translations and cleanup

* Remove unused scopes and methods

* Pass brakeman
This commit is contained in:
Zach Gollwitzer 2024-06-21 16:23:28 -04:00 committed by GitHub
parent 0bc0d87768
commit 12380dc8ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 478 additions and 346 deletions

View file

@ -0,0 +1,61 @@
class Account::ValuationsController < ApplicationController
before_action :set_account
before_action :set_valuation, only: %i[ show edit update destroy ]
def new
@valuation = @account.valuations.new
end
def show
end
def create
@valuation = @account.valuations.build(valuation_params)
if @valuation.save
@valuation.sync_account_later
redirect_to account_path(@account), notice: "Valuation created"
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:error] = @valuation.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def edit
end
def update
if @valuation.update(valuation_params)
@valuation.sync_account_later
redirect_to account_path(@account), notice: t(".success")
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:error] = @valuation.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def destroy
@valuation.destroy!
@valuation.sync_account_later
redirect_to account_path(@account), notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_valuation
@valuation = @account.valuations.find(params[:id])
end
def valuation_params
params.require(:account_valuation).permit(:date, :value, :currency)
end
end

View file

@ -35,7 +35,6 @@ class AccountsController < ApplicationController
def show
@balance_series = @account.series(period: @period)
@valuation_series = @account.valuations.to_series
end
def edit

View file

@ -1,70 +0,0 @@
class ValuationsController < ApplicationController
before_action :set_valuation, only: %i[ edit update destroy ]
def create
@account = Current.family.accounts.find(params[:account_id])
# TODO: placeholder logic until we have a better abstraction for trends
@valuation = @account.valuations.new(valuation_params.merge(currency: @account.currency))
if @valuation.save
@valuation.account.sync_later(@valuation.date)
respond_to do |format|
format.html { redirect_to account_path(@account), notice: "Valuation created" }
format.turbo_stream
end
else
render :new, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotUnique
flash.now[:error] = "Valuation already exists for this date"
render :new, status: :unprocessable_entity
end
def show
@valuation = Current.family.accounts.find(params[:account_id]).valuations.find(params[:id])
end
def edit
end
def update
sync_start_date = [ @valuation.date, Date.parse(valuation_params[:date]) ].compact.min
if @valuation.update(valuation_params)
@valuation.account.sync_later(sync_start_date)
redirect_to account_path(@valuation.account), notice: "Valuation updated"
else
render :edit, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotUnique
flash.now[:error] = "Valuation already exists for this date"
render :edit, status: :unprocessable_entity
end
def destroy
@account = @valuation.account
sync_start_date = @account.valuations.where("date < ?", @valuation.date).order(date: :desc).first&.date
@valuation.destroy!
@account.sync_later(sync_start_date)
respond_to do |format|
format.html { redirect_to account_path(@account), notice: "Valuation deleted" }
format.turbo_stream
end
end
def new
@account = Current.family.accounts.find(params[:account_id])
@valuation = @account.valuations.new
end
private
# Use callbacks to share common setup or constraints between actions.
def set_valuation
@valuation = Valuation.find(params[:id])
end
def valuation_params
params.require(:valuation).permit(:date, :value)
end
end

View file

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

View file

@ -0,0 +1,23 @@
module Account::ValuationsHelper
def valuation_icon(valuation)
if valuation.first_of_series?
"keyboard"
elsif valuation.trend.direction.up?
"arrow-up"
elsif valuation.trend.direction.down?
"arrow-down"
else
"minus"
end
end
def valuation_style(valuation)
color = valuation.first_of_series? ? "#D444F1" : valuation.trend.color
<<-STYLE.strip
background-color: color-mix(in srgb, #{color} 5%, white);
border-color: color-mix(in srgb, #{color} 10%, white);
color: #{color};
STYLE
end
end

View file

@ -6,6 +6,23 @@ module MenusHelper
end
end
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: nil)
link_to url, class: "flex items-center rounded-lg text-gray-900 hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-gray-500"))
concat(tag.span(label, class: "text-sm"))
end
end
def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
button_to url,
method: :delete,
class: "flex items-center w-full rounded-lg text-red-500 hover:bg-red-500/5 py-2 px-3 gap-2",
data: { turbo_confirm: turbo_confirm, turbo_frame: } do
concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5"))
concat(tag.span(label, class: "text-sm"))
end
end
private
def contextual_menu_icon
tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do

View file

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

View file

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

View file

@ -29,6 +29,10 @@ class Account < ApplicationRecord
balances.where("date <= ?", date).order(date: :desc).first&.balance
end
def favorable_direction
classification == "asset" ? "up" : "down"
end
# e.g. Wise, Revolut accounts that have transactions in multiple currencies
def multi_currency?
currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq

View file

@ -0,0 +1,52 @@
class Account::Valuation < ApplicationRecord
include Monetizable
monetize :value
belongs_to :account
validates :account, :date, :value, presence: true
validates :date, uniqueness: { scope: :account_id }
scope :chronological, -> { order(:date) }
scope :reverse_chronological, -> { order(date: :desc) }
def trend
@trend ||= create_trend
end
def first_of_series?
account.valuations.chronological.limit(1).pluck(:date).first == self.date
end
def last_of_series?
account.valuations.reverse_chronological.limit(1).pluck(:date).first == self.date
end
def sync_account_later
if destroyed?
sync_start_date = previous_valuation&.date
else
sync_start_date = [ date_previously_was, date ].compact.min
end
account.sync_later(sync_start_date)
end
private
def previous_valuation
@previous_valuation ||= self.account
.valuations
.where("date < ?", date)
.order(date: :desc)
.first
end
def create_trend
TimeSeries::Trend.new \
current: self.value,
previous: previous_valuation&.value,
favorable_direction: account.favorable_direction
end
end

View file

@ -1,16 +1,15 @@
class TimeSeries::Trend
include ActiveModel::Validations
attr_reader :current, :previous
delegate :favorable_direction, to: :series
attr_reader :current, :previous, :favorable_direction
validate :values_must_be_of_same_type, :values_must_be_of_known_type
def initialize(current:, previous:, series: nil)
def initialize(current:, previous:, series: nil, favorable_direction: nil)
@current = current
@previous = previous
@series = series
@favorable_direction = get_favorable_direction(favorable_direction)
validate!
end
@ -25,6 +24,17 @@ class TimeSeries::Trend
end.inquiry
end
def color
case direction
when "up"
favorable_direction.down? ? red_hex : green_hex
when "down"
favorable_direction.down? ? green_hex : red_hex
else
gray_hex
end
end
def value
if previous.nil?
current.is_a?(Money) ? Money.new(0) : 0
@ -56,8 +66,21 @@ class TimeSeries::Trend
end
private
attr_reader :series
def red_hex
"#F13636" # red-500
end
def green_hex
"#10A861" # green-600
end
def gray_hex
"#737373" # gray-500
end
def values_must_be_of_same_type
unless current.class == previous.class || [ previous, current ].any?(&:nil?)
errors.add :current, "must be of the same type as previous"
@ -90,4 +113,9 @@ class TimeSeries::Trend
obj
end
end
def get_favorable_direction(favorable_direction)
direction = favorable_direction.presence || series&.favorable_direction
(direction.presence_in(TimeSeries::DIRECTIONS) || "up").inquiry
end
end

View file

@ -1,13 +0,0 @@
class Valuation < ApplicationRecord
include Monetizable
belongs_to :account
validates :account, :date, :value, presence: true
monetize :value
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
def self.to_series
TimeSeries.from_collection all, :value_money
end
end

View file

@ -0,0 +1,23 @@
<%# locals: (valuation:) %>
<%= form_with model: valuation,
data: { turbo_frame: "_top" },
url: valuation.new_record? ? account_valuations_path(valuation.account) : account_valuation_path(valuation.account, valuation),
builder: ActionView::Helpers::FormBuilder do |f| %>
<div class="grid grid-cols-10 p-4 items-center">
<div class="col-span-7 flex items-center gap-4">
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center bg-gray-500/5">
<%= lucide_icon("pencil-line", class: "w-4 h-4 text-gray-500") %>
</div>
<div class="w-full flex items-center justify-between gap-2">
<%= f.date_field :date, required: "required", max: Date.today, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
<%= f.number_field :value, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
<%= f.hidden_field :currency, value: valuation.account.currency %>
</div>
</div>
<div class="col-span-3 flex gap-2 justify-end items-center">
<%= link_to t(".cancel"), account_valuations_path(valuation.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %>
<%= f.submit class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,50 @@
<%# locals: (valuation:) %>
<%= turbo_frame_tag dom_id(valuation) do %>
<div class="p-4 grid grid-cols-10 items-center">
<div class="col-span-5 flex items-center gap-4">
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: valuation_style(valuation).html_safe do %>
<%= lucide_icon valuation_icon(valuation), class: "w-4 h-4" %>
<% end %>
<div class="text-sm">
<%= tag.p valuation.date, class: "text-gray-900 font-medium" %>
<%= tag.p valuation.first_of_series? ? t(".start_balance") : t(".value_update"), class: "text-gray-500" %>
</div>
</div>
<div class="col-span-2 justify-self-end">
<%= tag.p format_money(valuation.value_money), class: "font-medium text-sm text-gray-900" %>
</div>
<div class="col-span-2 justify-self-end font-medium text-sm" style="color: <%= valuation.trend.color %>">
<% if valuation.trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %>
<%= tag.span format_money(valuation.trend.value) %>
<%= tag.span "(#{valuation.trend.percent}%)" %>
<% end %>
</div>
<div class="col-span-1 justify-self-end">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= contextual_menu_modal_action_item t(".edit_entry"), edit_account_valuation_path(valuation.account, valuation) %>
<%= contextual_menu_destructive_item t(".delete_entry"),
account_valuation_path(valuation.account, valuation),
turbo_frame: "_top",
turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body_html"),
accept: t(".confirm_accept")
} %>
</div>
<% end %>
</div>
</div>
<% unless valuation.last_of_series? %>
<div class="h-px bg-alpha-black-50 ml-16 mr-4"></div>
<% end %>
<% end %>

View file

@ -0,0 +1,3 @@
<%= turbo_frame_tag dom_id(@valuation) do %>
<%= render "form", valuation: @valuation %>
<% end %>

View file

@ -0,0 +1,15 @@
<%= turbo_frame_tag dom_id(@account, "valuations") do %>
<div class="grid grid-cols-10 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
<%= tag.p t(".date"), class: "col-span-5" %>
<%= tag.p t(".value"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".change"), class: "col-span-2 justify-self-end" %>
<%= tag.div class: "col-span-1" %>
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<%= turbo_frame_tag dom_id(Account::Valuation.new) %>
<% @account.valuations.reverse_chronological.each do |valuation| %>
<%= render valuation %>
<% end %>
</div>
<% end %>

View file

@ -0,0 +1,4 @@
<%= turbo_frame_tag dom_id(@valuation) do %>
<%= render "form", valuation: @valuation %>
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
<% end %>

View file

@ -0,0 +1 @@
<%= render "valuation", valuation: @valuation %>

View file

@ -1,29 +0,0 @@
<%# locals: (account:, valuations:) %>
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex justify-between items-center">
<h3 class="font-medium text-lg">History</h3>
<%= link_to new_account_valuation_path(account), data: { turbo_frame: dom_id(Valuation.new) }, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<span class="text-sm">New entry</span>
<% end %>
</div>
<div class="rounded-xl bg-gray-25 p-1">
<div class="flex flex-col rounded-lg space-y-1">
<div class="text-xs font-medium text-gray-500 uppercase flex items-center px-4 py-2">
<div class="w-16">date</div>
<div class="flex items-center justify-between grow">
<div></div>
<div>value</div>
</div>
<div class="w-56 text-right">change</div>
<div class="w-[72px]"></div>
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<%= turbo_frame_tag dom_id(Valuation.new) %>
<%= turbo_frame_tag "valuations_list" do %>
<%= render partial: "accounts/account_valuation_list", locals: { valuation_series: valuations } %>
<% end %>
</div>
</div>
</div>
</div>

View file

@ -1,57 +0,0 @@
<%# locals: (valuation_series:) %>
<% valuation_series.values.reverse_each.with_index do |valuation, index| %>
<% valuation_styles = trend_styles(valuation.trend) %>
<%= turbo_frame_tag dom_id(valuation.original) do %>
<div class="p-4 flex items-center">
<div class="w-16">
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center <%= valuation_styles[:bg_class] %>">
<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 #{valuation_styles[:text_class]}") %>
</div>
</div>
<div class="flex items-center justify-between grow">
<div class="text-sm">
<p><%= valuation.date %></p>
<%# TODO: Add descriptive name of valuation %>
<p class="text-gray-500">Manually entered</p>
</div>
<div class="flex text-sm font-medium text-right"><%= format_money valuation.value %></div>
</div>
<div class="flex w-56 justify-end text-right text-sm font-medium">
<% if valuation.trend.value == 0 %>
<span class="text-gray-500">No change</span>
<% else %>
<span class="<%= valuation_styles[:text_class] %>"><%= valuation_styles[:symbol] %><%= format_money valuation.trend.value.abs %></span>
<span class="<%= valuation_styles[:text_class] %>">(<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 align-text-bottom inline") %> <%= valuation.trend.percent %>%)</span>
<% end %>
</div>
<div class="relative w-[72px]" data-controller="menu">
<button
data-menu-target="button"
class="ml-auto flex items-center justify-center hover:bg-gray-50 w-8 h-8 rounded-lg">
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500") %>
</button>
<div
data-menu-target="content"
class="absolute hidden min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit">
<%= link_to edit_valuation_path(valuation.original),
class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %>
<span class="text-gray-900 text-sm">Edit entry</span>
<% end %>
<%= link_to valuation_path(valuation.original),
data: { turbo_method: :delete,
turbo_confirm: { title: t(".confirm_title"),
body: t(".confirm_body_html"),
accept: t(".confirm_accept") } },
class: "text-red-600 flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("trash-2", class: "w-5 h-5 shrink-0") %>
<span class="text-sm">Delete entry</span>
<% end %>
</div>
</div>
</div>
<% unless index == valuation_series.values.size - 1 %>
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
<% end %>
<% end %>
<% end %>

View file

@ -56,11 +56,11 @@
<div class="p-4 flex justify-between">
<div class="space-y-2">
<%= render partial: "shared/value_heading", locals: {
label: "Total Value",
period: @period,
value: @account.balance_money,
trend: @balance_series.trend
} %>
label: "Total Value",
period: @period,
value: @account.balance_money,
trend: @balance_series.trend
} %>
</div>
<%= form_with url: account_path(@account), method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do %>
<%= render partial: "shared/period_select", locals: { value: @period.name } %>
@ -77,7 +77,23 @@
</div>
<div class="min-h-[800px]">
<div data-tabs-target="tab" id="account-history-tab">
<%= render partial: "accounts/account_history", locals: { account: @account, valuations: @valuation_series } %>
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".valuations"), class: "font-medium text-lg" %>
<%= link_to new_account_valuation_path(@account), data: { turbo_frame: dom_id(Account::Valuation.new) }, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<%= tag.span t(".new_entry"), class: "text-sm" %>
<% end %>
</div>
<div class="rounded-xl bg-gray-25 p-1">
<%= turbo_frame_tag dom_id(@account, "valuations"), src: account_valuations_path(@account) do %>
<div class="p-5 flex justify-center items-center">
<%= tag.p t(".loading_history"), class: "text-gray-500 animate-pulse text-sm" %>
</div>
<% end %>
</div>
</div>
</div>
<div data-tabs-target="tab" id="account-transactions-tab" class="hidden">
<%= render partial: "accounts/transactions", locals: { account: @account, transactions: @account.transactions.order(date: :desc) } %>

View file

@ -1,16 +0,0 @@
<%# locals (f:, form_icon:, submit_button_text:)%>
<div class="h-[72px] p-4 flex items-center">
<div class="w-16">
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center bg-gray-500/5">
<%= lucide_icon(form_icon, class: "w-4 h-4 text-gray-500") %>
</div>
</div>
<div class="flex items-center justify-between grow">
<%= f.date_field :date, required: "required", max: Date.today, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
<%= f.number_field :value, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
</div>
<div class="w-[296px] flex gap-2 justify-end items-center">
<%= link_to "Cancel", account_path(@valuation.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %>
<%= f.submit submit_button_text, class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %>
</div>
</div>

View file

@ -1,4 +0,0 @@
<div>
<h1 class="font-bold text-4xl">Valuations#create</h1>
<p>Find me in app/views/valuations/create.html.erb</p>
</div>

View file

@ -1,4 +0,0 @@
<%= turbo_stream.replace Valuation.new, body: turbo_frame_tag(dom_id(Valuation.new)) %>
<%= turbo_stream.append "notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: "Valuation created" } } %>
<%= turbo_stream.replace "valuations_list", partial: "accounts/account_valuation_list", locals: { valuation_series: @account.valuations.to_series } %>
<%= turbo_stream.replace "sync_message", partial: "accounts/sync_message", locals: { is_syncing: true } %>

View file

@ -1,4 +0,0 @@
<div>
<h1 class="font-bold text-4xl">Valuations#destroy</h1>
<p>Find me in app/views/valuations/destroy.html.erb</p>
</div>

View file

@ -1,4 +0,0 @@
<%= turbo_stream.remove @valuation %>
<%= turbo_stream.append "notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: "Valuation deleted" } } %>
<%= turbo_stream.replace "valuations_list", partial: "accounts/account_valuation_list", locals: { valuation_series: @account.valuations.to_series } %>
<%= turbo_stream.replace "sync_message", partial: "accounts/sync_message", locals: { is_syncing: true } %>

View file

@ -1,8 +0,0 @@
<div>
<h1 class="font-bold text-4xl">Edit Valuation</h1>
<%= turbo_frame_tag dom_id(@valuation) do %>
<%= form_with model: @valuation, url: valuation_path(@valuation), html: { class: "" } do |f| %>
<%= render "form_row", f: f, form_icon: "pencil-line", submit_button_text: "Update" %>
<% end %>
<% end %>
</div>

View file

@ -1,9 +0,0 @@
<div>
<h1 class="font-bold text-4xl">Add Valuation: <%= @account.name %></h1>
<%= turbo_frame_tag dom_id(Valuation.new) do %>
<%= form_with model: [@account, @valuation], url: account_valuations_path(@account), html: { class: "" } do |f| %>
<%= render "form_row", f: f, form_icon: "plus", submit_button_text: "Add" %>
<% end %>
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
<% end %>
</div>

View file

@ -1,4 +0,0 @@
<div>
<h1 class="font-bold text-4xl">Valuation: <%= @valuation.type %></h1>
<p>Find me in app/views/valuations/show.html.erb</p>
</div>

View file

@ -1,4 +0,0 @@
<div>
<h1 class="font-bold text-4xl">Valuations#update</h1>
<p>Find me in app/views/valuations/update.html.erb</p>
</div>

View file

@ -0,0 +1,26 @@
---
en:
account:
valuations:
destroy:
success: Valuation deleted
form:
cancel: Cancel
index:
change: change
date: date
value: value
update:
success: Valuation updated
valuation:
confirm_accept: Delete entry
confirm_body_html: "<p>Deleting this entry will remove it from the accounts
history which will impact different parts of your account. This includes
the net worth and account graphs.</p></br><p>The only way youll be able
to add this entry back is by re-entering it manually via a new entry</p>"
confirm_title: Delete Entry?
delete_entry: Delete entry
edit_entry: Edit entry
no_change: No change
start_balance: Starting balance
value_update: Value update

View file

@ -1,13 +1,6 @@
---
en:
accounts:
account_valuation_list:
confirm_accept: Delete entry
confirm_body_html: "<p>Deleting this entry will remove it from the accounts
history which will impact different parts of your account. This includes the
net worth and account graphs.</p></br><p>The only way youll be able to add
this entry back is by re-entering it manually via a new entry</p>"
confirm_title: Delete Entry?
create:
success: New account created successfully
destroy:
@ -65,9 +58,12 @@ en:
confirm_title: Delete account?
edit: Edit
import: Import transactions
loading_history: Loading account history...
new_entry: New entry
sync_message_missing_rates: Since exchange rates haven't been synced, balance
graphs may not reflect accurate values.
sync_message_unknown_error: An error has occurred during the sync.
valuations: Value history
summary:
new: New account
sync:

View file

@ -80,8 +80,10 @@ Rails.application.routes.draw do
post :sync
end
resource :logo, only: :show, module: :account
resources :valuations, shallow: true
scope module: :account do
resource :logo, only: :show
resources :valuations
end
end
resources :institutions, except: %i[ index show ]

View file

@ -0,0 +1,5 @@
class RenameValuationTable < ActiveRecord::Migration[7.2]
def change
rename_table :valuations, :account_valuations
end
end

26
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_20_125026) do
ActiveRecord::Schema[7.2].define(version: 2024_06_20_221801) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -37,6 +37,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_20_125026) do
t.datetime "updated_at", null: false
end
create_table "account_valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.date "date", null: false
t.decimal "value", precision: 19, scale: 4, null: false
t.string "currency", default: "USD", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "date"], name: "index_account_valuations_on_account_id_and_date", unique: true
t.index ["account_id"], name: "index_account_valuations_on_account_id"
end
create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "subtype"
t.uuid "family_id", null: false
@ -335,23 +346,13 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_20_125026) do
t.index ["family_id"], name: "index_users_on_family_id"
end
create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.date "date", null: false
t.decimal "value", precision: 19, scale: 4, null: false
t.string "currency", default: "USD", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "date"], name: "index_valuations_on_account_id_and_date", unique: true
t.index ["account_id"], name: "index_valuations_on_account_id"
end
create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_foreign_key "account_balances", "accounts", on_delete: :cascade
add_foreign_key "account_valuations", "accounts", on_delete: :cascade
add_foreign_key "accounts", "families"
add_foreign_key "accounts", "institutions"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
@ -367,5 +368,4 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_20_125026) do
add_foreign_key "transactions", "categories", on_delete: :nullify
add_foreign_key "transactions", "merchants"
add_foreign_key "users", "families"
add_foreign_key "valuations", "accounts", on_delete: :cascade
end

View file

@ -0,0 +1,71 @@
require "test_helper"
class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@valuation = account_valuations(:savings_one)
@account = @valuation.account
end
test "get valuations for an account" do
get account_valuations_url(@account)
assert_response :success
end
test "new" do
get new_account_valuation_url(@account)
assert_response :success
end
test "should create valuation" do
assert_difference("Account::Valuation.count") do
post account_valuations_url(@account), params: {
account_valuation: {
value: 19800,
date: Date.current
}
}
end
assert_equal "Valuation created", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@account)
end
test "error when valuation already exists for date" do
assert_difference("Account::Valuation.count", 0) do
post account_valuations_url(@account), params: {
account_valuation: {
value: 19800,
date: @valuation.date
}
}
end
assert_equal "Date has already been taken", flash[:error]
assert_redirected_to account_path(@account)
end
test "should update valuation" do
patch account_valuation_url(@account, @valuation), params: {
account_valuation: {
value: 19550,
date: Date.current
}
}
assert_equal "Valuation updated", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@account)
end
test "should destroy valuation" do
assert_difference("Account::Valuation.count", -1) do
delete account_valuation_url(@account, @valuation)
end
assert_equal "Valuation deleted", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@account)
end
end

View file

@ -44,7 +44,7 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
end
test "should create an account" do
assert_difference [ "Account.count", "Valuation.count" ], 1 do
assert_difference [ "Account.count", "Account::Valuation.count" ], 1 do
post accounts_path, params: {
account: {
accountable_type: "Depository",
@ -60,7 +60,7 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
end
test "can add optional start date and balance to an account on create" do
assert_difference -> { Account.count } => 1, -> { Valuation.count } => 2 do
assert_difference -> { Account.count } => 1, -> { Account::Valuation.count } => 2 do
post accounts_path, params: {
account: {
accountable_type: "Depository",

View file

@ -75,7 +75,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end
assert_equal transaction_params[:amount].to_d, Transaction.order(created_at: :desc).first.amount
assert_equal flash[:notice], "New transaction created successfully"
assert_equal "New transaction created successfully", flash[:notice]
assert_enqueued_with(job: AccountSyncJob)
assert_redirected_to transactions_url
end

View file

@ -1,74 +0,0 @@
require "test_helper"
class ValuationsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@valuation = valuations(:savings_one)
@account = @valuation.account
end
test "new" do
get new_account_valuation_url(@account)
assert_response :success
end
test "should create valuation" do
assert_difference("Valuation.count") do
post account_valuations_url(@account), params: { valuation: { value: 1, date: Date.current, type: "Appraisal" } }
end
assert_redirected_to account_path(@valuation.account)
end
test "should create valuation with account's currency" do
foreign_account = accounts(:eur_checking)
post account_valuations_url(foreign_account), params: { valuation: { value: 1, date: Date.current, type: "Appraisal" } }
assert_equal foreign_account.currency, Valuation.order(created_at: :desc).first.currency
end
test "create should sync account with correct start date" do
date = Date.current - 1.day
assert_enqueued_with(job: AccountSyncJob, args: [ @account, date ]) do
post account_valuations_url(@account), params: { valuation: { value: 2, date:, type: "Appraisal" } }
end
end
test "should update valuation" do
date = @valuation.date
patch valuation_url(@valuation), params: { valuation: { account_id: @valuation.account_id, value: 1, date:, type: "Appraisal" } }
assert_redirected_to account_path(@valuation.account)
end
test "update should sync account with correct start date" do
new_date = @valuation.date - 1.day
assert_enqueued_with(job: AccountSyncJob, args: [ @account, new_date ]) do
patch valuation_url(@valuation), params: { valuation: { account_id: @valuation.account_id, value: @valuation.value, date: new_date, type: "Appraisal" } }
end
new_date = @valuation.reload.date + 1.day
assert_enqueued_with(job: AccountSyncJob, args: [ @account, @valuation.date ]) do
patch valuation_url(@valuation), params: { valuation: { account_id: @valuation.account_id, value: @valuation.value, date: new_date, type: "Appraisal" } }
end
end
test "should destroy valuation" do
assert_difference("Valuation.count", -1) do
delete valuation_url(@valuation)
end
assert_redirected_to account_path(@account)
end
test "destroy should sync account with correct start date" do
first, second = @account.valuations.order(:date).all
assert_enqueued_with(job: AccountSyncJob, args: [ @account, first.date ]) do
delete valuation_url(second)
end
assert_enqueued_with(job: AccountSyncJob, args: [ @account, nil ]) do
delete valuation_url(first)
end
end
end

View file

@ -1,6 +1,6 @@
require "test_helper"
class TransferTest < ActiveSupport::TestCase
class Account::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

View file

@ -0,0 +1,39 @@
require "test_helper"
class Account::ValuationTest < ActiveSupport::TestCase
setup do
@valuation = account_valuations :savings_one
@family = families :dylan_family
end
test "one valuation per day" do
assert_equal 12.days.ago.to_date, account_valuations(:savings_one).date
invalid_valuation = Account::Valuation.new date: 12.days.ago.to_date, value: 20000
assert invalid_valuation.invalid?
end
test "triggers sync with correct start date when valuation is set to prior date" do
prior_date = @valuation.date - 1
@valuation.update! date: prior_date
@valuation.account.expects(:sync_later).with(prior_date)
@valuation.sync_account_later
end
test "triggers sync with correct start date when valuation is set to future date" do
prior_date = @valuation.date
@valuation.update! date: @valuation.date + 1
@valuation.account.expects(:sync_later).with(prior_date)
@valuation.sync_account_later
end
test "triggers sync with correct start date when valuation deleted" do
prior_valuation = account_valuations :savings_two # 25 days ago
current_valuation = account_valuations :savings_one # 12 days ago
current_valuation.destroy!
current_valuation.account.expects(:sync_later).with(prior_valuation.date)
current_valuation.sync_account_later
end
end

View file

@ -111,7 +111,7 @@ class AccountTest < ActiveSupport::TestCase
end
test "should destroy dependent valuations" do
assert_difference("Valuation.count", -@account.valuations.count) do
assert_difference("Account::Valuation.count", -@account.valuations.count) do
@account.destroy
end
end

View file

@ -11,11 +11,13 @@ class TimeSeries::TrendTest < ActiveSupport::TestCase
test "up" do
trend = TimeSeries::Trend.new(current: 100, previous: 50)
assert_equal "up", trend.direction
assert_equal "#10A861", trend.color
end
test "down" do
trend = TimeSeries::Trend.new(current: 50, previous: 100)
assert_equal "down", trend.direction
assert_equal "#F13636", trend.color
end
test "flat" do
@ -25,6 +27,7 @@ class TimeSeries::TrendTest < ActiveSupport::TestCase
assert_equal "flat", trend1.direction
assert_equal "flat", trend2.direction
assert_equal "flat", trend3.direction
assert_equal "#737373", trend1.color
end
test "infinitely up" do

View file

@ -1,4 +0,0 @@
require "test_helper"
class ValuationTest < ActiveSupport::TestCase
end