1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-23 15:19:38 +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 def show
@balance_series = @account.series(period: @period) @balance_series = @account.series(period: @period)
@valuation_series = @account.valuations.to_series
end end
def edit 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
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 private
def contextual_menu_icon def contextual_menu_icon
tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do 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 balances.where("date <= ?", date).order(date: :desc).first&.balance
end end
def favorable_direction
classification == "asset" ? "up" : "down"
end
# e.g. Wise, Revolut accounts that have transactions in multiple currencies # e.g. Wise, Revolut accounts that have transactions in multiple currencies
def multi_currency? def multi_currency?
currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq 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 class TimeSeries::Trend
include ActiveModel::Validations include ActiveModel::Validations
attr_reader :current, :previous attr_reader :current, :previous, :favorable_direction
delegate :favorable_direction, to: :series
validate :values_must_be_of_same_type, :values_must_be_of_known_type 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 @current = current
@previous = previous @previous = previous
@series = series @series = series
@favorable_direction = get_favorable_direction(favorable_direction)
validate! validate!
end end
@ -25,6 +24,17 @@ class TimeSeries::Trend
end.inquiry end.inquiry
end 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 def value
if previous.nil? if previous.nil?
current.is_a?(Money) ? Money.new(0) : 0 current.is_a?(Money) ? Money.new(0) : 0
@ -56,8 +66,21 @@ class TimeSeries::Trend
end end
private private
attr_reader :series 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 def values_must_be_of_same_type
unless current.class == previous.class || [ previous, current ].any?(&:nil?) unless current.class == previous.class || [ previous, current ].any?(&:nil?)
errors.add :current, "must be of the same type as previous" errors.add :current, "must be of the same type as previous"
@ -90,4 +113,9 @@ class TimeSeries::Trend
obj obj
end end
end end
def get_favorable_direction(favorable_direction)
direction = favorable_direction.presence || series&.favorable_direction
(direction.presence_in(TimeSeries::DIRECTIONS) || "up").inquiry
end
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="p-4 flex justify-between">
<div class="space-y-2"> <div class="space-y-2">
<%= render partial: "shared/value_heading", locals: { <%= render partial: "shared/value_heading", locals: {
label: "Total Value", label: "Total Value",
period: @period, period: @period,
value: @account.balance_money, value: @account.balance_money,
trend: @balance_series.trend trend: @balance_series.trend
} %> } %>
</div> </div>
<%= form_with url: account_path(@account), method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do %> <%= 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 } %> <%= render partial: "shared/period_select", locals: { value: @period.name } %>
@ -77,7 +77,23 @@
</div> </div>
<div class="min-h-[800px]"> <div class="min-h-[800px]">
<div data-tabs-target="tab" id="account-history-tab"> <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>
<div data-tabs-target="tab" id="account-transactions-tab" class="hidden"> <div data-tabs-target="tab" id="account-transactions-tab" class="hidden">
<%= render partial: "accounts/transactions", locals: { account: @account, transactions: @account.transactions.order(date: :desc) } %> <%= 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: en:
accounts: 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: create:
success: New account created successfully success: New account created successfully
destroy: destroy:
@ -65,9 +58,12 @@ en:
confirm_title: Delete account? confirm_title: Delete account?
edit: Edit edit: Edit
import: Import transactions import: Import transactions
loading_history: Loading account history...
new_entry: New entry
sync_message_missing_rates: Since exchange rates haven't been synced, balance sync_message_missing_rates: Since exchange rates haven't been synced, balance
graphs may not reflect accurate values. graphs may not reflect accurate values.
sync_message_unknown_error: An error has occurred during the sync. sync_message_unknown_error: An error has occurred during the sync.
valuations: Value history
summary: summary:
new: New account new: New account
sync: sync:

View file

@ -80,8 +80,10 @@ Rails.application.routes.draw do
post :sync post :sync
end end
resource :logo, only: :show, module: :account scope module: :account do
resources :valuations, shallow: true resource :logo, only: :show
resources :valuations
end
end end
resources :institutions, except: %i[ index show ] 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -37,6 +37,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_20_125026) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
end 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| create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "subtype" t.string "subtype"
t.uuid "family_id", null: false 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" t.index ["family_id"], name: "index_users_on_family_id"
end 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| create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
end end
add_foreign_key "account_balances", "accounts", on_delete: :cascade 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", "families"
add_foreign_key "accounts", "institutions" add_foreign_key "accounts", "institutions"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" 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", "categories", on_delete: :nullify
add_foreign_key "transactions", "merchants" add_foreign_key "transactions", "merchants"
add_foreign_key "users", "families" add_foreign_key "users", "families"
add_foreign_key "valuations", "accounts", on_delete: :cascade
end 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 end
test "should create an account" do 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: { post accounts_path, params: {
account: { account: {
accountable_type: "Depository", accountable_type: "Depository",
@ -60,7 +60,7 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
end end
test "can add optional start date and balance to an account on create" do 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: { post accounts_path, params: {
account: { account: {
accountable_type: "Depository", accountable_type: "Depository",

View file

@ -75,7 +75,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end end
assert_equal transaction_params[:amount].to_d, Transaction.order(created_at: :desc).first.amount 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_enqueued_with(job: AccountSyncJob)
assert_redirected_to transactions_url assert_redirected_to transactions_url
end 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" require "test_helper"
class TransferTest < ActiveSupport::TestCase class Account::TransferTest < ActiveSupport::TestCase
setup do setup do
# Transfers can be posted on different dates # 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 @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 end
test "should destroy dependent valuations" do 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 @account.destroy
end end
end end

View file

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

View file

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