mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-07 14:35:23 +02:00
Merge remote-tracking branch 'origin/main' into bugfix/ui-pwa-ios
This commit is contained in:
commit
a1a05c8790
279 changed files with 4403 additions and 1156 deletions
26
Gemfile.lock
26
Gemfile.lock
|
@ -122,7 +122,7 @@ GEM
|
|||
bindex (0.8.1)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
brakeman (7.1.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
|
@ -192,9 +192,9 @@ GEM
|
|||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
faker (3.5.1)
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.1)
|
||||
faraday (2.13.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
|
@ -365,7 +365,7 @@ GEM
|
|||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
ostruct (0.6.2)
|
||||
pagy (9.3.4)
|
||||
pagy (9.3.5)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
|
@ -448,13 +448,13 @@ GEM
|
|||
ffi (~> 1.0)
|
||||
rbs (3.9.4)
|
||||
logger
|
||||
rdoc (6.14.1)
|
||||
rdoc (6.14.2)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (5.4.0)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.24.0)
|
||||
redis-client (0.25.0)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.1)
|
||||
|
@ -522,16 +522,16 @@ GEM
|
|||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.25.0)
|
||||
sentry-rails (5.26.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.25.0)
|
||||
sentry-ruby (5.25.0)
|
||||
sentry-ruby (~> 5.26.0)
|
||||
sentry-ruby (5.26.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.25.0)
|
||||
sentry-ruby (~> 5.25.0)
|
||||
sentry-sidekiq (5.26.0)
|
||||
sentry-ruby (~> 5.26.0)
|
||||
sidekiq (>= 3.0)
|
||||
sidekiq (8.0.4)
|
||||
sidekiq (8.0.5)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
|
@ -556,7 +556,7 @@ GEM
|
|||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.7)
|
||||
stripe (15.2.1)
|
||||
stripe (15.3.0)
|
||||
tailwindcss-rails (4.2.3)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 4.0)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class AlertComponent < ViewComponent::Base
|
||||
class DS::Alert < DesignSystemComponent
|
||||
def initialize(message:, variant: :info)
|
||||
@message = message
|
||||
@variant = variant
|
|
@ -1,6 +1,6 @@
|
|||
<%= container do %>
|
||||
<% if icon && (icon_position != :right) %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color, class: icon_classes) %>
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# An extension to `button_to` helper. All options are passed through to the `button_to` helper with some additional
|
||||
# options available.
|
||||
class ButtonComponent < ButtonishComponent
|
||||
class DS::Button < DS::Buttonish
|
||||
attr_reader :confirm
|
||||
|
||||
def initialize(confirm: nil, **opts)
|
|
@ -1,11 +1,11 @@
|
|||
class ButtonishComponent < ViewComponent::Base
|
||||
class DS::Buttonish < DesignSystemComponent
|
||||
VARIANTS = {
|
||||
primary: {
|
||||
container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
|
||||
icon_classes: "fg-inverse"
|
||||
},
|
||||
secondary: {
|
||||
container_classes: "text-secondary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
|
||||
container_classes: "text-primary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
|
||||
icon_classes: "fg-primary"
|
||||
},
|
||||
destructive: {
|
||||
|
@ -71,7 +71,7 @@ class ButtonishComponent < ViewComponent::Base
|
|||
end
|
||||
|
||||
def call
|
||||
raise NotImplementedError, "ButtonishComponent is an abstract class and cannot be instantiated directly."
|
||||
raise NotImplementedError, "Buttonish is an abstract class and cannot be instantiated directly."
|
||||
end
|
||||
|
||||
def container_classes(override_classes = nil)
|
|
@ -1,7 +1,7 @@
|
|||
<%= wrapper_element do %>
|
||||
<%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] #{drawer? ? "lg:p-3" : "lg:p-1"}", **merged_opts do %>
|
||||
<%= tag.div class: dialog_outer_classes do %>
|
||||
<%= tag.div class: dialog_inner_classes, data: { dialog_target: "content" } do %>
|
||||
<%= tag.div class: dialog_inner_classes, data: { DS__dialog_target: "content" } do %>
|
||||
<div class="grow overflow-y-auto py-4 space-y-4 flex flex-col">
|
||||
<% if header? %>
|
||||
<%= header %>
|
|
@ -1,9 +1,9 @@
|
|||
class DialogComponent < ViewComponent::Base
|
||||
class DS::Dialog < DesignSystemComponent
|
||||
renders_one :header, ->(title: nil, subtitle: nil, hide_close_icon: false, **opts, &block) do
|
||||
content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do
|
||||
title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do
|
||||
title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
|
||||
close_icon = render ButtonComponent.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "dialog#close" }) unless hide_close_icon
|
||||
close_icon = render DS::Button.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "DS--dialog#close" }) unless hide_close_icon
|
||||
safe_join([ title, close_icon ].compact)
|
||||
end
|
||||
|
||||
|
@ -19,16 +19,16 @@ class DialogComponent < ViewComponent::Base
|
|||
|
||||
renders_many :actions, ->(cancel_action: false, **button_opts) do
|
||||
merged_opts = if cancel_action
|
||||
button_opts.merge(type: "button", data: { action: "modal#close" })
|
||||
button_opts.merge(type: "button", data: { action: "DS--dialog#close" })
|
||||
else
|
||||
button_opts
|
||||
end
|
||||
|
||||
render ButtonComponent.new(**merged_opts)
|
||||
render DS::Button.new(**merged_opts)
|
||||
end
|
||||
|
||||
renders_many :sections, ->(title:, **disclosure_opts, &block) do
|
||||
render DisclosureComponent.new(title: title, align: :right, **disclosure_opts) do
|
||||
render DS::Disclosure.new(title: title, align: :right, **disclosure_opts) do
|
||||
block.call
|
||||
end
|
||||
end
|
||||
|
@ -99,11 +99,11 @@ class DialogComponent < ViewComponent::Base
|
|||
merged_opts = opts.dup
|
||||
data = merged_opts.delete(:data) || {}
|
||||
|
||||
data[:controller] = [ "dialog", "hotkey", data[:controller] ].compact.join(" ")
|
||||
data[:dialog_auto_open_value] = auto_open
|
||||
data[:dialog_reload_on_close_value] = reload_on_close
|
||||
data[:action] = [ "mousedown->dialog#clickOutside", data[:action] ].compact.join(" ")
|
||||
data[:hotkey] = "esc:dialog#close"
|
||||
data[:controller] = [ "DS--dialog", "hotkey", data[:controller] ].compact.join(" ")
|
||||
data[:DS__dialog_auto_open_value] = auto_open
|
||||
data[:DS__dialog_reload_on_close_value] = reload_on_close
|
||||
data[:action] = [ "mousedown->DS--dialog#clickOutside", data[:action] ].compact.join(" ")
|
||||
data[:hotkey] = "esc:DS--dialog#close"
|
||||
merged_opts[:data] = data
|
||||
|
||||
merged_opts
|
|
@ -1,4 +1,4 @@
|
|||
class DisclosureComponent < ViewComponent::Base
|
||||
class DS::Disclosure < DesignSystemComponent
|
||||
renders_one :summary_content
|
||||
|
||||
attr_reader :title, :align, :open, :opts
|
|
@ -1,4 +1,4 @@
|
|||
class FilledIconComponent < ViewComponent::Base
|
||||
class DS::FilledIcon < DesignSystemComponent
|
||||
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
|
||||
|
||||
VARIANTS = %i[default text surface container inverse].freeze
|
|
@ -1,6 +1,6 @@
|
|||
# An extension to `link_to` helper. All options are passed through to the `link_to` helper with some additional
|
||||
# options available.
|
||||
class LinkComponent < ButtonishComponent
|
||||
class DS::Link < DS::Buttonish
|
||||
attr_reader :frame
|
||||
|
||||
VARIANTS = VARIANTS.reverse_merge(
|
|
@ -1,17 +1,17 @@
|
|||
<%= tag.div data: { controller: "menu", menu_placement_value: placement, menu_offset_value: offset, testid: testid } do %>
|
||||
<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, testid: testid } do %>
|
||||
<% if variant == :icon %>
|
||||
<%= render ButtonComponent.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { menu_target: "button" }) %>
|
||||
<%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %>
|
||||
<% elsif variant == :button %>
|
||||
<%= button %>
|
||||
<% elsif variant == :avatar %>
|
||||
<button data-menu-target="button">
|
||||
<button data-DS--menu-target="button">
|
||||
<div class="w-9 h-9 cursor-pointer">
|
||||
<%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %>
|
||||
</div>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<div data-menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
|
||||
<div data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
|
||||
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
|
||||
<%= header %>
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MenuComponent < ViewComponent::Base
|
||||
class DS::Menu < DesignSystemComponent
|
||||
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid
|
||||
|
||||
renders_one :button, ->(**button_options, &block) do
|
||||
options_with_target = button_options.merge(data: { menu_target: "button" })
|
||||
options_with_target = button_options.merge(data: { DS__menu_target: "button" })
|
||||
|
||||
if block
|
||||
content_tag(:button, **options_with_target, &block)
|
||||
else
|
||||
ButtonComponent.new(**options_with_target)
|
||||
DS::Button.new(**options_with_target)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -19,7 +19,7 @@ class MenuComponent < ViewComponent::Base
|
|||
|
||||
renders_one :custom_content
|
||||
|
||||
renders_many :items, MenuItemComponent
|
||||
renders_many :items, DS::MenuItem
|
||||
|
||||
VARIANTS = %i[icon button avatar].freeze
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
class MenuItemComponent < ViewComponent::Base
|
||||
class DS::MenuItem < DesignSystemComponent
|
||||
VARIANTS = %i[link button divider].freeze
|
||||
|
||||
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts
|
|
@ -1,4 +1,4 @@
|
|||
class TabComponent < ViewComponent::Base
|
||||
class DS::Tab < DesignSystemComponent
|
||||
attr_reader :id, :label
|
||||
|
||||
def initialize(id:, label:)
|
18
app/components/DS/tabs.html.erb
Normal file
18
app/components/DS/tabs.html.erb
Normal file
|
@ -0,0 +1,18 @@
|
|||
<%= tag.div data: {
|
||||
controller: "DS--tabs",
|
||||
testid: testid,
|
||||
DS__tabs_session_key_value: session_key,
|
||||
DS__tabs_url_param_key_value: url_param_key,
|
||||
DS__tabs_nav_btn_active_class: active_btn_classes,
|
||||
DS__tabs_nav_btn_inactive_class: inactive_btn_classes
|
||||
} do %>
|
||||
<% if unstyled? %>
|
||||
<%= content %>
|
||||
<% else %>
|
||||
<%= nav %>
|
||||
|
||||
<% panels.each do |panel| %>
|
||||
<%= panel %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
|
@ -1,6 +1,6 @@
|
|||
class TabsComponent < ViewComponent::Base
|
||||
class DS::Tabs < DesignSystemComponent
|
||||
renders_one :nav, ->(classes: nil) do
|
||||
Tabs::NavComponent.new(
|
||||
DS::Tabs::Nav.new(
|
||||
active_tab: active_tab,
|
||||
active_btn_classes: active_btn_classes,
|
||||
inactive_btn_classes: inactive_btn_classes,
|
||||
|
@ -13,7 +13,7 @@ class TabsComponent < ViewComponent::Base
|
|||
content_tag(
|
||||
:div,
|
||||
class: ("hidden" unless tab_id == active_tab),
|
||||
data: { id: tab_id, tabs_target: "panel" },
|
||||
data: { id: tab_id, DS__tabs_target: "panel" },
|
||||
&block
|
||||
)
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
class Tabs::NavComponent < ViewComponent::Base
|
||||
class DS::Tabs::Nav < DesignSystemComponent
|
||||
erb_template <<~ERB
|
||||
<%= tag.nav class: classes do %>
|
||||
<% btns.each do |btn| %>
|
||||
|
@ -12,7 +12,7 @@ class Tabs::NavComponent < ViewComponent::Base
|
|||
:button, label, id: id,
|
||||
type: "button",
|
||||
class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes),
|
||||
data: { id: id, action: "tabs#show", tabs_target: "navBtn" },
|
||||
data: { id: id, action: "DS--tabs#show", DS__tabs_target: "navBtn" },
|
||||
&block
|
||||
)
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
class Tabs::PanelComponent < ViewComponent::Base
|
||||
class DS::Tabs::Panel < DesignSystemComponent
|
||||
attr_reader :tab_id
|
||||
|
||||
def initialize(tab_id:)
|
|
@ -1,4 +1,4 @@
|
|||
class ToggleComponent < ViewComponent::Base
|
||||
class DS::Toggle < DesignSystemComponent
|
||||
attr_reader :id, :name, :checked, :disabled, :checked_value, :unchecked_value, :opts
|
||||
|
||||
def initialize(id:, name: nil, checked: false, disabled: false, checked_value: "1", unchecked_value: "0", **opts)
|
9
app/components/DS/tooltip.html.erb
Normal file
9
app/components/DS/tooltip.html.erb
Normal file
|
@ -0,0 +1,9 @@
|
|||
<span data-controller="DS--tooltip" data-DS--tooltip-placement-value="<%= placement %>" data-DS--tooltip-offset-value="<%= offset %>" data-DS--tooltip-cross-axis-value="<%= cross_axis %>" class="inline-flex">
|
||||
<%= helpers.icon icon_name, size: size, color: color %>
|
||||
|
||||
<div role="tooltip" data-DS--tooltip-target="tooltip" class="hidden absolute z-50 bg-gray-700 text-sm px-1.5 py-1 rounded-md">
|
||||
<div class="fg-inverse font-normal max-w-[200px]">
|
||||
<%= tooltip_content %>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
17
app/components/DS/tooltip.rb
Normal file
17
app/components/DS/tooltip.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
class DS::Tooltip < ApplicationComponent
|
||||
attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color
|
||||
|
||||
def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
|
||||
@text = text
|
||||
@placement = placement
|
||||
@offset = offset
|
||||
@cross_axis = cross_axis
|
||||
@icon_name = icon
|
||||
@size = size
|
||||
@color = color
|
||||
end
|
||||
|
||||
def tooltip_content
|
||||
content? ? content : @text
|
||||
end
|
||||
end
|
87
app/components/DS/tooltip_controller.js
Normal file
87
app/components/DS/tooltip_controller.js
Normal file
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["tooltip"];
|
||||
static values = {
|
||||
placement: { type: String, default: "top" },
|
||||
offset: { type: Number, default: 10 },
|
||||
crossAxis: { type: Number, default: 0 },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this._cleanup = null;
|
||||
this.boundUpdate = this.update.bind(this);
|
||||
this.addEventListeners();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.removeEventListeners();
|
||||
this.stopAutoUpdate();
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
this.element.addEventListener("mouseenter", this.show);
|
||||
this.element.addEventListener("mouseleave", this.hide);
|
||||
}
|
||||
|
||||
removeEventListeners() {
|
||||
this.element.removeEventListener("mouseenter", this.show);
|
||||
this.element.removeEventListener("mouseleave", this.hide);
|
||||
}
|
||||
|
||||
show = () => {
|
||||
this.tooltipTarget.classList.remove("hidden");
|
||||
this.startAutoUpdate();
|
||||
this.update();
|
||||
};
|
||||
|
||||
hide = () => {
|
||||
this.tooltipTarget.classList.add("hidden");
|
||||
this.stopAutoUpdate();
|
||||
};
|
||||
|
||||
startAutoUpdate() {
|
||||
if (!this._cleanup) {
|
||||
const reference = this.element.querySelector("[data-icon]");
|
||||
this._cleanup = autoUpdate(
|
||||
reference || this.element,
|
||||
this.tooltipTarget,
|
||||
this.boundUpdate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stopAutoUpdate() {
|
||||
if (this._cleanup) {
|
||||
this._cleanup();
|
||||
this._cleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
const reference = this.element.querySelector("[data-icon]");
|
||||
computePosition(reference || this.element, this.tooltipTarget, {
|
||||
placement: this.placementValue,
|
||||
middleware: [
|
||||
offset({
|
||||
mainAxis: this.offsetValue,
|
||||
crossAxis: this.crossAxisValue,
|
||||
}),
|
||||
flip(),
|
||||
shift({ padding: 5 }),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
Object.assign(this.tooltipTarget.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
103
app/components/UI/account/activity_date.html.erb
Normal file
103
app/components/UI/account/activity_date.html.erb
Normal file
|
@ -0,0 +1,103 @@
|
|||
<%= tag.div id: id, data: { bulk_select_target: "group" }, class: "bg-container-inset rounded-xl p-1 w-full" do %>
|
||||
<details class="group">
|
||||
<summary>
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary">
|
||||
<div class="flex pl-0.5 items-center gap-4">
|
||||
<%= check_box_tag "#{date}_entries_selection",
|
||||
class: ["checkbox checkbox--light", "hidden": entries.size == 0],
|
||||
id: "selection_entry_#{date}",
|
||||
data: { action: "bulk-select#toggleGroupSelection" } %>
|
||||
|
||||
<p class="uppercase space-x-1.5">
|
||||
<%= tag.span I18n.l(date, format: :long) %>
|
||||
<span>·</span>
|
||||
<%= tag.span entries.size %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium"><%= balance_trend.current.format %></span>
|
||||
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
|
||||
</div>
|
||||
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Start of day balance
|
||||
<%= render DS::Tooltip.new(text: "The account balance at the beginning of this day, before any transactions or value changes", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd class="font-bold"><%= start_balance_money.format %></dd>
|
||||
</dl>
|
||||
|
||||
<% if account.balance_type == :investment %>
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ Cash
|
||||
<%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd><%= cash_change_money.format %></dd>
|
||||
</dl>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ Holdings
|
||||
<%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd><%= holdings_change_money.format %></dd>
|
||||
</dl>
|
||||
<% else %>
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ Cash
|
||||
<%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd><%= cash_change_money.format %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
End of day balance
|
||||
<%= render DS::Tooltip.new(text: "The calculated balance after all transactions but before any manual adjustments or reconciliations", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd class="font-medium"><%= end_balance_before_adjustments_money.format %></dd>
|
||||
</dl>
|
||||
|
||||
<hr class="border border-primary">
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ Value adjustments
|
||||
<%= render DS::Tooltip.new(text: "Adjustments are either manual reconciliations made by the user or adjustments due to market price changes throughout the day", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd><%= adjustments_money.format %></dd>
|
||||
</dl>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Closing balance
|
||||
<%= render DS::Tooltip.new(text: "The final account balance for the day, after all transactions and adjustments have been applied", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-primary">
|
||||
<dd class="font-bold"><%= end_balance_money.format %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="bg-container shadow-border-xs rounded-lg">
|
||||
<% entries.each do |entry| %>
|
||||
<%= render entry, view_ctx: "account" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
51
app/components/UI/account/activity_date.rb
Normal file
51
app/components/UI/account/activity_date.rb
Normal file
|
@ -0,0 +1,51 @@
|
|||
class UI::Account::ActivityDate < ApplicationComponent
|
||||
attr_reader :account, :data
|
||||
|
||||
delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data
|
||||
|
||||
def initialize(account:, data:)
|
||||
@account = account
|
||||
@data = data
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, "entries_#{date}")
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def start_balance_money
|
||||
balance_trend.previous
|
||||
end
|
||||
|
||||
def cash_change_money
|
||||
cash_balance_trend.value
|
||||
end
|
||||
|
||||
def holdings_change_money
|
||||
holdings_value_trend.value
|
||||
end
|
||||
|
||||
def end_balance_before_adjustments_money
|
||||
balance_trend.previous + cash_change_money + holdings_change_money
|
||||
end
|
||||
|
||||
def adjustments_money
|
||||
end_balance_money - end_balance_before_adjustments_money
|
||||
end
|
||||
|
||||
def end_balance_money
|
||||
balance_trend.current
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
broadcast_channel,
|
||||
target: id,
|
||||
renderable: self,
|
||||
layout: false
|
||||
)
|
||||
end
|
||||
end
|
94
app/components/UI/account/activity_feed.html.erb
Normal file
94
app/components/UI/account/activity_feed.html.erb
Normal file
|
@ -0,0 +1,94 @@
|
|||
<%= turbo_frame_tag dom_id(account, "entries") do %>
|
||||
<div class="bg-container p-5 shadow-border-xs rounded-xl">
|
||||
<div class="flex items-center justify-between mb-4" data-testid="activity-menu">
|
||||
<%= tag.h2 "Activity", class: "font-medium text-lg" %>
|
||||
|
||||
<% if account.manual? %>
|
||||
<%= render DS::Menu.new(variant: "button") do |menu| %>
|
||||
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
|
||||
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: "New balance",
|
||||
icon: "circle-dollar-sign",
|
||||
href: new_valuation_path(account_id: account.id),
|
||||
data: { turbo_frame: :modal }) %>
|
||||
|
||||
<% unless account.crypto? %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: "New transaction",
|
||||
icon: "credit-card",
|
||||
href: account.investment? ? new_trade_path(account_id: account.id) : new_transaction_path(account_id: account.id),
|
||||
data: { turbo_frame: :modal }) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form_with url: account_path(account),
|
||||
id: "entries-search",
|
||||
scope: :q,
|
||||
method: :get,
|
||||
data: { controller: "auto-submit-form" } do |form| %>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="grow">
|
||||
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
|
||||
<%= helpers.icon("search") %>
|
||||
|
||||
<%= hidden_field_tag :account_id, account.id %>
|
||||
|
||||
<%= form.search_field :search,
|
||||
placeholder: "Search entries by name",
|
||||
value: search,
|
||||
class: "form-field__input placeholder:text-sm placeholder:text-secondary",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if activity_dates.empty? %>
|
||||
<p class="text-secondary text-sm p-4">No entries yet</p>
|
||||
<% else %>
|
||||
<%= tag.div id: dom_id(account, "entries_bulk_select"),
|
||||
data: {
|
||||
controller: "bulk-select",
|
||||
bulk_select_singular_label_value: "entry",
|
||||
bulk_select_plural_label_value: "entries"
|
||||
} do %>
|
||||
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
|
||||
<%= render "entries/selection_bar" %>
|
||||
</div>
|
||||
|
||||
<div class="grid bg-container-inset rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-5 py-3 mb-4">
|
||||
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag "selection_entry",
|
||||
class: "checkbox checkbox--light",
|
||||
data: { action: "bulk-select#togglePageSelection" } %>
|
||||
<p>Date</p>
|
||||
</div>
|
||||
|
||||
<%= tag.p "Amount", class: "col-span-4 justify-self-end" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<% activity_dates.each do |activity_date_data| %>
|
||||
<%= render UI::Account::ActivityDate.new(
|
||||
account: account,
|
||||
data: activity_date_data
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-container rounded-bl-lg rounded-br-lg">
|
||||
<%= render "shared/pagination", pagy: pagy %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
35
app/components/UI/account/activity_feed.rb
Normal file
35
app/components/UI/account/activity_feed.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
class UI::Account::ActivityFeed < ApplicationComponent
|
||||
attr_reader :feed_data, :pagy, :search
|
||||
|
||||
def initialize(feed_data:, pagy:, search: nil)
|
||||
@feed_data = feed_data
|
||||
@pagy = pagy
|
||||
@search = search
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, :activity_feed)
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
broadcast_channel,
|
||||
target: id,
|
||||
renderable: self,
|
||||
layout: false
|
||||
)
|
||||
end
|
||||
|
||||
def activity_dates
|
||||
feed_data.entries_by_date
|
||||
end
|
||||
|
||||
private
|
||||
def account
|
||||
feed_data.account
|
||||
end
|
||||
end
|
|
@ -1,32 +1,28 @@
|
|||
<%# locals: (account:, tooltip: nil, chart_view: nil, **args) %>
|
||||
|
||||
<% period = @period || Period.last_30_days %>
|
||||
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
|
||||
|
||||
<div id="<%= dom_id(account, :chart) %>" class="bg-container shadow-border-xs rounded-xl space-y-2">
|
||||
<div class="flex justify-between flex-col-reverse lg:flex-row gap-2 px-4 pt-4 mb-2">
|
||||
<div class="space-y-2 w-full">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %>
|
||||
<%= tag.p title, class: "text-sm font-medium text-secondary" %>
|
||||
|
||||
<% if account.investment? %>
|
||||
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: account.balance_money - account.cash_balance_money, cash: account.cash_balance_money %>
|
||||
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: holdings_value_money, cash: account.cash_balance_money %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-baseline">
|
||||
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
|
||||
<% if account.currency != Current.family.currency %>
|
||||
<%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %>
|
||||
<%= tag.p view_balance_money.format, class: "text-primary text-3xl font-medium truncate" %>
|
||||
|
||||
<% if converted_balance_money %>
|
||||
<%= tag.p converted_balance_money.format, class: "text-sm font-medium text-secondary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||
<%= form_with url: account_path(account), method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||
<div class="flex items-center gap-2">
|
||||
<% if chart_view.present? %>
|
||||
<% if account.investment? %>
|
||||
<%= form.select :chart_view,
|
||||
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
|
||||
{ selected: chart_view },
|
||||
{ selected: view },
|
||||
class: "bg-container border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
|
||||
data: { "auto-submit-form-target": "auto" } %>
|
||||
<% end %>
|
||||
|
@ -40,7 +36,23 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key, chart_view: chart_view) do %>
|
||||
<%= render "accounts/chart_loader" %>
|
||||
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
||||
<div class="px-4">
|
||||
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: period.comparison_label } %>
|
||||
</div>
|
||||
|
||||
<div class="h-64 pb-4">
|
||||
<% if series.any? %>
|
||||
<div
|
||||
id="lineChart"
|
||||
class="w-full h-full"
|
||||
data-controller="time-series-chart"
|
||||
data-time-series-chart-data-value="<%= series.to_json %>"></div>
|
||||
<% else %>
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-secondary text-sm">No data available</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
72
app/components/UI/account/chart.rb
Normal file
72
app/components/UI/account/chart.rb
Normal file
|
@ -0,0 +1,72 @@
|
|||
class UI::Account::Chart < ApplicationComponent
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account:, period: nil, view: nil)
|
||||
@account = account
|
||||
@period = period
|
||||
@view = view
|
||||
end
|
||||
|
||||
def period
|
||||
@period ||= Period.last_30_days
|
||||
end
|
||||
|
||||
def holdings_value_money
|
||||
account.balance_money - account.cash_balance_money
|
||||
end
|
||||
|
||||
def view_balance_money
|
||||
case view
|
||||
when "balance"
|
||||
account.balance_money
|
||||
when "holdings_balance"
|
||||
holdings_value_money
|
||||
when "cash_balance"
|
||||
account.cash_balance_money
|
||||
end
|
||||
end
|
||||
|
||||
def title
|
||||
case account.accountable_type
|
||||
when "Investment", "Crypto"
|
||||
case view
|
||||
when "balance"
|
||||
"Total account value"
|
||||
when "holdings_balance"
|
||||
"Holdings value"
|
||||
when "cash_balance"
|
||||
"Cash value"
|
||||
end
|
||||
when "Property", "Vehicle"
|
||||
"Estimated #{account.accountable_type.humanize.downcase} value"
|
||||
when "CreditCard", "OtherLiability"
|
||||
"Debt balance"
|
||||
when "Loan"
|
||||
"Remaining principal balance"
|
||||
else
|
||||
"Balance"
|
||||
end
|
||||
end
|
||||
|
||||
def foreign_currency?
|
||||
account.currency != account.family.currency
|
||||
end
|
||||
|
||||
def converted_balance_money
|
||||
return nil unless foreign_currency?
|
||||
|
||||
account.balance_money.exchange_to(account.family.currency, fallback_rate: 1)
|
||||
end
|
||||
|
||||
def view
|
||||
@view ||= "balance"
|
||||
end
|
||||
|
||||
def series
|
||||
account.balance_series(period: period, view: view)
|
||||
end
|
||||
|
||||
def trend
|
||||
series.trend
|
||||
end
|
||||
end
|
29
app/components/UI/account_page.html.erb
Normal file
29
app/components/UI/account_page.html.erb
Normal file
|
@ -0,0 +1,29 @@
|
|||
<%= turbo_stream_from account %>
|
||||
|
||||
<%= turbo_frame_tag id do %>
|
||||
<%= tag.div class: "space-y-4 pb-32" do %>
|
||||
<%= render "accounts/show/header", account: account, title: title, subtitle: subtitle %>
|
||||
|
||||
<%= render UI::Account::Chart.new(account: account, period: chart_period, view: chart_view) %>
|
||||
|
||||
<div class="min-h-[800px]" data-testid="account-details">
|
||||
<% if tabs.count > 1 %>
|
||||
<%= render DS::Tabs.new(active_tab: active_tab, url_param_key: "tab") do |tabs_container| %>
|
||||
<% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
|
||||
<% tabs.each do |tab| %>
|
||||
<% nav.with_btn(id: tab, label: tab.to_s.humanize, classes: "px-6") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% tabs.each do |tab| %>
|
||||
<% tabs_container.with_panel(tab_id: tab) do %>
|
||||
<%= tab_content_for(tab) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= tab_content_for(tabs.first) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
59
app/components/UI/account_page.rb
Normal file
59
app/components/UI/account_page.rb
Normal file
|
@ -0,0 +1,59 @@
|
|||
class UI::AccountPage < ApplicationComponent
|
||||
attr_reader :account, :chart_view, :chart_period
|
||||
|
||||
renders_one :activity_feed, ->(feed_data:, pagy:, search:) { UI::Account::ActivityFeed.new(feed_data: feed_data, pagy: pagy, search: search) }
|
||||
|
||||
def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil)
|
||||
@account = account
|
||||
@chart_view = chart_view
|
||||
@chart_period = chart_period
|
||||
@active_tab = active_tab
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, :container)
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(broadcast_channel, target: id, renderable: self, layout: false)
|
||||
end
|
||||
|
||||
def title
|
||||
account.name
|
||||
end
|
||||
|
||||
def subtitle
|
||||
return nil unless account.property?
|
||||
|
||||
account.property.address
|
||||
end
|
||||
|
||||
def active_tab
|
||||
tabs.find { |tab| tab == @active_tab&.to_sym } || tabs.first
|
||||
end
|
||||
|
||||
def tabs
|
||||
case account.accountable_type
|
||||
when "Investment"
|
||||
[ :activity, :holdings ]
|
||||
when "Property", "Vehicle", "Loan"
|
||||
[ :activity, :overview ]
|
||||
else
|
||||
[ :activity ]
|
||||
end
|
||||
end
|
||||
|
||||
def tab_content_for(tab)
|
||||
case tab
|
||||
when :activity
|
||||
activity_feed
|
||||
when :holdings, :overview
|
||||
# Accountable is responsible for implementing the partial in the correct folder
|
||||
render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account
|
||||
end
|
||||
end
|
||||
end
|
4
app/components/application_component.rb
Normal file
4
app/components/application_component.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
class ApplicationComponent < ViewComponent::Base
|
||||
# These don't work as expected with helpers.turbo_frame_tag, etc., so we include them here
|
||||
include Turbo::FramesHelper, Turbo::StreamsHelper
|
||||
end
|
2
app/components/design_system_component.rb
Normal file
2
app/components/design_system_component.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
class DesignSystemComponent < ViewComponent::Base
|
||||
end
|
|
@ -1,18 +0,0 @@
|
|||
<%= tag.div data: {
|
||||
controller: "tabs",
|
||||
testid: testid,
|
||||
tabs_session_key_value: session_key,
|
||||
tabs_url_param_key_value: url_param_key,
|
||||
tabs_nav_btn_active_class: active_btn_classes,
|
||||
tabs_nav_btn_inactive_class: inactive_btn_classes
|
||||
} do %>
|
||||
<% if unstyled? %>
|
||||
<%= content %>
|
||||
<% else %>
|
||||
<%= nav %>
|
||||
|
||||
<% panels.each do |panel| %>
|
||||
<%= panel %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
|
@ -1,5 +1,5 @@
|
|||
class AccountsController < ApplicationController
|
||||
before_action :set_account, only: %i[sync chart sparkline toggle_active]
|
||||
before_action :set_account, only: %i[sync sparkline toggle_active show destroy]
|
||||
include Periodable
|
||||
|
||||
def index
|
||||
|
@ -9,6 +9,17 @@ class AccountsController < ApplicationController
|
|||
render layout: "settings"
|
||||
end
|
||||
|
||||
def show
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
@tab = params[:tab]
|
||||
@q = params.fetch(:q, {}).permit(:search)
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
|
||||
|
||||
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
|
||||
end
|
||||
|
||||
def sync
|
||||
unless @account.syncing?
|
||||
@account.sync_later
|
||||
|
@ -17,11 +28,6 @@ class AccountsController < ApplicationController
|
|||
redirect_to account_path(@account)
|
||||
end
|
||||
|
||||
def chart
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
render layout: "application"
|
||||
end
|
||||
|
||||
def sparkline
|
||||
etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true)
|
||||
|
||||
|
@ -42,6 +48,15 @@ class AccountsController < ApplicationController
|
|||
redirect_to accounts_path
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @account.linked?
|
||||
redirect_to account_path(@account), alert: "Cannot delete a linked account"
|
||||
else
|
||||
@account.destroy_later
|
||||
redirect_to accounts_path, notice: "Account scheduled for deletion"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Current.family
|
||||
|
|
|
@ -2,9 +2,9 @@ module AccountableResource
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include ScrollFocusable, Periodable
|
||||
include Periodable
|
||||
|
||||
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
|
||||
before_action :set_account, only: [ :show, :edit, :update ]
|
||||
before_action :set_link_options, only: :new
|
||||
end
|
||||
|
||||
|
@ -27,9 +27,7 @@ module AccountableResource
|
|||
@q = params.fetch(:q, {}).permit(:search)
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
set_focused_record(entries, params[:focused_record_id])
|
||||
|
||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10", params: ->(params) { params.except(:focused_record_id) })
|
||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
|
||||
end
|
||||
|
||||
def edit
|
||||
|
@ -45,12 +43,13 @@ module AccountableResource
|
|||
def update
|
||||
# Handle balance update if provided
|
||||
if account_params[:balance].present?
|
||||
result = @account.update_balance(balance: account_params[:balance], currency: account_params[:currency])
|
||||
result = @account.set_current_balance(account_params[:balance].to_d)
|
||||
unless result.success?
|
||||
@error_message = result.error_message
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
@account.sync_later
|
||||
end
|
||||
|
||||
# Update remaining account attributes
|
||||
|
@ -62,16 +61,7 @@ module AccountableResource
|
|||
end
|
||||
|
||||
@account.lock_saved_attributes!
|
||||
redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @account.linked?
|
||||
redirect_to account_path(@account), alert: "Cannot delete a linked account"
|
||||
else
|
||||
@account.destroy_later
|
||||
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
redirect_back_or_to account_path(@account), notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
module ScrollFocusable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def set_focused_record(record_scope, record_id, default_per_page: 10)
|
||||
return unless record_id.present?
|
||||
|
||||
@focused_record = record_scope.find_by(id: record_id)
|
||||
|
||||
record_index = record_scope.pluck(:id).index(record_id)
|
||||
|
||||
return unless record_index
|
||||
|
||||
page_of_focused_record = (record_index / (params[:per_page]&.to_i || default_per_page)) + 1
|
||||
|
||||
if params[:page]&.to_i != page_of_focused_record
|
||||
(
|
||||
redirect_to(url_for(page: page_of_focused_record, focused_record_id: record_id))
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -37,10 +37,10 @@ class PropertiesController < ApplicationController
|
|||
end
|
||||
|
||||
def update_balances
|
||||
result = @account.update_balance(balance: balance_params[:balance], currency: balance_params[:currency])
|
||||
result = @account.set_current_balance(balance_params[:balance].to_d)
|
||||
|
||||
if result.success?
|
||||
@success_message = result.updated? ? "Balance updated successfully." : "No changes made. Account is already up to date."
|
||||
@success_message = "Balance updated successfully."
|
||||
|
||||
if @account.active?
|
||||
render :balances
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class TransactionsController < ApplicationController
|
||||
include ScrollFocusable, EntryableResource
|
||||
include EntryableResource
|
||||
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
|
@ -21,12 +21,7 @@ class TransactionsController < ApplicationController
|
|||
:transfer_as_inflow, :transfer_as_outflow
|
||||
)
|
||||
|
||||
@pagy, @transactions = pagy(base_scope, limit: per_page, params: ->(p) { p.except(:focused_record_id) })
|
||||
|
||||
# No performance penalty by default. Only runs queries if the record is set.
|
||||
if params[:focused_record_id].present?
|
||||
set_focused_record(base_scope, params[:focused_record_id], default_per_page: per_page)
|
||||
end
|
||||
@pagy, @transactions = pagy(base_scope, limit: per_page)
|
||||
end
|
||||
|
||||
def clear_filter
|
||||
|
|
|
@ -1,22 +1,46 @@
|
|||
class ValuationsController < ApplicationController
|
||||
include EntryableResource, StreamExtensions
|
||||
|
||||
def confirm_create
|
||||
@account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
@entry = @account.entries.build(entry_params.merge(currency: @account.currency))
|
||||
|
||||
@reconciliation_dry_run = @entry.account.create_reconciliation(
|
||||
balance: entry_params[:amount],
|
||||
date: entry_params[:date],
|
||||
dry_run: true
|
||||
)
|
||||
|
||||
render :confirm_create
|
||||
end
|
||||
|
||||
def confirm_update
|
||||
@entry = Current.family.entries.find(params[:id])
|
||||
@account = @entry.account
|
||||
@entry.assign_attributes(entry_params.merge(currency: @account.currency))
|
||||
|
||||
@reconciliation_dry_run = @entry.account.update_reconciliation(
|
||||
@entry,
|
||||
balance: entry_params[:amount],
|
||||
date: entry_params[:date],
|
||||
dry_run: true
|
||||
)
|
||||
|
||||
render :confirm_update
|
||||
end
|
||||
|
||||
def create
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
|
||||
result = account.update_balance(
|
||||
result = account.create_reconciliation(
|
||||
balance: entry_params[:amount],
|
||||
date: entry_params[:date],
|
||||
currency: entry_params[:currency],
|
||||
notes: entry_params[:notes]
|
||||
)
|
||||
|
||||
if result.success?
|
||||
@success_message = result.updated? ? "Balance updated" : "No changes made. Account is already up to date."
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(account), notice: @success_message }
|
||||
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: @success_message) }
|
||||
format.html { redirect_back_or_to account_path(account), notice: "Account updated" }
|
||||
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: "Account updated") }
|
||||
end
|
||||
else
|
||||
@error_message = result.error_message
|
||||
|
@ -25,18 +49,22 @@ class ValuationsController < ApplicationController
|
|||
end
|
||||
|
||||
def update
|
||||
result = @entry.account.update_balance(
|
||||
date: @entry.date,
|
||||
balance: entry_params[:amount],
|
||||
currency: entry_params[:currency],
|
||||
notes: entry_params[:notes]
|
||||
)
|
||||
# Notes updating is independent of reconciliation, just a simple CRUD operation
|
||||
@entry.update!(notes: entry_params[:notes]) if entry_params[:notes].present?
|
||||
|
||||
if result.success?
|
||||
if entry_params[:date].present? && entry_params[:amount].present?
|
||||
result = @entry.account.update_reconciliation(
|
||||
@entry,
|
||||
balance: entry_params[:amount],
|
||||
date: entry_params[:date],
|
||||
)
|
||||
end
|
||||
|
||||
if result.nil? || result.success?
|
||||
@entry.reload
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: result.updated? ? "Balance updated" : "No changes made. Account is already up to date." }
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: "Entry updated" }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
|
@ -56,7 +84,6 @@ class ValuationsController < ApplicationController
|
|||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:entry)
|
||||
.permit(:date, :amount, :currency, :notes)
|
||||
params.require(:entry).permit(:date, :amount, :notes)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -21,7 +21,7 @@ module ApplicationHelper
|
|||
if custom
|
||||
inline_svg_tag("#{key}.svg", class: icon_classes, **opts)
|
||||
elsif as_button
|
||||
render ButtonComponent.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts)
|
||||
render DS::Button.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts)
|
||||
else
|
||||
lucide_icon(key, class: icon_classes, **opts)
|
||||
end
|
||||
|
|
|
@ -50,7 +50,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
|||
checked = object ? object.send(method) : options[:checked]
|
||||
|
||||
@template.render(
|
||||
ToggleComponent.new(
|
||||
DS::Toggle.new(
|
||||
id: field_id,
|
||||
name: field_name,
|
||||
checked: checked,
|
||||
|
@ -67,7 +67,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
|||
value ||= submit_default_value
|
||||
|
||||
@template.render(
|
||||
ButtonComponent.new(
|
||||
DS::Button.new(
|
||||
text: value,
|
||||
data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }),
|
||||
full_width: true
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="focus-record"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
id: String,
|
||||
};
|
||||
|
||||
connect() {
|
||||
const element = document.getElementById(this.idValue);
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
|
||||
// Remove the focused_record_id parameter from URL
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete("focused_record_id");
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
class Account < ApplicationRecord
|
||||
include Syncable, Monetizable, Chartable, Linkable, Enrichable
|
||||
include AASM
|
||||
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable
|
||||
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
|
@ -59,26 +58,14 @@ class Account < ApplicationRecord
|
|||
def create_and_sync(attributes)
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
|
||||
account = new(attributes.merge(cash_balance: attributes[:balance]))
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d
|
||||
|
||||
transaction do
|
||||
# Create 2 valuations for new accounts to establish a value history for users to see
|
||||
account.entries.build(
|
||||
name: "Current Balance",
|
||||
date: Date.current,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new
|
||||
)
|
||||
account.entries.build(
|
||||
name: "Initial Balance",
|
||||
date: 1.day.ago.to_date,
|
||||
amount: initial_balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new
|
||||
)
|
||||
|
||||
account.save!
|
||||
|
||||
manager = Account::OpeningBalanceManager.new(account)
|
||||
result = manager.set_opening_balance(balance: initial_balance || account.balance)
|
||||
raise result.error if result.error
|
||||
end
|
||||
|
||||
account.sync_later
|
||||
|
@ -127,11 +114,6 @@ class Account < ApplicationRecord
|
|||
.order(amount: :desc)
|
||||
end
|
||||
|
||||
|
||||
def update_balance(balance:, date: Date.current, currency: nil, notes: nil)
|
||||
Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:).update
|
||||
end
|
||||
|
||||
def start_date
|
||||
first_entry_date = entries.minimum(:date) || Date.current
|
||||
first_entry_date - 1.day
|
||||
|
@ -159,4 +141,23 @@ class Account < ApplicationRecord
|
|||
def long_subtype_label
|
||||
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
|
||||
end
|
||||
|
||||
# The balance type determines which "component" of balance is being tracked.
|
||||
# This is primarily used for balance related calculations and updates.
|
||||
#
|
||||
# "Cash" = "Liquid"
|
||||
# "Non-cash" = "Illiquid"
|
||||
# "Investment" = A mix of both, including brokerage cash (liquid) and holdings (illiquid)
|
||||
def balance_type
|
||||
case accountable_type
|
||||
when "Depository", "CreditCard"
|
||||
:cash
|
||||
when "Property", "Vehicle", "OtherAsset", "Loan", "OtherLiability"
|
||||
:non_cash
|
||||
when "Investment", "Crypto"
|
||||
:investment
|
||||
else
|
||||
raise "Unknown account type: #{accountable_type}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
219
app/models/account/activity_feed_data.rb
Normal file
219
app/models/account/activity_feed_data.rb
Normal file
|
@ -0,0 +1,219 @@
|
|||
# Data used to build the paginated feed of account "activity" (events like transfers, deposits, withdrawals, etc.)
|
||||
# This data object is useful for avoiding N+1 queries and having an easy way to pass around the required data to the
|
||||
# activity feed component in controllers and background jobs that refresh it.
|
||||
class Account::ActivityFeedData
|
||||
ActivityDateData = Data.define(:date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers)
|
||||
|
||||
attr_reader :account, :entries
|
||||
|
||||
def initialize(account, entries)
|
||||
@account = account
|
||||
@entries = entries.to_a
|
||||
end
|
||||
|
||||
def entries_by_date
|
||||
@entries_by_date_objects ||= begin
|
||||
grouped_entries.map do |date, date_entries|
|
||||
ActivityDateData.new(
|
||||
date: date,
|
||||
entries: date_entries,
|
||||
balance_trend: balance_trend_for_date(date),
|
||||
cash_balance_trend: cash_balance_trend_for_date(date),
|
||||
holdings_value_trend: holdings_value_trend_for_date(date),
|
||||
transfers: transfers_for_date(date)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def balance_trend_for_date(date)
|
||||
build_trend_for_date(date, :balance_money)
|
||||
end
|
||||
|
||||
def cash_balance_trend_for_date(date)
|
||||
date_entries = grouped_entries[date] || []
|
||||
has_valuation = date_entries.any?(&:valuation?)
|
||||
|
||||
if has_valuation
|
||||
# When there's a valuation, calculate cash change from transaction entries only
|
||||
transactions = date_entries.select { |e| e.transaction? }
|
||||
cash_change = sum_entries_with_exchange_rates(transactions, date) * -1
|
||||
|
||||
start_balance = start_balance_for_date(date)
|
||||
Trend.new(
|
||||
current: start_balance.cash_balance_money + cash_change,
|
||||
previous: start_balance.cash_balance_money
|
||||
)
|
||||
else
|
||||
build_trend_for_date(date, :cash_balance_money)
|
||||
end
|
||||
end
|
||||
|
||||
def holdings_value_trend_for_date(date)
|
||||
date_entries = grouped_entries[date] || []
|
||||
has_valuation = date_entries.any?(&:valuation?)
|
||||
|
||||
if has_valuation
|
||||
# When there's a valuation, calculate holdings change from trade entries only
|
||||
trades = date_entries.select { |e| e.trade? }
|
||||
holdings_change = sum_entries_with_exchange_rates(trades, date)
|
||||
|
||||
start_balance = start_balance_for_date(date)
|
||||
start_holdings = start_balance.balance_money - start_balance.cash_balance_money
|
||||
Trend.new(
|
||||
current: start_holdings + holdings_change,
|
||||
previous: start_holdings
|
||||
)
|
||||
else
|
||||
build_trend_for_date(date) do |balance|
|
||||
balance.balance_money - balance.cash_balance_money
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def transfers_for_date(date)
|
||||
date_entries = grouped_entries[date] || []
|
||||
return [] if date_entries.empty?
|
||||
|
||||
date_transaction_ids = date_entries.select(&:transaction?).map(&:entryable_id)
|
||||
return [] if date_transaction_ids.empty?
|
||||
|
||||
# Convert to Set for O(1) lookups
|
||||
date_transaction_id_set = Set.new(date_transaction_ids)
|
||||
|
||||
transfers.select { |txfr|
|
||||
date_transaction_id_set.include?(txfr.inflow_transaction_id) ||
|
||||
date_transaction_id_set.include?(txfr.outflow_transaction_id)
|
||||
}
|
||||
end
|
||||
|
||||
def build_trend_for_date(date, method = nil)
|
||||
start_balance = start_balance_for_date(date)
|
||||
end_balance = end_balance_for_date(date)
|
||||
|
||||
if block_given?
|
||||
Trend.new(
|
||||
current: yield(end_balance),
|
||||
previous: yield(start_balance)
|
||||
)
|
||||
else
|
||||
Trend.new(
|
||||
current: end_balance.send(method),
|
||||
previous: start_balance.send(method)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Finds the balance on date, or the most recent balance before it ("last observation carried forward")
|
||||
def start_balance_for_date(date)
|
||||
@start_balance_for_date ||= {}
|
||||
@start_balance_for_date[date] ||= last_observed_balance_before_date(date.prev_day) || generate_fallback_balance(date)
|
||||
end
|
||||
|
||||
# Finds the balance on date, or the most recent balance before it ("last observation carried forward")
|
||||
def end_balance_for_date(date)
|
||||
@end_balance_for_date ||= {}
|
||||
@end_balance_for_date[date] ||= last_observed_balance_before_date(date) || generate_fallback_balance(date)
|
||||
end
|
||||
|
||||
RequiredExchangeRate = Data.define(:date, :from, :to)
|
||||
|
||||
def grouped_entries
|
||||
@grouped_entries ||= entries.group_by(&:date)
|
||||
end
|
||||
|
||||
def needs_exchange_rates?
|
||||
entries.any? { |entry| entry.currency != account.currency }
|
||||
end
|
||||
|
||||
def required_exchange_rates
|
||||
multi_currency_entries = entries.select { |entry| entry.currency != account.currency }
|
||||
|
||||
multi_currency_entries.map do |entry|
|
||||
RequiredExchangeRate.new(date: entry.date, from: entry.currency, to: account.currency)
|
||||
end.uniq
|
||||
end
|
||||
|
||||
# If the account has entries denominated in a different currency than the main account, we attach necessary
|
||||
# exchange rates required to "roll up" the entry group balance into the normal account currency.
|
||||
def exchange_rates
|
||||
return [] unless needs_exchange_rates?
|
||||
|
||||
@exchange_rates ||= begin
|
||||
rate_requirements = required_exchange_rates
|
||||
return [] if rate_requirements.empty?
|
||||
|
||||
# Use ActiveRecord's or chain for better performance
|
||||
conditions = rate_requirements.map do |req|
|
||||
ExchangeRate.where(date: req.date, from_currency: req.from, to_currency: req.to)
|
||||
end.reduce(:or)
|
||||
|
||||
conditions.to_a
|
||||
end
|
||||
end
|
||||
|
||||
def exchange_rate_for(date, from_currency, to_currency)
|
||||
return 1.0 if from_currency == to_currency
|
||||
|
||||
rate = exchange_rates.find { |r| r.date == date && r.from_currency == from_currency && r.to_currency == to_currency }
|
||||
rate&.rate || 1.0 # Fallback to 1:1 if no rate found
|
||||
end
|
||||
|
||||
def sum_entries_with_exchange_rates(entries, date)
|
||||
return Money.new(0, account.currency) if entries.empty?
|
||||
|
||||
entries.sum do |entry|
|
||||
amount = entry.amount_money
|
||||
if entry.currency != account.currency
|
||||
rate = exchange_rate_for(date, entry.currency, account.currency)
|
||||
Money.new(amount.amount * rate, account.currency)
|
||||
else
|
||||
amount
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# We read balances so we can show "start of day" -> "end of day" balances for each entry date group in the feed
|
||||
def balances
|
||||
@balances ||= begin
|
||||
return [] if entries.empty?
|
||||
|
||||
min_date = entries.min_by(&:date).date.prev_day
|
||||
max_date = entries.max_by(&:date).date
|
||||
|
||||
account.balances.where(date: min_date..max_date, currency: account.currency).order(:date).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def transaction_ids
|
||||
entries.select { |entry| entry.transaction? }.map(&:entryable_id)
|
||||
end
|
||||
|
||||
def transfers
|
||||
return [] if entries.select { |e| e.transaction? && e.transaction.transfer? }.empty?
|
||||
return [] if transaction_ids.empty?
|
||||
|
||||
@transfers ||= Transfer.where(inflow_transaction_id: transaction_ids).or(Transfer.where(outflow_transaction_id: transaction_ids)).to_a
|
||||
end
|
||||
|
||||
# Use binary search since balances are sorted by date
|
||||
def last_observed_balance_before_date(date)
|
||||
idx = balances.bsearch_index { |b| b.date > date }
|
||||
|
||||
if idx
|
||||
idx > 0 ? balances[idx - 1] : nil
|
||||
else
|
||||
balances.last
|
||||
end
|
||||
end
|
||||
|
||||
def generate_fallback_balance(date)
|
||||
Balance.new(
|
||||
account: account,
|
||||
date: date,
|
||||
balance: 0,
|
||||
currency: account.currency
|
||||
)
|
||||
end
|
||||
end
|
56
app/models/account/anchorable.rb
Normal file
56
app/models/account/anchorable.rb
Normal file
|
@ -0,0 +1,56 @@
|
|||
# All accounts are "anchored" with start/end valuation records, with transactions,
|
||||
# trades, and reconciliations between them.
|
||||
module Account::Anchorable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include Monetizable
|
||||
|
||||
monetize :opening_balance
|
||||
end
|
||||
|
||||
def set_opening_anchor_balance(**opts)
|
||||
result = opening_balance_manager.set_opening_balance(**opts)
|
||||
sync_later if result.success?
|
||||
result
|
||||
end
|
||||
|
||||
def opening_anchor_date
|
||||
opening_balance_manager.opening_date
|
||||
end
|
||||
|
||||
def opening_anchor_balance
|
||||
opening_balance_manager.opening_balance
|
||||
end
|
||||
|
||||
def has_opening_anchor?
|
||||
opening_balance_manager.has_opening_anchor?
|
||||
end
|
||||
|
||||
def set_current_balance(balance)
|
||||
result = current_balance_manager.set_current_balance(balance)
|
||||
sync_later if result.success?
|
||||
result
|
||||
end
|
||||
|
||||
def current_anchor_balance
|
||||
current_balance_manager.current_balance
|
||||
end
|
||||
|
||||
def current_anchor_date
|
||||
current_balance_manager.current_date
|
||||
end
|
||||
|
||||
def has_current_anchor?
|
||||
current_balance_manager.has_current_anchor?
|
||||
end
|
||||
|
||||
private
|
||||
def opening_balance_manager
|
||||
@opening_balance_manager ||= Account::OpeningBalanceManager.new(self)
|
||||
end
|
||||
|
||||
def current_balance_manager
|
||||
@current_balance_manager ||= Account::CurrentBalanceManager.new(self)
|
||||
end
|
||||
end
|
|
@ -1,47 +0,0 @@
|
|||
class Account::BalanceUpdater
|
||||
def initialize(account, balance:, currency: nil, date: Date.current, notes: nil)
|
||||
@account = account
|
||||
@balance = balance.to_d
|
||||
@currency = currency
|
||||
@date = date.to_date
|
||||
@notes = notes
|
||||
end
|
||||
|
||||
def update
|
||||
return Result.new(success?: true, updated?: false) unless requires_update?
|
||||
|
||||
Account.transaction do
|
||||
if date == Date.current
|
||||
account.balance = balance
|
||||
account.currency = currency if currency.present?
|
||||
account.save!
|
||||
end
|
||||
|
||||
valuation_entry = account.entries.valuations.find_or_initialize_by(date: date) do |entry|
|
||||
entry.entryable = Valuation.new
|
||||
end
|
||||
|
||||
valuation_entry.amount = balance
|
||||
valuation_entry.currency = currency if currency.present?
|
||||
valuation_entry.name = "Manual #{account.accountable.balance_display_name} update"
|
||||
valuation_entry.notes = notes if notes.present?
|
||||
valuation_entry.save!
|
||||
end
|
||||
|
||||
account.sync_later
|
||||
|
||||
Result.new(success?: true, updated?: true)
|
||||
rescue => e
|
||||
message = Rails.env.development? ? e.message : "Unable to update account values. Please try again."
|
||||
Result.new(success?: false, updated?: false, error_message: message)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :balance, :currency, :date, :notes
|
||||
|
||||
Result = Struct.new(:success?, :updated?, :error_message)
|
||||
|
||||
def requires_update?
|
||||
date != Date.current || account.balance != balance || account.currency != currency
|
||||
end
|
||||
end
|
141
app/models/account/current_balance_manager.rb
Normal file
141
app/models/account/current_balance_manager.rb
Normal file
|
@ -0,0 +1,141 @@
|
|||
class Account::CurrentBalanceManager
|
||||
InvalidOperation = Class.new(StandardError)
|
||||
|
||||
Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def has_current_anchor?
|
||||
current_anchor_valuation.present?
|
||||
end
|
||||
|
||||
# Our system should always make sure there is a current anchor, and that it is up to date.
|
||||
# The fallback is provided for backwards compatibility, but should not be relied on since account.balance is a "cached/derived" value.
|
||||
def current_balance
|
||||
if current_anchor_valuation
|
||||
current_anchor_valuation.entry.amount
|
||||
else
|
||||
Rails.logger.warn "No current balance anchor found for account #{account.id}. Using cached balance instead, which may be out of date."
|
||||
account.balance
|
||||
end
|
||||
end
|
||||
|
||||
def current_date
|
||||
if current_anchor_valuation
|
||||
current_anchor_valuation.entry.date
|
||||
else
|
||||
Date.current
|
||||
end
|
||||
end
|
||||
|
||||
def set_current_balance(balance)
|
||||
if account.linked?
|
||||
result = set_current_balance_for_linked_account(balance)
|
||||
else
|
||||
result = set_current_balance_for_manual_account(balance)
|
||||
end
|
||||
|
||||
# Update cache field so changes appear immediately to the user
|
||||
account.update!(balance: balance)
|
||||
|
||||
result
|
||||
rescue => e
|
||||
Result.new(success?: false, changes_made?: false, error: e.message)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account
|
||||
|
||||
def opening_balance_manager
|
||||
@opening_balance_manager ||= Account::OpeningBalanceManager.new(account)
|
||||
end
|
||||
|
||||
def reconciliation_manager
|
||||
@reconciliation_manager ||= Account::ReconciliationManager.new(account)
|
||||
end
|
||||
|
||||
# Manual accounts do not manage the `current_anchor` valuation (otherwise, user would need to continually update it, which is bad UX)
|
||||
# Instead, we use a combination of "auto-update strategies" to set the current balance according to the user's intent.
|
||||
#
|
||||
# The "auto-update strategies" are:
|
||||
# 1. Value tracking - If the account has a reconciliation already, we assume they are tracking the account value primarily with reconciliations, so we append a new one
|
||||
# 2. Transaction adjustment - If the account doesn't have recons, we assume user is tracking with transactions, so we adjust the opening balance with a delta until it
|
||||
# gets us to the desired balance. This ensures we don't append unnecessary reconciliations to the account, which "reset" the value from that
|
||||
# date forward (not user's intent).
|
||||
#
|
||||
# For more documentation on these auto-update strategies, see the test cases.
|
||||
def set_current_balance_for_manual_account(balance)
|
||||
# If we're dealing with a cash account that has no reconciliations, use "Transaction adjustment" strategy (update opening balance to "back in" to the desired current balance)
|
||||
if account.balance_type == :cash && account.valuations.reconciliation.empty?
|
||||
adjust_opening_balance_with_delta(new_balance: balance, old_balance: account.balance)
|
||||
else
|
||||
existing_reconciliation = account.entries.valuations.find_by(date: Date.current)
|
||||
|
||||
result = reconciliation_manager.reconcile_balance(balance: balance, date: Date.current, existing_valuation_entry: existing_reconciliation)
|
||||
|
||||
# Normalize to expected result format
|
||||
Result.new(success?: result.success?, changes_made?: true, error: result.error_message)
|
||||
end
|
||||
end
|
||||
|
||||
def adjust_opening_balance_with_delta(new_balance:, old_balance:)
|
||||
delta = new_balance - old_balance
|
||||
|
||||
result = opening_balance_manager.set_opening_balance(balance: account.opening_anchor_balance + delta)
|
||||
|
||||
# Normalize to expected result format
|
||||
Result.new(success?: result.success?, changes_made?: true, error: result.error)
|
||||
end
|
||||
|
||||
# Linked accounts manage "current balance" via the special `current_anchor` valuation.
|
||||
# This is NOT a user-facing feature, and is primarily used in "processors" while syncing
|
||||
# linked account data (e.g. via Plaid)
|
||||
def set_current_balance_for_linked_account(balance)
|
||||
if current_anchor_valuation
|
||||
changes_made = update_current_anchor(balance)
|
||||
Result.new(success?: true, changes_made?: changes_made, error: nil)
|
||||
else
|
||||
create_current_anchor(balance)
|
||||
Result.new(success?: true, changes_made?: true, error: nil)
|
||||
end
|
||||
end
|
||||
|
||||
def current_anchor_valuation
|
||||
@current_anchor_valuation ||= account.valuations.current_anchor.includes(:entry).first
|
||||
end
|
||||
|
||||
def create_current_anchor(balance)
|
||||
account.entries.create!(
|
||||
date: Date.current,
|
||||
name: Valuation.build_current_anchor_name(account.accountable_type),
|
||||
amount: balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new(kind: "current_anchor")
|
||||
)
|
||||
end
|
||||
|
||||
def update_current_anchor(balance)
|
||||
changes_made = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Update associated entry attributes
|
||||
entry = current_anchor_valuation.entry
|
||||
|
||||
if entry.amount != balance
|
||||
entry.amount = balance
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
if entry.date != Date.current
|
||||
entry.date = Date.current
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
entry.save! if entry.changed?
|
||||
end
|
||||
|
||||
changes_made
|
||||
end
|
||||
end
|
|
@ -15,4 +15,5 @@ module Account::Linkable
|
|||
def unlinked?
|
||||
!linked?
|
||||
end
|
||||
alias_method :manual?, :unlinked?
|
||||
end
|
||||
|
|
99
app/models/account/opening_balance_manager.rb
Normal file
99
app/models/account/opening_balance_manager.rb
Normal file
|
@ -0,0 +1,99 @@
|
|||
class Account::OpeningBalanceManager
|
||||
Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def has_opening_anchor?
|
||||
opening_anchor_valuation.present?
|
||||
end
|
||||
|
||||
# Most accounts should have an opening anchor. If not, we derive the opening date from the oldest entry date
|
||||
def opening_date
|
||||
return opening_anchor_valuation.entry.date if opening_anchor_valuation.present?
|
||||
|
||||
[
|
||||
account.entries.valuations.order(:date).first&.date,
|
||||
account.entries.where.not(entryable_type: "Valuation").order(:date).first&.date&.prev_day
|
||||
].compact.min || Date.current
|
||||
end
|
||||
|
||||
def opening_balance
|
||||
opening_anchor_valuation&.entry&.amount || 0
|
||||
end
|
||||
|
||||
def set_opening_balance(balance:, date: nil)
|
||||
resolved_date = date || default_date
|
||||
|
||||
# Validate date is before oldest entry
|
||||
if date && oldest_entry_date && resolved_date >= oldest_entry_date
|
||||
return Result.new(success?: false, changes_made?: false, error: "Opening balance date must be before the oldest entry date")
|
||||
end
|
||||
|
||||
if opening_anchor_valuation.nil?
|
||||
create_opening_anchor(
|
||||
balance: balance,
|
||||
date: resolved_date
|
||||
)
|
||||
Result.new(success?: true, changes_made?: true, error: nil)
|
||||
else
|
||||
changes_made = update_opening_anchor(balance: balance, date: date)
|
||||
Result.new(success?: true, changes_made?: changes_made, error: nil)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account
|
||||
|
||||
def opening_anchor_valuation
|
||||
@opening_anchor_valuation ||= account.valuations.opening_anchor.includes(:entry).first
|
||||
end
|
||||
|
||||
def oldest_entry_date
|
||||
@oldest_entry_date ||= account.entries.minimum(:date)
|
||||
end
|
||||
|
||||
def default_date
|
||||
if oldest_entry_date
|
||||
[ oldest_entry_date - 1.day, 2.years.ago.to_date ].min
|
||||
else
|
||||
2.years.ago.to_date
|
||||
end
|
||||
end
|
||||
|
||||
def create_opening_anchor(balance:, date:)
|
||||
account.entries.create!(
|
||||
date: date,
|
||||
name: Valuation.build_opening_anchor_name(account.accountable_type),
|
||||
amount: balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new(
|
||||
kind: "opening_anchor"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def update_opening_anchor(balance:, date: nil)
|
||||
changes_made = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Update associated entry attributes
|
||||
entry = opening_anchor_valuation.entry
|
||||
|
||||
if entry.amount != balance
|
||||
entry.amount = balance
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
if date.present? && entry.date != date
|
||||
entry.date = date
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
entry.save! if entry.changed?
|
||||
end
|
||||
|
||||
changes_made
|
||||
end
|
||||
end
|
20
app/models/account/reconcileable.rb
Normal file
20
app/models/account/reconcileable.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
module Account::Reconcileable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def create_reconciliation(balance:, date:, dry_run: false)
|
||||
result = reconciliation_manager.reconcile_balance(balance: balance, date: date, dry_run: dry_run)
|
||||
sync_later if result.success? && !dry_run
|
||||
result
|
||||
end
|
||||
|
||||
def update_reconciliation(existing_valuation_entry, balance:, date:, dry_run: false)
|
||||
result = reconciliation_manager.reconcile_balance(balance: balance, date: date, existing_valuation_entry: existing_valuation_entry, dry_run: dry_run)
|
||||
sync_later if result.success? && !dry_run
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
def reconciliation_manager
|
||||
@reconciliation_manager ||= Account::ReconciliationManager.new(self)
|
||||
end
|
||||
end
|
89
app/models/account/reconciliation_manager.rb
Normal file
89
app/models/account/reconciliation_manager.rb
Normal file
|
@ -0,0 +1,89 @@
|
|||
class Account::ReconciliationManager
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
# Reconciles balance by creating a Valuation entry. If existing valuation is provided, it will be updated instead of creating a new one.
|
||||
def reconcile_balance(balance:, date: Date.current, dry_run: false, existing_valuation_entry: nil)
|
||||
old_balance_components = old_balance_components(reconciliation_date: date, existing_valuation_entry: existing_valuation_entry)
|
||||
prepared_valuation = prepare_reconciliation(balance, date, existing_valuation_entry)
|
||||
|
||||
unless dry_run
|
||||
prepared_valuation.save!
|
||||
end
|
||||
|
||||
ReconciliationResult.new(
|
||||
success?: true,
|
||||
old_cash_balance: old_balance_components[:cash_balance],
|
||||
old_balance: old_balance_components[:balance],
|
||||
new_cash_balance: derived_cash_balance(date: date, total_balance: prepared_valuation.amount),
|
||||
new_balance: prepared_valuation.amount,
|
||||
error_message: nil
|
||||
)
|
||||
rescue => e
|
||||
ReconciliationResult.new(
|
||||
success?: false,
|
||||
error_message: e.message
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
# Returns before -> after OR error message
|
||||
ReconciliationResult = Struct.new(
|
||||
:success?,
|
||||
:old_cash_balance,
|
||||
:old_balance,
|
||||
:new_cash_balance,
|
||||
:new_balance,
|
||||
:error_message,
|
||||
keyword_init: true
|
||||
)
|
||||
|
||||
def prepare_reconciliation(balance, date, existing_valuation)
|
||||
valuation_record = existing_valuation ||
|
||||
account.entries.valuations.find_by(date: date) || # In case of conflict, where existing valuation is not passed as arg, but one exists
|
||||
account.entries.build(
|
||||
name: Valuation.build_reconciliation_name(account.accountable_type),
|
||||
entryable: Valuation.new(kind: "reconciliation")
|
||||
)
|
||||
|
||||
valuation_record.assign_attributes(
|
||||
date: date,
|
||||
amount: balance,
|
||||
currency: account.currency
|
||||
)
|
||||
|
||||
valuation_record
|
||||
end
|
||||
|
||||
def derived_cash_balance(date:, total_balance:)
|
||||
balance_components_for_reconciliation_date = get_balance_components_for_date(date)
|
||||
|
||||
return nil unless balance_components_for_reconciliation_date[:balance] && balance_components_for_reconciliation_date[:cash_balance]
|
||||
|
||||
# We calculate the existing non-cash balance, which for investments would represents "holdings" for the date of reconciliation
|
||||
# Since the user is setting "total balance", we have to subtract the existing non-cash balance from the total balance to get the new cash balance
|
||||
existing_non_cash_balance = balance_components_for_reconciliation_date[:balance] - balance_components_for_reconciliation_date[:cash_balance]
|
||||
|
||||
total_balance - existing_non_cash_balance
|
||||
end
|
||||
|
||||
def old_balance_components(reconciliation_date:, existing_valuation_entry: nil)
|
||||
if existing_valuation_entry
|
||||
get_balance_components_for_date(existing_valuation_entry.date)
|
||||
else
|
||||
get_balance_components_for_date(reconciliation_date)
|
||||
end
|
||||
end
|
||||
|
||||
def get_balance_components_for_date(date)
|
||||
balance_record = account.balances.find_by(date: date, currency: account.currency)
|
||||
|
||||
{
|
||||
cash_balance: balance_record&.cash_balance,
|
||||
balance: balance_record&.balance
|
||||
}
|
||||
end
|
||||
end
|
|
@ -1,4 +1,6 @@
|
|||
class AccountImport < Import
|
||||
OpeningBalanceError = Class.new(StandardError)
|
||||
|
||||
def import!
|
||||
transaction do
|
||||
rows.each do |row|
|
||||
|
@ -15,13 +17,13 @@ class AccountImport < Import
|
|||
|
||||
account.save!
|
||||
|
||||
account.entries.create!(
|
||||
amount: row.amount,
|
||||
currency: row.currency,
|
||||
date: Date.current,
|
||||
name: "Imported account value",
|
||||
entryable: Valuation.new
|
||||
)
|
||||
manager = Account::OpeningBalanceManager.new(account)
|
||||
result = manager.set_opening_balance(balance: row.amount.to_d)
|
||||
|
||||
# Re-raise since we should never have an error here
|
||||
if result.error
|
||||
raise OpeningBalanceError, result.error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,7 +3,7 @@ class Balance < ApplicationRecord
|
|||
|
||||
belongs_to :account
|
||||
validates :account, :date, :balance, presence: true
|
||||
monetize :balance
|
||||
monetize :balance, :cash_balance
|
||||
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
|
||||
scope :chronological, -> { order(:date) }
|
||||
end
|
||||
|
|
69
app/models/balance/base_calculator.rb
Normal file
69
app/models/balance/base_calculator.rb
Normal file
|
@ -0,0 +1,69 @@
|
|||
class Balance::BaseCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def calculate
|
||||
raise NotImplementedError, "Subclasses must implement this method"
|
||||
end
|
||||
|
||||
private
|
||||
def sync_cache
|
||||
@sync_cache ||= Balance::SyncCache.new(account)
|
||||
end
|
||||
|
||||
def holdings_value_for_date(date)
|
||||
holdings = sync_cache.get_holdings(date)
|
||||
holdings.sum(&:amount)
|
||||
end
|
||||
|
||||
def derive_cash_balance_on_date_from_total(total_balance:, date:)
|
||||
if account.balance_type == :investment
|
||||
total_balance - holdings_value_for_date(date)
|
||||
elsif account.balance_type == :cash
|
||||
total_balance
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
def derive_cash_balance(cash_balance, date)
|
||||
entries = sync_cache.get_entries(date)
|
||||
|
||||
if account.balance_type == :non_cash
|
||||
0
|
||||
else
|
||||
cash_balance + signed_entry_flows(entries)
|
||||
end
|
||||
end
|
||||
|
||||
def derive_non_cash_balance(non_cash_balance, date, direction: :forward)
|
||||
entries = sync_cache.get_entries(date)
|
||||
# Loans are a special case (loan payment reducing principal, which is non-cash)
|
||||
if account.balance_type == :non_cash && account.accountable_type == "Loan"
|
||||
non_cash_balance + signed_entry_flows(entries)
|
||||
elsif account.balance_type == :investment
|
||||
# For reverse calculations, we need the previous day's holdings
|
||||
target_date = direction == :forward ? date : date.prev_day
|
||||
holdings_value_for_date(target_date)
|
||||
else
|
||||
non_cash_balance
|
||||
end
|
||||
end
|
||||
|
||||
def signed_entry_flows(entries)
|
||||
raise NotImplementedError, "Directional calculators must implement this method"
|
||||
end
|
||||
|
||||
def build_balance(date:, cash_balance:, non_cash_balance:)
|
||||
Balance.new(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
balance: non_cash_balance + cash_balance,
|
||||
cash_balance: cash_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
end
|
||||
end
|
|
@ -1,61 +1,66 @@
|
|||
class Balance::ForwardCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
class Balance::ForwardCalculator < Balance::BaseCalculator
|
||||
def calculate
|
||||
Rails.logger.tagged("Balance::ForwardCalculator") do
|
||||
calculate_balances
|
||||
start_cash_balance = derive_cash_balance_on_date_from_total(
|
||||
total_balance: account.opening_anchor_balance,
|
||||
date: account.opening_anchor_date
|
||||
)
|
||||
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
|
||||
|
||||
calc_start_date.upto(calc_end_date).map do |date|
|
||||
valuation = sync_cache.get_reconciliation_valuation(date)
|
||||
|
||||
if valuation
|
||||
end_cash_balance = derive_cash_balance_on_date_from_total(
|
||||
total_balance: valuation.amount,
|
||||
date: date
|
||||
)
|
||||
end_non_cash_balance = valuation.amount - end_cash_balance
|
||||
else
|
||||
end_cash_balance = derive_end_cash_balance(start_cash_balance: start_cash_balance, date: date)
|
||||
end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date)
|
||||
end
|
||||
|
||||
output_balance = build_balance(
|
||||
date: date,
|
||||
cash_balance: end_cash_balance,
|
||||
non_cash_balance: end_non_cash_balance
|
||||
)
|
||||
|
||||
# Set values for the next iteration
|
||||
start_cash_balance = end_cash_balance
|
||||
start_non_cash_balance = end_non_cash_balance
|
||||
|
||||
output_balance
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_balances
|
||||
current_cash_balance = 0
|
||||
next_cash_balance = nil
|
||||
|
||||
@balances = []
|
||||
|
||||
account.start_date.upto(Date.current).each do |date|
|
||||
entries = sync_cache.get_entries(date)
|
||||
holdings = sync_cache.get_holdings(date)
|
||||
holdings_value = holdings.sum(&:amount)
|
||||
valuation = sync_cache.get_valuation(date)
|
||||
|
||||
next_cash_balance = if valuation
|
||||
valuation.amount - holdings_value
|
||||
else
|
||||
calculate_next_balance(current_cash_balance, entries, direction: :forward)
|
||||
end
|
||||
|
||||
@balances << build_balance(date, next_cash_balance, holdings_value)
|
||||
|
||||
current_cash_balance = next_cash_balance
|
||||
end
|
||||
|
||||
@balances
|
||||
def calc_start_date
|
||||
account.opening_anchor_date
|
||||
end
|
||||
|
||||
def sync_cache
|
||||
@sync_cache ||= Balance::SyncCache.new(account)
|
||||
def calc_end_date
|
||||
[ account.entries.order(:date).last&.date, account.holdings.order(:date).last&.date ].compact.max || Date.current
|
||||
end
|
||||
|
||||
def build_balance(date, cash_balance, holdings_value)
|
||||
Balance.new(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
balance: holdings_value + cash_balance,
|
||||
cash_balance: cash_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
# Negative entries amount on an "asset" account means, "account value has increased"
|
||||
# Negative entries amount on a "liability" account means, "account debt has decreased"
|
||||
# Positive entries amount on an "asset" account means, "account value has decreased"
|
||||
# Positive entries amount on a "liability" account means, "account debt has increased"
|
||||
def signed_entry_flows(entries)
|
||||
entry_flows = entries.sum(&:amount)
|
||||
account.asset? ? -entry_flows : entry_flows
|
||||
end
|
||||
|
||||
def calculate_next_balance(prior_balance, transactions, direction: :forward)
|
||||
flows = transactions.sum(&:amount)
|
||||
negated = direction == :forward ? account.asset? : account.liability?
|
||||
flows *= -1 if negated
|
||||
prior_balance + flows
|
||||
# Derives cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
|
||||
def derive_end_cash_balance(start_cash_balance:, date:)
|
||||
derive_cash_balance(start_cash_balance, date)
|
||||
end
|
||||
|
||||
# Derives non-cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
|
||||
def derive_end_non_cash_balance(start_non_cash_balance:, date:)
|
||||
derive_non_cash_balance(start_non_cash_balance, date, direction: :forward)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,71 +1,79 @@
|
|||
class Balance::ReverseCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
class Balance::ReverseCalculator < Balance::BaseCalculator
|
||||
def calculate
|
||||
Rails.logger.tagged("Balance::ReverseCalculator") do
|
||||
calculate_balances
|
||||
# Since it's a reverse sync, we're starting with the "end of day" balance components and
|
||||
# calculating backwards to derive the "start of day" balance components.
|
||||
end_cash_balance = derive_cash_balance_on_date_from_total(
|
||||
total_balance: account.current_anchor_balance,
|
||||
date: account.current_anchor_date
|
||||
)
|
||||
end_non_cash_balance = account.current_anchor_balance - end_cash_balance
|
||||
|
||||
# Calculates in reverse-chronological order (End of day -> Start of day)
|
||||
account.current_anchor_date.downto(account.opening_anchor_date).map do |date|
|
||||
if use_opening_anchor_for_date?(date)
|
||||
end_cash_balance = derive_cash_balance_on_date_from_total(
|
||||
total_balance: account.opening_anchor_balance,
|
||||
date: date
|
||||
)
|
||||
end_non_cash_balance = account.opening_anchor_balance - end_cash_balance
|
||||
|
||||
start_cash_balance = end_cash_balance
|
||||
start_non_cash_balance = end_non_cash_balance
|
||||
|
||||
build_balance(
|
||||
date: date,
|
||||
cash_balance: end_cash_balance,
|
||||
non_cash_balance: end_non_cash_balance
|
||||
)
|
||||
else
|
||||
start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)
|
||||
start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)
|
||||
|
||||
# Even though we've just calculated "start" balances, we set today equal to end of day, then use those
|
||||
# in our next iteration (slightly confusing, but just the nature of a "reverse" sync)
|
||||
output_balance = build_balance(
|
||||
date: date,
|
||||
cash_balance: end_cash_balance,
|
||||
non_cash_balance: end_non_cash_balance
|
||||
)
|
||||
|
||||
end_cash_balance = start_cash_balance
|
||||
end_non_cash_balance = start_non_cash_balance
|
||||
|
||||
output_balance
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_balances
|
||||
current_cash_balance = account.cash_balance
|
||||
previous_cash_balance = nil
|
||||
|
||||
@balances = []
|
||||
|
||||
Date.current.downto(account.start_date).map do |date|
|
||||
entries = sync_cache.get_entries(date)
|
||||
holdings = sync_cache.get_holdings(date)
|
||||
holdings_value = holdings.sum(&:amount)
|
||||
valuation = sync_cache.get_valuation(date)
|
||||
|
||||
previous_cash_balance = if valuation
|
||||
valuation.amount - holdings_value
|
||||
else
|
||||
calculate_next_balance(current_cash_balance, entries, direction: :reverse)
|
||||
end
|
||||
|
||||
if valuation.present?
|
||||
@balances << build_balance(date, previous_cash_balance, holdings_value)
|
||||
else
|
||||
# If date is today, we don't distinguish cash vs. total since provider's are inconsistent with treatment
|
||||
# of the cash component. Instead, just set the balance equal to the "total value" reported by the provider
|
||||
if date == Date.current
|
||||
@balances << build_balance(date, account.cash_balance, account.balance - account.cash_balance)
|
||||
else
|
||||
@balances << build_balance(date, current_cash_balance, holdings_value)
|
||||
end
|
||||
end
|
||||
|
||||
current_cash_balance = previous_cash_balance
|
||||
end
|
||||
|
||||
@balances
|
||||
# Negative entries amount on an "asset" account means, "account value has increased"
|
||||
# Negative entries amount on a "liability" account means, "account debt has decreased"
|
||||
# Positive entries amount on an "asset" account means, "account value has decreased"
|
||||
# Positive entries amount on a "liability" account means, "account debt has increased"
|
||||
def signed_entry_flows(entries)
|
||||
entry_flows = entries.sum(&:amount)
|
||||
account.asset? ? entry_flows : -entry_flows
|
||||
end
|
||||
|
||||
def sync_cache
|
||||
@sync_cache ||= Balance::SyncCache.new(account)
|
||||
# Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
|
||||
# to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed
|
||||
# explanation, see the test suite.
|
||||
def use_opening_anchor_for_date?(date)
|
||||
account.has_opening_anchor? && date == account.opening_anchor_date
|
||||
end
|
||||
|
||||
def build_balance(date, cash_balance, holdings_value)
|
||||
Balance.new(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
balance: holdings_value + cash_balance,
|
||||
cash_balance: cash_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
# Alias method, for algorithmic clarity
|
||||
# Derives cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
|
||||
def derive_start_cash_balance(end_cash_balance:, date:)
|
||||
derive_cash_balance(end_cash_balance, date)
|
||||
end
|
||||
|
||||
def calculate_next_balance(prior_balance, transactions, direction: :forward)
|
||||
flows = transactions.sum(&:amount)
|
||||
negated = direction == :forward ? account.asset? : account.liability?
|
||||
flows *= -1 if negated
|
||||
prior_balance + flows
|
||||
# Alias method, for algorithmic clarity
|
||||
# Derives non-cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
|
||||
def derive_start_non_cash_balance(end_non_cash_balance:, date:)
|
||||
derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,8 +3,8 @@ class Balance::SyncCache
|
|||
@account = account
|
||||
end
|
||||
|
||||
def get_valuation(date)
|
||||
converted_entries.find { |e| e.date == date && e.valuation? }
|
||||
def get_reconciliation_valuation(date)
|
||||
converted_entries.find { |e| e.date == date && e.valuation? && e.valuation.reconciliation? }
|
||||
end
|
||||
|
||||
def get_holdings(date)
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
# The current system calculates a single, end-of-day balance every day for each account for simplicity.
|
||||
# In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances
|
||||
# to show users how each entry affects their balances. This class calculates intraday balances by
|
||||
# interpolating between end-of-day balances.
|
||||
class Balance::TrendCalculator
|
||||
BalanceTrend = Struct.new(:trend, :cash, keyword_init: true)
|
||||
|
||||
def initialize(balances)
|
||||
@balances = balances
|
||||
end
|
||||
|
||||
def trend_for(date)
|
||||
balance = @balances.find { |b| b.date == date }
|
||||
prior_balance = @balances.find { |b| b.date == date - 1.day }
|
||||
|
||||
return BalanceTrend.new(trend: nil) unless balance.present?
|
||||
|
||||
BalanceTrend.new(
|
||||
trend: Trend.new(
|
||||
current: Money.new(balance.balance, balance.currency),
|
||||
previous: Money.new(prior_balance.balance, balance.currency),
|
||||
favorable_direction: balance.account.favorable_direction
|
||||
),
|
||||
cash: Money.new(balance.cash_balance, balance.currency),
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :balances
|
||||
end
|
|
@ -47,7 +47,7 @@ module Syncable
|
|||
end
|
||||
|
||||
def sync_error
|
||||
latest_sync&.error
|
||||
latest_sync&.error || latest_sync&.children&.map(&:error)&.compact&.first
|
||||
end
|
||||
|
||||
def last_synced_at
|
||||
|
|
|
@ -1174,42 +1174,42 @@ class Demo::Generator
|
|||
|
||||
# Property valuations (these accounts are valued, not transaction-driven)
|
||||
@home.entries.create!(
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "current_anchor"),
|
||||
amount: 350_000,
|
||||
name: "Current Market Value",
|
||||
name: Valuation.build_current_anchor_name(@home.accountable_type),
|
||||
currency: "USD",
|
||||
date: Date.current
|
||||
)
|
||||
|
||||
# Vehicle valuations (these depreciate over time)
|
||||
@honda_accord.entries.create!(
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "current_anchor"),
|
||||
amount: 18_000,
|
||||
name: "Current Market Value",
|
||||
name: Valuation.build_current_anchor_name(@honda_accord.accountable_type),
|
||||
currency: "USD",
|
||||
date: Date.current
|
||||
)
|
||||
|
||||
@tesla_model3.entries.create!(
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "current_anchor"),
|
||||
amount: 4_500,
|
||||
name: "Current Market Value",
|
||||
name: Valuation.build_current_anchor_name(@tesla_model3.accountable_type),
|
||||
currency: "USD",
|
||||
date: Date.current
|
||||
)
|
||||
|
||||
@jewelry.entries.create!(
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "reconciliation"),
|
||||
amount: 2000,
|
||||
name: "Current Market Value",
|
||||
name: Valuation.build_reconciliation_name(@jewelry.accountable_type),
|
||||
currency: "USD",
|
||||
date: 90.days.ago.to_date
|
||||
)
|
||||
|
||||
@personal_loc.entries.create!(
|
||||
entryable: Valuation.new,
|
||||
entryable: Valuation.new(kind: "reconciliation"),
|
||||
amount: 800,
|
||||
name: "Owed",
|
||||
name: Valuation.build_reconciliation_name(@personal_loc.accountable_type),
|
||||
currency: "USD",
|
||||
date: 120.days.ago.to_date
|
||||
)
|
||||
|
|
|
@ -51,6 +51,13 @@ class PlaidAccount::Processor
|
|||
)
|
||||
|
||||
account.save!
|
||||
|
||||
# Create or update the current balance anchor valuation for event-sourced ledger
|
||||
# Note: This is a partial implementation. In the future, we'll introduce HoldingValuation
|
||||
# to properly track the holdings vs. cash breakdown, but for now we're only tracking
|
||||
# the total balance in the current anchor. The cash_balance field on the account model
|
||||
# is still being used for the breakdown.
|
||||
account.set_current_balance(balance_calculator.balance)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -8,6 +8,13 @@ class Trade < ApplicationRecord
|
|||
validates :qty, presence: true
|
||||
validates :price, :currency, presence: true
|
||||
|
||||
class << self
|
||||
def build_name(type, qty, ticker)
|
||||
prefix = type == "buy" ? "Buy" : "Sell"
|
||||
"#{prefix} #{qty.to_d.abs} shares of #{ticker}"
|
||||
end
|
||||
end
|
||||
|
||||
def unrealized_gain_loss
|
||||
return nil if qty.negative?
|
||||
current_price = security.current_price
|
||||
|
|
|
@ -29,13 +29,11 @@ class Trade::CreateForm
|
|||
end
|
||||
|
||||
def create_trade
|
||||
prefix = type == "sell" ? "Sell " : "Buy "
|
||||
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
||||
signed_qty = type == "sell" ? -qty.to_d : qty.to_d
|
||||
signed_amount = signed_qty * price.to_d
|
||||
|
||||
trade_entry = account.entries.new(
|
||||
name: trade_name,
|
||||
name: Trade.build_name(type, qty, security.ticker),
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
|
|
|
@ -1,3 +1,23 @@
|
|||
class Valuation < ApplicationRecord
|
||||
include Entryable
|
||||
|
||||
enum :kind, {
|
||||
reconciliation: "reconciliation",
|
||||
opening_anchor: "opening_anchor",
|
||||
current_anchor: "current_anchor"
|
||||
}, validate: true, default: "reconciliation"
|
||||
|
||||
class << self
|
||||
def build_reconciliation_name(accountable_type)
|
||||
Valuation::Name.new("reconciliation", accountable_type).to_s
|
||||
end
|
||||
|
||||
def build_opening_anchor_name(accountable_type)
|
||||
Valuation::Name.new("opening_anchor", accountable_type).to_s
|
||||
end
|
||||
|
||||
def build_current_anchor_name(accountable_type)
|
||||
Valuation::Name.new("current_anchor", accountable_type).to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
57
app/models/valuation/name.rb
Normal file
57
app/models/valuation/name.rb
Normal file
|
@ -0,0 +1,57 @@
|
|||
class Valuation::Name
|
||||
def initialize(valuation_kind, accountable_type)
|
||||
@valuation_kind = valuation_kind
|
||||
@accountable_type = accountable_type
|
||||
end
|
||||
|
||||
def to_s
|
||||
case valuation_kind
|
||||
when "opening_anchor"
|
||||
opening_anchor_name
|
||||
when "current_anchor"
|
||||
current_anchor_name
|
||||
else
|
||||
recon_name
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :valuation_kind, :accountable_type
|
||||
|
||||
def opening_anchor_name
|
||||
case accountable_type
|
||||
when "Property", "Vehicle"
|
||||
"Original purchase price"
|
||||
when "Loan"
|
||||
"Original principal"
|
||||
when "Investment", "Crypto", "OtherAsset"
|
||||
"Opening account value"
|
||||
else
|
||||
"Opening balance"
|
||||
end
|
||||
end
|
||||
|
||||
def current_anchor_name
|
||||
case accountable_type
|
||||
when "Property", "Vehicle"
|
||||
"Current market value"
|
||||
when "Loan"
|
||||
"Current loan balance"
|
||||
when "Investment", "Crypto", "OtherAsset"
|
||||
"Current account value"
|
||||
else
|
||||
"Current balance"
|
||||
end
|
||||
end
|
||||
|
||||
def recon_name
|
||||
case accountable_type
|
||||
when "Property", "Investment", "Vehicle", "Crypto", "OtherAsset"
|
||||
"Manual value update"
|
||||
when "Loan"
|
||||
"Manual principal update"
|
||||
else
|
||||
"Manual balance update"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -41,7 +41,7 @@
|
|||
<% end %>
|
||||
|
||||
<% if account.draft? %>
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "Complete setup",
|
||||
href: edit_account_path(account, return_to: return_to),
|
||||
variant: :outline,
|
||||
|
@ -49,7 +49,7 @@
|
|||
) %>
|
||||
<% elsif account.active? || account.disabled? %>
|
||||
<%= form_with model: account, url: toggle_active_account_path(account), method: :patch, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
|
||||
<%= render ToggleComponent.new(
|
||||
<%= render DS::Toggle.new(
|
||||
id: "account_#{account.id}_active",
|
||||
name: "active",
|
||||
checked: account.active?,
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
</details>
|
||||
<% end %>
|
||||
|
||||
<%= render TabsComponent.new(active_tab: active_tab, session_key: "account_sidebar_tab", testid: "account-sidebar-tabs") do |tabs| %>
|
||||
<%= render DS::Tabs.new(active_tab: active_tab, session_key: "account_sidebar_tab", testid: "account-sidebar-tabs") do |tabs| %>
|
||||
<% tabs.with_nav do |nav| %>
|
||||
<% nav.with_btn(id: "asset", label: "Assets") %>
|
||||
<% nav.with_btn(id: "liability", label: "Debts") %>
|
||||
|
@ -30,7 +30,7 @@
|
|||
|
||||
<% tabs.with_panel(tab_id: "asset") do %>
|
||||
<div class="space-y-2">
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "New asset",
|
||||
variant: "ghost",
|
||||
href: new_account_path(step: "method_select", classification: "asset"),
|
||||
|
@ -50,7 +50,7 @@
|
|||
|
||||
<% tabs.with_panel(tab_id: "liability") do %>
|
||||
<div class="space-y-2">
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "New debt",
|
||||
variant: "ghost",
|
||||
href: new_account_path(step: "method_select", classification: "liability"),
|
||||
|
@ -70,7 +70,7 @@
|
|||
|
||||
<% tabs.with_panel(tab_id: "all") do %>
|
||||
<div class="space-y-2">
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "New account",
|
||||
variant: "ghost",
|
||||
full_width: true,
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<%= link_to new_polymorphic_path(accountable, step: "method_select", return_to: params[:return_to]),
|
||||
class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %>
|
||||
<%= render FilledIconComponent.new(
|
||||
<%= render DS::FilledIcon.new(
|
||||
icon: accountable.icon,
|
||||
hex_color: accountable.color,
|
||||
) %>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<div id="<%= account_group.dom_id(tab: all_tab ? :all : nil, mobile: mobile) %>">
|
||||
<% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %>
|
||||
<%= render DisclosureComponent.new(align: :left, open: is_open) do |disclosure| %>
|
||||
<%= render DS::Disclosure.new(align: :left, open: is_open) do |disclosure| %>
|
||||
<% disclosure.with_summary_content do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
|
@ -51,7 +51,7 @@
|
|||
</div>
|
||||
|
||||
<div class="my-2">
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
href: new_polymorphic_path(account_group.key, step: "method_select"),
|
||||
text: "New #{account_group.name.downcase.singularize}",
|
||||
icon: "plus",
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
<div class="px-4">
|
||||
<div class="bg-loader rounded-md h-5 w-32"></div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 h-60 flex items-center justify-center">
|
||||
<div class="bg-loader rounded-md h-full w-full"></div>
|
||||
</div>
|
|
@ -3,7 +3,7 @@
|
|||
<%= tag.p t(".no_accounts"), class: "text-primary mb-1 font-medium" %>
|
||||
<%= tag.p t(".empty_message"), class: "text-secondary mb-4" %>
|
||||
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: t(".new_account"),
|
||||
href: new_account_path,
|
||||
frame: :modal
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<%# locals: (account:, url:) %>
|
||||
|
||||
<% if @error_message.present? %>
|
||||
<%= render AlertComponent.new(message: @error_message, variant: :error) %>
|
||||
<%= render DS::Alert.new(message: @error_message, variant: :error) %>
|
||||
<% end %>
|
||||
|
||||
<%= styled_form_with model: account, url: url, scope: :account, data: { turbo: false }, class: "flex flex-col gap-4 justify-between grow text-primary" do |form| %>
|
||||
|
|
|
@ -12,5 +12,5 @@
|
|||
<% elsif account.logo.attached? %>
|
||||
<%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %>
|
||||
<% else %>
|
||||
<%= render FilledIconComponent.new(variant: :text, hex_color: color || account.accountable.color, text: account.name, size: size, rounded: true) %>
|
||||
<%= render DS::FilledIcon.new(variant: :text, hex_color: color || account.accountable.color, text: account.name, size: size, rounded: true) %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
<% series = @account.balance_series(period: @period, view: @chart_view) %>
|
||||
<% trend = series.trend %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
||||
<div class="px-4">
|
||||
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
|
||||
</div>
|
||||
|
||||
<div class="h-64 pb-4">
|
||||
<% if series.any? %>
|
||||
<div
|
||||
id="lineChart"
|
||||
class="w-full h-full"
|
||||
data-controller="time-series-chart"
|
||||
data-time-series-chart-data-value="<%= series.to_json %>"></div>
|
||||
<% else %>
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
|
@ -2,7 +2,7 @@
|
|||
<h1 class="text-xl"><%= t(".accounts") %></h1>
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "New account",
|
||||
href: new_account_path(return_to: accounts_path),
|
||||
variant: "primary",
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
<%= button_to imports_path(import: { type: "AccountImport" }),
|
||||
data: { turbo_frame: :_top },
|
||||
class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %>
|
||||
<%= render FilledIconComponent.new(
|
||||
<%= render DS::FilledIcon.new(
|
||||
icon: "download",
|
||||
hex_color: "#F79009",
|
||||
) %>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<%# locals: (title:, back_path: nil) %>
|
||||
|
||||
<%= render DialogComponent.new do |dialog| %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<div class="flex flex-col relative" data-controller="list-keyboard-navigation">
|
||||
<div class="border-b border-tertiary md:border-alpha-black-25 px-4 pb-4 text-gray-800 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<% if back_path %>
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "arrow-left",
|
||||
href: back_path,
|
||||
|
|
8
app/views/accounts/show.html.erb
Normal file
8
app/views/accounts/show.html.erb
Normal file
|
@ -0,0 +1,8 @@
|
|||
<%= render UI::AccountPage.new(
|
||||
account: @account,
|
||||
chart_view: @chart_view,
|
||||
chart_period: @period,
|
||||
active_tab: @tab
|
||||
) do |account_page| %>
|
||||
<%= account_page.with_activity_feed(feed_data: @activity_feed_data, pagy: @pagy, search: @q[:search]) %>
|
||||
<% end %>
|
|
@ -1,11 +1,11 @@
|
|||
<%# locals: (account:) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(account, "entries") do %>
|
||||
<div class="bg-container p-5 shadow-border-xs rounded-xl" data-controller="focus-record" data-focus-record-id-value="<%= @focused_record ? dom_id(@focused_record) : nil %>">
|
||||
<div class="bg-container p-5 shadow-border-xs rounded-xl">
|
||||
<div class="flex items-center justify-between mb-4" data-testid="activity-menu">
|
||||
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
|
||||
<% unless @account.plaid_account_id.present? %>
|
||||
<%= render MenuComponent.new(variant: "button") do |menu| %>
|
||||
<%= render DS::Menu.new(variant: "button") do |menu| %>
|
||||
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
|
||||
|
||||
<% menu.with_item(
|
||||
|
@ -76,11 +76,9 @@
|
|||
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<% calculator = Balance::TrendCalculator.new(@account.balances) %>
|
||||
|
||||
<%= entries_by_date(@entries) do |entries| %>
|
||||
<% entries.each_with_index do |entry, index| %>
|
||||
<%= render entry, balance_trend: index == 0 ? calculator.trend_for(entry.date) : nil, view_ctx: "account" %>
|
||||
<%= render entry, view_ctx: "account" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -1,36 +1,30 @@
|
|||
<%# locals: (account:, title: nil, subtitle: nil) %>
|
||||
<%# locals: (account:, title:, subtitle: nil) %>
|
||||
|
||||
<header class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<% content = yield %>
|
||||
<div class="flex items-center gap-3 overflow-hidden">
|
||||
<%= render "accounts/logo", account: account %>
|
||||
|
||||
<% if content.present? %>
|
||||
<%= content %>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-3 overflow-hidden">
|
||||
<%= render "accounts/logo", account: account %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="truncate">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title || account.name %></h2>
|
||||
<% if account.draft? %>
|
||||
<%= render LinkComponent.new(
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="truncate">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title %></h2>
|
||||
<% if account.draft? %>
|
||||
<%= render DS::Link.new(
|
||||
text: "Complete setup",
|
||||
href: edit_account_path(account),
|
||||
variant: :outline,
|
||||
size: :sm,
|
||||
frame: :modal
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if subtitle.present? %>
|
||||
<p class="text-sm text-secondary"><%= subtitle %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if subtitle.present? %>
|
||||
<p class="text-sm text-secondary"><%= subtitle %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<% if Rails.env.development? || self_hosted? %>
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
<div class="p-5">
|
||||
<p class="text-secondary animate-pulse">Loading account...</p>
|
||||
</div>
|
|
@ -1,6 +1,6 @@
|
|||
<%# locals: (account:) %>
|
||||
|
||||
<%= render MenuComponent.new(testid: "account-menu") do |menu| %>
|
||||
<%= render DS::Menu.new(testid: "account-menu") do |menu| %>
|
||||
<% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
|
||||
|
||||
<% unless account.crypto? %>
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<%# locals: (account:, key:, is_selected:) %>
|
||||
|
||||
<%= link_to key.titleize,
|
||||
account_path(account, tab: key),
|
||||
data: { turbo: false },
|
||||
class: [
|
||||
"px-2 py-1.5 rounded-md border border-transparent",
|
||||
"bg-container shadow-xs border-alpha-black-50": is_selected
|
||||
] %>
|
|
@ -1,17 +0,0 @@
|
|||
<%# locals: (account:, tabs:) %>
|
||||
|
||||
<% active_tab = tabs.find { |tab| tab[:key] == params[:tab] } || tabs.first %>
|
||||
|
||||
<%= render TabsComponent.new(active_tab: active_tab[:key], url_param_key: "tab") do |tabs_container| %>
|
||||
<% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
|
||||
<% tabs.each do |tab| %>
|
||||
<% nav.with_btn(id: tab[:key], label: tab[:key].humanize, classes: "px-6") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% tabs.each do |tab| %>
|
||||
<% tabs_container.with_panel(tab_id: tab[:key]) do %>
|
||||
<%= tab[:contents] %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
|
@ -1,27 +0,0 @@
|
|||
<%# locals: (account:, header: nil, chart: nil, chart_view: nil, tabs: nil) %>
|
||||
|
||||
<%= turbo_stream_from account %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(account, :container) do %>
|
||||
<%= tag.div class: "space-y-4 pb-32" do %>
|
||||
<% if header.present? %>
|
||||
<%= header %>
|
||||
<% else %>
|
||||
<%= render "accounts/show/header", account: account %>
|
||||
<% end %>
|
||||
|
||||
<% if chart.present? %>
|
||||
<%= chart %>
|
||||
<% else %>
|
||||
<%= render "accounts/show/chart", account: account, chart_view: chart_view %>
|
||||
<% end %>
|
||||
|
||||
<div class="min-h-[800px]" data-testid="account-details">
|
||||
<% if tabs.present? %>
|
||||
<%= tabs %>
|
||||
<% else %>
|
||||
<%= render "accounts/show/activity", account: account %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
|
@ -12,7 +12,7 @@
|
|||
<% if budget_category.category.lucide_icon %>
|
||||
<%= icon(budget_category.category.lucide_icon, color: "current") %>
|
||||
<% else %>
|
||||
<%= render FilledIconComponent.new(
|
||||
<%= render DS::FilledIcon.new(
|
||||
variant: :text,
|
||||
hex_color: budget_category.category.color,
|
||||
text: budget_category.category.name,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<div id="<%= dom_id(budget, :confirm_button) %>">
|
||||
<%= render ButtonComponent.new(
|
||||
<%= render DS::Button.new(
|
||||
text: "Confirm",
|
||||
variant: "primary",
|
||||
full_width: true,
|
||||
|
|
|
@ -6,12 +6,12 @@
|
|||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= render ButtonComponent.new(
|
||||
<%= render DS::Button.new(
|
||||
text: "Use defaults (recommended)",
|
||||
href: bootstrap_categories_path,
|
||||
) %>
|
||||
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "New category",
|
||||
variant: "outline",
|
||||
icon: "plus",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<%= render DialogComponent.new(variant: :drawer) do |dialog| %>
|
||||
<%= render DS::Dialog.new(variant: :drawer) do |dialog| %>
|
||||
<% dialog.with_header do %>
|
||||
<div>
|
||||
<p class="text-sm text-secondary">Category</p>
|
||||
|
@ -107,7 +107,7 @@
|
|||
<%= transaction.entry.date.strftime("%b %d") %>
|
||||
</p>
|
||||
<%= link_to transaction.entry.name,
|
||||
transactions_path(focused_record_id: transaction.id),
|
||||
transactions_path,
|
||||
class: "text-primary hover:underline",
|
||||
data: { turbo_frame: :_top } %>
|
||||
</div>
|
||||
|
@ -119,7 +119,7 @@
|
|||
<% end %>
|
||||
</ul>
|
||||
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "View all category transactions",
|
||||
variant: "outline",
|
||||
full_width: true,
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<%= format_money(budget.actual_spending_money) %>
|
||||
</div>
|
||||
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "of #{budget.budgeted_spending_money.format}",
|
||||
variant: "secondary",
|
||||
icon: "pencil",
|
||||
|
@ -25,7 +25,7 @@
|
|||
<span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span>
|
||||
</div>
|
||||
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "New budget",
|
||||
size: "sm",
|
||||
icon: "plus",
|
||||
|
@ -46,7 +46,7 @@
|
|||
<%= format_money(bc.actual_spending_money) %>
|
||||
</p>
|
||||
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "of #{bc.budgeted_spending_money.format(precision: 0)}",
|
||||
variant: "secondary",
|
||||
icon: "pencil",
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div class="flex items-center gap-1 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<% if budget.previous_budget_param %>
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "chevron-left",
|
||||
href: budget_path(budget.previous_budget_param),
|
||||
|
@ -15,7 +15,7 @@
|
|||
<% end %>
|
||||
|
||||
<% if budget.next_budget_param %>
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
variant: "icon",
|
||||
icon: "chevron-right",
|
||||
href: budget_path(budget.next_budget_param),
|
||||
|
@ -27,7 +27,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render MenuComponent.new(variant: "button") do |menu| %>
|
||||
<%= render DS::Menu.new(variant: "button") do |menu| %>
|
||||
<% menu.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
|
||||
<span class="text-primary font-medium text-lg lg:text-base"><%= @budget.name %></span>
|
||||
<%= icon("chevron-down") %>
|
||||
|
@ -39,7 +39,7 @@
|
|||
<% end %>
|
||||
|
||||
<div class="ml-auto">
|
||||
<%= render LinkComponent.new(
|
||||
<%= render DS::Link.new(
|
||||
text: "Today",
|
||||
variant: "outline",
|
||||
href: budget_path(Budget.date_to_param(Date.current)),
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue