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

Rework account views and addition flow (#1324)

* Move accountable partials

* Split accountables into separate view partials

* Fix test

* Add form to permitted partials

* Fix failing system tests

* Update new account modal views

* New sync algorithm implementation

* Update account system test assertions to match new behavior

* Fix off by 1 date error

* Revert new balance sync algorithm

* Add missing account overviews
This commit is contained in:
Zach Gollwitzer 2024-10-18 14:37:42 -04:00 committed by GitHub
parent c7c281073f
commit e8e100e1d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 763 additions and 526 deletions

View file

@ -1,10 +0,0 @@
class Account::LogosController < ApplicationController
def show
@account = Current.family.accounts.find(params[:account_id])
render_placeholder
end
def render_placeholder
render formats: :svg
end
end

View file

@ -23,11 +23,8 @@ class AccountsController < ApplicationController
end
def new
@account = Account.new(
accountable: Accountable.from_type(params[:type])&.new,
currency: Current.family.currency
)
@account = Account.new(currency: Current.family.currency)
@account.accountable = Accountable.from_type(params[:type])&.new if params[:type].present?
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
if params[:institution_id]
@ -36,8 +33,6 @@ class AccountsController < ApplicationController
end
def show
@series = @account.series(period: @period)
@trend = @series.trend
end
def edit
@ -57,6 +52,7 @@ class AccountsController < ApplicationController
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
@account.sync_later
redirect_back_or_to account_path(@account), notice: t(".success")
end

View file

@ -1,4 +1,14 @@
module AccountsHelper
def permitted_accountable_partial(account, name = nil)
permitted_names = %w[tooltip header tabs form]
folder = account.accountable_type.underscore
name ||= account.accountable_type.underscore
raise "Unpermitted accountable partial: #{name}" unless permitted_names.include?(name)
"accounts/accountables/#{folder}/#{name}"
end
def summary_card(title:, &block)
content = capture(&block)
render "accounts/summary_card", title: title, content: content

View file

@ -9,10 +9,6 @@ module ApplicationHelper
content_for(:header_title) { page_title }
end
def permitted_accountable_partial(name)
name.underscore
end
def family_notifications_stream
turbo_stream_from [ Current.family, :notifications ] if Current.family
end
@ -80,8 +76,8 @@ module ApplicationHelper
color = hex || "#1570EF" # blue-600
<<-STYLE.strip
background-color: color-mix(in srgb, #{color} 5%, white);
border-color: color-mix(in srgb, #{color} 10%, white);
background-color: color-mix(in srgb, #{color} 10%, white);
border-color: color-mix(in srgb, #{color} 30%, white);
color: #{color};
STYLE
end

View file

@ -51,12 +51,17 @@ export default class extends Controller {
_normalizeDataPoints() {
this._normalDataPoints = (this.dataValue.values || []).map((d) => ({
...d,
date: new Date(d.date),
date: this._parseDate(d.date),
value: d.value.amount ? +d.value.amount : +d.value,
currency: d.value.currency,
}));
}
_parseDate(dateString) {
const [year, month, day] = dateString.split("-").map(Number);
return new Date(year, month - 1, day);
}
_rememberInitialContainerSize() {
this._d3InitialContainerWidth = this._d3Container.node().clientWidth;
this._d3InitialContainerHeight = this._d3Container.node().clientHeight;

View file

@ -27,6 +27,8 @@ class Account < ApplicationRecord
scope :alphabetically, -> { order(:name) }
scope :ungrouped, -> { where(institution_id: nil) }
has_one_attached :logo
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
accepts_nested_attributes_for :accountable

View file

@ -0,0 +1,57 @@
class Account::Balance::Calculator
def initialize(account, sync_start_date)
@account = account
@sync_start_date = sync_start_date
end
def calculate(is_partial_sync: false)
cached_entries = account.entries.where("date >= ?", sync_start_date).to_a
sync_starting_balance = is_partial_sync ? find_start_balance_for_partial_sync : find_start_balance_for_full_sync(cached_entries)
prior_balance = sync_starting_balance
(sync_start_date..Date.current).map do |date|
current_balance = calculate_balance_for_date(date, entries: cached_entries, prior_balance:)
prior_balance = current_balance
build_balance(date, current_balance)
end
end
private
attr_reader :account, :sync_start_date
def find_start_balance_for_partial_sync
account.balances.find_by(currency: account.currency, date: sync_start_date - 1.day).balance
end
def find_start_balance_for_full_sync(cached_entries)
account.balance + net_entry_flows(cached_entries)
end
def calculate_balance_for_date(date, entries:, prior_balance:)
valuation = entries.find { |e| e.date == date && e.account_valuation? }
return valuation.amount if valuation
entries = entries.select { |e| e.date == date }
prior_balance - net_entry_flows(entries)
end
def net_entry_flows(entries, target_currency = account.currency)
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
flows = converted_entry_amounts.sum(&:amount)
account.liability? ? flows * -1 : flows
end
def build_balance(date, balance, currency = nil)
account.balances.build \
date: date,
balance: balance,
currency: currency || account.currency
end
end

View file

@ -0,0 +1,46 @@
class Account::Balance::Converter
def initialize(account, sync_start_date)
@account = account
@sync_start_date = sync_start_date
end
def convert(balances)
calculate_converted_balances(balances)
end
private
attr_reader :account, :sync_start_date
def calculate_converted_balances(balances)
from_currency = account.currency
to_currency = account.family.currency
if ExchangeRate.exchange_rates_provider.nil?
account.observe_missing_exchange_rate_provider
return []
end
exchange_rates = ExchangeRate.find_rates from: from_currency,
to: to_currency,
start_date: sync_start_date
missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date)
if missing_exchange_rates.any?
account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates)
return []
end
balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
end
end
def build_balance(date, balance, currency = nil)
account.balances.build \
date: date,
balance: balance,
currency: currency || account.currency
end
end

View file

@ -0,0 +1,37 @@
class Account::Balance::Loader
def initialize(account)
@account = account
end
def load(balances, start_date)
Account::Balance.transaction do
upsert_balances!(balances)
purge_stale_balances!(start_date)
account.reload
update_account_balance!(balances)
end
end
private
attr_reader :account
def update_account_balance!(balances)
last_balance = balances.select { |db| db.currency == account.currency }.last&.balance
account.update! balance: last_balance if last_balance.present?
end
def upsert_balances!(balances)
current_time = Time.now
balances_to_upsert = balances.map do |balance|
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
end
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
end
def purge_stale_balances!(start_date)
account.balances.delete_by("date < ?", start_date)
end
end

View file

@ -1,133 +1,51 @@
class Account::Balance::Syncer
def initialize(account, start_date: nil)
@account = account
@provided_start_date = start_date
@sync_start_date = calculate_sync_start_date(start_date)
@loader = Account::Balance::Loader.new(account)
@converter = Account::Balance::Converter.new(account, sync_start_date)
@calculator = Account::Balance::Calculator.new(account, sync_start_date)
end
def run
daily_balances = calculate_daily_balances
daily_balances += calculate_converted_balances(daily_balances) if account.currency != account.family.currency
daily_balances = calculator.calculate(is_partial_sync: is_partial_sync?)
daily_balances += converter.convert(daily_balances) if account.currency != account.family.currency
Account::Balance.transaction do
upsert_balances!(daily_balances)
purge_stale_balances!
if daily_balances.any?
account.reload
last_balance = daily_balances.select { |db| db.currency == account.currency }.last&.balance
account.update! balance: last_balance
end
end
loader.load(daily_balances, account_start_date)
rescue Money::ConversionError => e
account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ])
end
private
attr_reader :sync_start_date, :account
def upsert_balances!(balances)
current_time = Time.now
balances_to_upsert = balances.map do |balance|
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
end
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
end
def purge_stale_balances!
account.balances.delete_by("date < ?", account_start_date)
end
def calculate_balance_for_date(date, entries:, prior_balance:)
valuation = entries.find { |e| e.date == date && e.account_valuation? }
return valuation.amount if valuation
return derived_sync_start_balance(entries) unless prior_balance
entries = entries.select { |e| e.date == date }
prior_balance - net_entry_flows(entries)
end
def calculate_daily_balances
entries = account.entries.where("date >= ?", sync_start_date).to_a
prior_balance = find_prior_balance
(sync_start_date..Date.current).map do |date|
current_balance = calculate_balance_for_date(date, entries:, prior_balance:)
prior_balance = current_balance
build_balance(date, current_balance)
end
end
def calculate_converted_balances(balances)
from_currency = account.currency
to_currency = account.family.currency
if ExchangeRate.exchange_rates_provider.nil?
account.observe_missing_exchange_rate_provider
return []
end
exchange_rates = ExchangeRate.find_rates from: from_currency,
to: to_currency,
start_date: sync_start_date
missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date)
if missing_exchange_rates.any?
account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates)
return []
end
balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
end
end
def build_balance(date, balance, currency = nil)
account.balances.build \
date: date,
balance: balance,
currency: currency || account.currency
end
def derived_sync_start_balance(entries)
transactions_and_trades = entries.reject { |e| e.account_valuation? }.select { |e| e.date > sync_start_date }
account.balance + net_entry_flows(transactions_and_trades)
end
def find_prior_balance
account.balances.where(currency: account.currency).where("date < ?", sync_start_date).order(date: :desc).first&.balance
end
def net_entry_flows(entries, target_currency = account.currency)
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
flows = converted_entry_amounts.sum(&:amount)
account.liability? ? flows * -1 : flows
end
attr_reader :sync_start_date, :provided_start_date, :account, :loader, :converter, :calculator
def account_start_date
@account_start_date ||= begin
oldest_entry_date = account.entries.chronological.first.try(:date)
oldest_entry = account.entries.chronological.first
return Date.current unless oldest_entry_date
return Date.current unless oldest_entry.present?
oldest_entry_is_valuation = account.entries.account_valuations.where(date: oldest_entry_date).exists?
oldest_entry_date -= 1 unless oldest_entry_is_valuation
oldest_entry_date
if oldest_entry.account_valuation?
oldest_entry.date
else
oldest_entry.date - 1.day
end
end
end
def calculate_sync_start_date(provided_start_date)
[ provided_start_date, account_start_date ].compact.max
return provided_start_date if provided_start_date.present? && prior_balance_available?(provided_start_date)
account_start_date
end
def prior_balance_available?(date)
account.balances.find_by(currency: account.currency, date: date - 1.day).present?
end
def is_partial_sync?
sync_start_date == provided_start_date && sync_start_date < Date.current
end
end

View file

@ -12,4 +12,8 @@ class CreditCard < ApplicationRecord
def annual_fee_money
annual_fee ? Money.new(annual_fee, account.currency) : nil
end
def color
"#F13636"
end
end

View file

@ -1,3 +1,7 @@
class Crypto < ApplicationRecord
include Accountable
def color
"#737373"
end
end

View file

@ -1,3 +1,7 @@
class Depository < ApplicationRecord
include Accountable
def color
"#875BF7"
end
end

View file

@ -46,4 +46,8 @@ class Investment < ApplicationRecord
rescue Money::ConversionError
TimeSeries.new([])
end
def color
"#1570EF"
end
end

View file

@ -16,4 +16,8 @@ class Loan < ApplicationRecord
Money.new(payment.round, account.currency)
end
def color
"#D444F1"
end
end

View file

@ -1,3 +1,7 @@
class OtherAsset < ApplicationRecord
include Accountable
def color
"#12B76A"
end
end

View file

@ -1,3 +1,7 @@
class OtherLiability < ApplicationRecord
include Accountable
def color
"#737373"
end
end

View file

@ -19,6 +19,10 @@ class Property < ApplicationRecord
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
def color
"#06AED4"
end
private
def first_valuation_amount
account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money

View file

@ -15,6 +15,10 @@ class Vehicle < ApplicationRecord
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
def color
"#F23E94"
end
private
def first_valuation_amount
account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money

View file

@ -1,21 +0,0 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32"
aria-hidden="true">
<g>
<rect width="100%" height="100%" rx="50%" fill="<%= accountable_color(@account.accountable_type) %>" opacity="0.1" />
<text
x="50%"
y="50%"
fill="<%= accountable_color(@account.accountable_type) %>"
text-anchor="middle"
dy="0.35em"
font-family="ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji"
font-size="16"
font-weight="400">
<%= @account.name[0].upcase %>
</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 653 B

View file

@ -30,7 +30,7 @@
<% group.children.sort_by(&:name).each do |account_value_node| %>
<% account = account_value_node.original %>
<%= link_to account_path(account), class: "flex items-center w-full gap-3 px-3 py-2 mb-1 hover:bg-gray-100 rounded-[10px]" do %>
<%= image_tag account_logo_url(account), class: "w-6 h-6" %>
<%= render "accounts/logo", account: account, size: "sm" %>
<div>
<p class="font-medium"><%= account_value_node.name %></p>
<% if account.subtype %>
@ -63,7 +63,7 @@
<% end %>
<%= link_to new_account_path(step: "method", type: type.name.demodulize), class: "flex items-center min-h-10 gap-4 px-3 py-2 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p>New <%= type.model_name.human.downcase %></p>
<%= t(".new_account", type: type.model_name.human.downcase) %>
<% end %>
</details>
<% end %>

View file

@ -1,5 +1,4 @@
<%= link_to new_account_path(
step: "method",
type: type.class.name.demodulize,
institution_id: params[:institution_id]
),

View file

@ -3,7 +3,7 @@
<%= tag.p t(".no_accounts"), class: "text-gray-900 mb-1 font-medium" %>
<%= tag.p t(".empty_message"), class: "text-gray-500 mb-4" %>
<%= link_to new_account_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<%= link_to new_account_path(step: "method"), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new_account") %></span>
<% end %>

View file

@ -1,12 +1,14 @@
<% if local_assigns[:disabled] && disabled %>
<span class="flex items-center w-full gap-4 p-2 px-2 text-center border border-transparent rounded-lg cursor-not-allowed focus:outline-none focus:bg-gray-50 focus:border focus:border-gray-200 hover:bg-gray-50">
<%# locals: (text:, icon:, disabled: false) %>
<% if disabled %>
<span class="flex items-center w-full gap-4 p-2 px-2 text-center border border-transparent rounded-lg cursor-not-allowed focus:outline-none focus:bg-gray-50 focus:border focus:border-gray-200 text-gray-400">
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon(icon, class: "text-gray-500 w-5 h-5") %>
</span>
<%= text %>
</span>
<% else %>
<%= link_to new_account_path(type: type.class.name.demodulize, institution_id: params[:institution_id]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %>
<%= link_to new_account_path(institution_id: params[:institution_id]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon(icon, class: "text-gray-500 w-5 h-5") %>
</span>

View file

@ -2,8 +2,8 @@
<%= styled_form_with model: account, url: url, scope: :account, class: "flex flex-col gap-4 justify-between grow", data: { turbo: false } do |f| %>
<div class="grow space-y-2">
<%= f.hidden_field :accountable_type %>
<%= f.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label"), autofocus: true %>
<%= f.select :accountable_type, Accountable::TYPES.map { |type| [type.titleize, type] }, { label: t(".accountable_type"), prompt: t(".type_prompt") }, required: true, autofocus: true %>
<%= f.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label") %>
<% if account.new_record? %>
<%= f.hidden_field :institution_id %>
@ -13,15 +13,10 @@
<%= f.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %>
<% if account.new_record? %>
<div class="flex items-center gap-2 mt-3 mb-6">
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.yesterday, min: Account::Entry.min_supported_date %></div>
<div class="w-1/2"><%= f.money_field :start_balance, label: t(".start_balance"), placeholder: 90, hide_currency: true, default_currency: Current.family.currency %></div>
</div>
<% if account.accountable %>
<%= render permitted_accountable_partial(account, "form"), f: f %>
<% end %>
<%= render "accounts/accountables/#{permitted_accountable_partial(account.accountable_type)}", f: f %>
</div>
<%= f.submit "#{account.new_record? ? "Add" : "Update"} #{account.accountable.model_name.human.downcase}" %>
<%= f.submit %>
<% end %>

View file

@ -13,7 +13,7 @@
<% end %>
<%= link_to new_account_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= link_to new_account_path(step: "method"), class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p class="text-sm font-medium"><%= t(".new") %></p>
<% end %>

View file

@ -0,0 +1,14 @@
<%# locals: (account:, size: "md") %>
<% size_classes = {
"sm" => "w-6 h-6",
"md" => "w-9 h-9",
"lg" => "w-10 h-10",
"full" => "w-full h-full"
} %>
<% if account.logo.attached? %>
<%= image_tag account.logo, class: size_classes[size] %>
<% else %>
<%= circle_logo(account.name, hex: account.accountable.color, size: size) %>
<% end %>

View file

@ -0,0 +1,33 @@
<%# locals: (account:) %>
<%= 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">
<%= link_to edit_account_path(account),
data: { turbo_frame: :modal },
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".edit") %></span>
<% end %>
<%= link_to new_import_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".import") %></span>
<% end %>
<%= button_to account_path(account),
method: :delete,
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
data: {
turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body_html"),
accept: t(".confirm_accept", name: account.name)
}
} do %>
<%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account
<% end %>
</div>
<% end %>

View file

@ -0,0 +1,8 @@
<div class="flex items-center gap-2 rounded-xl justify-between shadow-xs bg-white p-4 border border-alpha-black-25">
<p class="text-lg font-medium text-gray-900">Setup your new account</p>
<div class="flex items-center gap-2">
<%= link_to "Track balances only", new_account_valuation_path(@account), class: "btn btn--ghost", data: { turbo_frame: dom_id(@account.entries.account_valuations.new) } %>
<%= link_to "Add your first transaction", new_transaction_path(account_id: @account.id), class: "btn btn--primary", data: { turbo_frame: :modal } %>
</div>
</div>

View file

@ -1,4 +1,4 @@
<%= button_to sync_all_accounts_path, class: "btn btn--outline flex items-center gap-2", title: "Sync All" do %>
<%= button_to sync_all_accounts_path, class: "btn btn--outline flex items-center gap-2", title: t(".sync") do %>
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
<span><%= t("accounts.sync_all.button_text") %></span>
<span><%= t(".sync") %></span>
<% end %>

View file

@ -0,0 +1,7 @@
<div class="flex items-center gap-3">
<%= render "accounts/logo", account: account %>
<div>
<h2 class="font-medium text-xl"><%= account.name %></h2>
</div>
</div>

View file

@ -0,0 +1,7 @@
<%# locals: (account:) %>
<% if account.transactions.any? %>
<%= render "accounts/accountables/transactions", account: account %>
<% else %>
<%= render "accounts/accountables/valuations", account: account %>
<% end %>

