diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 5417530b..e1c4d234 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -9,9 +9,7 @@ class AccountsController < ApplicationController
end
def show
- # Temporary while dummy data is being used
- # @account = Current.family.accounts.find(params[:id])
- @account = sample_account
+ @account = Current.family.accounts.find(params[:id])
end
def create
@@ -25,40 +23,9 @@ class AccountsController < ApplicationController
end
end
-
-
private
def account_params
params.require(:account).permit(:name, :accountable_type, :original_balance, :original_currency, :subtype)
end
-
- def sample_account
- OpenStruct.new(
- id: 1,
- name: "Sample Account",
- original_balance: BigDecimal("1115181"),
- original_currency: "USD",
- converted_balance: BigDecimal("1115181"), # Assuming conversion rate is 1 for simplicity
- converted_currency: "USD",
- dollar_change: BigDecimal("1553.43"), # Added dollar change
- percent_change: BigDecimal("0.9"), # Added percent change
- subtype: "Checking",
- accountable_type: "Depository",
- balances: sample_balances
- )
- end
-
- def sample_balances
- 4.times.map do |i|
- OpenStruct.new(
- date: "Feb #{12 + i} 2024",
- description: "Manually entered",
- amount: BigDecimal("1000") + (i * BigDecimal("100")),
- change: i == 3 ? -50 : (i == 2 ? 0 : 100 + (i * 10)),
- percentage_change: i == 3 ? -5 : (i == 2 ? 0 : 10 + i),
- icon: i == 3 ? "arrow-down" : (i == 2 ? "minus" : (i.even? ? "arrow-down" : "arrow-up"))
- )
- end
- end
end
diff --git a/app/controllers/valuations_controller.rb b/app/controllers/valuations_controller.rb
new file mode 100644
index 00000000..6e9bc9ef
--- /dev/null
+++ b/app/controllers/valuations_controller.rb
@@ -0,0 +1,62 @@
+class ValuationsController < ApplicationController
+ before_action :authenticate_user!
+
+ def create
+ @account = Current.family.accounts.find(params[:account_id])
+
+ # TODO: handle STI once we allow for different types of valuations
+ @valuation = @account.valuations.new(valuation_params.merge(type: "Appraisal", currency: Current.family.currency))
+ if @valuation.save
+ 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
+ @valuation = Valuation.find(params[:id])
+ end
+
+ def update
+ @valuation = Valuation.find(params[:id])
+ if @valuation.update(valuation_params)
+ 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
+ @valuation = Valuation.find(params[:id])
+ account = @valuation.account
+ @valuation.destroy
+
+ 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
+ def valuation_params
+ params.require(:valuation).permit(:date, :value)
+ end
+end
diff --git a/app/helpers/application_form_builder.rb b/app/helpers/application_form_builder.rb
index 1fad76b3..0f560255 100644
--- a/app/helpers/application_form_builder.rb
+++ b/app/helpers/application_form_builder.rb
@@ -8,7 +8,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
(field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]).each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
- def #{selector}(method, options)
+ def #{selector}(method, options = {})
default_options = { class: "form-field__input" }
merged_options = default_options.merge(options)
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 1090869d..e87992e7 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -48,6 +48,40 @@ module ApplicationHelper
end
end
+ # Styles to use when displaying a change in value
+ def trend_styles(trend_direction)
+ bg_class, text_class, symbol, icon = case trend_direction
+ when "up"
+ [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
+ when "down"
+ [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ]
+ when "flat"
+ [ "bg-gray-500/5", "text-gray-500", "", "minus" ]
+ else
+ raise ArgumentError, "Invalid trend direction: #{trend_direction}"
+ end
+
+ { bg_class: bg_class, text_class: text_class, symbol: symbol, icon: icon }
+ end
+
+ def trend_label(date_range)
+ start_date, end_date = date_range.values_at(:start, :end)
+ days_apart = (end_date - start_date).to_i
+
+ case days_apart
+ when 1
+ "vs. yesterday"
+ when 7
+ "vs. last week"
+ when 30, 31
+ "vs. last month"
+ when 365, 366
+ "vs. last year"
+ else
+ "from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
+ end
+ end
+
def format_currency(number, options = {})
user_currency_preference = Current.family.try(:currency) || "USD"
diff --git a/app/helpers/valuations_helper.rb b/app/helpers/valuations_helper.rb
new file mode 100644
index 00000000..9fe5ad7f
--- /dev/null
+++ b/app/helpers/valuations_helper.rb
@@ -0,0 +1,2 @@
+module ValuationsHelper
+end
diff --git a/app/javascript/controllers/line_chart_controller.js b/app/javascript/controllers/line_chart_controller.js
index 4dc93236..e38a43b9 100644
--- a/app/javascript/controllers/line_chart_controller.js
+++ b/app/javascript/controllers/line_chart_controller.js
@@ -4,82 +4,78 @@ import * as d3 from "d3";
// Connects to data-controller="line-chart"
export default class extends Controller {
+ static values = { series: Array };
+
connect() {
- this.drawChart();
+ this.renderChart(this.seriesValue);
+ document.addEventListener("turbo:load", this.renderChart.bind(this));
}
- drawChart() {
- // TODO: Replace with live data through controller targets
- const data = [
- {
- date: new Date(2021, 0, 1),
- value: 985000,
- formatted: "$985,000",
- change: { value: "$0", direction: "none", percentage: "0%" },
+ disconnect() {
+ document.removeEventListener("turbo:load", this.renderChart.bind(this));
+ }
+
+ renderChart() {
+ this.drawChart(this.seriesValue);
+ }
+
+ trendStyles(trendDirection) {
+ return {
+ up: {
+ icon: "↑",
+ color: tailwindColors.success,
},
- {
- date: new Date(2021, 1, 1),
- value: 990000,
- formatted: "$990,000",
- change: { value: "$5,000", direction: "up", percentage: "0.51%" },
+ down: {
+ icon: "↓",
+ color: tailwindColors.error,
},
- {
- date: new Date(2021, 2, 1),
- value: 995000,
- formatted: "$995,000",
- change: { value: "$5,000", direction: "up", percentage: "0.51%" },
+ flat: {
+ icon: "→",
+ color: tailwindColors.gray[500],
},
- {
- date: new Date(2021, 3, 1),
- value: 1000000,
- formatted: "$1,000,000",
- change: { value: "$5,000", direction: "up", percentage: "0.50%" },
+ }[trendDirection];
+ }
+
+ /**
+ * @param {Array} balances - An array of objects where each object represents a balance entry. Each object should have the following properties:
+ * - date: {Date} The date of the balance entry.
+ * - value: {number} The numerical value of the balance.
+ * - formatted: {string} The formatted string representation of the balance value.
+ * - trend: {Object} An object containing information about the trend compared to the previous balance entry. It should have:
+ * - amount: {number} The numerical difference in value from the previous entry.
+ * - direction: {string} A string indicating the direction of the trend ("up", "down", or "flat").
+ * - percent: {number} The percentage change from the previous entry.
+ */
+ drawChart(balances) {
+ const data = balances.map((b) => ({
+ ...b,
+ value: +b.value,
+ date: new Date(b.date),
+ styles: this.trendStyles(b.trend.direction),
+ formatted: {
+ value: Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: b.currency || "USD",
+ }).format(b.value),
+ change: Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: b.currency || "USD",
+ signDisplay: "always",
+ }).format(b.trend.amount),
},
- {
- date: new Date(2021, 4, 1),
- value: 1005000,
- formatted: "$997,000",
- change: { value: "$3,000", direction: "down", percentage: "-0.30%" },
- },
- {
- date: new Date(2021, 5, 1),
- value: 1010000,
- formatted: "$1,010,000",
- change: { value: "$5,000", direction: "up", percentage: "0.50%" },
- },
- {
- date: new Date(2021, 6, 1),
- value: 1050000,
- formatted: "$1,050,000",
- change: { value: "$40,000", direction: "up", percentage: "3.96%" },
- },
- {
- date: new Date(2021, 7, 1),
- value: 1080000,
- formatted: "$1,080,000",
- change: { value: "$30,000", direction: "up", percentage: "2.86%" },
- },
- {
- date: new Date(2021, 8, 1),
- value: 1100000,
- formatted: "$1,100,000",
- change: { value: "$20,000", direction: "up", percentage: "1.85%" },
- },
- {
- date: new Date(2021, 9, 1),
- value: 1115181,
- formatted: "$1,115,181",
- change: { value: "$15,181", direction: "up", percentage: "1.38%" },
- },
- ];
+ }));
+
+ const chartContainer = d3.select("#lineChart");
+
+ // Clear any existing chart
+ chartContainer.selectAll("svg").remove();
const initialDimensions = {
- width: document.querySelector("#lineChart").clientWidth,
- height: document.querySelector("#lineChart").clientHeight,
+ width: chartContainer.node().clientWidth,
+ height: chartContainer.node().clientHeight,
};
- const svg = d3
- .select("#lineChart")
+ const svg = chartContainer
.append("svg")
.attr("width", initialDimensions.width)
.attr("height", initialDimensions.height)
@@ -182,13 +178,22 @@ export default class extends Controller {
.on("mousemove", (event) => {
tooltip.style("opacity", 1);
+ const tooltipWidth = 250; // Estimate or dynamically calculate the tooltip width
+ const pageWidth = document.body.clientWidth;
+ const tooltipX = event.pageX + 10;
+ const overflowX = tooltipX + tooltipWidth - pageWidth;
+
const [xPos] = d3.pointer(event);
- const x0 = bisectDate(data, x.invert(xPos));
+ const x0 = bisectDate(data, x.invert(xPos), 1);
const d0 = data[x0 - 1];
const d1 = data[x0];
const d = xPos - x(d0.date) > x(d1.date) - xPos ? d1 : d0;
+ // Adjust tooltip position based on overflow
+ const adjustedX =
+ overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX;
+
g.selectAll(".data-point-circle").remove(); // Remove existing circles to ensure only one is shown at a time
g.append("circle")
.attr("class", "data-point-circle")
@@ -196,14 +201,16 @@ export default class extends Controller {
.attr("cy", y(d.value))
.attr("r", 8)
.attr("fill", tailwindColors.green[500])
- .attr("fill-opacity", "0.1");
+ .attr("fill-opacity", "0.1")
+ .attr("pointer-events", "none");
g.append("circle")
.attr("class", "data-point-circle")
.attr("cx", x(d.date))
.attr("cy", y(d.value))
.attr("r", 3)
- .attr("fill", tailwindColors.green[500]);
+ .attr("fill", tailwindColors.green[500])
+ .attr("pointer-events", "none");
tooltip
.html(
@@ -213,30 +220,15 @@ export default class extends Controller {
- ${d.formatted} ${
- d.change.direction === "up"
- ? "+"
- : d.change.direction === "down"
- ? "-"
- : ""
- } ${d.change.value} (${d.change.percentage})
-
-
`
+ ${d.formatted.value} ${d.formatted.change} (${d.trend.percent}%)
+ `
)
- .style("left", event.pageX + 10 + "px")
+ .style("left", adjustedX + "px")
.style("top", event.pageY - 10 + "px");
g.selectAll(".guideline").remove(); // Remove existing line to ensure only one is shown at a time
diff --git a/app/models/account.rb b/app/models/account.rb
index 61eeebd4..1b0608bf 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -1,6 +1,7 @@
class Account < ApplicationRecord
belongs_to :family
- has_many :account_balances
+ has_many :balances, class_name: "AccountBalance"
+ has_many :valuations
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
@@ -8,6 +9,15 @@ class Account < ApplicationRecord
before_create :check_currency
+ # Show all valuations in history table (no date range filtering)
+ def valuations_with_trend
+ series_for(valuations, :value)
+ end
+
+ def balances_with_trend(date_range = default_date_range)
+ series_for(balances, :balance, date_range)
+ end
+
def check_currency
if self.original_currency == self.family.currency
self.converted_balance = self.original_balance
@@ -17,4 +27,36 @@ class Account < ApplicationRecord
self.converted_currency = self.family.currency
end
end
+
+ private
+
+ def default_date_range
+ { start: 30.days.ago.to_date, end: Date.today }
+ end
+
+ # TODO: probably a better abstraction for this in the future
+ def series_for(collection, value_attr, date_range = {})
+ collection = filtered_by_date_for(collection, date_range)
+ overall_trend = Trend.new(collection.last&.send(value_attr), collection.first&.send(value_attr))
+
+ collection_with_trends = [ nil, *collection ].each_cons(2).map do |previous, current|
+ {
+ current: current,
+ previous: previous,
+ date: current.date,
+ currency: current.currency,
+ value: current.send(value_attr),
+ trend: Trend.new(current.send(value_attr), previous&.send(value_attr))
+ }
+ end
+
+ { date_range: date_range, trend: overall_trend, series: collection_with_trends }
+ end
+
+ def filtered_by_date_for(association, date_range)
+ scope = association
+ scope = scope.where("date >= ?", date_range[:start]) if date_range[:start]
+ scope = scope.where("date <= ?", date_range[:end]) if date_range[:end]
+ scope.order(:date).to_a
+ end
end
diff --git a/app/models/adjustment.rb b/app/models/adjustment.rb
new file mode 100644
index 00000000..1cef0cca
--- /dev/null
+++ b/app/models/adjustment.rb
@@ -0,0 +1,3 @@
+# Used for manual account value adjustments (e.g. to correct for a missing transaction)
+class Adjustment < Valuation
+end
diff --git a/app/models/appraisal.rb b/app/models/appraisal.rb
new file mode 100644
index 00000000..ca58791a
--- /dev/null
+++ b/app/models/appraisal.rb
@@ -0,0 +1,3 @@
+# Used to update the value of an account based on a manual or external appraisal (i.e. Zillow)
+class Appraisal < Valuation
+end
diff --git a/app/models/trend.rb b/app/models/trend.rb
new file mode 100644
index 00000000..0d59ee33
--- /dev/null
+++ b/app/models/trend.rb
@@ -0,0 +1,26 @@
+class Trend
+ attr_reader :current, :previous
+
+ def initialize(current, previous)
+ @current = current
+ @previous = previous
+ end
+
+ def direction
+ return "flat" unless @previous
+ return "up" if @current > @previous
+ return "down" if @current < @previous
+ "flat"
+ end
+
+ def amount
+ return 0 if @previous.nil?
+ @current - @previous
+ end
+
+ def percent
+ return 0 if @previous.nil?
+ return Float::INFINITY if @previous == 0
+ ((@current - @previous).abs / @previous.to_f * 100).round(1)
+ end
+end
diff --git a/app/models/valuation.rb b/app/models/valuation.rb
new file mode 100644
index 00000000..70dff5de
--- /dev/null
+++ b/app/models/valuation.rb
@@ -0,0 +1,5 @@
+# STI model to represent a point-in-time "valuation" of an account's value
+# Types include: Appraisal, Adjustment
+class Valuation < ApplicationRecord
+ belongs_to :account
+end
diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb
index 3c73ac33..25a82cfc 100644
--- a/app/views/accounts/show.html.erb
+++ b/app/views/accounts/show.html.erb
@@ -1,3 +1,5 @@
+<% balances = @account.balances_with_trend %>
+<% balance_styles = trend_styles(balances[:trend].direction) %>
@@ -22,24 +24,19 @@
Total Value
- <%# TODO: Will need a normalized way to split a formatted monetary value into these 3 parts %>
+ <%# TODO: Will need a better way to split a formatted monetary value into these 3 parts %>
<%= number_to_currency(@account.converted_balance)[0] %>
<%= number_with_delimiter(@account.converted_balance.round) %>
.<%= number_to_currency(@account.converted_balance, precision: 2)[-2, 2] %>
- <% if @account.dollar_change == 0 %>
-
- No change vs last month
-
+ <% if balances[:trend].amount == 0 %>
+
No change vs. prior period
<% else %>
-
- <%= @account.dollar_change > 0 ? '+' : '-' %><%= number_to_currency(@account.dollar_change.abs, precision: 2) %>
-
- (<%= lucide_icon(@account.dollar_change > 0 ? 'arrow-up' : 'arrow-down', class: "w-4 h-4 align-text-bottom inline") %>
- <%= @account.percent_change %>% )
-
- vs last month
+
+ <%= balance_styles[:symbol] %><%= number_to_currency(balances[:trend].amount.abs, precision: 2) %>
+ (<%= lucide_icon(balances[:trend].amount > 0 ? 'arrow-up' : 'arrow-down', class: "w-4 h-4 align-text-bottom inline") %> <%= balances[:trend].percent %>%)
+ <%= trend_label(balances[:date_range]) %>
<% end %>
@@ -51,64 +48,80 @@
-
History
-
+ History
+ <%= 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") %>
- New entry
-
+ New entry
+ <% end %>
-
- DATE
-
+
DATE
VALUE
CHANGE
- <% @account.balances.each_with_index do |balance, index| %>
-
+ <% series = @account.valuations_with_trend[:series].reverse_each %>
+ <% series.with_index do |valuation, index| %>
+ <% valuation_styles = trend_styles(valuation[:trend].direction) %>
+ <%= turbo_frame_tag dom_id(valuation[:current]) do %>
-
- <%= lucide_icon(balance.icon, class: "w-4 h-4 #{balance.change > 0 ? 'text-green-500' : balance.change < 0 ? 'text-red-500' : 'text-gray-500'}") %>
+
+ <%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 #{valuation_styles[:text_class]}") %>
-
<%= balance.date %>
-
<%= balance.description %>
+
<%= valuation[:date] %>
+ <%# TODO: Add descriptive name of valuation %>
+
Manually entered
-
<%= number_to_currency(balance.amount, precision: 2) %>
+
<%= format_currency(valuation[:value]) %>
- <% if balance.change == 0 %>
+ <% if valuation[:trend].amount == 0 %>
No change
<% else %>
- <%= balance.change > 0 ? '+' : '' %>$<%= balance.change.abs %>
-
- (<%= lucide_icon(balance.icon, class: "w-4 h-4 align-text-bottom inline") %> <%= balance.percentage_change %>%)
+ <%= valuation_styles[:symbol] %><%= format_currency(valuation[:trend].amount.abs) %>
+ (<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 align-text-bottom inline") %> <%= valuation[:trend].percent %>%)
<% end %>
-
- <%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500") %>
+
+
+ <%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500" ) %>
+
+
+ <%= link_to edit_valuation_path(valuation[:current]), 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") %>
+ Edit entry
+ <% end %>
+ <%= link_to valuation_path(valuation[:current]), data: { turbo_method: :delete, turbo_confirm: "Are you sure?" }, 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") %>
+ Delete entry
+ <% end %>
+
- <% if index < @account.balances.size - 1 %>
-
- <% end %>
-
+
+ <% if index < series.size - 1 %>
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+ <%= turbo_frame_tag dom_id(Valuation.new) do %>
<% end %>
-
- <%= lucide_icon("plus", class: "w-4 h-4") %> New entry
-
+ <%= link_to new_account_valuation_path(@account), data: { turbo_frame: "new_valuation" }, class: "hover:bg-white w-full text-sm flex items-center justify-center gap-2 text-gray-500 px-4 py-2 rounded-md" do %>
+ <%= lucide_icon("plus", class: "w-4 h-4") %> New entry
+ <% end %>
diff --git a/app/views/valuations/_form_row.html.erb b/app/views/valuations/_form_row.html.erb
new file mode 100644
index 00000000..8c0ddb9a
--- /dev/null
+++ b/app/views/valuations/_form_row.html.erb
@@ -0,0 +1,15 @@
+<%#
+ Locals:
+ - f: form object for valuation
+ - form_icon: string representing the icon to be displayed
+ - submit_button_text: string representing the text on the submit button
+%>
+
+
+ <%= lucide_icon(form_icon, class: "w-4 h-4 text-gray-500") %>
+
+ <%= f.date_field :date, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5" %>
+ <%= f.number_field :value, step: :any, placeholder: number_to_currency(0), in: 0.00..100000000.00, required: 'required', class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs px-3 py-1.5" %>
+ <%= 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" %>
+
diff --git a/app/views/valuations/create.html.erb b/app/views/valuations/create.html.erb
new file mode 100644
index 00000000..f9aa4ce8
--- /dev/null
+++ b/app/views/valuations/create.html.erb
@@ -0,0 +1,4 @@
+
+
Valuations#create
+
Find me in app/views/valuations/create.html.erb
+
diff --git a/app/views/valuations/create.turbo_stream.erb b/app/views/valuations/create.turbo_stream.erb
new file mode 100644
index 00000000..aeae48ed
--- /dev/null
+++ b/app/views/valuations/create.turbo_stream.erb
@@ -0,0 +1,3 @@
+<%# TODO: We need a way to determine the order the new valuation needs to be in the array, calculate the trend, and append it to the right spot %>
+<%= turbo_stream.update Valuation.new, "" %>
+<%= turbo_stream.append "notification-tray", partial: "shared/notification", locals: { type: "success", content: "Valuation created" } %>
diff --git a/app/views/valuations/destroy.html.erb b/app/views/valuations/destroy.html.erb
new file mode 100644
index 00000000..1bbc64cf
--- /dev/null
+++ b/app/views/valuations/destroy.html.erb
@@ -0,0 +1,4 @@
+
+
Valuations#destroy
+
Find me in app/views/valuations/destroy.html.erb
+
diff --git a/app/views/valuations/destroy.turbo_stream.erb b/app/views/valuations/destroy.turbo_stream.erb
new file mode 100644
index 00000000..fad56f55
--- /dev/null
+++ b/app/views/valuations/destroy.turbo_stream.erb
@@ -0,0 +1,2 @@
+<%= turbo_stream.remove @valuation %>
+<%= turbo_stream.append "notification-tray", partial: "shared/notification", locals: { type: "success", content: "Valuation deleted" } %>
diff --git a/app/views/valuations/edit.html.erb b/app/views/valuations/edit.html.erb
new file mode 100644
index 00000000..0c9f5bd0
--- /dev/null
+++ b/app/views/valuations/edit.html.erb
@@ -0,0 +1,8 @@
+
+
Edit Valuation: <%= @valuation.type %>
+ <%= turbo_frame_tag dom_id(@valuation) do %>
+ <%= form_with model: @valuation, url: valuation_path(@valuation), scope: :valuation do |f| %>
+ <%= render 'form_row', f: f, form_icon: "pencil-line", submit_button_text: "Update" %>
+ <% end %>
+ <% end %>
+
diff --git a/app/views/valuations/new.html.erb b/app/views/valuations/new.html.erb
new file mode 100644
index 00000000..7ceeb4b8
--- /dev/null
+++ b/app/views/valuations/new.html.erb
@@ -0,0 +1,8 @@
+
+
Add Valuation: <%= @account.name %>
+ <%= turbo_frame_tag dom_id(Valuation.new) do %>
+ <%= form_with model: [@account, @valuation], url: account_valuations_path(@account), scope: :valuation do |f| %>
+ <%= render 'form_row', f: f, form_icon: "plus", submit_button_text: "Add" %>
+ <% end %>
+ <% end %>
+
diff --git a/app/views/valuations/show.html.erb b/app/views/valuations/show.html.erb
new file mode 100644
index 00000000..8ed05622
--- /dev/null
+++ b/app/views/valuations/show.html.erb
@@ -0,0 +1,4 @@
+
+
Valuation: <%= @valuation.type %>
+
Find me in app/views/valuations/show.html.erb
+
diff --git a/app/views/valuations/update.html.erb b/app/views/valuations/update.html.erb
new file mode 100644
index 00000000..52863970
--- /dev/null
+++ b/app/views/valuations/update.html.erb
@@ -0,0 +1,4 @@
+
+
Valuations#update
+
Find me in app/views/valuations/update.html.erb
+
diff --git a/config/routes.rb b/config/routes.rb
index 5ba54a28..184c6834 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -7,7 +7,9 @@ Rails.application.routes.draw do
resource :password
resource :settings, only: %i[edit update]
- resources :accounts
+ resources :accounts, shallow: true do
+ resources :valuations
+ end
scope "accounts/new" do
scope "bank" do
diff --git a/db/migrate/20240215201527_create_valuations.rb b/db/migrate/20240215201527_create_valuations.rb
new file mode 100644
index 00000000..1a04cc9c
--- /dev/null
+++ b/db/migrate/20240215201527_create_valuations.rb
@@ -0,0 +1,16 @@
+class CreateValuations < ActiveRecord::Migration[7.2]
+ def change
+ create_table :valuations, id: :uuid do |t|
+ t.string :type, null: false
+ t.references :account, null: false, type: :uuid, foreign_key: { on_delete: :cascade }
+ t.date :date, null: false
+ t.decimal :value, precision: 19, scale: 4, null: false
+ t.string :currency, default: "USD", null: false
+
+ t.timestamps
+ end
+
+ # Since all dates are daily (no concept of time of day), limit account to 1 valuation per day
+ add_index :valuations, [ :account_id, :date ], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c4cf32ff..7479f19b 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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_02_12_150110) do
+ActiveRecord::Schema[7.2].define(version: 2024_02_15_201527) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -206,7 +206,20 @@ ActiveRecord::Schema[7.2].define(version: 2024_02_12_150110) 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.string "type", null: false
+ 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
+
add_foreign_key "account_balances", "accounts", on_delete: :cascade
add_foreign_key "accounts", "families"
add_foreign_key "users", "families"
+ add_foreign_key "valuations", "accounts", on_delete: :cascade
end
diff --git a/db/seeds.rb b/db/seeds.rb
index 2de3ca9c..23187d3d 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -8,6 +8,9 @@
# MovieGenre.find_or_create_by!(name: genre_name)
# end
+# https://github.com/rails/rails/issues/29112#issuecomment-320653056
+ApplicationRecord.reset_column_information
+
# Create the default user
family = Family.create_or_find_by(name: "The Maybe Family")
puts "Family created: #{family.name}"
@@ -22,3 +25,74 @@ puts "User created: #{user.email} for family: #{family.name}"
# Create default currency
Currency.find_or_create_by(iso_code: "USD", name: "United States Dollar")
+
+current_balance = 350000
+
+account = Account.create_or_find_by(name: "Seed Property Account", accountable: Account::Property.new, family: family, original_balance: current_balance, original_currency: "USD")
+puts "Account created: #{account.name}"
+
+# Represent user-defined "Valuations" at various dates
+appraisals = [
+ { date: Date.today - 30, balance: 300000 },
+ { date: Date.today - 22, balance: 300700 },
+ { date: Date.today - 17, balance: 301400 },
+ { date: Date.today - 10, balance: 300000 },
+ { date: Date.today - 3, balance: 301900 }
+]
+
+# In prod, this would be calculated from the current balance and the appraisals with a background job
+# Hardcoded for readability
+balances = [
+ { date: Date.today - 30, balance: 300000 },
+ { date: Date.today - 29, balance: 300000 },
+ { date: Date.today - 28, balance: 300000 },
+ { date: Date.today - 27, balance: 300000 },
+ { date: Date.today - 26, balance: 300000 },
+ { date: Date.today - 25, balance: 300000 },
+ { date: Date.today - 24, balance: 300000 },
+ { date: Date.today - 23, balance: 300000 },
+ { date: Date.today - 22, balance: 300700 },
+ { date: Date.today - 21, balance: 300700 },
+ { date: Date.today - 20, balance: 300700 },
+ { date: Date.today - 19, balance: 300700 },
+ { date: Date.today - 18, balance: 300700 },
+ { date: Date.today - 17, balance: 301400 },
+ { date: Date.today - 16, balance: 301400 },
+ { date: Date.today - 15, balance: 301400 },
+ { date: Date.today - 14, balance: 301400 },
+ { date: Date.today - 13, balance: 301400 },
+ { date: Date.today - 12, balance: 301400 },
+ { date: Date.today - 11, balance: 301400 },
+ { date: Date.today - 10, balance: 300000 },
+ { date: Date.today - 9, balance: 300000 },
+ { date: Date.today - 8, balance: 300000 },
+ { date: Date.today - 7, balance: 300000 },
+ { date: Date.today - 6, balance: 300000 },
+ { date: Date.today - 5, balance: 300000 },
+ { date: Date.today - 4, balance: 300000 },
+ { date: Date.today - 3, balance: 301900 },
+ { date: Date.today - 2, balance: 301900 },
+ { date: Date.today - 1, balance: 301900 },
+ { date: Date.today, balance: 302000 }
+]
+
+
+appraisals.each do |appraisal|
+ Appraisal.find_or_create_by(
+ account_id: account.id,
+ date: appraisal[:date]
+ ) do |appraisal_record|
+ appraisal_record.value = appraisal[:balance]
+ appraisal_record.currency = "USD"
+ end
+end
+
+balances.each do |balance|
+ AccountBalance.find_or_create_by(
+ account_id: account.id,
+ date: balance[:date]
+ ) do |balance_record|
+ balance_record.balance = balance[:balance]
+ balance_record.currency = "USD"
+ end
+end
diff --git a/test/controllers/valuations_controller_test.rb b/test/controllers/valuations_controller_test.rb
new file mode 100644
index 00000000..177341e4
--- /dev/null
+++ b/test/controllers/valuations_controller_test.rb
@@ -0,0 +1,19 @@
+require "test_helper"
+
+class ValuationsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in @user = users(:bob)
+ @account = accounts(:dylan_checking)
+ end
+
+ test "new" do
+ get new_account_valuation_url(@account)
+ assert_response :success
+ end
+
+ test "create" do
+ assert_difference("Valuation.count") do
+ post account_valuations_url(@account), params: { valuation: { value: 1, date: Date.current, type: "Appraisal" } }
+ end
+ end
+end
diff --git a/test/fixtures/valuations.yml b/test/fixtures/valuations.yml
new file mode 100644
index 00000000..e51bcdbd
--- /dev/null
+++ b/test/fixtures/valuations.yml
@@ -0,0 +1,13 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ type: "Appraisal"
+ value: 9.99
+ date: 2024-02-15
+ account: dylan_checking
+
+two:
+ type: "Appraisal"
+ value: 9.99
+ date: 2024-02-15
+ account: richards_savings
diff --git a/test/models/valuation_test.rb b/test/models/valuation_test.rb
new file mode 100644
index 00000000..0b775e65
--- /dev/null
+++ b/test/models/valuation_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class ValuationTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end