mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 21:29:38 +02:00
Add Live Data to Account Page (#464)
* Add trends, time series, seed data * Remove test data * Replace old view values with helpers * Fix tooltip bugs in D3 chart * Fix tests * Fix smoke test * Add CRUD actions for valuations * Scaffold out inline editing with Turbo
This commit is contained in:
parent
298b50a909
commit
b5b2d335fd
28 changed files with 512 additions and 167 deletions
|
@ -9,9 +9,7 @@ class AccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
# Temporary while dummy data is being used
|
@account = Current.family.accounts.find(params[:id])
|
||||||
# @account = Current.family.accounts.find(params[:id])
|
|
||||||
@account = sample_account
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
@ -25,40 +23,9 @@ class AccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def account_params
|
def account_params
|
||||||
params.require(:account).permit(:name, :accountable_type, :original_balance, :original_currency, :subtype)
|
params.require(:account).permit(:name, :accountable_type, :original_balance, :original_currency, :subtype)
|
||||||
end
|
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
|
end
|
||||||
|
|
62
app/controllers/valuations_controller.rb
Normal file
62
app/controllers/valuations_controller.rb
Normal file
|
@ -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
|
|
@ -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|
|
(field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]).each do |selector|
|
||||||
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
||||||
def #{selector}(method, options)
|
def #{selector}(method, options = {})
|
||||||
default_options = { class: "form-field__input" }
|
default_options = { class: "form-field__input" }
|
||||||
merged_options = default_options.merge(options)
|
merged_options = default_options.merge(options)
|
||||||
|
|
||||||
|
|
|
@ -48,6 +48,40 @@ module ApplicationHelper
|
||||||
end
|
end
|
||||||
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 = {})
|
def format_currency(number, options = {})
|
||||||
user_currency_preference = Current.family.try(:currency) || "USD"
|
user_currency_preference = Current.family.try(:currency) || "USD"
|
||||||
|
|
||||||
|
|
2
app/helpers/valuations_helper.rb
Normal file
2
app/helpers/valuations_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module ValuationsHelper
|
||||||
|
end
|
|
@ -4,82 +4,78 @@ import * as d3 from "d3";
|
||||||
|
|
||||||
// Connects to data-controller="line-chart"
|
// Connects to data-controller="line-chart"
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
|
static values = { series: Array };
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.drawChart();
|
this.renderChart(this.seriesValue);
|
||||||
|
document.addEventListener("turbo:load", this.renderChart.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
drawChart() {
|
disconnect() {
|
||||||
// TODO: Replace with live data through controller targets
|
document.removeEventListener("turbo:load", this.renderChart.bind(this));
|
||||||
const data = [
|
}
|
||||||
{
|
|
||||||
date: new Date(2021, 0, 1),
|
renderChart() {
|
||||||
value: 985000,
|
this.drawChart(this.seriesValue);
|
||||||
formatted: "$985,000",
|
}
|
||||||
change: { value: "$0", direction: "none", percentage: "0%" },
|
|
||||||
|
trendStyles(trendDirection) {
|
||||||
|
return {
|
||||||
|
up: {
|
||||||
|
icon: "↑",
|
||||||
|
color: tailwindColors.success,
|
||||||
},
|
},
|
||||||
{
|
down: {
|
||||||
date: new Date(2021, 1, 1),
|
icon: "↓",
|
||||||
value: 990000,
|
color: tailwindColors.error,
|
||||||
formatted: "$990,000",
|
|
||||||
change: { value: "$5,000", direction: "up", percentage: "0.51%" },
|
|
||||||
},
|
},
|
||||||
{
|
flat: {
|
||||||
date: new Date(2021, 2, 1),
|
icon: "→",
|
||||||
value: 995000,
|
color: tailwindColors.gray[500],
|
||||||
formatted: "$995,000",
|
|
||||||
change: { value: "$5,000", direction: "up", percentage: "0.51%" },
|
|
||||||
},
|
},
|
||||||
{
|
}[trendDirection];
|
||||||
date: new Date(2021, 3, 1),
|
}
|
||||||
value: 1000000,
|
|
||||||
formatted: "$1,000,000",
|
/**
|
||||||
change: { value: "$5,000", direction: "up", percentage: "0.50%" },
|
* @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,
|
const chartContainer = d3.select("#lineChart");
|
||||||
formatted: "$997,000",
|
|
||||||
change: { value: "$3,000", direction: "down", percentage: "-0.30%" },
|
// Clear any existing chart
|
||||||
},
|
chartContainer.selectAll("svg").remove();
|
||||||
{
|
|
||||||
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 initialDimensions = {
|
const initialDimensions = {
|
||||||
width: document.querySelector("#lineChart").clientWidth,
|
width: chartContainer.node().clientWidth,
|
||||||
height: document.querySelector("#lineChart").clientHeight,
|
height: chartContainer.node().clientHeight,
|
||||||
};
|
};
|
||||||
|
|
||||||
const svg = d3
|
const svg = chartContainer
|
||||||
.select("#lineChart")
|
|
||||||
.append("svg")
|
.append("svg")
|
||||||
.attr("width", initialDimensions.width)
|
.attr("width", initialDimensions.width)
|
||||||
.attr("height", initialDimensions.height)
|
.attr("height", initialDimensions.height)
|
||||||
|
@ -182,13 +178,22 @@ export default class extends Controller {
|
||||||
.on("mousemove", (event) => {
|
.on("mousemove", (event) => {
|
||||||
tooltip.style("opacity", 1);
|
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 [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 d0 = data[x0 - 1];
|
||||||
const d1 = data[x0];
|
const d1 = data[x0];
|
||||||
const d = xPos - x(d0.date) > x(d1.date) - xPos ? d1 : d0;
|
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.selectAll(".data-point-circle").remove(); // Remove existing circles to ensure only one is shown at a time
|
||||||
g.append("circle")
|
g.append("circle")
|
||||||
.attr("class", "data-point-circle")
|
.attr("class", "data-point-circle")
|
||||||
|
@ -196,14 +201,16 @@ export default class extends Controller {
|
||||||
.attr("cy", y(d.value))
|
.attr("cy", y(d.value))
|
||||||
.attr("r", 8)
|
.attr("r", 8)
|
||||||
.attr("fill", tailwindColors.green[500])
|
.attr("fill", tailwindColors.green[500])
|
||||||
.attr("fill-opacity", "0.1");
|
.attr("fill-opacity", "0.1")
|
||||||
|
.attr("pointer-events", "none");
|
||||||
|
|
||||||
g.append("circle")
|
g.append("circle")
|
||||||
.attr("class", "data-point-circle")
|
.attr("class", "data-point-circle")
|
||||||
.attr("cx", x(d.date))
|
.attr("cx", x(d.date))
|
||||||
.attr("cy", y(d.value))
|
.attr("cy", y(d.value))
|
||||||
.attr("r", 3)
|
.attr("r", 3)
|
||||||
.attr("fill", tailwindColors.green[500]);
|
.attr("fill", tailwindColors.green[500])
|
||||||
|
.attr("pointer-events", "none");
|
||||||
|
|
||||||
tooltip
|
tooltip
|
||||||
.html(
|
.html(
|
||||||
|
@ -213,30 +220,15 @@ export default class extends Controller {
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
<svg width="10" height="10">
|
<svg width="10" height="10">
|
||||||
<circle cx="5" cy="5" r="4" stroke="${
|
<circle cx="5" cy="5" r="4" stroke="${
|
||||||
d.change.direction === "up"
|
d.styles.color
|
||||||
? tailwindColors.success
|
|
||||||
: d.change.direction === "down"
|
|
||||||
? tailwindColors.error
|
|
||||||
: tailwindColors.gray[500]
|
|
||||||
}" fill="transparent" stroke-width="1"></circle>
|
}" fill="transparent" stroke-width="1"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
${d.formatted} <span style="color: ${
|
${d.formatted.value} <span style="color: ${
|
||||||
d.change.direction === "up"
|
d.styles.color
|
||||||
? tailwindColors.success
|
};">${d.formatted.change} (${d.trend.percent}%)</span>
|
||||||
: d.change.direction === "down"
|
|
||||||
? tailwindColors.error
|
|
||||||
: tailwindColors.gray[500]
|
|
||||||
};"><span>${
|
|
||||||
d.change.direction === "up"
|
|
||||||
? "+"
|
|
||||||
: d.change.direction === "down"
|
|
||||||
? "-"
|
|
||||||
: ""
|
|
||||||
}</span>${d.change.value} (${d.change.percentage})</span>
|
|
||||||
|
|
||||||
</div>`
|
</div>`
|
||||||
)
|
)
|
||||||
.style("left", event.pageX + 10 + "px")
|
.style("left", adjustedX + "px")
|
||||||
.style("top", event.pageY - 10 + "px");
|
.style("top", event.pageY - 10 + "px");
|
||||||
|
|
||||||
g.selectAll(".guideline").remove(); // Remove existing line to ensure only one is shown at a time
|
g.selectAll(".guideline").remove(); // Remove existing line to ensure only one is shown at a time
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
belongs_to :family
|
belongs_to :family
|
||||||
has_many :account_balances
|
has_many :balances, class_name: "AccountBalance"
|
||||||
|
has_many :valuations
|
||||||
|
|
||||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||||
|
|
||||||
|
@ -8,6 +9,15 @@ class Account < ApplicationRecord
|
||||||
|
|
||||||
before_create :check_currency
|
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
|
def check_currency
|
||||||
if self.original_currency == self.family.currency
|
if self.original_currency == self.family.currency
|
||||||
self.converted_balance = self.original_balance
|
self.converted_balance = self.original_balance
|
||||||
|
@ -17,4 +27,36 @@ class Account < ApplicationRecord
|
||||||
self.converted_currency = self.family.currency
|
self.converted_currency = self.family.currency
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
3
app/models/adjustment.rb
Normal file
3
app/models/adjustment.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Used for manual account value adjustments (e.g. to correct for a missing transaction)
|
||||||
|
class Adjustment < Valuation
|
||||||
|
end
|
3
app/models/appraisal.rb
Normal file
3
app/models/appraisal.rb
Normal file
|
@ -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
|
26
app/models/trend.rb
Normal file
26
app/models/trend.rb
Normal file
|
@ -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
|
5
app/models/valuation.rb
Normal file
5
app/models/valuation.rb
Normal file
|
@ -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
|
|
@ -1,3 +1,5 @@
|
||||||
|
<% balances = @account.balances_with_trend %>
|
||||||
|
<% balance_styles = trend_styles(balances[:trend].direction) %>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
@ -22,24 +24,19 @@
|
||||||
<div class="p-4 flex justify-between">
|
<div class="p-4 flex justify-between">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="text-sm text-gray-500">Total Value</p>
|
<p class="text-sm text-gray-500">Total Value</p>
|
||||||
<%# 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 %>
|
||||||
<p class="text-gray-900">
|
<p class="text-gray-900">
|
||||||
<span class="text-gray-500"><%= number_to_currency(@account.converted_balance)[0] %></span>
|
<span class="text-gray-500"><%= number_to_currency(@account.converted_balance)[0] %></span>
|
||||||
<span class="text-xl font-medium"><%= number_with_delimiter(@account.converted_balance.round) %></span>
|
<span class="text-xl font-medium"><%= number_with_delimiter(@account.converted_balance.round) %></span>
|
||||||
<span class="text-gray-500">.<%= number_to_currency(@account.converted_balance, precision: 2)[-2, 2] %></span>
|
<span class="text-gray-500">.<%= number_to_currency(@account.converted_balance, precision: 2)[-2, 2] %></span>
|
||||||
</p>
|
</p>
|
||||||
<% if @account.dollar_change == 0 %>
|
<% if balances[:trend].amount == 0 %>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">No change vs. prior period</p>
|
||||||
<span class="text-gray-500">No change vs last month</span>
|
|
||||||
</p>
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-sm <%= @account.dollar_change > 0 ? 'text-green-600' : 'text-red-600' %>">
|
<p class="text-sm <%= balance_styles[:text_class] %>">
|
||||||
<span><%= @account.dollar_change > 0 ? '+' : '-' %><%= number_to_currency(@account.dollar_change.abs, precision: 2) %></span>
|
<span><%= balance_styles[:symbol] %><%= number_to_currency(balances[:trend].amount.abs, precision: 2) %></span>
|
||||||
<span>
|
<span>(<%= lucide_icon(balances[:trend].amount > 0 ? 'arrow-up' : 'arrow-down', class: "w-4 h-4 align-text-bottom inline") %> <%= balances[:trend].percent %>%)</span>
|
||||||
(<%= lucide_icon(@account.dollar_change > 0 ? 'arrow-up' : 'arrow-down', class: "w-4 h-4 align-text-bottom inline") %>
|
<span class="text-gray-500"><%= trend_label(balances[:date_range]) %></span>
|
||||||
<span><%= @account.percent_change %>%</span>)
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-500">vs last month</span>
|
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -51,64 +48,80 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
||||||
<div data-controller="line-chart" id="lineChart" class="w-full h-full"></div>
|
<div data-controller="line-chart" id="lineChart" class="w-full h-full" data-line-chart-series-value="<%= balances[:series].map { |b| b.merge(trend: { amount: b[:trend].amount, direction: b[:trend].direction, percent: b[:trend].percent }) }.to_json %>"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
<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">
|
<div class="flex justify-between items-center">
|
||||||
<h3 class="font-medium text-xl">History</h3>
|
<h3 class="font-medium text-lg">History</h3>
|
||||||
<button class="cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg">
|
<%= 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") %>
|
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||||
<span>New entry</span>
|
<span class="text-sm">New entry</span>
|
||||||
</button>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-gray-25 p-1">
|
<div class="rounded-xl bg-gray-25 p-1">
|
||||||
<div class="flex flex-col rounded-lg space-y-1">
|
<div class="flex flex-col rounded-lg space-y-1">
|
||||||
<div class="flex justify-between gap-10 text-xs font-medium text-gray-500 uppercase py-2">
|
<div class="flex justify-between gap-10 text-xs font-medium text-gray-500 uppercase py-2">
|
||||||
<div class="ml-4 flex-1">
|
<div class="ml-4 flex-1">DATE</div>
|
||||||
DATE
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 text-right">VALUE</div>
|
<div class="flex-1 text-right">VALUE</div>
|
||||||
<div class="flex-1 text-right">CHANGE</div>
|
<div class="flex-1 text-right">CHANGE</div>
|
||||||
<div class="w-12"></div>
|
<div class="w-12"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg py-2 bg-white border-alpha-black-25 shadow-xs">
|
<div class="rounded-lg py-2 bg-white border-alpha-black-25 shadow-xs">
|
||||||
<% @account.balances.each_with_index do |balance, index| %>
|
<% series = @account.valuations_with_trend[:series].reverse_each %>
|
||||||
<div>
|
<% series.with_index do |valuation, index| %>
|
||||||
|
<% valuation_styles = trend_styles(valuation[:trend].direction) %>
|
||||||
|
<%= turbo_frame_tag dom_id(valuation[:current]) do %>
|
||||||
<div class="p-4 flex items-center justify-between gap-10 w-full">
|
<div class="p-4 flex items-center justify-between gap-10 w-full">
|
||||||
<div class="flex-1 flex items-center gap-4">
|
<div class="flex-1 flex items-center gap-4">
|
||||||
<div class="w-8 h-8 rounded-full bg-gray-500/5 p-1.5 flex items-center justify-center">
|
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center <%= valuation_styles[:bg_class] %>">
|
||||||
<%= 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]}") %>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<p class=""><%= balance.date %></p>
|
<p><%= valuation[:date] %></p>
|
||||||
<p class="text-gray-500"><%= balance.description %></p>
|
<%# TODO: Add descriptive name of valuation %>
|
||||||
|
<p class="text-gray-500">Manually entered</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 text-sm font-medium text-right"><%= number_to_currency(balance.amount, precision: 2) %></div>
|
<div class="flex-1 text-sm font-medium text-right"><%= format_currency(valuation[:value]) %></div>
|
||||||
<div class="flex-1 text-right text-sm font-medium">
|
<div class="flex-1 text-right text-sm font-medium">
|
||||||
<% if balance.change == 0 %>
|
<% if valuation[:trend].amount == 0 %>
|
||||||
<span class="text-gray-500">No change</span>
|
<span class="text-gray-500">No change</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="<%= balance.change > 0 ? 'text-green-500' : 'text-red-500' %>"><%= balance.change > 0 ? '+' : '' %>$<%= balance.change.abs %></span>
|
<span class="<%= valuation_styles[:text_class] %>"><%= valuation_styles[:symbol] %><%= format_currency(valuation[:trend].amount.abs) %></span>
|
||||||
<span class="<%= balance.change > 0 ? 'text-green-500' : 'text-red-500' %>">
|
<span class="<%= valuation_styles[:text_class] %>">(<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 align-text-bottom inline") %> <%= valuation[:trend].percent %>%)</span>
|
||||||
(<%= lucide_icon(balance.icon, class: "w-4 h-4 align-text-bottom inline") %> <%= balance.percentage_change %>%)</span>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-8 cursor-not-allowed">
|
<div class="relative w-8" data-controller="dropdown">
|
||||||
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500") %>
|
<button data-action="click->dropdown#toggleMenu" class="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 class="hidden absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit" data-dropdown-target="menu">
|
||||||
|
<%= 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") %>
|
||||||
|
<span class="text-gray-900 text-sm">Edit entry</span>
|
||||||
|
<% 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") %>
|
||||||
|
<span class="text-sm">Delete entry</span>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% if index < @account.balances.size - 1 %>
|
</div>
|
||||||
|
<div>
|
||||||
|
<% if index < series.size - 1 %>
|
||||||
<div class="h-px bg-alpha-black-50 mr-6 ml-16"></div>
|
<div class="h-px bg-alpha-black-50 mr-6 ml-16"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<%= turbo_frame_tag dom_id(Valuation.new) do %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<button class="cursor-not-allowed hover:bg-white w-full flex items-center justify-center gap-2 text-gray-500 px-4 py-2 rounded-md">
|
</div>
|
||||||
|
<%= 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
|
<%= lucide_icon("plus", class: "w-4 h-4") %> New entry
|
||||||
</button>
|
<% end %>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
15
app/views/valuations/_form_row.html.erb
Normal file
15
app/views/valuations/_form_row.html.erb
Normal file
|
@ -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
|
||||||
|
%>
|
||||||
|
<div class="p-4 flex items-center justify-between w-full">
|
||||||
|
<div class="shrink-0 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>
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
4
app/views/valuations/create.html.erb
Normal file
4
app/views/valuations/create.html.erb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Valuations#create</h1>
|
||||||
|
<p>Find me in app/views/valuations/create.html.erb</p>
|
||||||
|
</div>
|
3
app/views/valuations/create.turbo_stream.erb
Normal file
3
app/views/valuations/create.turbo_stream.erb
Normal file
|
@ -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" } %>
|
4
app/views/valuations/destroy.html.erb
Normal file
4
app/views/valuations/destroy.html.erb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Valuations#destroy</h1>
|
||||||
|
<p>Find me in app/views/valuations/destroy.html.erb</p>
|
||||||
|
</div>
|
2
app/views/valuations/destroy.turbo_stream.erb
Normal file
2
app/views/valuations/destroy.turbo_stream.erb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<%= turbo_stream.remove @valuation %>
|
||||||
|
<%= turbo_stream.append "notification-tray", partial: "shared/notification", locals: { type: "success", content: "Valuation deleted" } %>
|
8
app/views/valuations/edit.html.erb
Normal file
8
app/views/valuations/edit.html.erb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Edit Valuation: <%= @valuation.type %></h1>
|
||||||
|
<%= 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 %>
|
||||||
|
</div>
|
8
app/views/valuations/new.html.erb
Normal file
8
app/views/valuations/new.html.erb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<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), scope: :valuation do |f| %>
|
||||||
|
<%= render 'form_row', f: f, form_icon: "plus", submit_button_text: "Add" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
4
app/views/valuations/show.html.erb
Normal file
4
app/views/valuations/show.html.erb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Valuation: <%= @valuation.type %></h1>
|
||||||
|
<p>Find me in app/views/valuations/show.html.erb</p>
|
||||||
|
</div>
|
4
app/views/valuations/update.html.erb
Normal file
4
app/views/valuations/update.html.erb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Valuations#update</h1>
|
||||||
|
<p>Find me in app/views/valuations/update.html.erb</p>
|
||||||
|
</div>
|
|
@ -7,7 +7,9 @@ Rails.application.routes.draw do
|
||||||
resource :password
|
resource :password
|
||||||
resource :settings, only: %i[edit update]
|
resource :settings, only: %i[edit update]
|
||||||
|
|
||||||
resources :accounts
|
resources :accounts, shallow: true do
|
||||||
|
resources :valuations
|
||||||
|
end
|
||||||
|
|
||||||
scope "accounts/new" do
|
scope "accounts/new" do
|
||||||
scope "bank" do
|
scope "bank" do
|
||||||
|
|
16
db/migrate/20240215201527_create_valuations.rb
Normal file
16
db/migrate/20240215201527_create_valuations.rb
Normal file
|
@ -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
|
15
db/schema.rb
generated
15
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2024_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
|
# 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"
|
||||||
|
@ -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"
|
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.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 "account_balances", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "accounts", "families"
|
add_foreign_key "accounts", "families"
|
||||||
add_foreign_key "users", "families"
|
add_foreign_key "users", "families"
|
||||||
|
add_foreign_key "valuations", "accounts", on_delete: :cascade
|
||||||
end
|
end
|
||||||
|
|
74
db/seeds.rb
74
db/seeds.rb
|
@ -8,6 +8,9 @@
|
||||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||||
# end
|
# end
|
||||||
|
|
||||||
|
# https://github.com/rails/rails/issues/29112#issuecomment-320653056
|
||||||
|
ApplicationRecord.reset_column_information
|
||||||
|
|
||||||
# Create the default user
|
# Create the default user
|
||||||
family = Family.create_or_find_by(name: "The Maybe Family")
|
family = Family.create_or_find_by(name: "The Maybe Family")
|
||||||
puts "Family created: #{family.name}"
|
puts "Family created: #{family.name}"
|
||||||
|
@ -22,3 +25,74 @@ puts "User created: #{user.email} for family: #{family.name}"
|
||||||
|
|
||||||
# Create default currency
|
# Create default currency
|
||||||
Currency.find_or_create_by(iso_code: "USD", name: "United States Dollar")
|
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
|
||||||
|
|
19
test/controllers/valuations_controller_test.rb
Normal file
19
test/controllers/valuations_controller_test.rb
Normal file
|
@ -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
|
13
test/fixtures/valuations.yml
vendored
Normal file
13
test/fixtures/valuations.yml
vendored
Normal file
|
@ -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
|
7
test/models/valuation_test.rb
Normal file
7
test/models/valuation_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ValuationTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
Loading…
Add table
Add a link
Reference in a new issue