View file

@ -0,0 +1,8 @@
<%# locals: (account:, key:, is_selected:) %>
<%= link_to key.titleize,
account_path(account, tab: key),
class: [
"px-2 py-1.5 rounded-md border border-transparent",
"bg-white shadow-xs border-alpha-black-50": is_selected
] %>

View file

@ -0,0 +1,5 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :transactions), src: account_transactions_path(account) do %>
<%= render "account/entries/loading" %>
<% end %>

View file

@ -0,0 +1,5 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :valuations), src: account_valuations_path(account) do %>
<%= render "account/entries/loading" %>
<% end %>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/default_header", account: account %>

View file

@ -25,3 +25,7 @@
<%= format_money(account.credit_card.annual_fee_money || Money.new(0, account.currency)) %>
<% end %>
</div>
<div class="flex justify-center py-8">
<%= link_to "Edit account details", edit_account_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %>
</div>

View file

@ -0,0 +1,15 @@
<%# locals: (account:, selected_tab:) %>
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
<%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %>
<%= render "accounts/accountables/tab", account: account, key: "transactions", is_selected: selected_tab == "transactions" %>
</div>
<div class="min-h-[800px]">
<% case selected_tab %>
<% when nil, "overview" %>
<%= render "accounts/accountables/credit_card/overview", account: account %>
<% when "transactions" %>
<%= render "accounts/accountables/transactions", account: account %>
<% end %>
</div>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/default_header", account: account %>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/default_tabs", account: account %>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/default_header", account: account %>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/default_tabs", account: account %>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/default_header", account: account %>

