Compare commits
60 Commits
zachgoll/s
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
224f21354a | ||
|
|
3fb379d140 | ||
|
|
d90d35d97b | ||
|
|
5baf258a32 | ||
|
|
bacab94a1b | ||
|
|
7698ec03b9 | ||
|
|
0329a5f211 | ||
|
|
b7c56e2fb7 | ||
|
|
764164cf57 | ||
|
|
ef49268278 | ||
|
|
527a6128b6 | ||
|
|
32ec57146e | ||
|
|
f7f6ebb091 | ||
|
|
3f92fe0f6f | ||
|
|
da2045dbd8 | ||
|
|
347c0a7906 | ||
|
|
321a343df4 | ||
|
|
e8eb32d2ae | ||
|
|
ab6fdbbb68 | ||
|
|
d5b147f2cd | ||
|
|
8c97c9d31a | ||
|
|
3eea5a9891 | ||
|
|
52333e3fa6 | ||
|
|
89cc64418e | ||
|
|
c1d98fe73b | ||
|
|
9110ab27d2 | ||
|
|
afbfb474c2 | ||
|
|
fe8aebe920 | ||
|
|
188126d402 | ||
|
|
1a2d973f4b | ||
|
|
a91441bcc8 | ||
|
|
8d0c1c5a56 | ||
|
|
e848db2aa1 | ||
|
|
e7043328e4 | ||
|
|
d77c683d59 | ||
|
|
aaf24e1309 | ||
|
|
f9b131a5db | ||
|
|
a63d36d10c | ||
|
|
662f2c04ce | ||
|
|
ba7e8d3893 | ||
|
|
65329b333d | ||
|
|
0974783a6b | ||
|
|
48f792c20e | ||
|
|
869462a9a5 | ||
|
|
e4a82d85e8 | ||
|
|
18148acd69 | ||
|
|
8db95623cf | ||
|
|
e60b5df442 | ||
|
|
f3ab4a27ee | ||
|
|
4b50acff2b | ||
|
|
637d630388 | ||
|
|
72a0f87a9c | ||
|
|
cea49d5038 | ||
|
|
c0617f74cd | ||
|
|
653decbc0b | ||
|
|
1cfa6cfca8 | ||
|
|
e809335a47 | ||
|
|
956008acbf | ||
|
|
8b56262573 | ||
|
|
615912040c |
3
Gemfile
3
Gemfile
@@ -26,7 +26,7 @@ gem "view_component"
|
||||
|
||||
# https://github.com/lookbook-hq/lookbook/issues/712
|
||||
# TODO: Remove max version constraint when fixed
|
||||
gem "lookbook", "2.3.9"
|
||||
gem "lookbook", "2.3.11"
|
||||
|
||||
gem "hotwire_combobox"
|
||||
|
||||
@@ -72,6 +72,7 @@ gem "plaid"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 3.0"
|
||||
gem "activerecord-import"
|
||||
gem "rubyzip", "~> 2.3"
|
||||
|
||||
# State machines
|
||||
gem "aasm"
|
||||
|
||||
43
Gemfile.lock
43
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)
|
||||
@@ -151,7 +151,7 @@ GEM
|
||||
addressable
|
||||
csv (3.3.5)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
derailed_benchmarks (2.2.1)
|
||||
@@ -192,17 +192,17 @@ 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
|
||||
faraday-multipart (1.1.0)
|
||||
faraday-multipart (1.1.1)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.1)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-retry (2.3.1)
|
||||
faraday-retry (2.3.2)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
@@ -273,7 +273,7 @@ GEM
|
||||
activesupport (>= 5.0.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.12.2)
|
||||
jwt (2.10.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
@@ -301,7 +301,7 @@ GEM
|
||||
loofah (2.24.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
lookbook (2.3.9)
|
||||
lookbook (2.3.11)
|
||||
activemodel
|
||||
css_parser
|
||||
htmlbeautifier (~> 1.3)
|
||||
@@ -364,8 +364,8 @@ GEM
|
||||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
ostruct (0.6.1)
|
||||
pagy (9.3.4)
|
||||
ostruct (0.6.2)
|
||||
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.0)
|
||||
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)
|
||||
@@ -516,22 +516,22 @@ GEM
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.33.0)
|
||||
selenium-webdriver (4.34.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
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)
|
||||
@@ -651,7 +651,7 @@ DEPENDENCIES
|
||||
jwt
|
||||
letter_opener
|
||||
logtail-rails
|
||||
lookbook (= 2.3.9)
|
||||
lookbook (= 2.3.11)
|
||||
lucide-rails!
|
||||
mocha
|
||||
octokit
|
||||
@@ -672,6 +672,7 @@ DEPENDENCIES
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
ruby-openai
|
||||
rubyzip (~> 2.3)
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
|
||||
@@ -334,6 +334,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* New form field structure components */
|
||||
.form-field__header {
|
||||
@apply flex items-center justify-between gap-2;
|
||||
}
|
||||
|
||||
.form-field__body {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.form-field__actions {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply block text-xs text-secondary peer-disabled:text-subdued;
|
||||
}
|
||||
@@ -347,10 +360,6 @@
|
||||
@apply transition-opacity duration-300;
|
||||
@apply placeholder:text-subdued;
|
||||
|
||||
&select {
|
||||
@apply pr-8;
|
||||
}
|
||||
|
||||
@variant theme-dark {
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
@@ -358,6 +367,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select.form-field__input {
|
||||
@apply pr-10 appearance-none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right -0.15rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-primary;
|
||||
@@ -425,7 +442,5 @@
|
||||
@variant theme-dark {
|
||||
fill: var(--color-white);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
7
app/components/DS/alert.html.erb
Normal file
7
app/components/DS/alert.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="<%= container_classes %>">
|
||||
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %>
|
||||
|
||||
<div class="flex-1 text-sm">
|
||||
<%= message %>
|
||||
</div>
|
||||
</div>
|
||||
52
app/components/DS/alert.rb
Normal file
52
app/components/DS/alert.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
class DS::Alert < DesignSystemComponent
|
||||
def initialize(message:, variant: :info)
|
||||
@message = message
|
||||
@variant = variant
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :message, :variant
|
||||
|
||||
def container_classes
|
||||
base_classes = "flex items-start gap-3 p-4 rounded-lg border"
|
||||
|
||||
variant_classes = case variant
|
||||
when :info
|
||||
"bg-blue-50 text-blue-700 border-blue-200 theme-dark:bg-blue-900/20 theme-dark:text-blue-400 theme-dark:border-blue-800"
|
||||
when :success
|
||||
"bg-green-50 text-green-700 border-green-200 theme-dark:bg-green-900/20 theme-dark:text-green-400 theme-dark:border-green-800"
|
||||
when :warning
|
||||
"bg-yellow-50 text-yellow-700 border-yellow-200 theme-dark:bg-yellow-900/20 theme-dark:text-yellow-400 theme-dark:border-yellow-800"
|
||||
when :error, :destructive
|
||||
"bg-red-50 text-red-700 border-red-200 theme-dark:bg-red-900/20 theme-dark:text-red-400 theme-dark:border-red-800"
|
||||
end
|
||||
|
||||
"#{base_classes} #{variant_classes}"
|
||||
end
|
||||
|
||||
def icon_name
|
||||
case variant
|
||||
when :info
|
||||
"info"
|
||||
when :success
|
||||
"check-circle"
|
||||
when :warning
|
||||
"alert-triangle"
|
||||
when :error, :destructive
|
||||
"x-circle"
|
||||
end
|
||||
end
|
||||
|
||||
def icon_color
|
||||
case variant
|
||||
when :success
|
||||
"success"
|
||||
when :warning
|
||||
"warning"
|
||||
when :error, :destructive
|
||||
"destructive"
|
||||
else
|
||||
"blue-600"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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 #{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
|
||||
27
app/components/DS/disclosure.html.erb
Normal file
27
app/components/DS/disclosure.html.erb
Normal file
@@ -0,0 +1,27 @@
|
||||
<details class="group" <%= "open" if open %>>
|
||||
<%= tag.summary class: class_names(
|
||||
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
|
||||
) do %>
|
||||
<% if summary_content? %>
|
||||
<%= summary_content %>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-3">
|
||||
<% if align == :left %>
|
||||
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %>
|
||||
<%= title %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if align == :right %>
|
||||
<%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-2">
|
||||
<%= content %>
|
||||
</div>
|
||||
</details>
|
||||
@@ -1,9 +1,9 @@
|
||||
class DisclosureComponent < ViewComponent::Base
|
||||
class DS::Disclosure < DesignSystemComponent
|
||||
renders_one :summary_content
|
||||
|
||||
attr_reader :title, :align, :open, :opts
|
||||
|
||||
def initialize(title:, align: "right", open: false, **opts)
|
||||
def initialize(title: nil, align: "right", open: false, **opts)
|
||||
@title = title
|
||||
@align = align.to_sym
|
||||
@open = open
|
||||
@@ -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,7 +1,6 @@
|
||||
<%= link_to href, **merged_opts do %>
|
||||
<% if icon && (icon_position != "right") %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
||||
@@ -10,6 +9,5 @@
|
||||
|
||||
<% if icon && icon_position == "right" %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
42
app/components/UI/account/activity_date.html.erb
Normal file
42
app/components/UI/account/activity_date.html.erb
Normal file
@@ -0,0 +1,42 @@
|
||||
<%= 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"><%= end_balance_money.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">
|
||||
<% if balance %>
|
||||
<%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %>
|
||||
<% else %>
|
||||
<p class="text-sm text-secondary">No balance data available for this date</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="bg-container shadow-border-xs rounded-lg">
|
||||
<% entries.each do |entry| %>
|
||||
<%= render entry, view_ctx: "account" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
31
app/components/UI/account/activity_date.rb
Normal file
31
app/components/UI/account/activity_date.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
class UI::Account::ActivityDate < ApplicationComponent
|
||||
attr_reader :account, :data
|
||||
|
||||
delegate :date, :entries, :balance, :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 end_balance_money
|
||||
balance&.end_balance_money || Money.new(0, account.currency)
|
||||
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
|
||||
22
app/components/UI/account/balance_reconciliation.html.erb
Normal file
22
app/components/UI/account/balance_reconciliation.html.erb
Normal file
@@ -0,0 +1,22 @@
|
||||
<div class="space-y-3">
|
||||
<% reconciliation_items.each_with_index do |item, index| %>
|
||||
<% if item[:style] == :subtotal %>
|
||||
<hr class="border border-primary">
|
||||
<% end %>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
<%= item[:label] %>
|
||||
<%= render DS::Tooltip.new(text: item[:tooltip], placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed <%= item[:style] == :final ? "border-primary" : "border-secondary" %>">
|
||||
<dd class="<%= item[:style] == :start || item[:style] == :final ? "font-bold" : item[:style] == :subtotal ? "font-medium" : "" %>">
|
||||
<%= item[:value].format %>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<% if item[:style] == :adjustment %>
|
||||
<hr class="border border-primary">
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
155
app/components/UI/account/balance_reconciliation.rb
Normal file
155
app/components/UI/account/balance_reconciliation.rb
Normal file
@@ -0,0 +1,155 @@
|
||||
class UI::Account::BalanceReconciliation < ApplicationComponent
|
||||
attr_reader :balance, :account
|
||||
|
||||
def initialize(balance:, account:)
|
||||
@balance = balance
|
||||
@account = account
|
||||
end
|
||||
|
||||
def reconciliation_items
|
||||
case account.accountable_type
|
||||
when "Depository", "OtherAsset", "OtherLiability"
|
||||
default_items
|
||||
when "CreditCard"
|
||||
credit_card_items
|
||||
when "Investment"
|
||||
investment_items
|
||||
when "Loan"
|
||||
loan_items
|
||||
when "Property", "Vehicle"
|
||||
asset_items
|
||||
when "Crypto"
|
||||
crypto_items
|
||||
else
|
||||
default_items
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The account balance at the beginning of this day", style: :start },
|
||||
{ label: "Net cash flow", value: net_cash_flow, tooltip: "Net change in balance from all transactions during the day", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final account balance for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def credit_card_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The balance owed at the beginning of this day", style: :start },
|
||||
{ label: "Charges", value: balance.cash_outflows_money, tooltip: "New charges made during the day", style: :flow },
|
||||
{ label: "Payments", value: balance.cash_inflows_money * -1, tooltip: "Payments made to the card during the day", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final balance owed for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def investment_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The total portfolio value at the beginning of this day", style: :start }
|
||||
]
|
||||
|
||||
# Change in brokerage cash (includes deposits, withdrawals, and cash from trades)
|
||||
items << { label: "Change in brokerage cash", value: net_cash_flow, tooltip: "Net change in cash from deposits, withdrawals, and trades", style: :flow }
|
||||
|
||||
# Change in holdings from trading activity
|
||||
items << { label: "Change in holdings (buys/sells)", value: net_non_cash_flow, tooltip: "Impact on holdings from buying and selling securities", style: :flow }
|
||||
|
||||
# Market price changes
|
||||
items << { label: "Change in holdings (market price activity)", value: balance.net_market_flows_money, tooltip: "Change in holdings value from market price movements", style: :flow }
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final portfolio value for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def loan_items
|
||||
items = [
|
||||
{ label: "Start principal", value: balance.start_balance_money, tooltip: "The principal balance at the beginning of this day", style: :start },
|
||||
{ label: "Net principal change", value: net_non_cash_flow, tooltip: "Principal payments and new borrowing during the day", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End principal", value: end_balance_before_adjustments, tooltip: "The calculated principal after all transactions", style: :subtotal }
|
||||
items << { label: "Adjustments", value: balance.non_cash_adjustments_money, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final principal", value: balance.end_balance_money, tooltip: "The final principal balance for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def asset_items # Property/Vehicle
|
||||
items = [
|
||||
{ label: "Start value", value: balance.start_balance_money, tooltip: "The asset value at the beginning of this day", style: :start },
|
||||
{ label: "Net value change", value: net_total_flow, tooltip: "All value changes including improvements and depreciation", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End value", value: end_balance_before_adjustments, tooltip: "The calculated value after all changes", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual value adjustments or appraisals", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final value", value: balance.end_balance_money, tooltip: "The final asset value for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def crypto_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The crypto holdings value at the beginning of this day", style: :start }
|
||||
]
|
||||
|
||||
items << { label: "Buys", value: balance.cash_outflows_money * -1, tooltip: "Crypto purchases during the day", style: :flow } if balance.cash_outflows != 0
|
||||
items << { label: "Sells", value: balance.cash_inflows_money, tooltip: "Crypto sales during the day", style: :flow } if balance.cash_inflows != 0
|
||||
items << { label: "Market changes", value: balance.net_market_flows_money, tooltip: "Value changes from market price movements", style: :flow } if balance.net_market_flows != 0
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final crypto holdings value for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def net_cash_flow
|
||||
balance.cash_inflows_money - balance.cash_outflows_money
|
||||
end
|
||||
|
||||
def net_non_cash_flow
|
||||
balance.non_cash_inflows_money - balance.non_cash_outflows_money
|
||||
end
|
||||
|
||||
def net_total_flow
|
||||
net_cash_flow + net_non_cash_flow + balance.net_market_flows_money
|
||||
end
|
||||
|
||||
def total_adjustments
|
||||
balance.cash_adjustments_money + balance.non_cash_adjustments_money
|
||||
end
|
||||
|
||||
def has_adjustments?
|
||||
balance.cash_adjustments != 0 || balance.non_cash_adjustments != 0
|
||||
end
|
||||
|
||||
def end_balance_before_adjustments
|
||||
balance.end_balance_money - total_adjustments
|
||||
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,25 +0,0 @@
|
||||
<details class="group" <%= "open" if open %>>
|
||||
<%= tag.summary class: class_names(
|
||||
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
|
||||
) do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<% if align == :left %>
|
||||
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %>
|
||||
<%= title %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if align == :right %>
|
||||
<%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %>
|
||||
<% elsif summary_content? %>
|
||||
<%= summary_content %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-2">
|
||||
<%= content %>
|
||||
</div>
|
||||
</details>
|
||||
@@ -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 %>
|
||||
@@ -32,7 +32,7 @@ class AccountableSparklinesController < ApplicationController
|
||||
end
|
||||
|
||||
def account_ids
|
||||
family.accounts.active.where(accountable_type: accountable.name).pluck(:id)
|
||||
family.accounts.visible.where(accountable_type: accountable.name).pluck(:id)
|
||||
end
|
||||
|
||||
def cache_key
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class AccountsController < ApplicationController
|
||||
before_action :set_account, only: %i[sync chart sparkline]
|
||||
before_action :set_account, only: %i[sync sparkline toggle_active show destroy]
|
||||
include Periodable
|
||||
|
||||
def index
|
||||
@@ -9,6 +9,22 @@ class AccountsController < ApplicationController
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def sync_all
|
||||
family.sync_later
|
||||
redirect_to accounts_path, notice: "Syncing accounts..."
|
||||
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 +33,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)
|
||||
|
||||
@@ -33,6 +44,24 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_active
|
||||
if @account.active?
|
||||
@account.disable!
|
||||
elsif @account.disabled?
|
||||
@account.enable!
|
||||
end
|
||||
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
|
||||
|
||||
@@ -9,7 +9,7 @@ class Api::V1::AccountsController < Api::V1::BaseController
|
||||
def index
|
||||
# Test with Pagy pagination
|
||||
family = current_resource_owner.family
|
||||
accounts_query = family.accounts.active.alphabetically
|
||||
accounts_query = family.accounts.visible.alphabetically
|
||||
|
||||
# Handle pagination with Pagy
|
||||
@pagy, @accounts = pagy(
|
||||
|
||||
@@ -98,7 +98,7 @@ class Api::V1::BaseController < ApplicationController
|
||||
@current_user = @api_key.user
|
||||
@api_key.update_last_used!
|
||||
@authentication_method = :api_key
|
||||
@rate_limiter = ApiRateLimiter.new(@api_key)
|
||||
@rate_limiter = ApiRateLimiter.limit(@api_key)
|
||||
setup_current_context_for_api
|
||||
true
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController
|
||||
|
||||
def index
|
||||
family = current_resource_owner.family
|
||||
transactions_query = family.transactions.active
|
||||
transactions_query = family.transactions.visible
|
||||
|
||||
# Apply filters
|
||||
transactions_query = apply_filters(transactions_query)
|
||||
|
||||
@@ -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
|
||||
@@ -43,19 +41,27 @@ module AccountableResource
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params.except(:return_to))
|
||||
@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)
|
||||
# Handle balance update if provided
|
||||
if account_params[:balance].present?
|
||||
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
|
||||
update_params = account_params.except(:return_to, :balance, :currency)
|
||||
unless @account.update(update_params)
|
||||
@error_message = @account.errors.full_messages.join(", ")
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@account.lock_saved_attributes!
|
||||
redirect_back_or_to account_path(@account), notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -74,7 +80,7 @@ module AccountableResource
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(
|
||||
:name, :is_active, :balance, :subtype, :currency, :accountable_type, :return_to,
|
||||
:name, :balance, :subtype, :currency, :accountable_type, :return_to,
|
||||
accountable_attributes: self.class.permitted_accountable_attributes
|
||||
)
|
||||
end
|
||||
|
||||
@@ -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
|
||||
47
app/controllers/family_exports_controller.rb
Normal file
47
app/controllers/family_exports_controller.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class FamilyExportsController < ApplicationController
|
||||
include StreamExtensions
|
||||
|
||||
before_action :require_admin
|
||||
before_action :set_export, only: [ :download ]
|
||||
|
||||
def new
|
||||
# Modal view for initiating export
|
||||
end
|
||||
|
||||
def create
|
||||
@export = Current.family.family_exports.create!
|
||||
FamilyDataExportJob.perform_later(@export)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." }
|
||||
format.turbo_stream {
|
||||
stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly."
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
@exports = Current.family.family_exports.ordered.limit(10)
|
||||
render layout: false # For turbo frame
|
||||
end
|
||||
|
||||
def download
|
||||
if @export.downloadable?
|
||||
redirect_to @export.export_file, allow_other_host: true
|
||||
else
|
||||
redirect_to settings_profile_path, alert: "Export not ready for download"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_export
|
||||
@export = Current.family.family_exports.find(params[:id])
|
||||
end
|
||||
|
||||
def require_admin
|
||||
unless Current.user.admin?
|
||||
redirect_to root_path, alert: "Access denied"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,7 @@ class PagesController < ApplicationController
|
||||
|
||||
def dashboard
|
||||
@balance_sheet = Current.family.balance_sheet
|
||||
@accounts = Current.family.accounts.active.with_attached_logo
|
||||
@accounts = Current.family.accounts.visible.with_attached_logo
|
||||
|
||||
period_param = params[:cashflow_period]
|
||||
@cashflow_period = if period_param.present?
|
||||
|
||||
@@ -1,21 +1,99 @@
|
||||
class PropertiesController < ApplicationController
|
||||
include AccountableResource
|
||||
include AccountableResource, StreamExtensions
|
||||
|
||||
permitted_accountable_attributes(
|
||||
:id, :year_built, :area_unit, :area_value,
|
||||
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
|
||||
)
|
||||
before_action :set_property, only: [ :balances, :address, :update_balances, :update_address ]
|
||||
|
||||
def new
|
||||
@account = Current.family.accounts.build(
|
||||
currency: Current.family.currency,
|
||||
accountable: Property.new(
|
||||
address: Address.new
|
||||
)
|
||||
@account = Current.family.accounts.build(accountable: Property.new)
|
||||
end
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.create!(
|
||||
property_params.merge(currency: Current.family.currency, balance: 0, status: "draft")
|
||||
)
|
||||
|
||||
redirect_to balances_property_path(@account)
|
||||
end
|
||||
|
||||
def update
|
||||
if @account.update(property_params)
|
||||
@success_message = "Property details updated successfully."
|
||||
|
||||
if @account.active?
|
||||
render :edit
|
||||
else
|
||||
redirect_to balances_property_path(@account)
|
||||
end
|
||||
else
|
||||
@error_message = "Unable to update property details."
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@account.accountable.address ||= Address.new
|
||||
end
|
||||
|
||||
def balances
|
||||
end
|
||||
|
||||
def update_balances
|
||||
result = @account.set_current_balance(balance_params[:balance].to_d)
|
||||
|
||||
if result.success?
|
||||
@success_message = "Balance updated successfully."
|
||||
|
||||
if @account.active?
|
||||
render :balances
|
||||
else
|
||||
redirect_to address_property_path(@account)
|
||||
end
|
||||
else
|
||||
@error_message = result.error_message
|
||||
render :balances, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def address
|
||||
@property = @account.property
|
||||
@property.address ||= Address.new
|
||||
end
|
||||
|
||||
def update_address
|
||||
if @account.property.update(address_params)
|
||||
if @account.draft?
|
||||
@account.activate!
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account) }
|
||||
format.turbo_stream { stream_redirect_to account_path(@account) }
|
||||
end
|
||||
else
|
||||
@success_message = "Address updated successfully."
|
||||
render :address
|
||||
end
|
||||
else
|
||||
@error_message = "Unable to update address. Please check the required fields."
|
||||
render :address, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def balance_params
|
||||
params.require(:account).permit(:balance, :currency)
|
||||
end
|
||||
|
||||
def address_params
|
||||
params.require(:property)
|
||||
.permit(address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ])
|
||||
end
|
||||
|
||||
def property_params
|
||||
params.require(:account)
|
||||
.permit(:name, :subtype, :accountable_type, accountable_attributes: [ :id, :year_built, :area_unit, :area_value ])
|
||||
end
|
||||
|
||||
def set_property
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
@property = @account.property
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -115,7 +110,7 @@ class TransactionsController < ApplicationController
|
||||
|
||||
private
|
||||
def per_page
|
||||
params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
|
||||
params[:per_page].to_i.positive? ? params[:per_page].to_i : 20
|
||||
end
|
||||
|
||||
def needs_rule_notification?(transaction)
|
||||
@@ -159,10 +154,6 @@ class TransactionsController < ApplicationController
|
||||
|
||||
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
||||
|
||||
# Only add default start_date if params are blank AND filters weren't explicitly cleared
|
||||
if cleaned_params.blank? && params[:filter_cleared].blank?
|
||||
cleaned_params[:start_date] = 30.days.ago.to_date
|
||||
end
|
||||
|
||||
cleaned_params
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ class TransferMatchesController < ApplicationController
|
||||
before_action :set_entry
|
||||
|
||||
def new
|
||||
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||
@accounts = Current.family.accounts.visible.alphabetically.where.not(id: @entry.account_id)
|
||||
@transfer_match_candidates = @entry.transaction.transfer_match_candidates
|
||||
end
|
||||
|
||||
|
||||
@@ -1,30 +1,70 @@
|
||||
class ValuationsController < ApplicationController
|
||||
include EntryableResource
|
||||
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))
|
||||
@entry = account.entries.new(entry_params.merge(entryable: Valuation.new))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = "Balance created"
|
||||
result = account.create_reconciliation(
|
||||
balance: entry_params[:amount],
|
||||
date: entry_params[:date],
|
||||
)
|
||||
|
||||
if result.success?
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) }
|
||||
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
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(entry_params)
|
||||
@entry.sync_account_later
|
||||
# Notes updating is independent of reconciliation, just a simple CRUD operation
|
||||
@entry.update!(notes: entry_params[:notes]) if entry_params[:notes].present?
|
||||
|
||||
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: "Balance updated" }
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: "Entry updated" }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
@@ -37,13 +77,13 @@ class ValuationsController < ApplicationController
|
||||
end
|
||||
end
|
||||
else
|
||||
@error_message = result.error_message
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:entry)
|
||||
.permit(:name, :date, :amount, :currency, :notes)
|
||||
params.require(:entry).permit(:date, :amount, :notes)
|
||||
end
|
||||
end
|
||||
|
||||
59
app/data_migrations/balance_component_migrator.rb
Normal file
59
app/data_migrations/balance_component_migrator.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
class BalanceComponentMigrator
|
||||
def self.run
|
||||
ActiveRecord::Base.transaction do
|
||||
# Step 1: Update flows factor
|
||||
ActiveRecord::Base.connection.execute <<~SQL
|
||||
UPDATE balances SET
|
||||
flows_factor = CASE WHEN a.classification = 'asset' THEN 1 ELSE -1 END
|
||||
FROM accounts a
|
||||
WHERE a.id = balances.account_id
|
||||
SQL
|
||||
|
||||
# Step 2: Set start values using LOCF (Last Observation Carried Forward)
|
||||
ActiveRecord::Base.connection.execute <<~SQL
|
||||
UPDATE balances b1
|
||||
SET
|
||||
start_cash_balance = COALESCE(prev.cash_balance, 0),
|
||||
start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0)
|
||||
FROM balances b1_inner
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
b2.cash_balance,
|
||||
b2.balance
|
||||
FROM balances b2
|
||||
WHERE b2.account_id = b1_inner.account_id
|
||||
AND b2.currency = b1_inner.currency
|
||||
AND b2.date < b1_inner.date
|
||||
ORDER BY b2.date DESC
|
||||
LIMIT 1
|
||||
) prev ON true
|
||||
WHERE b1.id = b1_inner.id
|
||||
SQL
|
||||
|
||||
# Step 3: Calculate net inflows
|
||||
# A slight workaround to the fact that we can't easily derive inflows/outflows from our current data model, and
|
||||
# the tradeoff not worth it since each new sync will fix it. So instead, we sum up *net* flows, and throw the signed
|
||||
# amount in the "inflows" column, and zero-out the "outflows" column so our math works correctly with incomplete data.
|
||||
ActiveRecord::Base.connection.execute <<~SQL
|
||||
UPDATE balances SET
|
||||
cash_inflows = (cash_balance - start_cash_balance) * flows_factor,
|
||||
cash_outflows = 0,
|
||||
non_cash_inflows = ((balance - cash_balance) - start_non_cash_balance) * flows_factor,
|
||||
non_cash_outflows = 0,
|
||||
net_market_flows = 0
|
||||
SQL
|
||||
|
||||
# Verify data integrity
|
||||
# All end_balance values should match the original balance
|
||||
invalid_count = ActiveRecord::Base.connection.select_value(<<~SQL)
|
||||
SELECT COUNT(*)
|
||||
FROM balances b
|
||||
WHERE ABS(b.balance - b.end_balance) > 0.0001
|
||||
SQL
|
||||
|
||||
if invalid_count > 0
|
||||
raise "Data migration failed validation: #{invalid_count} balances have incorrect end_balance values"
|
||||
end
|
||||
end
|
||||
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
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
module SettingsHelper
|
||||
SETTINGS_ORDER = [
|
||||
{ name: I18n.t("settings.settings_nav.profile_label"), path: :settings_profile_path },
|
||||
{ name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
|
||||
{ name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
|
||||
{ name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
|
||||
{ name: "Account", path: :settings_profile_path },
|
||||
{ name: "Preferences", path: :settings_preferences_path },
|
||||
{ name: "Security", path: :settings_security_path },
|
||||
{ name: "Self hosting", path: :settings_hosting_path, condition: :self_hosted? },
|
||||
{ name: "API Key", path: :settings_api_key_path },
|
||||
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? },
|
||||
{ name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
|
||||
{ name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },
|
||||
{ name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path },
|
||||
{ name: I18n.t("settings.settings_nav.categories_label"), path: :categories_path },
|
||||
{ name: "Billing", path: :settings_billing_path, condition: :not_self_hosted? },
|
||||
{ name: "Accounts", path: :accounts_path },
|
||||
{ name: "Imports", path: :imports_path },
|
||||
{ name: "Tags", path: :tags_path },
|
||||
{ name: "Categories", path: :categories_path },
|
||||
{ name: "Rules", path: :rules_path },
|
||||
{ name: I18n.t("settings.settings_nav.merchants_label"), path: :family_merchants_path },
|
||||
{ name: I18n.t("settings.settings_nav.whats_new_label"), path: :changelog_path },
|
||||
{ name: I18n.t("settings.settings_nav.feedback_label"), path: :feedback_path }
|
||||
{ name: "Merchants", path: :family_merchants_path },
|
||||
{ name: "What's new", path: :changelog_path },
|
||||
{ name: "Feedback", path: :feedback_path }
|
||||
]
|
||||
|
||||
def adjacent_setting(current_path, offset)
|
||||
|
||||
@@ -1,42 +1,38 @@
|
||||
class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
# Fields that visually inherit from "text field"
|
||||
class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]
|
||||
|
||||
# Wraps "text" inputs with custom structure + base styles
|
||||
text_field_helpers.each do |selector|
|
||||
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
||||
def #{selector}(method, options = {})
|
||||
merged_options = { class: "form-field__input" }.merge(options)
|
||||
label = build_label(method, options)
|
||||
field = super(method, merged_options)
|
||||
form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)
|
||||
html_options = options.except(:label, :label_tooltip, :inline, :container_class)
|
||||
|
||||
build_styled_field(label, field, merged_options)
|
||||
build_field(method, form_options, html_options) do |merged_options|
|
||||
super(method, merged_options)
|
||||
end
|
||||
end
|
||||
RUBY_EVAL
|
||||
end
|
||||
|
||||
def radio_button(method, tag_value, options = {})
|
||||
merged_options = { class: "form-field__radio" }.merge(options)
|
||||
|
||||
super(method, tag_value, merged_options)
|
||||
end
|
||||
|
||||
def select(method, choices, options = {}, html_options = {})
|
||||
merged_html_options = { class: "form-field__input" }.merge(html_options)
|
||||
field_options = normalize_options(options, html_options)
|
||||
|
||||
label = build_label(method, options.merge(required: merged_html_options[:required]))
|
||||
field = super(method, choices, options, merged_html_options)
|
||||
|
||||
build_styled_field(label, field, options, remove_padding_right: true)
|
||||
build_field(method, field_options, html_options) do |merged_html_options|
|
||||
super(method, choices, options, merged_html_options)
|
||||
end
|
||||
end
|
||||
|
||||
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
||||
merged_html_options = { class: "form-field__input" }.merge(html_options)
|
||||
field_options = normalize_options(options, html_options)
|
||||
|
||||
label = build_label(method, options.merge(required: merged_html_options[:required]))
|
||||
field = super(method, collection, value_method, text_method, options, merged_html_options)
|
||||
|
||||
build_styled_field(label, field, options, remove_padding_right: true)
|
||||
build_field(method, field_options, html_options) do |merged_html_options|
|
||||
super(method, collection, value_method, text_method, options, merged_html_options)
|
||||
end
|
||||
end
|
||||
|
||||
def money_field(amount_method, options = {})
|
||||
@@ -48,22 +44,15 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
}
|
||||
end
|
||||
|
||||
# A custom styled "toggle" switch input. Underlying input is a `check_box` (uses same API)
|
||||
def toggle(method, options = {}, checked_value = "1", unchecked_value = "0")
|
||||
if object
|
||||
id = "#{object.id}_#{object_name}_#{method}"
|
||||
name = "#{object_name}[#{method}]"
|
||||
checked = object.send(method)
|
||||
else
|
||||
id = "#{method}_toggle_id"
|
||||
name = method
|
||||
checked = options[:checked]
|
||||
end
|
||||
field_id = field_id(method)
|
||||
field_name = field_name(method)
|
||||
checked = object ? object.send(method) : options[:checked]
|
||||
|
||||
@template.render(
|
||||
ToggleComponent.new(
|
||||
id: id,
|
||||
name: name,
|
||||
DS::Toggle.new(
|
||||
id: field_id,
|
||||
name: field_name,
|
||||
checked: checked,
|
||||
disabled: options[:disabled],
|
||||
checked_value: checked_value,
|
||||
@@ -74,12 +63,11 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
# Rails superclass logic to extract the submit text
|
||||
value, options = nil, value if value.is_a?(Hash)
|
||||
value ||= submit_default_value
|
||||
|
||||
@template.render(
|
||||
ButtonComponent.new(
|
||||
DS::Button.new(
|
||||
text: value,
|
||||
data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }),
|
||||
full_width: true
|
||||
@@ -88,16 +76,39 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
end
|
||||
|
||||
private
|
||||
def build_styled_field(label, field, options, remove_padding_right: false)
|
||||
if options[:inline]
|
||||
label + field
|
||||
else
|
||||
@template.tag.div class: [ "form-field", options[:container_class], ("pr-0" if remove_padding_right) ] do
|
||||
label + field
|
||||
def build_field(method, options = {}, html_options = {}, &block)
|
||||
if options[:inline] || options[:label] == false
|
||||
return yield({ class: "form-field__input" }.merge(html_options))
|
||||
end
|
||||
|
||||
label_element = build_label(method, options)
|
||||
field_element = yield({ class: "form-field__input" }.merge(html_options))
|
||||
|
||||
container_classes = [ "form-field", options[:container_class] ].compact
|
||||
|
||||
@template.tag.div class: container_classes do
|
||||
if options[:label_tooltip]
|
||||
@template.tag.div(class: "form-field__header") do
|
||||
label_element +
|
||||
@template.tag.div(class: "form-field__actions") do
|
||||
build_tooltip(options[:label_tooltip])
|
||||
end
|
||||
end +
|
||||
@template.tag.div(class: "form-field__body") do
|
||||
field_element
|
||||
end
|
||||
else
|
||||
@template.tag.div(class: "form-field__body") do
|
||||
label_element + field_element
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_options(options, html_options)
|
||||
options.merge(required: options[:required] || html_options[:required])
|
||||
end
|
||||
|
||||
def build_label(method, options)
|
||||
return "".html_safe unless options[:label]
|
||||
|
||||
@@ -113,4 +124,15 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
return label(method, class: "form-field__label") if label_text == true
|
||||
label(method, label_text, class: "form-field__label")
|
||||
end
|
||||
|
||||
def build_tooltip(tooltip_text)
|
||||
return nil unless tooltip_text
|
||||
|
||||
@template.tag.div(data: { controller: "tooltip" }) do
|
||||
@template.safe_join([
|
||||
@template.icon("help-circle", size: "sm", color: "default", class: "cursor-help"),
|
||||
@template.tag.div(tooltip_text, role: "tooltip", data: { tooltip_target: "tooltip" }, class: "tooltip bg-gray-700 text-sm p-2 rounded w-64 text-white")
|
||||
])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,16 +10,14 @@ export default class extends Controller {
|
||||
|
||||
connect() {
|
||||
this.autoTargets.forEach((element) => {
|
||||
const event =
|
||||
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
|
||||
const event = this.#getTriggerEvent(element);
|
||||
element.addEventListener(event, this.handleInput);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.autoTargets.forEach((element) => {
|
||||
const event =
|
||||
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
|
||||
const event = this.#getTriggerEvent(element);
|
||||
element.removeEventListener(event, this.handleInput);
|
||||
});
|
||||
}
|
||||
@@ -33,6 +31,50 @@ export default class extends Controller {
|
||||
}, this.#debounceTimeout(target));
|
||||
};
|
||||
|
||||
#getTriggerEvent(element) {
|
||||
// Check if element has explicit trigger event set
|
||||
if (element.dataset.autosubmitTriggerEvent) {
|
||||
return element.dataset.autosubmitTriggerEvent;
|
||||
}
|
||||
|
||||
// Check if form has explicit trigger event set
|
||||
if (this.triggerEventValue !== "input") {
|
||||
return this.triggerEventValue;
|
||||
}
|
||||
|
||||
// Otherwise, choose trigger event based on element type
|
||||
const type = element.type || element.tagName;
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "password":
|
||||
case "search":
|
||||
case "tel":
|
||||
case "url":
|
||||
case "textarea":
|
||||
return "blur";
|
||||
case "number":
|
||||
case "date":
|
||||
case "datetime-local":
|
||||
case "month":
|
||||
case "time":
|
||||
case "week":
|
||||
case "color":
|
||||
return "change";
|
||||
case "checkbox":
|
||||
case "radio":
|
||||
case "select":
|
||||
case "select-one":
|
||||
case "select-multiple":
|
||||
return "change";
|
||||
case "range":
|
||||
return "input";
|
||||
default:
|
||||
return "blur";
|
||||
}
|
||||
}
|
||||
|
||||
#debounceTimeout(element) {
|
||||
if (element.dataset.autosubmitDebounceTimeout) {
|
||||
return Number.parseInt(element.dataset.autosubmitDebounceTimeout);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,15 +508,57 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
get _d3YScale() {
|
||||
const reductionPercent = this.useLabelsValue ? 0.3 : 0.05;
|
||||
const dataMin = d3.min(this._normalDataPoints, this._getDatumValue);
|
||||
const dataMax = d3.max(this._normalDataPoints, this._getDatumValue);
|
||||
const padding = (dataMax - dataMin) * reductionPercent;
|
||||
|
||||
// Handle edge case where all values are the same
|
||||
if (dataMin === dataMax) {
|
||||
const padding = dataMax === 0 ? 100 : Math.abs(dataMax) * 0.5;
|
||||
return d3
|
||||
.scaleLinear()
|
||||
.rangeRound([this._d3ContainerHeight, 0])
|
||||
.domain([dataMin - padding, dataMax + padding]);
|
||||
}
|
||||
|
||||
const dataRange = dataMax - dataMin;
|
||||
const avgValue = (dataMax + dataMin) / 2;
|
||||
|
||||
// Calculate relative change as a percentage
|
||||
const relativeChange = avgValue !== 0 ? dataRange / Math.abs(avgValue) : 1;
|
||||
|
||||
// Dynamic baseline calculation
|
||||
let yMin;
|
||||
let yMax;
|
||||
|
||||
// For small relative changes (< 10%), use a tighter scale
|
||||
if (relativeChange < 0.1 && dataMin > 0) {
|
||||
// Start axis at a percentage below the minimum, not at 0
|
||||
const baselinePadding = dataRange * 2; // Show 2x the data range below min
|
||||
yMin = Math.max(0, dataMin - baselinePadding);
|
||||
yMax = dataMax + dataRange * 0.5; // Add 50% padding above
|
||||
} else {
|
||||
// For larger changes or when data crosses zero, use more context
|
||||
// Always include 0 when data is negative or close to 0
|
||||
if (dataMin < 0 || (dataMin >= 0 && dataMin < avgValue * 0.1)) {
|
||||
yMin = Math.min(0, dataMin * 1.1);
|
||||
} else {
|
||||
// Otherwise use dynamic baseline
|
||||
yMin = dataMin - dataRange * 0.3;
|
||||
}
|
||||
yMax = dataMax + dataRange * 0.1;
|
||||
}
|
||||
|
||||
// Adjust padding for labels if needed
|
||||
if (this.useLabelsValue) {
|
||||
const extraPadding = (yMax - yMin) * 0.1;
|
||||
yMin -= extraPadding;
|
||||
yMax += extraPadding;
|
||||
}
|
||||
|
||||
return d3
|
||||
.scaleLinear()
|
||||
.rangeRound([this._d3ContainerHeight, 0])
|
||||
.domain([dataMin - padding, dataMax + padding]);
|
||||
.domain([yMin, yMax]);
|
||||
}
|
||||
|
||||
_setupResizeObserver() {
|
||||
|
||||
22
app/jobs/family_data_export_job.rb
Normal file
22
app/jobs/family_data_export_job.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class FamilyDataExportJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(family_export)
|
||||
family_export.update!(status: :processing)
|
||||
|
||||
exporter = Family::DataExporter.new(family_export.family)
|
||||
zip_file = exporter.generate_export
|
||||
|
||||
family_export.export_file.attach(
|
||||
io: zip_file,
|
||||
filename: family_export.filename,
|
||||
content_type: "application/zip"
|
||||
)
|
||||
|
||||
family_export.update!(status: :completed)
|
||||
rescue => e
|
||||
Rails.logger.error "Family export failed: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
family_export.update!(status: :failed)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account < ApplicationRecord
|
||||
include Syncable, Monetizable, Chartable, Linkable, Enrichable
|
||||
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable
|
||||
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
@@ -18,7 +18,7 @@ class Account < ApplicationRecord
|
||||
|
||||
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
scope :visible, -> { where(status: [ "draft", "active" ]) }
|
||||
scope :assets, -> { where(classification: "asset") }
|
||||
scope :liabilities, -> { where(classification: "liability") }
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
@@ -30,30 +30,42 @@ class Account < ApplicationRecord
|
||||
|
||||
accepts_nested_attributes_for :accountable, update_only: true
|
||||
|
||||
# Account state machine
|
||||
aasm column: :status, timestamps: true do
|
||||
state :active, initial: true
|
||||
state :draft
|
||||
state :disabled
|
||||
state :pending_deletion
|
||||
|
||||
event :activate do
|
||||
transitions from: [ :draft, :disabled ], to: :active
|
||||
end
|
||||
|
||||
event :disable do
|
||||
transitions from: [ :draft, :active ], to: :disabled
|
||||
end
|
||||
|
||||
event :enable do
|
||||
transitions from: :disabled, to: :active
|
||||
end
|
||||
|
||||
event :mark_for_deletion do
|
||||
transitions from: [ :draft, :active, :disabled ], to: :pending_deletion
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
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
|
||||
@@ -77,57 +89,29 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true, is_active: false)
|
||||
mark_for_deletion!
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
# Override destroy to handle error recovery for accounts
|
||||
def destroy
|
||||
super
|
||||
rescue => e
|
||||
# If destruction fails, transition back to disabled state
|
||||
# This provides a cleaner recovery path than the generic scheduled_for_deletion flag
|
||||
disable! if may_disable?
|
||||
raise e
|
||||
end
|
||||
|
||||
def current_holdings
|
||||
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
|
||||
end
|
||||
|
||||
def update_with_sync!(attributes)
|
||||
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
|
||||
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)
|
||||
should_update_initial_balance = initial_balance && initial_balance.to_d != accountable.initial_balance
|
||||
|
||||
transaction do
|
||||
update!(attributes)
|
||||
update_balance!(attributes[:balance]) if should_update_balance
|
||||
update_inital_balance!(attributes[:accountable_attributes][:initial_balance]) if should_update_initial_balance
|
||||
end
|
||||
|
||||
sync_later
|
||||
end
|
||||
|
||||
def update_balance!(balance)
|
||||
valuation = entries.valuations.find_by(date: Date.current)
|
||||
|
||||
if valuation
|
||||
valuation.update! amount: balance
|
||||
else
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
name: "Balance update",
|
||||
amount: balance,
|
||||
currency: currency,
|
||||
entryable: Valuation.new
|
||||
end
|
||||
end
|
||||
|
||||
def update_inital_balance!(initial_balance)
|
||||
valuation = first_valuation
|
||||
|
||||
if valuation
|
||||
valuation.update! amount: initial_balance
|
||||
else
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
name: "Initial Balance",
|
||||
amount: initial_balance,
|
||||
currency: currency,
|
||||
entryable: Valuation.new
|
||||
end
|
||||
holdings.where(currency: currency)
|
||||
.where.not(qty: 0)
|
||||
.where(
|
||||
id: holdings.select("DISTINCT ON (security_id) id")
|
||||
.where(currency: currency)
|
||||
.order(:security_id, date: :desc)
|
||||
)
|
||||
.order(amount: :desc)
|
||||
end
|
||||
|
||||
def start_date
|
||||
@@ -157,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
|
||||
|
||||
85
app/models/account/activity_feed_data.rb
Normal file
85
app/models/account/activity_feed_data.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
# 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, :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: balance_for_date(date),
|
||||
transfers: transfers_for_date(date)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def balance_for_date(date)
|
||||
balances_by_date[date]
|
||||
end
|
||||
|
||||
def transfers_for_date(date)
|
||||
transfers_by_date[date] || []
|
||||
end
|
||||
|
||||
def grouped_entries
|
||||
@grouped_entries ||= entries.group_by(&:date)
|
||||
end
|
||||
|
||||
def balances_by_date
|
||||
@balances_by_date ||= begin
|
||||
return {} if entries.empty?
|
||||
|
||||
dates = grouped_entries.keys
|
||||
account.balances
|
||||
.where(date: dates, currency: account.currency)
|
||||
.index_by(&:date)
|
||||
end
|
||||
end
|
||||
|
||||
def transfers_by_date
|
||||
@transfers_by_date ||= begin
|
||||
return {} if transaction_ids.empty?
|
||||
|
||||
transfers = Transfer
|
||||
.where(inflow_transaction_id: transaction_ids)
|
||||
.or(Transfer.where(outflow_transaction_id: transaction_ids))
|
||||
.to_a
|
||||
|
||||
# Group transfers by the date of their transaction entries
|
||||
result = Hash.new { |h, k| h[k] = [] }
|
||||
|
||||
entries.each do |entry|
|
||||
next unless entry.transaction? && transaction_ids.include?(entry.entryable_id)
|
||||
|
||||
transfers.each do |transfer|
|
||||
if transfer.inflow_transaction_id == entry.entryable_id ||
|
||||
transfer.outflow_transaction_id == entry.entryable_id
|
||||
result[entry.date] << transfer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Remove duplicates
|
||||
result.transform_values(&:uniq)
|
||||
end
|
||||
end
|
||||
|
||||
def transaction_ids
|
||||
@transaction_ids ||= entries
|
||||
.select(&:transaction?)
|
||||
.map(&:entryable_id)
|
||||
.compact
|
||||
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
|
||||
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&.end_cash_balance,
|
||||
balance: balance_record&.end_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
|
||||
|
||||
@@ -56,7 +56,7 @@ class Assistant::Function
|
||||
end
|
||||
|
||||
def family_account_names
|
||||
@family_account_names ||= family.accounts.active.pluck(:name)
|
||||
@family_account_names ||= family.accounts.visible.pluck(:name)
|
||||
end
|
||||
|
||||
def family_category_names
|
||||
|
||||
@@ -22,7 +22,7 @@ class Assistant::Function::GetAccounts < Assistant::Function
|
||||
type: account.accountable_type,
|
||||
start_date: account.start_date,
|
||||
is_plaid_linked: account.plaid_account_id.present?,
|
||||
is_active: account.is_active,
|
||||
status: account.status,
|
||||
historical_balances: historical_balances(account)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -44,7 +44,7 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
|
||||
|
||||
private
|
||||
def historical_data(period, classification: nil)
|
||||
scope = family.accounts.active
|
||||
scope = family.accounts.visible
|
||||
scope = scope.where(classification: classification) if classification.present?
|
||||
|
||||
if period.start_date == Date.current
|
||||
|
||||
@@ -134,7 +134,8 @@ class Assistant::Function::GetTransactions < Assistant::Function
|
||||
def call(params = {})
|
||||
search_params = params.except("order", "page")
|
||||
|
||||
transactions_query = family.transactions.active.search(search_params)
|
||||
search = Transaction::Search.new(family, filters: search_params)
|
||||
transactions_query = search.transactions_scope
|
||||
pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological
|
||||
|
||||
# By default, we give a small page size to force the AI to use filters effectively and save on tokens
|
||||
@@ -149,7 +150,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
|
||||
limit: default_page_size
|
||||
)
|
||||
|
||||
totals = family.income_statement.totals(transactions_scope: transactions_query)
|
||||
totals = search.totals
|
||||
|
||||
normalized_transactions = paginated_transactions.map do |txn|
|
||||
entry = txn.entry
|
||||
|
||||
@@ -2,8 +2,30 @@ class Balance < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :account
|
||||
|
||||
validates :account, :date, :balance, presence: true
|
||||
monetize :balance
|
||||
validates :flows_factor, inclusion: { in: [ -1, 1 ] }
|
||||
|
||||
monetize :balance, :cash_balance,
|
||||
:start_cash_balance, :start_non_cash_balance, :start_balance,
|
||||
:cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows,
|
||||
:cash_adjustments, :non_cash_adjustments,
|
||||
:end_cash_balance, :end_non_cash_balance, :end_balance
|
||||
|
||||
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
|
||||
scope :chronological, -> { order(:date) }
|
||||
|
||||
def balance_trend
|
||||
Trend.new(
|
||||
current: end_balance_money,
|
||||
previous: start_balance_money,
|
||||
favorable_direction: favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def favorable_direction
|
||||
flows_factor == -1 ? "down" : "up"
|
||||
end
|
||||
end
|
||||
|
||||
140
app/models/balance/base_calculator.rb
Normal file
140
app/models/balance/base_calculator.rb
Normal file
@@ -0,0 +1,140 @@
|
||||
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_value_for_date ||= {}
|
||||
@holdings_value_for_date[date] ||= sync_cache.get_holdings(date).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 cash_adjustments_for_date(start_cash, end_cash, net_cash_flows)
|
||||
return 0 unless account.balance_type != :non_cash
|
||||
|
||||
end_cash - start_cash - net_cash_flows
|
||||
end
|
||||
|
||||
def non_cash_adjustments_for_date(start_non_cash, end_non_cash, non_cash_flows)
|
||||
return 0 unless account.balance_type == :non_cash
|
||||
|
||||
end_non_cash - start_non_cash - non_cash_flows
|
||||
end
|
||||
|
||||
# If holdings value goes from $100 -> $200 (change_holdings_value is $100)
|
||||
# And non-cash flows (i.e. "buys") for day are +$50 (net_buy_sell_value is $50)
|
||||
# That means value increased by $100, where $50 of that is due to the change in holdings value, and $50 is due to the buy/sell
|
||||
def market_value_change_on_date(date, flows)
|
||||
return 0 unless account.balance_type == :investment
|
||||
|
||||
start_of_day_holdings_value = holdings_value_for_date(date.prev_day)
|
||||
end_of_day_holdings_value = holdings_value_for_date(date)
|
||||
|
||||
change_holdings_value = end_of_day_holdings_value - start_of_day_holdings_value
|
||||
net_buy_sell_value = flows[:non_cash_inflows] - flows[:non_cash_outflows]
|
||||
|
||||
change_holdings_value - net_buy_sell_value
|
||||
end
|
||||
|
||||
def flows_for_date(date)
|
||||
entries = sync_cache.get_entries(date)
|
||||
|
||||
cash_inflows = 0
|
||||
cash_outflows = 0
|
||||
non_cash_inflows = 0
|
||||
non_cash_outflows = 0
|
||||
|
||||
txn_inflow_sum = entries.select { |e| e.amount < 0 && e.transaction? }.sum(&:amount)
|
||||
txn_outflow_sum = entries.select { |e| e.amount >= 0 && e.transaction? }.sum(&:amount)
|
||||
|
||||
trade_cash_inflow_sum = entries.select { |e| e.amount < 0 && e.trade? }.sum(&:amount)
|
||||
trade_cash_outflow_sum = entries.select { |e| e.amount >= 0 && e.trade? }.sum(&:amount)
|
||||
|
||||
if account.balance_type == :non_cash && account.accountable_type == "Loan"
|
||||
non_cash_inflows = txn_inflow_sum.abs
|
||||
non_cash_outflows = txn_outflow_sum
|
||||
elsif account.balance_type != :non_cash
|
||||
cash_inflows = txn_inflow_sum.abs + trade_cash_inflow_sum.abs
|
||||
cash_outflows = txn_outflow_sum + trade_cash_outflow_sum
|
||||
|
||||
# Trades are inverse (a "buy" is outflow of cash, but "inflow" of non-cash, aka "holdings")
|
||||
non_cash_outflows = trade_cash_inflow_sum.abs
|
||||
non_cash_inflows = trade_cash_outflow_sum
|
||||
end
|
||||
|
||||
{
|
||||
cash_inflows: cash_inflows,
|
||||
cash_outflows: cash_outflows,
|
||||
non_cash_inflows: non_cash_inflows,
|
||||
non_cash_outflows: non_cash_outflows
|
||||
}
|
||||
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:, **args)
|
||||
Balance.new(
|
||||
account_id: account.id,
|
||||
currency: account.currency,
|
||||
date: date,
|
||||
balance: args[:balance],
|
||||
cash_balance: args[:cash_balance],
|
||||
start_cash_balance: args[:start_cash_balance] || 0,
|
||||
start_non_cash_balance: args[:start_non_cash_balance] || 0,
|
||||
cash_inflows: args[:cash_inflows] || 0,
|
||||
cash_outflows: args[:cash_outflows] || 0,
|
||||
non_cash_inflows: args[:non_cash_inflows] || 0,
|
||||
non_cash_outflows: args[:non_cash_outflows] || 0,
|
||||
cash_adjustments: args[:cash_adjustments] || 0,
|
||||
non_cash_adjustments: args[:non_cash_adjustments] || 0,
|
||||
net_market_flows: args[:net_market_flows] || 0,
|
||||
flows_factor: account.classification == "asset" ? 1 : -1
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -8,21 +8,21 @@ class Balance::ChartSeriesBuilder
|
||||
end
|
||||
|
||||
def balance_series
|
||||
build_series_for(:balance)
|
||||
build_series_for(:end_balance)
|
||||
rescue => e
|
||||
Rails.logger.error "Balance series error: #{e.message} for accounts #{@account_ids}"
|
||||
raise
|
||||
end
|
||||
|
||||
def cash_balance_series
|
||||
build_series_for(:cash_balance)
|
||||
build_series_for(:end_cash_balance)
|
||||
rescue => e
|
||||
Rails.logger.error "Cash balance series error: #{e.message} for accounts #{@account_ids}"
|
||||
raise
|
||||
end
|
||||
|
||||
def holdings_balance_series
|
||||
build_series_for(:holdings_balance)
|
||||
build_series_for(:end_holdings_balance)
|
||||
rescue => e
|
||||
Rails.logger.error "Holdings balance series error: #{e.message} for accounts #{@account_ids}"
|
||||
raise
|
||||
@@ -37,13 +37,20 @@ class Balance::ChartSeriesBuilder
|
||||
|
||||
def build_series_for(column)
|
||||
values = query_data.map do |datum|
|
||||
# Map column names to their start equivalents
|
||||
previous_column = case column
|
||||
when :end_balance then :start_balance
|
||||
when :end_cash_balance then :start_cash_balance
|
||||
when :end_holdings_balance then :start_holdings_balance
|
||||
end
|
||||
|
||||
Series::Value.new(
|
||||
date: datum.date,
|
||||
date_formatted: I18n.l(datum.date, format: :long),
|
||||
value: Money.new(datum.send(column), currency),
|
||||
trend: Trend.new(
|
||||
current: Money.new(datum.send(column), currency),
|
||||
previous: Money.new(datum.send("previous_#{column}"), currency),
|
||||
previous: Money.new(datum.send(previous_column), currency),
|
||||
favorable_direction: favorable_direction
|
||||
)
|
||||
)
|
||||
@@ -88,66 +95,57 @@ class Balance::ChartSeriesBuilder
|
||||
WITH dates AS (
|
||||
SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date
|
||||
UNION DISTINCT
|
||||
SELECT :end_date::date -- Pass in date to ensure timezone-aware "today" date
|
||||
), aggregated_balances AS (
|
||||
SELECT
|
||||
d.date,
|
||||
-- Total balance (assets positive, liabilities negative)
|
||||
SUM(
|
||||
CASE WHEN accounts.classification = 'asset'
|
||||
THEN COALESCE(last_bal.balance, 0)
|
||||
ELSE -COALESCE(last_bal.balance, 0)
|
||||
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
|
||||
) AS balance,
|
||||
-- Cash-only balance
|
||||
SUM(
|
||||
CASE WHEN accounts.classification = 'asset'
|
||||
THEN COALESCE(last_bal.cash_balance, 0)
|
||||
ELSE -COALESCE(last_bal.cash_balance, 0)
|
||||
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
|
||||
) AS cash_balance,
|
||||
-- Holdings value (balance ‑ cash)
|
||||
SUM(
|
||||
CASE WHEN accounts.classification = 'asset'
|
||||
THEN COALESCE(last_bal.balance, 0) - COALESCE(last_bal.cash_balance, 0)
|
||||
ELSE 0
|
||||
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
|
||||
) AS holdings_balance
|
||||
FROM dates d
|
||||
JOIN accounts ON accounts.id = ANY(array[:account_ids]::uuid[])
|
||||
|
||||
-- Last observation carried forward (LOCF), use the most recent balance on or before the chart date
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT b.balance, b.cash_balance
|
||||
FROM balances b
|
||||
WHERE b.account_id = accounts.id
|
||||
AND b.date <= d.date
|
||||
ORDER BY b.date DESC
|
||||
LIMIT 1
|
||||
) last_bal ON TRUE
|
||||
|
||||
-- Last observation carried forward (LOCF), use the most recent exchange rate on or before the chart date
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT er.rate
|
||||
FROM exchange_rates er
|
||||
WHERE er.from_currency = accounts.currency
|
||||
AND er.to_currency = :target_currency
|
||||
AND er.date <= d.date
|
||||
ORDER BY er.date DESC
|
||||
LIMIT 1
|
||||
) er ON TRUE
|
||||
GROUP BY d.date
|
||||
SELECT :end_date::date -- Ensure end date is included
|
||||
)
|
||||
SELECT
|
||||
date,
|
||||
balance,
|
||||
cash_balance,
|
||||
holdings_balance,
|
||||
COALESCE(LAG(balance) OVER (ORDER BY date), 0) AS previous_balance,
|
||||
COALESCE(LAG(cash_balance) OVER (ORDER BY date), 0) AS previous_cash_balance,
|
||||
COALESCE(LAG(holdings_balance) OVER (ORDER BY date), 0) AS previous_holdings_balance
|
||||
FROM aggregated_balances
|
||||
ORDER BY date
|
||||
d.date,
|
||||
-- Use flows_factor: already handles asset (+1) vs liability (-1)
|
||||
COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance,
|
||||
COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance,
|
||||
-- Holdings only for assets (flows_factor = 1)
|
||||
COALESCE(SUM(
|
||||
CASE WHEN last_bal.flows_factor = 1
|
||||
THEN last_bal.end_non_cash_balance
|
||||
ELSE 0
|
||||
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
|
||||
), 0) AS end_holdings_balance,
|
||||
-- Previous balances
|
||||
COALESCE(SUM(last_bal.start_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_balance,
|
||||
COALESCE(SUM(last_bal.start_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_cash_balance,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN last_bal.flows_factor = 1
|
||||
THEN last_bal.start_non_cash_balance
|
||||
ELSE 0
|
||||
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
|
||||
), 0) AS start_holdings_balance
|
||||
FROM dates d
|
||||
CROSS JOIN accounts
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT b.end_balance,
|
||||
b.end_cash_balance,
|
||||
b.end_non_cash_balance,
|
||||
b.start_balance,
|
||||
b.start_cash_balance,
|
||||
b.start_non_cash_balance,
|
||||
b.flows_factor
|
||||
FROM balances b
|
||||
WHERE b.account_id = accounts.id
|
||||
AND b.date <= d.date
|
||||
ORDER BY b.date DESC
|
||||
LIMIT 1
|
||||
) last_bal ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT er.rate
|
||||
FROM exchange_rates er
|
||||
WHERE er.from_currency = accounts.currency
|
||||
AND er.to_currency = :target_currency
|
||||
AND er.date <= d.date
|
||||
ORDER BY er.date DESC
|
||||
LIMIT 1
|
||||
) er ON TRUE
|
||||
WHERE accounts.id = ANY(array[:account_ids]::uuid[])
|
||||
GROUP BY d.date
|
||||
ORDER BY d.date
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,61 +1,85 @@
|
||||
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_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
|
||||
|
||||
flows = flows_for_date(date)
|
||||
market_value_change = market_value_change_on_date(date, flows)
|
||||
|
||||
cash_adjustments = cash_adjustments_for_date(start_cash_balance, end_cash_balance, (flows[:cash_inflows] - flows[:cash_outflows]) * flows_factor)
|
||||
non_cash_adjustments = non_cash_adjustments_for_date(start_non_cash_balance, end_non_cash_balance, (flows[:non_cash_inflows] - flows[:non_cash_outflows]) * flows_factor)
|
||||
|
||||
output_balance = build_balance(
|
||||
date: date,
|
||||
balance: end_cash_balance + end_non_cash_balance,
|
||||
cash_balance: end_cash_balance,
|
||||
start_cash_balance: start_cash_balance,
|
||||
start_non_cash_balance: start_non_cash_balance,
|
||||
cash_inflows: flows[:cash_inflows],
|
||||
cash_outflows: flows[:cash_outflows],
|
||||
non_cash_inflows: flows[:non_cash_inflows],
|
||||
non_cash_outflows: flows[:non_cash_outflows],
|
||||
cash_adjustments: cash_adjustments,
|
||||
non_cash_adjustments: non_cash_adjustments,
|
||||
net_market_flows: market_value_change
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
def flows_factor
|
||||
account.asset? ? 1 : -1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,9 +28,20 @@ class Balance::Materializer
|
||||
end
|
||||
|
||||
def update_account_info
|
||||
calculated_balance = @balances.sort_by(&:date).last&.balance || 0
|
||||
calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
|
||||
calculated_cash_balance = calculated_balance - calculated_holdings_value
|
||||
# Query fresh balance from DB to get generated column values
|
||||
current_balance = account.balances
|
||||
.where(currency: account.currency)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
|
||||
if current_balance
|
||||
calculated_balance = current_balance.end_balance
|
||||
calculated_cash_balance = current_balance.end_cash_balance
|
||||
else
|
||||
# Fallback if no balance exists
|
||||
calculated_balance = 0
|
||||
calculated_cash_balance = 0
|
||||
end
|
||||
|
||||
Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")
|
||||
|
||||
@@ -48,14 +59,23 @@ class Balance::Materializer
|
||||
current_time = Time.now
|
||||
account.balances.upsert_all(
|
||||
@balances.map { |b| b.attributes
|
||||
.slice("date", "balance", "cash_balance", "currency")
|
||||
.slice("date", "balance", "cash_balance", "currency",
|
||||
"start_cash_balance", "start_non_cash_balance",
|
||||
"cash_inflows", "cash_outflows",
|
||||
"non_cash_inflows", "non_cash_outflows",
|
||||
"net_market_flows",
|
||||
"cash_adjustments", "non_cash_adjustments",
|
||||
"flows_factor")
|
||||
.merge("updated_at" => current_time) },
|
||||
unique_by: %i[account_id date currency]
|
||||
)
|
||||
end
|
||||
|
||||
def purge_stale_balances
|
||||
deleted_count = account.balances.delete_by("date < ?", account.start_date)
|
||||
sorted_balances = @balances.sort_by(&:date)
|
||||
oldest_calculated_balance_date = sorted_balances.first&.date
|
||||
newest_calculated_balance_date = sorted_balances.last&.date
|
||||
deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_calculated_balance_date, newest_calculated_balance_date)
|
||||
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
|
||||
end
|
||||
|
||||
|
||||
@@ -1,71 +1,82 @@
|
||||
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|
|
||||
flows = flows_for_date(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
|
||||
market_value_change = 0
|
||||
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)
|
||||
market_value_change = market_value_change_on_date(date, flows)
|
||||
end
|
||||
|
||||
output_balance = build_balance(
|
||||
date: date,
|
||||
balance: end_cash_balance + end_non_cash_balance,
|
||||
cash_balance: end_cash_balance,
|
||||
start_cash_balance: start_cash_balance,
|
||||
start_non_cash_balance: start_non_cash_balance,
|
||||
cash_inflows: flows[:cash_inflows],
|
||||
cash_outflows: flows[:cash_outflows],
|
||||
non_cash_inflows: flows[:non_cash_inflows],
|
||||
non_cash_outflows: flows[:non_cash_outflows],
|
||||
net_market_flows: market_value_change
|
||||
)
|
||||
|
||||
end_cash_balance = start_cash_balance
|
||||
end_non_cash_balance = start_non_cash_balance
|
||||
|
||||
output_balance
|
||||
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)
|
||||
# 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 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 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
|
||||
|
||||
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
|
||||
# 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
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -23,8 +23,8 @@ class BalanceSheet::AccountTotals
|
||||
delegate_missing_to :account
|
||||
end
|
||||
|
||||
def active_accounts
|
||||
@active_accounts ||= family.accounts.active.with_attached_logo
|
||||
def visible_accounts
|
||||
@visible_accounts ||= family.accounts.visible.with_attached_logo
|
||||
end
|
||||
|
||||
def account_rows
|
||||
@@ -46,7 +46,7 @@ class BalanceSheet::AccountTotals
|
||||
|
||||
def query
|
||||
@query ||= Rails.cache.fetch(cache_key) do
|
||||
active_accounts
|
||||
visible_accounts
|
||||
.joins(ActiveRecord::Base.sanitize_sql_array([
|
||||
"LEFT JOIN exchange_rates ON exchange_rates.date = ? AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?",
|
||||
Date.current,
|
||||
|
||||
@@ -6,7 +6,7 @@ class BalanceSheet::NetWorthSeriesBuilder
|
||||
def net_worth_series(period: Period.last_30_days)
|
||||
Rails.cache.fetch(cache_key(period)) do
|
||||
builder = Balance::ChartSeriesBuilder.new(
|
||||
account_ids: active_account_ids,
|
||||
account_ids: visible_account_ids,
|
||||
currency: family.currency,
|
||||
period: period,
|
||||
favorable_direction: "up"
|
||||
@@ -19,8 +19,8 @@ class BalanceSheet::NetWorthSeriesBuilder
|
||||
private
|
||||
attr_reader :family
|
||||
|
||||
def active_account_ids
|
||||
@active_account_ids ||= family.accounts.active.with_attached_logo.pluck(:id)
|
||||
def visible_account_ids
|
||||
@visible_account_ids ||= family.accounts.visible.with_attached_logo.pluck(:id)
|
||||
end
|
||||
|
||||
def cache_key(period)
|
||||
|
||||
@@ -17,7 +17,7 @@ class BalanceSheet::SyncStatusMonitor
|
||||
def syncing_account_ids
|
||||
Rails.cache.fetch(cache_key) do
|
||||
Sync.visible
|
||||
.where(syncable_type: "Account", syncable_id: family.accounts.active.pluck(:id))
|
||||
.where(syncable_type: "Account", syncable_id: family.accounts.visible.pluck(:id))
|
||||
.pluck(:syncable_id)
|
||||
.to_set
|
||||
end
|
||||
|
||||
@@ -49,7 +49,10 @@ class Budget < ApplicationRecord
|
||||
|
||||
private
|
||||
def oldest_valid_budget_date(family)
|
||||
@oldest_valid_budget_date ||= family.oldest_entry_date.beginning_of_month
|
||||
# Allow going back to either the earliest entry date OR 2 years ago, whichever is earlier
|
||||
two_years_ago = 2.years.ago.beginning_of_month
|
||||
oldest_entry_date = family.oldest_entry_date.beginning_of_month
|
||||
[ two_years_ago, oldest_entry_date ].min
|
||||
end
|
||||
end
|
||||
|
||||
@@ -88,7 +91,7 @@ class Budget < ApplicationRecord
|
||||
end
|
||||
|
||||
def transactions
|
||||
family.transactions.active.in_period(period)
|
||||
family.transactions.visible.in_period(period)
|
||||
end
|
||||
|
||||
def name
|
||||
|
||||
@@ -72,6 +72,14 @@ module Accountable
|
||||
self.class.display_name
|
||||
end
|
||||
|
||||
def balance_display_name
|
||||
"account value"
|
||||
end
|
||||
|
||||
def opening_balance_display_name
|
||||
"opening balance"
|
||||
end
|
||||
|
||||
def icon
|
||||
self.class.icon
|
||||
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
|
||||
)
|
||||
|
||||
@@ -14,8 +14,8 @@ class Entry < ApplicationRecord
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }
|
||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||
|
||||
scope :active, -> {
|
||||
joins(:account).where(accounts: { is_active: true })
|
||||
scope :visible, -> {
|
||||
joins(:account).where(accounts: { status: [ "draft", "active" ] })
|
||||
}
|
||||
|
||||
scope :chronological, -> {
|
||||
|
||||
@@ -14,7 +14,7 @@ module Entryable
|
||||
|
||||
scope :with_entry, -> { joins(:entry) }
|
||||
|
||||
scope :active, -> { with_entry.merge(Entry.active) }
|
||||
scope :visible, -> { with_entry.merge(Entry.visible) }
|
||||
|
||||
scope :in_period, ->(period) {
|
||||
with_entry.where(entries: { date: period.start_date..period.end_date })
|
||||
|
||||
@@ -18,6 +18,7 @@ class Family < ApplicationRecord
|
||||
has_many :invitations, dependent: :destroy
|
||||
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :family_exports, dependent: :destroy
|
||||
|
||||
has_many :entries, through: :accounts
|
||||
has_many :transactions, through: :accounts
|
||||
@@ -100,7 +101,8 @@ class Family < ApplicationRecord
|
||||
[
|
||||
id,
|
||||
key,
|
||||
data_invalidation_key
|
||||
data_invalidation_key,
|
||||
accounts.maximum(:updated_at)
|
||||
].compact.join("_")
|
||||
end
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ module Family::AutoTransferMatchable
|
||||
JOIN entries outflow_candidates ON (
|
||||
inflow_candidates.amount < 0 AND
|
||||
outflow_candidates.amount > 0 AND
|
||||
inflow_candidates.amount = -outflow_candidates.amount AND
|
||||
inflow_candidates.currency = outflow_candidates.currency AND
|
||||
inflow_candidates.account_id <> outflow_candidates.account_id AND
|
||||
inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4
|
||||
)
|
||||
@@ -24,12 +22,26 @@ module Family::AutoTransferMatchable
|
||||
rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND
|
||||
rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id
|
||||
)")
|
||||
.joins("LEFT JOIN exchange_rates ON (
|
||||
exchange_rates.date = outflow_candidates.date AND
|
||||
exchange_rates.from_currency = outflow_candidates.currency AND
|
||||
exchange_rates.to_currency = inflow_candidates.currency
|
||||
)")
|
||||
.joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
|
||||
.joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
|
||||
.where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id)
|
||||
.where("inflow_accounts.is_active = true")
|
||||
.where("outflow_accounts.is_active = true")
|
||||
.where("inflow_accounts.status IN ('draft', 'active')")
|
||||
.where("outflow_accounts.status IN ('draft', 'active')")
|
||||
.where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'")
|
||||
.where("
|
||||
(
|
||||
inflow_candidates.currency = outflow_candidates.currency AND
|
||||
inflow_candidates.amount = -outflow_candidates.amount
|
||||
) OR (
|
||||
inflow_candidates.currency <> outflow_candidates.currency AND
|
||||
ABS(inflow_candidates.amount / NULLIF(outflow_candidates.amount * exchange_rates.rate, 0)) BETWEEN 0.95 AND 1.05
|
||||
)
|
||||
")
|
||||
.where(existing_transfers: { id: nil })
|
||||
.order("date_diff ASC") # Closest matches first
|
||||
end
|
||||
|
||||
238
app/models/family/data_exporter.rb
Normal file
238
app/models/family/data_exporter.rb
Normal file
@@ -0,0 +1,238 @@
|
||||
require "zip"
|
||||
require "csv"
|
||||
|
||||
class Family::DataExporter
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def generate_export
|
||||
# Create a StringIO to hold the zip data in memory
|
||||
zip_data = Zip::OutputStream.write_buffer do |zipfile|
|
||||
# Add accounts.csv
|
||||
zipfile.put_next_entry("accounts.csv")
|
||||
zipfile.write generate_accounts_csv
|
||||
|
||||
# Add transactions.csv
|
||||
zipfile.put_next_entry("transactions.csv")
|
||||
zipfile.write generate_transactions_csv
|
||||
|
||||
# Add trades.csv
|
||||
zipfile.put_next_entry("trades.csv")
|
||||
zipfile.write generate_trades_csv
|
||||
|
||||
# Add categories.csv
|
||||
zipfile.put_next_entry("categories.csv")
|
||||
zipfile.write generate_categories_csv
|
||||
|
||||
# Add all.ndjson
|
||||
zipfile.put_next_entry("all.ndjson")
|
||||
zipfile.write generate_ndjson
|
||||
end
|
||||
|
||||
# Rewind and return the StringIO
|
||||
zip_data.rewind
|
||||
zip_data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_accounts_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "id", "name", "type", "subtype", "balance", "currency", "created_at" ]
|
||||
|
||||
# Only export accounts belonging to this family
|
||||
@family.accounts.includes(:accountable).find_each do |account|
|
||||
csv << [
|
||||
account.id,
|
||||
account.name,
|
||||
account.accountable_type,
|
||||
account.subtype,
|
||||
account.balance.to_s,
|
||||
account.currency,
|
||||
account.created_at.iso8601
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_transactions_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ]
|
||||
|
||||
# Only export transactions from accounts belonging to this family
|
||||
@family.transactions
|
||||
.includes(:category, :tags, entry: :account)
|
||||
.find_each do |transaction|
|
||||
csv << [
|
||||
transaction.entry.date.iso8601,
|
||||
transaction.entry.account.name,
|
||||
transaction.entry.amount.to_s,
|
||||
transaction.entry.name,
|
||||
transaction.category&.name,
|
||||
transaction.tags.pluck(:name).join(","),
|
||||
transaction.entry.notes,
|
||||
transaction.entry.currency
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_trades_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ]
|
||||
|
||||
# Only export trades from accounts belonging to this family
|
||||
@family.trades
|
||||
.includes(:security, entry: :account)
|
||||
.find_each do |trade|
|
||||
csv << [
|
||||
trade.entry.date.iso8601,
|
||||
trade.entry.account.name,
|
||||
trade.security.ticker,
|
||||
trade.qty.to_s,
|
||||
trade.price.to_s,
|
||||
trade.entry.amount.to_s,
|
||||
trade.currency
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_categories_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "name", "color", "parent_category", "classification" ]
|
||||
|
||||
# Only export categories belonging to this family
|
||||
@family.categories.includes(:parent).find_each do |category|
|
||||
csv << [
|
||||
category.name,
|
||||
category.color,
|
||||
category.parent&.name,
|
||||
category.classification
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_ndjson
|
||||
lines = []
|
||||
|
||||
# Export accounts with full accountable data
|
||||
@family.accounts.includes(:accountable).find_each do |account|
|
||||
lines << {
|
||||
type: "Account",
|
||||
data: account.as_json(
|
||||
include: {
|
||||
accountable: {}
|
||||
}
|
||||
)
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export categories
|
||||
@family.categories.find_each do |category|
|
||||
lines << {
|
||||
type: "Category",
|
||||
data: category.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export tags
|
||||
@family.tags.find_each do |tag|
|
||||
lines << {
|
||||
type: "Tag",
|
||||
data: tag.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export merchants (only family merchants)
|
||||
@family.merchants.find_each do |merchant|
|
||||
lines << {
|
||||
type: "Merchant",
|
||||
data: merchant.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export transactions with full data
|
||||
@family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction|
|
||||
lines << {
|
||||
type: "Transaction",
|
||||
data: {
|
||||
id: transaction.id,
|
||||
entry_id: transaction.entry.id,
|
||||
account_id: transaction.entry.account_id,
|
||||
date: transaction.entry.date,
|
||||
amount: transaction.entry.amount,
|
||||
currency: transaction.entry.currency,
|
||||
name: transaction.entry.name,
|
||||
notes: transaction.entry.notes,
|
||||
excluded: transaction.entry.excluded,
|
||||
category_id: transaction.category_id,
|
||||
merchant_id: transaction.merchant_id,
|
||||
tag_ids: transaction.tag_ids,
|
||||
kind: transaction.kind,
|
||||
created_at: transaction.created_at,
|
||||
updated_at: transaction.updated_at
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export trades with full data
|
||||
@family.trades.includes(:security, entry: :account).find_each do |trade|
|
||||
lines << {
|
||||
type: "Trade",
|
||||
data: {
|
||||
id: trade.id,
|
||||
entry_id: trade.entry.id,
|
||||
account_id: trade.entry.account_id,
|
||||
security_id: trade.security_id,
|
||||
ticker: trade.security.ticker,
|
||||
date: trade.entry.date,
|
||||
qty: trade.qty,
|
||||
price: trade.price,
|
||||
amount: trade.entry.amount,
|
||||
currency: trade.currency,
|
||||
created_at: trade.created_at,
|
||||
updated_at: trade.updated_at
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export valuations
|
||||
@family.entries.valuations.includes(:account, :entryable).find_each do |entry|
|
||||
lines << {
|
||||
type: "Valuation",
|
||||
data: {
|
||||
id: entry.entryable.id,
|
||||
entry_id: entry.id,
|
||||
account_id: entry.account_id,
|
||||
date: entry.date,
|
||||
amount: entry.amount,
|
||||
currency: entry.currency,
|
||||
name: entry.name,
|
||||
created_at: entry.created_at,
|
||||
updated_at: entry.updated_at
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export budgets
|
||||
@family.budgets.find_each do |budget|
|
||||
lines << {
|
||||
type: "Budget",
|
||||
data: budget.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export budget categories
|
||||
@family.budget_categories.includes(:budget, :category).find_each do |budget_category|
|
||||
lines << {
|
||||
type: "BudgetCategory",
|
||||
data: budget_category.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
lines.join("\n")
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user