View file

@ -0,0 +1,28 @@
<%# locals: (account:, selected_tab:) %>
<% if account.entries.account_trades.any? || account.entries.account_transactions.any? %>
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
<%= render "accounts/accountables/tab", account: account, key: "holdings", is_selected: selected_tab.in?([nil, "holdings"]) %>
<%= render "accounts/accountables/tab", account: account, key: "cash", is_selected: selected_tab == "cash" %>
<%= render "accounts/accountables/tab", account: account, key: "transactions", is_selected: selected_tab == "transactions" %>
</div>
<div class="min-h-[800px]">
<% case selected_tab %>
<% when nil, "holdings" %>
<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account) do %>
<%= render "account/entries/loading" %>
<% end %>
<% when "cash" %>
<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account) do %>
<%= render "account/entries/loading" %>
<% end %>
<% when "transactions" %>
<%= turbo_frame_tag dom_id(account, :trades), src: account_trades_path(account) do %>
<%= render "account/entries/loading" %>
<% end %>
<% end %>
</div>
<% else %>
<%= render "accounts/accountables/valuations", account: account %>
<% end %>

View file

@ -1,4 +1,5 @@
<%# locals: (account:) -%>
<div data-controller="tooltip" data-tooltip-placement-value="right" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %>
<div role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64">

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/default_header", account: account %>

View file

@ -43,3 +43,7 @@
<%= account.loan.rate_type&.titleize || t(".unknown") %>
<% end %>
</div>
<div class="flex justify-center py-8">
<%= link_to "Edit account details", edit_account_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %>
</div>

View file

@ -0,0 +1,15 @@
<%# locals: (account:, selected_tab:) %>
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
<%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %>
<%= render "accounts/accountables/tab", account: account, key: "value", is_selected: selected_tab == "value" %>
</div>
<div class="min-h-[800px]">
<% case selected_tab %>
<% when nil, "overview" %>
<%= render "accounts/accountables/loan/overview", account: account %>
<% when "value" %>
<%= render "accounts/accountables/valuations", account: account %>
<% end %>
</div>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/default_header", account: account %>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/valuations", account: account %>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/default_header", account: account %>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/valuations", account: account %>

View file

@ -0,0 +1,11 @@
<div class="flex items-center gap-3">
<%= render "accounts/logo", account: account %>
<div>
<h2 class="font-medium text-xl"><%= account.name %></h2>
<% if account.property.address&.line1.present? %>
<p class="text-gray-500"><%= account.property.address %></p>
<% end %>
</div>
</div>

View file

@ -27,3 +27,7 @@
<%= account.property.area || t(".unknown") %>
<% end %>
</div>
<div class="flex justify-center py-8">
<%= link_to "Edit account details", edit_account_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %>
</div>

View file

@ -0,0 +1,15 @@
<%# locals: (account:, selected_tab:) %>
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
<%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %>
<%= render "accounts/accountables/tab", account: account, key: "value", is_selected: selected_tab == "value" %>
</div>
<div class="min-h-[800px]">
<% case selected_tab %>
<% when nil, "overview" %>
<%= render "accounts/accountables/property/overview", account: account %>
<% when "value" %>
<%= render "accounts/accountables/valuations", account: account %>
<% end %>
</div>

View file

@ -0,0 +1 @@
<%= render "accounts/accountables/default_header", account: account %>

View file

@ -31,3 +31,7 @@
</div>
<% end %>
</div>
<div class="flex justify-center py-8">
<%= link_to "Edit account details", edit_account_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %>
</div>

View file

@ -0,0 +1,15 @@
<%# locals: (account:, selected_tab:) %>
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
<%= render "accounts/accountables/tab", account: account, key: "overview", is_selected: selected_tab.in?([nil, "overview"]) %>
<%= render "accounts/accountables/tab", account: account, key: "value", is_selected: selected_tab == "value" %>
</div>
<div class="min-h-[800px]">
<% case selected_tab %>
<% when nil, "overview" %>
<%= render "accounts/accountables/vehicle/overview", account: account %>
<% when "value" %>
<%= render "accounts/accountables/valuations", account: account %>
<% end %>
</div>

View file

@ -20,7 +20,7 @@
<%= render "sync_all_button" %>
<%= link_to new_account_path,
<%= link_to new_account_path(step: "method"),
data: { turbo_frame: "modal" },
class: "btn btn--primary flex items-center gap-1" do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>

View file

@ -1,58 +1,24 @@
<h1 class="text-3xl font-semibold font-display"><%= t(".title") %></h1>
<%= modal do %>
<div class="flex flex-col w-screen max-w-xl" data-controller="list-keyboard-navigation">
<% if @account.accountable.blank? %>
<div class="border-b border-alpha-black-25 p-4 text-gray-400">
<%= t ".select_accountable_type" %>
</div>
<div class="flex flex-col p-2 text-sm grow">
<button hidden data-controller="hotkey" data-hotkey="k,K,ArrowUp,ArrowLeft" data-action="list-keyboard-navigation#focusPrevious">Previous</button>
<button hidden data-controller="hotkey" data-hotkey="j,J,ArrowDown,ArrowRight" data-action="list-keyboard-navigation#focusNext">Next</button>
<%= render "account_type", type: Depository.new, bg_color: "bg-blue-500/5", text_color: "text-blue-500", icon: "landmark" %>
<%= render "account_type", type: Investment.new, bg_color: "bg-green-500/5", text_color: "text-green-500", icon: "line-chart" %>
<%= render "account_type", type: Crypto.new, bg_color: "bg-orange-500/5", text_color: "text-orange-500", icon: "bitcoin" %>
<%= render "account_type", type: Property.new, bg_color: "bg-pink-500/5", text_color: "text-pink-500", icon: "home" %>
<%= render "account_type", type: Vehicle.new, bg_color: "bg-cyan-500/5", text_color: "text-cyan-500", icon: "car-front" %>
<%= render "account_type", type: CreditCard.new, bg_color: "bg-violet-500/5", text_color: "text-violet-500", icon: "credit-card" %>
<%= render "account_type", type: Loan.new, bg_color: "bg-yellow-500/5", text_color: "text-yellow-500", icon: "hand-coins" %>
<%= render "account_type", type: OtherAsset.new, bg_color: "bg-green-500/5", text_color: "text-green-500", icon: "plus" %>
<%= render "account_type", type: OtherLiability.new, bg_color: "bg-red-500/5", text_color: "text-red-500", icon: "minus" %>
</div>
<div class="border-t border-alpha-black-25 p-4 text-gray-500 text-sm flex justify-between">
<div class="flex space-x-5">
<div class="flex items-center space-x-2">
<span>Select</span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("corner-down-left", class: "inline w-3 h-3") %></kbd>
</div>
<div class="flex items-center space-x-2">
<span>Navigate</span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-up", class: "inline w-3 h-3") %></kbd>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-down", class: "inline w-3 h-3") %></kbd>
</div>
</div>
<div class="flex items-center space-x-2">
<button data-action="modal#close">Close</button>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-8 h-5 shrink-0 grow-0 items-center justify-center text-xs">ESC</kbd>
</div>
</div>
<% elsif params[:step] == 'method' && @account.accountable.present? %>
<% if params[:step] == 'method' %>
<div class="border-b border-alpha-black-25 p-4 text-gray-400 flex items-center space-x-3">
<%= link_to new_account_path, class: "flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 back focus:outline-gray-300 focus:outline" do %>
<%= lucide_icon("arrow-left", class: "text-gray-500 w-5 h-5") %>
<% end %>
<span>How would you like to add it?</span>
</div>
<div class="flex flex-col p-2 text-sm grow">
<button hidden data-controller="hotkey" data-hotkey="k,K,ArrowUp,ArrowLeft" data-action="list-keyboard-navigation#focusPrevious">Previous</button>
<button hidden data-controller="hotkey" data-hotkey="j,J,ArrowDown,ArrowRight" data-action="list-keyboard-navigation#focusNext">Next</button>
<%= render "entry_method", type: @account.accountable, text: "Enter account balance manually", icon: "keyboard" %>
<%= link_to new_import_path, class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %>
<%= render "entry_method", text: t(".manual_entry"), icon: "keyboard" %>
<%= link_to new_import_path(import: { type: "AccountImport" }), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon("sheet", class: "text-gray-500 w-5 h-5") %>
</span>
Upload CSV
<%= t(".csv_entry") %>
<% end %>
<%= render "entry_method", type: @account.accountable, text: "Securely link bank account with data provider (coming soon)", icon: "link-2", disabled: true %>
<%= render "entry_method", text: t(".connected_entry"), icon: "link-2", disabled: true %>
</div>
<div class="border-t border-alpha-black-25 p-4 text-gray-500 text-sm flex justify-between">
<div class="flex space-x-5">
@ -73,13 +39,13 @@
</div>
<% else %>
<div class="border-b border-alpha-black-25 p-4 text-gray-800 flex items-center space-x-3">
<%= link_to new_account_path(step: "method", type: params[:type]), class: "flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 focus:outline-gray-300 focus:outline" do %>
<%= link_to new_account_path(step: "method"), class: "flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 focus:outline-gray-300 focus:outline" do %>
<%= lucide_icon("arrow-left", class: "text-gray-500 w-5 h-5") %>
<% end %>
<span>Add <%= @account.accountable.model_name.human.downcase %></span>
<span>Add account</span>
</div>
<div class="p-4 pt-1">
<div class="p-4">
<%= render "form", account: @account, url: new_account_form_url(@account) %>
</div>
<% end %>

View file

@ -1,56 +1,25 @@
<%= turbo_stream_from @account %>
<%= tag.div id: dom_id(@account), class: "space-y-4" do %>
<header class="flex justify-between items-center">
<div class="flex items-center gap-3">
<%= image_tag account_logo_url(@account), class: "w-8 h-8" %>
<div>
<h2 class="font-medium text-xl"><%= @account.name %></h2>
<% series = @account.series(period: @period) %>
<% trend = series.trend %>
<% if @account.property? && @account.property.address&.line1.present? %>
<p class="text-gray-500"><%= @account.property.address %></p>
<% end %>
</div>
</div>
<div class="flex items-center gap-3">
<%= tag.div id: dom_id(@account), class: "space-y-4" do %>
<header class="flex items-center gap-4">
<%= render permitted_accountable_partial(@account, "header"), account: @account %>
<div class="flex items-center gap-3 ml-auto">
<%= button_to sync_account_path(@account), method: :post, class: "flex items-center gap-2", title: "Sync Account" do %>
<%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %>
<% 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">
<%= link_to edit_account_path(@account),
data: { turbo_frame: :modal },
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".edit") %></span>
<% end %>
<%= link_to new_import_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".import") %></span>
<% end %>
<%= button_to account_path(@account),
method: :delete,
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
data: {
turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body_html"),
accept: t(".confirm_accept", name: @account.name)
}
} do %>
<%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account
<% end %>
</div>
<% end %>
<%= render "menu", account: @account %>
</div>
</header>
<% if @account.entries.empty? && @account.depository? %>
<%= render "accounts/new_account_setup_bar", account: @account %>
<% end %>
<% if @account.highest_priority_issue %>
<%= render partial: "issues/issue", locals: { issue: @account.highest_priority_issue } %>
<% end %>
@ -66,47 +35,35 @@
<%= tag.p t(".total_owed"), class: "text-sm font-medium text-gray-500" %>
<% end %>
</div>
<%= render "tooltip", account: @account if @account.investment? %>
<%= render permitted_accountable_partial(@account, "tooltip"), account: @account if @account.investment? %>
</div>
<%= tag.p format_money(@account.value), class: "text-gray-900 text-3xl font-medium" %>
<div>
<% if @series.trend.direction.flat? %>
<% if trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %>
<%= tag.span format_money(@series.trend.value), style: "color: #{@trend.color}" %>
<%= tag.span "(#{@trend.percent}%)", style: "color: #{@trend.color}" %>
<%= tag.span format_money(trend.value), style: "color: #{trend.color}" %>
<%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %>
<% end %>
<%= tag.span period_label(@period), class: "text-gray-500" %>
</div>
</div>
<%= form_with url: account_path(@account), method: :get, data: { controller: "auto-submit-form" } do |form| %>
<%= period_select form: form, selected: @period.name %>
<% end %>
</div>
<div class="h-96 flex items-center justify-center text-2xl font-bold">
<%= render partial: "shared/line_chart", locals: { series: @series } %>
<%= render "shared/line_chart", series: @account.series(period: @period) %>
</div>
</div>
<% selected_tab = selected_account_tab(@account) %>
<% selected_tab_key = selected_tab[:key] %>
<% selected_tab_partial_path = selected_tab[:partial_path] %>
<% selected_tab_route = selected_tab[:route] %>
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
<% account_tabs(@account).each do |tab| %>
<%= link_to tab[:label], tab[:path], class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab_key == tab[:key]] %>
<% end %>
</div>
<div class="min-h-[800px]">
<% if selected_tab_route.present? %>
<%= turbo_frame_tag dom_id(@account, selected_tab_key), src: selected_tab_route do %>
<%= render "account/entries/loading" %>
<% end %>
<% else %>
<%= render selected_tab_partial_path, account: @account %>
<% end %>
<%= render permitted_accountable_partial(@account, "tabs"), account: @account, selected_tab: params[:tab] %>
</div>
<% end %>

View file

@ -41,7 +41,7 @@
<div class="flex justify-between items-center mb-5">
<h2 class="text-lg font-medium text-gray-900">Assets</h2>
<div class="flex items-center gap-2">
<%= link_to new_account_path, class: "btn btn--secondary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= link_to new_account_path(step: "method"), class: "btn btn--secondary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
<p><%= t(".new") %></p>
<% end %>
@ -66,7 +66,7 @@
<div class="flex justify-between items-center mb-5">
<h2 class="text-lg font-medium text-gray-900">Liabilities</h2>
<div class="flex items-center gap-2">
<%= link_to new_account_path, class: "btn btn--secondary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= link_to new_account_path(step: "method"), class: "btn btn--secondary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
<p><%= t(".new") %></p>
<% end %>

View file

@ -15,14 +15,14 @@
<h3 class="uppercase text-gray-500 text-xs font-medium px-3 py-1.5"><%= t(".sources") %></h3>
<ul class="bg-white border border-alpha-black-25 rounded-lg shadow-xs">
<li>
<% if @pending_import.present? %>
<% if @pending_import.present? && (params[:type].nil? || params[:type] == @pending_import.type) %>
<%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
<div class="flex items-center gap-2">
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
</div>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".resume") %>
<%= t(".resume", type: @pending_import.type.titleize) %>
</span>
</div>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
@ -33,6 +33,8 @@
</div>
</li>
<% end %>
<% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == "TransactionImport") %>
<li>
<%= button_to imports_path(import: { type: "TransactionImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
<div class="flex items-center gap-2">
@ -50,7 +52,9 @@
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<% end %>
<% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == "TradeImport") %>
<li>
<%= button_to imports_path(import: { type: "TradeImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
<div class="flex items-center gap-2">
@ -68,7 +72,9 @@
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<% end %>
<% if params[:type].nil? || params[:type] == "AccountImport" %>
<li>
<%= button_to imports_path(import: { type: "AccountImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
<div class="flex items-center gap-2">
@ -86,7 +92,9 @@
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<% end %>
<% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == "MintImport" || params[:type] == "TransactionImport") %>
<li>
<%= button_to imports_path(import: { type: "MintImport" }), class: "flex items-center justify-between p-4 group w-full", data: { turbo: false } do %>
<div class="flex items-center gap-2">
@ -102,6 +110,7 @@
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<% end %>
</ul>
</div>
</div>

View file

@ -103,7 +103,7 @@
<%= period_select form: form, selected: "last_30_days", classes: "w-full border-none pl-2 pr-7 text-xs bg-transparent gap-1 cursor-pointer font-semibold tracking-wide focus:outline-none focus:ring-0" %>
<% end %>
</div>
<%= link_to new_account_path, id: "sidebar-new-account", class: "block hover:bg-gray-100 font-semibold text-gray-900 flex items-center rounded", title: t(".new_account"), data: { turbo_frame: "modal" } do %>
<%= link_to new_account_path(step: "method"), id: "sidebar-new-account", class: "block hover:bg-gray-100 font-semibold text-gray-900 flex items-center rounded p-1", title: t(".new_account"), data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
<% end %>
</div>
@ -114,7 +114,7 @@
<%= render "accounts/account_list", group: group %>
<% end %>
<% else %>
<%= link_to new_account_path, class: "flex items-center min-h-10 gap-4 px-3 py-2 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
<%= link_to new_account_path(step: "method"), class: "flex items-center min-h-10 gap-4 px-3 py-2 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<%= tag.p t(".new_account") %>
<% end %>

View file

@ -25,7 +25,7 @@
<% accountable_group.children.map do |account_value_node| %>
<div class="flex items-center justify-between text-sm font-medium text-gray-900">
<div class="flex items-center gap-4">
<%= image_tag account_logo_url(account_value_node.original), class: "w-8 h-8" %>
<%= render "accounts/logo", account: account_value_node.original, size: "sm" %>
<div>
<p><%= account_value_node.name %></p>
</div>

View file

@ -17,7 +17,7 @@
</div>
<% end %>
<%= link_to new_account_path, class: "flex items-center gap-1 btn btn--primary", data: { turbo_frame: "modal" } do %>
<%= link_to new_account_path(step: "method"), class: "flex items-center gap-1 btn btn--primary", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new") %></span>
<% end %>
@ -70,10 +70,10 @@
data-time-series-chart-use-labels-value="false"
data-time-series-chart-use-tooltip-value="false"></div>
</div>
<div class="flex gap-1.5">
<div class="flex gap-1.5 mt-auto">
<% @top_earners.first(3).each do |account| %>
<%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %>
<%= image_tag account_logo_url(account), class: "w-5 h-5" %>
<%= render "accounts/logo", account: account, size: "sm" %>
<span>+<%= Money.new(account.income, account.currency) %></span>
<%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %>
<% end %>
@ -103,10 +103,10 @@
data-time-series-chart-use-labels-value="false"
data-time-series-chart-use-tooltip-value="false"></div>
</div>
<div class="flex gap-1.5">
<div class="mt-auto flex gap-1.5">
<% @top_spenders.first(3).each do |account| %>
<%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %>
<%= image_tag account_logo_url(account), class: "w-5 h-5" %>
<%= render "accounts/logo", account: account, size: "sm" %>
-<%= Money.new(account.spending, account.currency) %>
<%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %>
<% end %>
@ -141,7 +141,7 @@
<% @top_savers.first(3).each do |account| %>
<% unless account.savings_rate.infinite? %>
<%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %>
<%= image_tag account_logo_url(account), class: "w-5 h-5" %>
<%= render "accounts/logo", account: account, size: "sm" %>
<span><%= account.savings_rate > 0 ? "+" : "-" %><%= number_to_percentage(account.savings_rate.abs * 100, precision: 2) %></span>
<%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %>
<% end %>

View file

@ -2,7 +2,7 @@
<% size_classes = {
"sm" => "w-6 h-6",
"md" => "w-8 h-8",
"md" => "w-9 h-9",
"lg" => "w-10 h-10",
"full" => "w-full h-full"
} %>

View file

@ -7,7 +7,7 @@
<p class="text-gray-500"><%= t(".no_account_subtitle") %></p>
</div>
<%= link_to new_account_path, class: "btn btn--primary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= link_to new_account_path(step: "method"), class: "btn btn--primary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new_account") %></span>
<% end %>

View file

@ -23,6 +23,74 @@
],
"note": ""
},
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "42595161ffdc9ce9a10c4ba2a75fd2bb668e273bc4e683880b0ea906d0bd28f8",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/accounts/show.html.erb",
"line": 8,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => permitted_accountable_partial(Current.family.accounts.find(params[:id]), \"header\"), { :account => Current.family.accounts.find(params[:id]) })",
"render_path": [
{
"type": "controller",
"class": "AccountsController",
"method": "show",
"line": 39,
"file": "app/controllers/accounts_controller.rb",
"rendered": {
"name": "accounts/show",
"file": "app/views/accounts/show.html.erb"
}
}
],
"location": {
"type": "template",
"template": "accounts/show"
},
"user_input": "params[:id]",
"confidence": "Weak",
"cwe_id": [
22
],
"note": ""
},
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "a35b18785608dbdf35607501363573576ed8c304039f8387997acd1408ca1025",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/accounts/show.html.erb",
"line": 35,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => permitted_accountable_partial(Current.family.accounts.find(params[:id]), \"tooltip\"), { :account => Current.family.accounts.find(params[:id]) })",
"render_path": [
{
"type": "controller",
"class": "AccountsController",
"method": "show",
"line": 39,
"file": "app/controllers/accounts_controller.rb",
"rendered": {
"name": "accounts/show",
"file": "app/views/accounts/show.html.erb"
}
}
],
"location": {
"type": "template",
"template": "accounts/show"
},
"user_input": "params[:id]",
"confidence": "Weak",
"cwe_id": [
22
],
"note": ""
},
{
"warning_type": "Cross-Site Scripting",
"warning_code": 2,
@ -38,7 +106,7 @@
"type": "controller",
"class": "PagesController",
"method": "changelog",
"line": 35,
"line": 36,
"file": "app/controllers/pages_controller.rb",
"rendered": {
"name": "pages/changelog",
@ -60,19 +128,19 @@
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "b7a59d6dd91f4d30873b271659636c7975e25b47f436b4f03900a08809af2e92",
"fingerprint": "c5c512a13c34c9696024bd4e2367a657a5c140b5b6a0f5c352e9b69965f63e1b",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/accounts/show.html.erb",
"line": 105,
"line": 63,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => selected_account_tab(Current.family.accounts.find(params[:id]))[:partial_path], { :account => Current.family.accounts.find(params[:id]) })",
"code": "render(action => permitted_accountable_partial(Current.family.accounts.find(params[:id]), \"tabs\"), { :account => Current.family.accounts.find(params[:id]), :selected_tab => params[:tab] })",
"render_path": [
{
"type": "controller",
"class": "AccountsController",
"method": "show",
"line": 38,
"line": 39,
"file": "app/controllers/accounts_controller.rb",
"rendered": {
"name": "accounts/show",
@ -98,7 +166,7 @@
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/import/configurations/show.html.erb",
"line": 13,
"line": 15,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(partial => permitted_import_configuration_path(Current.family.imports.find(params[:import_id])), { :locals => ({ :import => Current.family.imports.find(params[:import_id]) }) })",
"render_path": [
@ -126,6 +194,6 @@
"note": ""
}
],
"updated": "2024-09-28 13:27:09 -0400",
"updated": "2024-10-17 11:30:15 -0400",
"brakeman_version": "6.2.1"
}

View file

@ -1,37 +1,88 @@
---
en:
accounts:
sync_all_button:
sync: Sync all
account:
has_issues: Issue detected.
troubleshoot: Troubleshoot
account_list:
new_account: "New %{type}"
empty:
no_accounts: No accounts yet
empty_message: Add an account either via connection, importing or entering manually.
new_account: New account
form:
name_label: Account name
name_placeholder: Example account name
institution: Financial institution
ungrouped: "(none)"
balance: Today's balance
accountable_type: Account type
type_prompt: Select a type
header:
accounts: Accounts
manage: Manage accounts
new: New account
institution_accounts:
add_account_to_institution: Add new account
has_issues: Issue detected, see accounts
syncing: Syncing...
status: "Last synced %{last_synced_at} ago"
status_never: Requires data sync
edit: Edit institution
delete: Delete institution
confirm_title: Delete financial institution?
confirm_body: Don't worry, none of the accounts within this institution will be affected by this deletion. Accounts will be ungrouped and all historical data will remain intact.
confirm_accept: Delete institution
new_account: Add account
institutionless_accounts:
other_accounts: Other accounts
menu:
edit: Edit
import: Import transactions
confirm_title: Delete account?
confirm_body_html: "<p>By deleting this account, you will erase its value history, affecting various aspects of your overall account. This action will have a direct impact on your net worth calculations and the account graphs.</p><br /> <p>After deletion, there is no way you'll be able to restore the account information because you'll need to add it as a new account.</p>"
confirm_accept: 'Delete "%{name}"'
accountables:
investment:
prompt: Select a subtype
none: None
credit_card:
annual_fee: Annual fee
annual_fee_placeholder: '99'
apr: APR
apr_placeholder: '15.99'
form:
available_credit: Available credit
available_credit_placeholder: '10000'
expiration_date: Expiration date
minimum_payment: Minimum payment
minimum_payment_placeholder: '100'
apr: APR
apr_placeholder: '15.99'
expiration_date: Expiration date
annual_fee: Annual fee
annual_fee_placeholder: '99'
overview:
amount_owed: Amount Owed
annual_fee: Annual Fee
apr: APR
available_credit: Available Credit
expiration_date: Expiration Date
minimum_payment: Minimum Payment
apr: APR
expiration_date: Expiration Date
annual_fee: Annual Fee
unknown: Unknown
depository:
prompt: Select a subtype
form:
none: None
prompt: Select a subtype
investment:
form:
none: None
prompt: Select a subtype
tooltip:
cash: Cash
holdings: Holdings
total_value_tooltip: The total value is the sum of cash balance and your holdings value, minus margin loans.
loan:
form:
interest_rate: Interest rate
interest_rate_placeholder: '5.25'
rate_type: Rate type
term_months: Term (months)
term_months_placeholder: '360'
overview:
interest_rate: Interest Rate
monthly_payment: Monthly Payment
@ -41,10 +92,8 @@ en:
term: Term
type: Type
unknown: Unknown
rate_type: Rate type
term_months: Term (months)
term_months_placeholder: '360'
property:
form:
additional_info: Additional info
area_unit: Area unit
area_value: Area value
@ -53,6 +102,9 @@ en:
line1: Address line 1
line2: Address line 2
optional: optional
postal_code: Postal code
state: State
year_built: Year built
overview:
living_area: Living Area
market_value: Market Value
@ -60,10 +112,8 @@ en:
trend: Trend
unknown: Unknown
year_built: Year Built
postal_code: Postal code
state: State
year_built: Year built
vehicle:
form:
make: Make
make_placeholder: Toyota
mileage: Mileage
@ -71,6 +121,8 @@ en:
mileage_unit: Unit
model: Model
model_placeholder: Camry
year: Year
year_placeholder: '2023'
overview:
current_price: Current Price
make_model: Make & Model
@ -79,70 +131,22 @@ en:
trend: Trend
unknown: Unknown
year: Year
year: Year
year_placeholder: '2023'
create:
success: New account created successfully
destroy:
success: Account deleted successfully
edit:
edit: Edit %{account}
empty:
empty_message: Add an account either via connection, importing or entering manually.
new_account: New account
no_accounts: No accounts yet
form:
institution: Financial institution
ungrouped: "(none)"
balance: Current balance
name_label: Account name
name_placeholder: Example account name
start_balance: Start balance (optional)
start_date: Start date (optional)
header:
accounts: Accounts
manage: Manage accounts
new: New account
edit: "Edit %{account}"
index:
accounts: Accounts
add_institution: Add institution
new_account: New account
institution_accounts:
add_account_to_institution: Add new account
confirm_accept: Delete institution
confirm_body: Don't worry, none of the accounts within this institution will
be affected by this deletion. Accounts will be ungrouped and all historical
data will remain intact.
confirm_title: Delete financial institution?
delete: Delete institution
edit: Edit institution
has_issues: Issue detected, see accounts
new_account: Add account
status: Last synced %{last_synced_at} ago
status_never: Requires data sync
syncing: Syncing...
institutionless_accounts:
other_accounts: Other accounts
new:
select_accountable_type: What would you like to add?
title: Add an account
manual_entry: Enter account manually
csv_entry: Import accounts CSV
connected_entry: Securely link account with Plaid (coming soon)
show:
cash: Cash
confirm_accept: Delete "%{name}"
confirm_body_html: "<p>By deleting this account, you will erase its value history,
affecting various aspects of your overall account. This action will have a
direct impact on your net worth calculations and the account graphs.</p><br
/> <p>After deletion, there is no way you'll be able to restore the account
information because you'll need to add it as a new account.</p>"
confirm_title: Delete account?
edit: Edit
holdings: Holdings
import: Import transactions
no_change: No change
overview: Overview
sync_message_missing_rates: Since exchange rates haven't been synced, balance
graphs may not reflect accurate values.
sync_message_unknown_error: An error has occurred during the sync.
total_owed: Total Owed
total_value: Total Value
trades: Transactions
@ -151,21 +155,17 @@ en:
summary:
new: New
no_assets: No assets found
no_assets_description: Add an asset either via connection, importing or entering
manually.
no_assets_description: Add an asset either via connection, importing or entering manually.
no_liabilities: No liabilities found
no_liabilities_description: Add a liability either via connection, importing
or entering manually.
sync_all:
button_text: Sync all
success: Successfully queued accounts for syncing.
tooltip:
cash: Cash
holdings: Holdings
total_value_tooltip: The total value is the sum of cash balance and your holdings
value, minus margin loans.
no_liabilities_description: Add a liability either via connection, importing or entering manually.
create:
success: New account created successfully
destroy:
success: Account deleted successfully
update:
success: Account updated
sync_all:
success: Successfully queued accounts for syncing.
credit_cards:
create:
success: Credit card created successfully

View file

@ -69,7 +69,7 @@ en:
import_mint: Import from Mint
import_portfolio: Import investments
import_transactions: Import transactions
resume: Resume latest import
resume: Resume %{type}
sources: Sources
title: New CSV Import
ready:

View file

@ -61,8 +61,6 @@ Rails.application.routes.draw do
end
scope module: :account do
resource :logo, only: :show
resources :holdings, only: %i[index new show destroy]
resources :cashes, only: :index

View file

@ -11,12 +11,6 @@ class ApplicationHelperTest < ActionView::TestCase
assert_equal "Test Header Title", content_for(:header_title)
end
test "#permitted_accountable_partial(accountable_type)" do
assert_equal "account", permitted_accountable_partial("Account")
assert_equal "user", permitted_accountable_partial("User")
assert_equal "admin_user", permitted_accountable_partial("AdminUser")
end
def setup
@account1 = Account.new(currency: "USD", balance: 1)
@account2 = Account.new(currency: "USD", balance: 2)

View file

@ -35,15 +35,6 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
assert_equal [ 19600, 19500, 19500, 20000, 20000, 20000 ], @account.balances.chronological.map(&:balance)
end
test "syncs account with trades only" do
aapl = securities(:aapl)
create_trade(aapl, account: @investment_account, date: 1.day.ago.to_date, qty: 10)
run_sync_for @investment_account
assert_equal [ 52140, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance)
end
test "syncs account with valuations and transactions" do
create_valuation(account: @account, date: 5.days.ago.to_date, amount: 20000)
create_transaction(account: @account, date: 3.days.ago.to_date, amount: -500)

View file

View file

@ -80,19 +80,15 @@ class AccountsTest < ApplicationSystemTestCase
end
def assert_account_created(accountable_type, &block)
click_link humanized_accountable(accountable_type)
click_link "Enter account balance manually"
click_link "Enter account manually"
account_name = "[system test] #{accountable_type} Account"
select accountable_type.titleize, from: "Account type"
fill_in "Account name", with: account_name
fill_in "account[balance]", with: 100.99
fill_in "Start date (optional)", with: 10.days.ago.to_date
fill_in "account[start_balance]", with: 95.25
yield if block_given?
click_button "Add #{humanized_accountable(accountable_type).downcase}"
click_button "Create Account"
find("details", text: humanized_accountable(accountable_type)).click
assert_text account_name
@ -107,8 +103,10 @@ class AccountsTest < ApplicationSystemTestCase
click_on "Edit"
end
yield if block_given?
fill_in "Account name", with: "Updated account name"
click_button "Update #{humanized_accountable(accountable_type).downcase}"
click_button "Update Account"
assert_selector "h2", text: "Updated account name"
end

View file

@ -1,28 +0,0 @@
require "application_system_test_case"
class TooltipsTest < ApplicationSystemTestCase
include ActionView::Helpers::NumberHelper
include ApplicationHelper
setup do
sign_in @user = users(:family_admin)
@account = accounts(:investment)
end
test "can see account information tooltip" do
visit account_path(@account)
tooltip_element = find('[data-controller="tooltip"]')
tooltip_element.hover
tooltip_contents = find('[data-tooltip-target="tooltip"]')
assert tooltip_contents.visible?
within tooltip_contents do
assert_text I18n.t("accounts.tooltip.total_value_tooltip")
assert_text I18n.t("accounts.tooltip.holdings")
assert_text format_money(@account.investment.holdings_value, precision: 0)
assert_text I18n.t("accounts.tooltip.cash")
assert_text format_money(@account.balance_money, precision: 0)
end
find("body").click
assert find('[data-tooltip-target="tooltip"]', visible: false)
end
end

View file

@ -62,6 +62,6 @@ class TradesTest < ApplicationSystemTestCase
end
def visit_account_trades
visit account_url(@account, tab: "trades")
visit account_url(@account, tab: "transactions")
end
end

View file

@ -156,6 +156,7 @@ class TransactionsTest < ApplicationSystemTestCase
test "can create deposit transaction for investment account" do
investment_account = accounts(:investment)
investment_account.entries.create!(name: "Investment account", date: Date.current, amount: 1000, currency: "USD", entryable: Account::Transaction.new)
transfer_date = Date.current
visit account_path(investment_account)
click_on "New transaction"