1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-08 06:55:21 +02:00

Merge remote-tracking branch 'origin/main' into bugfix/ui-pwa-ios

This commit is contained in:
Ken Tandrian 2025-07-20 07:01:34 +00:00
commit a1a05c8790
279 changed files with 4403 additions and 1156 deletions

View file

@ -122,7 +122,7 @@ GEM
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.6) bootsnap (1.18.6)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.0.2) brakeman (7.1.0)
racc racc
builder (3.3.0) builder (3.3.0)
capybara (3.40.0) capybara (3.40.0)
@ -192,9 +192,9 @@ GEM
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
event_stream_parser (1.0.0) event_stream_parser (1.0.0)
faker (3.5.1) faker (3.5.2)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.13.1) faraday (2.13.2)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@ -365,7 +365,7 @@ GEM
faraday (>= 1, < 3) faraday (>= 1, < 3)
sawyer (~> 0.9) sawyer (~> 0.9)
ostruct (0.6.2) ostruct (0.6.2)
pagy (9.3.4) pagy (9.3.5)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.8.0) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
@ -448,13 +448,13 @@ GEM
ffi (~> 1.0) ffi (~> 1.0)
rbs (3.9.4) rbs (3.9.4)
logger logger
rdoc (6.14.1) rdoc (6.14.2)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
redcarpet (3.6.1) redcarpet (3.6.1)
redis (5.4.0) redis (5.4.0)
redis-client (>= 0.22.0) redis-client (>= 0.22.0)
redis-client (0.24.0) redis-client (0.25.0)
connection_pool connection_pool
regexp_parser (2.10.0) regexp_parser (2.10.0)
reline (0.6.1) reline (0.6.1)
@ -522,16 +522,16 @@ GEM
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)
sentry-rails (5.25.0) sentry-rails (5.26.0)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.25.0) sentry-ruby (~> 5.26.0)
sentry-ruby (5.25.0) sentry-ruby (5.26.0)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.25.0) sentry-sidekiq (5.26.0)
sentry-ruby (~> 5.25.0) sentry-ruby (~> 5.26.0)
sidekiq (>= 3.0) sidekiq (>= 3.0)
sidekiq (8.0.4) sidekiq (8.0.5)
connection_pool (>= 2.5.0) connection_pool (>= 2.5.0)
json (>= 2.9.0) json (>= 2.9.0)
logger (>= 1.6.2) logger (>= 1.6.2)
@ -556,7 +556,7 @@ GEM
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.7) stringio (3.1.7)
stripe (15.2.1) stripe (15.3.0)
tailwindcss-rails (4.2.3) tailwindcss-rails (4.2.3)
railties (>= 7.0.0) railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0) tailwindcss-ruby (~> 4.0)

View file

@ -1,4 +1,4 @@
class AlertComponent < ViewComponent::Base class DS::Alert < DesignSystemComponent
def initialize(message:, variant: :info) def initialize(message:, variant: :info)
@message = message @message = message
@variant = variant @variant = variant

View file

@ -1,6 +1,6 @@
<%= container do %> <%= container do %>
<% if icon && (icon_position != :right) %> <% 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 %> <% end %>
<% unless icon_only? %> <% unless icon_only? %>

View file

@ -2,7 +2,7 @@
# An extension to `button_to` helper. All options are passed through to the `button_to` helper with some additional # An extension to `button_to` helper. All options are passed through to the `button_to` helper with some additional
# options available. # options available.
class ButtonComponent < ButtonishComponent class DS::Button < DS::Buttonish
attr_reader :confirm attr_reader :confirm
def initialize(confirm: nil, **opts) def initialize(confirm: nil, **opts)

View file

@ -1,11 +1,11 @@
class ButtonishComponent < ViewComponent::Base class DS::Buttonish < DesignSystemComponent
VARIANTS = { VARIANTS = {
primary: { primary: {
container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400", container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
icon_classes: "fg-inverse" icon_classes: "fg-inverse"
}, },
secondary: { 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" icon_classes: "fg-primary"
}, },
destructive: { destructive: {
@ -71,7 +71,7 @@ class ButtonishComponent < ViewComponent::Base
end end
def call 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 end
def container_classes(override_classes = nil) def container_classes(override_classes = nil)

View file

@ -1,7 +1,7 @@
<%= wrapper_element do %> <%= wrapper_element do %>
<%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] #{drawer? ? "lg:p-3" : "lg:p-1"}", **merged_opts do %> <%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] #{drawer? ? "lg:p-3" : "lg:p-1"}", **merged_opts do %>
<%= tag.div class: dialog_outer_classes do %> <%= tag.div class: dialog_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"> <div class="grow overflow-y-auto py-4 space-y-4 flex flex-col">
<% if header? %> <% if header? %>
<%= header %> <%= header %>

View file

@ -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 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 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_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 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) safe_join([ title, close_icon ].compact)
end end
@ -19,16 +19,16 @@ class DialogComponent < ViewComponent::Base
renders_many :actions, ->(cancel_action: false, **button_opts) do renders_many :actions, ->(cancel_action: false, **button_opts) do
merged_opts = if cancel_action 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 else
button_opts button_opts
end end
render ButtonComponent.new(**merged_opts) render DS::Button.new(**merged_opts)
end end
renders_many :sections, ->(title:, **disclosure_opts, &block) do 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 block.call
end end
end end
@ -99,11 +99,11 @@ class DialogComponent < ViewComponent::Base
merged_opts = opts.dup merged_opts = opts.dup
data = merged_opts.delete(:data) || {} data = merged_opts.delete(:data) || {}
data[:controller] = [ "dialog", "hotkey", data[:controller] ].compact.join(" ") data[:controller] = [ "DS--dialog", "hotkey", data[:controller] ].compact.join(" ")
data[:dialog_auto_open_value] = auto_open data[:DS__dialog_auto_open_value] = auto_open
data[:dialog_reload_on_close_value] = reload_on_close data[:DS__dialog_reload_on_close_value] = reload_on_close
data[:action] = [ "mousedown->dialog#clickOutside", data[:action] ].compact.join(" ") data[:action] = [ "mousedown->DS--dialog#clickOutside", data[:action] ].compact.join(" ")
data[:hotkey] = "esc:dialog#close" data[:hotkey] = "esc:DS--dialog#close"
merged_opts[:data] = data merged_opts[:data] = data
merged_opts merged_opts

View file

@ -1,4 +1,4 @@
class DisclosureComponent < ViewComponent::Base class DS::Disclosure < DesignSystemComponent
renders_one :summary_content renders_one :summary_content
attr_reader :title, :align, :open, :opts attr_reader :title, :align, :open, :opts

View file

@ -1,4 +1,4 @@
class FilledIconComponent < ViewComponent::Base class DS::FilledIcon < DesignSystemComponent
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
VARIANTS = %i[default text surface container inverse].freeze VARIANTS = %i[default text surface container inverse].freeze

View file

@ -1,6 +1,6 @@
# An extension to `link_to` helper. All options are passed through to the `link_to` helper with some additional # An extension to `link_to` helper. All options are passed through to the `link_to` helper with some additional
# options available. # options available.
class LinkComponent < ButtonishComponent class DS::Link < DS::Buttonish
attr_reader :frame attr_reader :frame
VARIANTS = VARIANTS.reverse_merge( VARIANTS = VARIANTS.reverse_merge(

View file

@ -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 %> <% 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 %> <% elsif variant == :button %>
<%= button %> <%= button %>
<% elsif variant == :avatar %> <% elsif variant == :avatar %>
<button data-menu-target="button"> <button data-DS--menu-target="button">
<div class="w-9 h-9 cursor-pointer"> <div class="w-9 h-9 cursor-pointer">
<%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %> <%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %>
</div> </div>
</button> </button>
<% end %> <% 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"> <div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
<%= header %> <%= header %>

View file

@ -1,15 +1,15 @@
# frozen_string_literal: true # 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 attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid
renders_one :button, ->(**button_options, &block) do 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 if block
content_tag(:button, **options_with_target, &block) content_tag(:button, **options_with_target, &block)
else else
ButtonComponent.new(**options_with_target) DS::Button.new(**options_with_target)
end end
end end
@ -19,7 +19,7 @@ class MenuComponent < ViewComponent::Base
renders_one :custom_content renders_one :custom_content
renders_many :items, MenuItemComponent renders_many :items, DS::MenuItem
VARIANTS = %i[icon button avatar].freeze VARIANTS = %i[icon button avatar].freeze

View file

@ -1,4 +1,4 @@
class MenuItemComponent < ViewComponent::Base class DS::MenuItem < DesignSystemComponent
VARIANTS = %i[link button divider].freeze VARIANTS = %i[link button divider].freeze
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts

View file

@ -1,4 +1,4 @@
class TabComponent < ViewComponent::Base class DS::Tab < DesignSystemComponent
attr_reader :id, :label attr_reader :id, :label
def initialize(id:, label:) def initialize(id:, label:)

View 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 %>

View file

@ -1,6 +1,6 @@
class TabsComponent < ViewComponent::Base class DS::Tabs < DesignSystemComponent
renders_one :nav, ->(classes: nil) do renders_one :nav, ->(classes: nil) do
Tabs::NavComponent.new( DS::Tabs::Nav.new(
active_tab: active_tab, active_tab: active_tab,
active_btn_classes: active_btn_classes, active_btn_classes: active_btn_classes,
inactive_btn_classes: inactive_btn_classes, inactive_btn_classes: inactive_btn_classes,
@ -13,7 +13,7 @@ class TabsComponent < ViewComponent::Base
content_tag( content_tag(
:div, :div,
class: ("hidden" unless tab_id == active_tab), class: ("hidden" unless tab_id == active_tab),
data: { id: tab_id, tabs_target: "panel" }, data: { id: tab_id, DS__tabs_target: "panel" },
&block &block
) )
end end

View file

@ -1,4 +1,4 @@
class Tabs::NavComponent < ViewComponent::Base class DS::Tabs::Nav < DesignSystemComponent
erb_template <<~ERB erb_template <<~ERB
<%= tag.nav class: classes do %> <%= tag.nav class: classes do %>
<% btns.each do |btn| %> <% btns.each do |btn| %>
@ -12,7 +12,7 @@ class Tabs::NavComponent < ViewComponent::Base
:button, label, id: id, :button, label, id: id,
type: "button", type: "button",
class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes), 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 &block
) )
end end

View file

@ -1,4 +1,4 @@
class Tabs::PanelComponent < ViewComponent::Base class DS::Tabs::Panel < DesignSystemComponent
attr_reader :tab_id attr_reader :tab_id
def initialize(tab_id:) def initialize(tab_id:)

View file

@ -1,4 +1,4 @@
class ToggleComponent < ViewComponent::Base class DS::Toggle < DesignSystemComponent
attr_reader :id, :name, :checked, :disabled, :checked_value, :unchecked_value, :opts 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) def initialize(id:, name: nil, checked: false, disabled: false, checked_value: "1", unchecked_value: "0", **opts)

View 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>

View 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

View 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`,
});
});
}
}

View file

@ -0,0 +1,103 @@
<%= tag.div id: id, data: { bulk_select_target: "group" }, class: "bg-container-inset rounded-xl p-1 w-full" do %>
<details class="group">
<summary>
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary">
<div class="flex pl-0.5 items-center gap-4">
<%= check_box_tag "#{date}_entries_selection",
class: ["checkbox checkbox--light", "hidden": entries.size == 0],
id: "selection_entry_#{date}",
data: { action: "bulk-select#toggleGroupSelection" } %>
<p class="uppercase space-x-1.5">
<%= tag.span I18n.l(date, format: :long) %>
<span>&middot;</span>
<%= tag.span entries.size %>
</p>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="font-medium"><%= balance_trend.current.format %></span>
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
</div>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
</div>
</div>
</summary>
<div class="p-4 space-y-3">
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
Start of day balance
<%= render DS::Tooltip.new(text: "The account balance at the beginning of this day, before any transactions or value changes", placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd class="font-bold"><%= start_balance_money.format %></dd>
</dl>
<% if account.balance_type == :investment %>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
&#916; Cash
<%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd><%= cash_change_money.format %></dd>
</dl>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
&#916; Holdings
<%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd><%= holdings_change_money.format %></dd>
</dl>
<% else %>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
&#916; Cash
<%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd><%= cash_change_money.format %></dd>
</dl>
<% end %>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
End of day balance
<%= render DS::Tooltip.new(text: "The calculated balance after all transactions but before any manual adjustments or reconciliations", placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd class="font-medium"><%= end_balance_before_adjustments_money.format %></dd>
</dl>
<hr class="border border-primary">
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
&#916; Value adjustments
<%= render DS::Tooltip.new(text: "Adjustments are either manual reconciliations made by the user or adjustments due to market price changes throughout the day", placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed border-secondary">
<dd><%= adjustments_money.format %></dd>
</dl>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
Closing balance
<%= render DS::Tooltip.new(text: "The final account balance for the day, after all transactions and adjustments have been applied", placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed border-primary">
<dd class="font-bold"><%= end_balance_money.format %></dd>
</dl>
</div>
</details>
<div class="bg-container shadow-border-xs rounded-lg">
<% entries.each do |entry| %>
<%= render entry, view_ctx: "account" %>
<% end %>
</div>
<% end %>

View file

@ -0,0 +1,51 @@
class UI::Account::ActivityDate < ApplicationComponent
attr_reader :account, :data
delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data
def initialize(account:, data:)
@account = account
@data = data
end
def id
dom_id(account, "entries_#{date}")
end
def broadcast_channel
account
end
def start_balance_money
balance_trend.previous
end
def cash_change_money
cash_balance_trend.value
end
def holdings_change_money
holdings_value_trend.value
end
def end_balance_before_adjustments_money
balance_trend.previous + cash_change_money + holdings_change_money
end
def adjustments_money
end_balance_money - end_balance_before_adjustments_money
end
def end_balance_money
balance_trend.current
end
def broadcast_refresh!
Turbo::StreamsChannel.broadcast_replace_to(
broadcast_channel,
target: id,
renderable: self,
layout: false
)
end
end

View 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 %>

View 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

View file

@ -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 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="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="space-y-2 w-full">
<div class="flex items-center gap-1"> <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? %> <% 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 %> <% end %>
</div> </div>
<div class="flex flex-row gap-2 items-baseline"> <div class="flex flex-row gap-2 items-baseline">
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %> <%= tag.p view_balance_money.format, 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" %> <% if converted_balance_money %>
<%= tag.p converted_balance_money.format, class: "text-sm font-medium text-secondary" %>
<% end %> <% end %>
</div> </div>
</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"> <div class="flex items-center gap-2">
<% if chart_view.present? %> <% if account.investment? %>
<%= form.select :chart_view, <%= form.select :chart_view,
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]], [["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", 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" } %> data: { "auto-submit-form-target": "auto" } %>
<% end %> <% end %>
@ -40,7 +36,23 @@
<% end %> <% end %>
</div> </div>
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key, chart_view: chart_view) do %> <%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<%= render "accounts/chart_loader" %> <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 %> <% end %>
</div> </div>

View 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

View 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 %>

View 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

View 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

View file

@ -0,0 +1,2 @@
class DesignSystemComponent < ViewComponent::Base
end

View file

@ -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 %>

View file

@ -1,5 +1,5 @@
class AccountsController < ApplicationController class AccountsController < ApplicationController
before_action :set_account, only: %i[sync chart sparkline toggle_active] before_action :set_account, only: %i[sync sparkline toggle_active show destroy]
include Periodable include Periodable
def index def index
@ -9,6 +9,17 @@ class AccountsController < ApplicationController
render layout: "settings" render layout: "settings"
end 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 def sync
unless @account.syncing? unless @account.syncing?
@account.sync_later @account.sync_later
@ -17,11 +28,6 @@ class AccountsController < ApplicationController
redirect_to account_path(@account) redirect_to account_path(@account)
end end
def chart
@chart_view = params[:chart_view] || "balance"
render layout: "application"
end
def sparkline def sparkline
etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true) etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true)
@ -42,6 +48,15 @@ class AccountsController < ApplicationController
redirect_to accounts_path redirect_to accounts_path
end 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 private
def family def family
Current.family Current.family

View file

@ -2,9 +2,9 @@ module AccountableResource
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do 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 before_action :set_link_options, only: :new
end end
@ -27,9 +27,7 @@ module AccountableResource
@q = params.fetch(:q, {}).permit(:search) @q = params.fetch(:q, {}).permit(:search)
entries = @account.entries.search(@q).reverse_chronological entries = @account.entries.search(@q).reverse_chronological
set_focused_record(entries, params[:focused_record_id]) @pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10", params: ->(params) { params.except(:focused_record_id) })
end end
def edit def edit
@ -45,12 +43,13 @@ module AccountableResource
def update def update
# Handle balance update if provided # Handle balance update if provided
if account_params[:balance].present? if account_params[:balance].present?
result = @account.update_balance(balance: account_params[:balance], currency: account_params[:currency]) result = @account.set_current_balance(account_params[:balance].to_d)
unless result.success? unless result.success?
@error_message = result.error_message @error_message = result.error_message
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
return return
end end
@account.sync_later
end end
# Update remaining account attributes # Update remaining account attributes
@ -62,16 +61,7 @@ module AccountableResource
end end
@account.lock_saved_attributes! @account.lock_saved_attributes!
redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize) redirect_back_or_to account_path(@account), notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
end
def destroy
if @account.linked?
redirect_to account_path(@account), alert: "Cannot delete a linked account"
else
@account.destroy_later
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
end
end end
private private

View file

@ -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

View file

@ -37,10 +37,10 @@ class PropertiesController < ApplicationController
end end
def update_balances def update_balances
result = @account.update_balance(balance: balance_params[:balance], currency: balance_params[:currency]) result = @account.set_current_balance(balance_params[:balance].to_d)
if result.success? if result.success?
@success_message = result.updated? ? "Balance updated successfully." : "No changes made. Account is already up to date." @success_message = "Balance updated successfully."
if @account.active? if @account.active?
render :balances render :balances

View file

@ -1,5 +1,5 @@
class TransactionsController < ApplicationController class TransactionsController < ApplicationController
include ScrollFocusable, EntryableResource include EntryableResource
before_action :store_params!, only: :index before_action :store_params!, only: :index
@ -21,12 +21,7 @@ class TransactionsController < ApplicationController
:transfer_as_inflow, :transfer_as_outflow :transfer_as_inflow, :transfer_as_outflow
) )
@pagy, @transactions = pagy(base_scope, limit: per_page, params: ->(p) { p.except(:focused_record_id) }) @pagy, @transactions = pagy(base_scope, limit: per_page)
# 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
end end
def clear_filter def clear_filter

View file

@ -1,22 +1,46 @@
class ValuationsController < ApplicationController class ValuationsController < ApplicationController
include EntryableResource, StreamExtensions 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 def create
account = Current.family.accounts.find(params.dig(:entry, :account_id)) account = Current.family.accounts.find(params.dig(:entry, :account_id))
result = account.update_balance( result = account.create_reconciliation(
balance: entry_params[:amount], balance: entry_params[:amount],
date: entry_params[:date], date: entry_params[:date],
currency: entry_params[:currency],
notes: entry_params[:notes]
) )
if result.success? if result.success?
@success_message = result.updated? ? "Balance updated" : "No changes made. Account is already up to date."
respond_to do |format| respond_to do |format|
format.html { redirect_back_or_to account_path(account), notice: @success_message } format.html { redirect_back_or_to account_path(account), notice: "Account updated" }
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: @success_message) } format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: "Account updated") }
end end
else else
@error_message = result.error_message @error_message = result.error_message
@ -25,18 +49,22 @@ class ValuationsController < ApplicationController
end end
def update def update
result = @entry.account.update_balance( # Notes updating is independent of reconciliation, just a simple CRUD operation
date: @entry.date, @entry.update!(notes: entry_params[:notes]) if entry_params[:notes].present?
balance: entry_params[:amount],
currency: entry_params[:currency],
notes: entry_params[:notes]
)
if result.success? if entry_params[:date].present? && entry_params[:amount].present?
result = @entry.account.update_reconciliation(
@entry,
balance: entry_params[:amount],
date: entry_params[:date],
)
end
if result.nil? || result.success?
@entry.reload @entry.reload
respond_to do |format| respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: result.updated? ? "Balance updated" : "No changes made. Account is already up to date." } format.html { redirect_back_or_to account_path(@entry.account), notice: "Entry updated" }
format.turbo_stream do format.turbo_stream do
render turbo_stream: [ render turbo_stream: [
turbo_stream.replace( turbo_stream.replace(
@ -56,7 +84,6 @@ class ValuationsController < ApplicationController
private private
def entry_params def entry_params
params.require(:entry) params.require(:entry).permit(:date, :amount, :notes)
.permit(:date, :amount, :currency, :notes)
end end
end end

View file

@ -21,7 +21,7 @@ module ApplicationHelper
if custom if custom
inline_svg_tag("#{key}.svg", class: icon_classes, **opts) inline_svg_tag("#{key}.svg", class: icon_classes, **opts)
elsif as_button 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 else
lucide_icon(key, class: icon_classes, **opts) lucide_icon(key, class: icon_classes, **opts)
end end

View file

@ -50,7 +50,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
checked = object ? object.send(method) : options[:checked] checked = object ? object.send(method) : options[:checked]
@template.render( @template.render(
ToggleComponent.new( DS::Toggle.new(
id: field_id, id: field_id,
name: field_name, name: field_name,
checked: checked, checked: checked,
@ -67,7 +67,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
value ||= submit_default_value value ||= submit_default_value
@template.render( @template.render(
ButtonComponent.new( DS::Button.new(
text: value, text: value,
data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }), data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }),
full_width: true full_width: true

View file

@ -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);
}
}
}

View file

@ -1,6 +1,5 @@
class Account < ApplicationRecord class Account < ApplicationRecord
include Syncable, Monetizable, Chartable, Linkable, Enrichable include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable
include AASM
validates :name, :balance, :currency, presence: true validates :name, :balance, :currency, presence: true
@ -59,26 +58,14 @@ class Account < ApplicationRecord
def create_and_sync(attributes) def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes.merge(cash_balance: attributes[:balance])) 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 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! account.save!
manager = Account::OpeningBalanceManager.new(account)
result = manager.set_opening_balance(balance: initial_balance || account.balance)
raise result.error if result.error
end end
account.sync_later account.sync_later
@ -127,11 +114,6 @@ class Account < ApplicationRecord
.order(amount: :desc) .order(amount: :desc)
end end
def update_balance(balance:, date: Date.current, currency: nil, notes: nil)
Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:).update
end
def start_date def start_date
first_entry_date = entries.minimum(:date) || Date.current first_entry_date = entries.minimum(:date) || Date.current
first_entry_date - 1.day first_entry_date - 1.day
@ -159,4 +141,23 @@ class Account < ApplicationRecord
def long_subtype_label def long_subtype_label
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
end 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 end

View file

@ -0,0 +1,219 @@
# Data used to build the paginated feed of account "activity" (events like transfers, deposits, withdrawals, etc.)
# This data object is useful for avoiding N+1 queries and having an easy way to pass around the required data to the
# activity feed component in controllers and background jobs that refresh it.
class Account::ActivityFeedData
ActivityDateData = Data.define(:date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers)
attr_reader :account, :entries
def initialize(account, entries)
@account = account
@entries = entries.to_a
end
def entries_by_date
@entries_by_date_objects ||= begin
grouped_entries.map do |date, date_entries|
ActivityDateData.new(
date: date,
entries: date_entries,
balance_trend: balance_trend_for_date(date),
cash_balance_trend: cash_balance_trend_for_date(date),
holdings_value_trend: holdings_value_trend_for_date(date),
transfers: transfers_for_date(date)
)
end
end
end
private
def balance_trend_for_date(date)
build_trend_for_date(date, :balance_money)
end
def cash_balance_trend_for_date(date)
date_entries = grouped_entries[date] || []
has_valuation = date_entries.any?(&:valuation?)
if has_valuation
# When there's a valuation, calculate cash change from transaction entries only
transactions = date_entries.select { |e| e.transaction? }
cash_change = sum_entries_with_exchange_rates(transactions, date) * -1
start_balance = start_balance_for_date(date)
Trend.new(
current: start_balance.cash_balance_money + cash_change,
previous: start_balance.cash_balance_money
)
else
build_trend_for_date(date, :cash_balance_money)
end
end
def holdings_value_trend_for_date(date)
date_entries = grouped_entries[date] || []
has_valuation = date_entries.any?(&:valuation?)
if has_valuation
# When there's a valuation, calculate holdings change from trade entries only
trades = date_entries.select { |e| e.trade? }
holdings_change = sum_entries_with_exchange_rates(trades, date)
start_balance = start_balance_for_date(date)
start_holdings = start_balance.balance_money - start_balance.cash_balance_money
Trend.new(
current: start_holdings + holdings_change,
previous: start_holdings
)
else
build_trend_for_date(date) do |balance|
balance.balance_money - balance.cash_balance_money
end
end
end
def transfers_for_date(date)
date_entries = grouped_entries[date] || []
return [] if date_entries.empty?
date_transaction_ids = date_entries.select(&:transaction?).map(&:entryable_id)
return [] if date_transaction_ids.empty?
# Convert to Set for O(1) lookups
date_transaction_id_set = Set.new(date_transaction_ids)
transfers.select { |txfr|
date_transaction_id_set.include?(txfr.inflow_transaction_id) ||
date_transaction_id_set.include?(txfr.outflow_transaction_id)
}
end
def build_trend_for_date(date, method = nil)
start_balance = start_balance_for_date(date)
end_balance = end_balance_for_date(date)
if block_given?
Trend.new(
current: yield(end_balance),
previous: yield(start_balance)
)
else
Trend.new(
current: end_balance.send(method),
previous: start_balance.send(method)
)
end
end
# Finds the balance on date, or the most recent balance before it ("last observation carried forward")
def start_balance_for_date(date)
@start_balance_for_date ||= {}
@start_balance_for_date[date] ||= last_observed_balance_before_date(date.prev_day) || generate_fallback_balance(date)
end
# Finds the balance on date, or the most recent balance before it ("last observation carried forward")
def end_balance_for_date(date)
@end_balance_for_date ||= {}
@end_balance_for_date[date] ||= last_observed_balance_before_date(date) || generate_fallback_balance(date)
end
RequiredExchangeRate = Data.define(:date, :from, :to)
def grouped_entries
@grouped_entries ||= entries.group_by(&:date)
end
def needs_exchange_rates?
entries.any? { |entry| entry.currency != account.currency }
end
def required_exchange_rates
multi_currency_entries = entries.select { |entry| entry.currency != account.currency }
multi_currency_entries.map do |entry|
RequiredExchangeRate.new(date: entry.date, from: entry.currency, to: account.currency)
end.uniq
end
# If the account has entries denominated in a different currency than the main account, we attach necessary
# exchange rates required to "roll up" the entry group balance into the normal account currency.
def exchange_rates
return [] unless needs_exchange_rates?
@exchange_rates ||= begin
rate_requirements = required_exchange_rates
return [] if rate_requirements.empty?
# Use ActiveRecord's or chain for better performance
conditions = rate_requirements.map do |req|
ExchangeRate.where(date: req.date, from_currency: req.from, to_currency: req.to)
end.reduce(:or)
conditions.to_a
end
end
def exchange_rate_for(date, from_currency, to_currency)
return 1.0 if from_currency == to_currency
rate = exchange_rates.find { |r| r.date == date && r.from_currency == from_currency && r.to_currency == to_currency }
rate&.rate || 1.0 # Fallback to 1:1 if no rate found
end
def sum_entries_with_exchange_rates(entries, date)
return Money.new(0, account.currency) if entries.empty?
entries.sum do |entry|
amount = entry.amount_money
if entry.currency != account.currency
rate = exchange_rate_for(date, entry.currency, account.currency)
Money.new(amount.amount * rate, account.currency)
else
amount
end
end
end
# We read balances so we can show "start of day" -> "end of day" balances for each entry date group in the feed
def balances
@balances ||= begin
return [] if entries.empty?
min_date = entries.min_by(&:date).date.prev_day
max_date = entries.max_by(&:date).date
account.balances.where(date: min_date..max_date, currency: account.currency).order(:date).to_a
end
end
def transaction_ids
entries.select { |entry| entry.transaction? }.map(&:entryable_id)
end
def transfers
return [] if entries.select { |e| e.transaction? && e.transaction.transfer? }.empty?
return [] if transaction_ids.empty?
@transfers ||= Transfer.where(inflow_transaction_id: transaction_ids).or(Transfer.where(outflow_transaction_id: transaction_ids)).to_a
end
# Use binary search since balances are sorted by date
def last_observed_balance_before_date(date)
idx = balances.bsearch_index { |b| b.date > date }
if idx
idx > 0 ? balances[idx - 1] : nil
else
balances.last
end
end
def generate_fallback_balance(date)
Balance.new(
account: account,
date: date,
balance: 0,
currency: account.currency
)
end
end

View 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

View file

@ -1,47 +0,0 @@
class Account::BalanceUpdater
def initialize(account, balance:, currency: nil, date: Date.current, notes: nil)
@account = account
@balance = balance.to_d
@currency = currency
@date = date.to_date
@notes = notes
end
def update
return Result.new(success?: true, updated?: false) unless requires_update?
Account.transaction do
if date == Date.current
account.balance = balance
account.currency = currency if currency.present?
account.save!
end
valuation_entry = account.entries.valuations.find_or_initialize_by(date: date) do |entry|
entry.entryable = Valuation.new
end
valuation_entry.amount = balance
valuation_entry.currency = currency if currency.present?
valuation_entry.name = "Manual #{account.accountable.balance_display_name} update"
valuation_entry.notes = notes if notes.present?
valuation_entry.save!
end
account.sync_later
Result.new(success?: true, updated?: true)
rescue => e
message = Rails.env.development? ? e.message : "Unable to update account values. Please try again."
Result.new(success?: false, updated?: false, error_message: message)
end
private
attr_reader :account, :balance, :currency, :date, :notes
Result = Struct.new(:success?, :updated?, :error_message)
def requires_update?
date != Date.current || account.balance != balance || account.currency != currency
end
end

View 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

View file

@ -15,4 +15,5 @@ module Account::Linkable
def unlinked? def unlinked?
!linked? !linked?
end end
alias_method :manual?, :unlinked?
end end

View 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

View 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

View file

@ -0,0 +1,89 @@
class Account::ReconciliationManager
attr_reader :account
def initialize(account)
@account = account
end
# Reconciles balance by creating a Valuation entry. If existing valuation is provided, it will be updated instead of creating a new one.
def reconcile_balance(balance:, date: Date.current, dry_run: false, existing_valuation_entry: nil)
old_balance_components = old_balance_components(reconciliation_date: date, existing_valuation_entry: existing_valuation_entry)
prepared_valuation = prepare_reconciliation(balance, date, existing_valuation_entry)
unless dry_run
prepared_valuation.save!
end
ReconciliationResult.new(
success?: true,
old_cash_balance: old_balance_components[:cash_balance],
old_balance: old_balance_components[:balance],
new_cash_balance: derived_cash_balance(date: date, total_balance: prepared_valuation.amount),
new_balance: prepared_valuation.amount,
error_message: nil
)
rescue => e
ReconciliationResult.new(
success?: false,
error_message: e.message
)
end
private
# Returns before -> after OR error message
ReconciliationResult = Struct.new(
:success?,
:old_cash_balance,
:old_balance,
:new_cash_balance,
:new_balance,
:error_message,
keyword_init: true
)
def prepare_reconciliation(balance, date, existing_valuation)
valuation_record = existing_valuation ||
account.entries.valuations.find_by(date: date) || # In case of conflict, where existing valuation is not passed as arg, but one exists
account.entries.build(
name: Valuation.build_reconciliation_name(account.accountable_type),
entryable: Valuation.new(kind: "reconciliation")
)
valuation_record.assign_attributes(
date: date,
amount: balance,
currency: account.currency
)
valuation_record
end
def derived_cash_balance(date:, total_balance:)
balance_components_for_reconciliation_date = get_balance_components_for_date(date)
return nil unless balance_components_for_reconciliation_date[:balance] && balance_components_for_reconciliation_date[:cash_balance]
# We calculate the existing non-cash balance, which for investments would represents "holdings" for the date of reconciliation
# Since the user is setting "total balance", we have to subtract the existing non-cash balance from the total balance to get the new cash balance
existing_non_cash_balance = balance_components_for_reconciliation_date[:balance] - balance_components_for_reconciliation_date[:cash_balance]
total_balance - existing_non_cash_balance
end
def old_balance_components(reconciliation_date:, existing_valuation_entry: nil)
if existing_valuation_entry
get_balance_components_for_date(existing_valuation_entry.date)
else
get_balance_components_for_date(reconciliation_date)
end
end
def get_balance_components_for_date(date)
balance_record = account.balances.find_by(date: date, currency: account.currency)
{
cash_balance: balance_record&.cash_balance,
balance: balance_record&.balance
}
end
end

View file

@ -1,4 +1,6 @@
class AccountImport < Import class AccountImport < Import
OpeningBalanceError = Class.new(StandardError)
def import! def import!
transaction do transaction do
rows.each do |row| rows.each do |row|
@ -15,13 +17,13 @@ class AccountImport < Import
account.save! account.save!
account.entries.create!( manager = Account::OpeningBalanceManager.new(account)
amount: row.amount, result = manager.set_opening_balance(balance: row.amount.to_d)
currency: row.currency,
date: Date.current, # Re-raise since we should never have an error here
name: "Imported account value", if result.error
entryable: Valuation.new raise OpeningBalanceError, result.error
) end
end end
end end
end end

View file

@ -3,7 +3,7 @@ class Balance < ApplicationRecord
belongs_to :account belongs_to :account
validates :account, :date, :balance, presence: true validates :account, :date, :balance, presence: true
monetize :balance monetize :balance, :cash_balance
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) } scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
scope :chronological, -> { order(:date) } scope :chronological, -> { order(:date) }
end end

View file

@ -0,0 +1,69 @@
class Balance::BaseCalculator
attr_reader :account
def initialize(account)
@account = account
end
def calculate
raise NotImplementedError, "Subclasses must implement this method"
end
private
def sync_cache
@sync_cache ||= Balance::SyncCache.new(account)
end
def holdings_value_for_date(date)
holdings = sync_cache.get_holdings(date)
holdings.sum(&:amount)
end
def derive_cash_balance_on_date_from_total(total_balance:, date:)
if account.balance_type == :investment
total_balance - holdings_value_for_date(date)
elsif account.balance_type == :cash
total_balance
else
0
end
end
def derive_cash_balance(cash_balance, date)
entries = sync_cache.get_entries(date)
if account.balance_type == :non_cash
0
else
cash_balance + signed_entry_flows(entries)
end
end
def derive_non_cash_balance(non_cash_balance, date, direction: :forward)
entries = sync_cache.get_entries(date)
# Loans are a special case (loan payment reducing principal, which is non-cash)
if account.balance_type == :non_cash && account.accountable_type == "Loan"
non_cash_balance + signed_entry_flows(entries)
elsif account.balance_type == :investment
# For reverse calculations, we need the previous day's holdings
target_date = direction == :forward ? date : date.prev_day
holdings_value_for_date(target_date)
else
non_cash_balance
end
end
def signed_entry_flows(entries)
raise NotImplementedError, "Directional calculators must implement this method"
end
def build_balance(date:, cash_balance:, non_cash_balance:)
Balance.new(
account_id: account.id,
date: date,
balance: non_cash_balance + cash_balance,
cash_balance: cash_balance,
currency: account.currency
)
end
end

View file

@ -1,61 +1,66 @@
class Balance::ForwardCalculator class Balance::ForwardCalculator < Balance::BaseCalculator
attr_reader :account
def initialize(account)
@account = account
end
def calculate def calculate
Rails.logger.tagged("Balance::ForwardCalculator") do Rails.logger.tagged("Balance::ForwardCalculator") do
calculate_balances start_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
date: account.opening_anchor_date
)
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
calc_start_date.upto(calc_end_date).map do |date|
valuation = sync_cache.get_reconciliation_valuation(date)
if valuation
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: valuation.amount,
date: date
)
end_non_cash_balance = valuation.amount - end_cash_balance
else
end_cash_balance = derive_end_cash_balance(start_cash_balance: start_cash_balance, date: date)
end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date)
end
output_balance = build_balance(
date: date,
cash_balance: end_cash_balance,
non_cash_balance: end_non_cash_balance
)
# Set values for the next iteration
start_cash_balance = end_cash_balance
start_non_cash_balance = end_non_cash_balance
output_balance
end
end end
end end
private private
def calculate_balances def calc_start_date
current_cash_balance = 0 account.opening_anchor_date
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 end
@balances << build_balance(date, next_cash_balance, holdings_value) def calc_end_date
[ account.entries.order(:date).last&.date, account.holdings.order(:date).last&.date ].compact.max || Date.current
current_cash_balance = next_cash_balance
end 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 end
def sync_cache # Derives cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
@sync_cache ||= Balance::SyncCache.new(account) def derive_end_cash_balance(start_cash_balance:, date:)
derive_cash_balance(start_cash_balance, date)
end end
def build_balance(date, cash_balance, holdings_value) # Derives non-cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
Balance.new( def derive_end_non_cash_balance(start_non_cash_balance:, date:)
account_id: account.id, derive_non_cash_balance(start_non_cash_balance, date, direction: :forward)
date: date,
balance: holdings_value + cash_balance,
cash_balance: cash_balance,
currency: account.currency
)
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
end end
end end

View file

@ -1,71 +1,79 @@
class Balance::ReverseCalculator class Balance::ReverseCalculator < Balance::BaseCalculator
attr_reader :account
def initialize(account)
@account = account
end
def calculate def calculate
Rails.logger.tagged("Balance::ReverseCalculator") do Rails.logger.tagged("Balance::ReverseCalculator") do
calculate_balances # Since it's a reverse sync, we're starting with the "end of day" balance components and
# calculating backwards to derive the "start of day" balance components.
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.current_anchor_balance,
date: account.current_anchor_date
)
end_non_cash_balance = account.current_anchor_balance - end_cash_balance
# Calculates in reverse-chronological order (End of day -> Start of day)
account.current_anchor_date.downto(account.opening_anchor_date).map do |date|
if use_opening_anchor_for_date?(date)
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
date: date
)
end_non_cash_balance = account.opening_anchor_balance - end_cash_balance
start_cash_balance = end_cash_balance
start_non_cash_balance = end_non_cash_balance
build_balance(
date: date,
cash_balance: end_cash_balance,
non_cash_balance: end_non_cash_balance
)
else
start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)
start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)
# Even though we've just calculated "start" balances, we set today equal to end of day, then use those
# in our next iteration (slightly confusing, but just the nature of a "reverse" sync)
output_balance = build_balance(
date: date,
cash_balance: end_cash_balance,
non_cash_balance: end_non_cash_balance
)
end_cash_balance = start_cash_balance
end_non_cash_balance = start_non_cash_balance
output_balance
end
end
end end
end end
private private
def calculate_balances
current_cash_balance = account.cash_balance
previous_cash_balance = nil
@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"
Date.current.downto(account.start_date).map do |date| # Positive entries amount on an "asset" account means, "account value has decreased"
entries = sync_cache.get_entries(date) # Positive entries amount on a "liability" account means, "account debt has increased"
holdings = sync_cache.get_holdings(date) def signed_entry_flows(entries)
holdings_value = holdings.sum(&:amount) entry_flows = entries.sum(&:amount)
valuation = sync_cache.get_valuation(date) account.asset? ? entry_flows : -entry_flows
previous_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :reverse)
end end
if valuation.present? # Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
@balances << build_balance(date, previous_cash_balance, holdings_value) # to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed
else # explanation, see the test suite.
# If date is today, we don't distinguish cash vs. total since provider's are inconsistent with treatment def use_opening_anchor_for_date?(date)
# of the cash component. Instead, just set the balance equal to the "total value" reported by the provider account.has_opening_anchor? && date == account.opening_anchor_date
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 end
current_cash_balance = previous_cash_balance # 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 end
@balances # Alias method, for algorithmic clarity
end # 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:)
def sync_cache derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
@sync_cache ||= Balance::SyncCache.new(account)
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
)
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
end end
end end

View file

@ -3,8 +3,8 @@ class Balance::SyncCache
@account = account @account = account
end end
def get_valuation(date) def get_reconciliation_valuation(date)
converted_entries.find { |e| e.date == date && e.valuation? } converted_entries.find { |e| e.date == date && e.valuation? && e.valuation.reconciliation? }
end end
def get_holdings(date) def get_holdings(date)

View file

@ -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

View file

@ -47,7 +47,7 @@ module Syncable
end end
def sync_error def sync_error
latest_sync&.error latest_sync&.error || latest_sync&.children&.map(&:error)&.compact&.first
end end
def last_synced_at def last_synced_at

View file

@ -1174,42 +1174,42 @@ class Demo::Generator
# Property valuations (these accounts are valued, not transaction-driven) # Property valuations (these accounts are valued, not transaction-driven)
@home.entries.create!( @home.entries.create!(
entryable: Valuation.new, entryable: Valuation.new(kind: "current_anchor"),
amount: 350_000, amount: 350_000,
name: "Current Market Value", name: Valuation.build_current_anchor_name(@home.accountable_type),
currency: "USD", currency: "USD",
date: Date.current date: Date.current
) )
# Vehicle valuations (these depreciate over time) # Vehicle valuations (these depreciate over time)
@honda_accord.entries.create!( @honda_accord.entries.create!(
entryable: Valuation.new, entryable: Valuation.new(kind: "current_anchor"),
amount: 18_000, amount: 18_000,
name: "Current Market Value", name: Valuation.build_current_anchor_name(@honda_accord.accountable_type),
currency: "USD", currency: "USD",
date: Date.current date: Date.current
) )
@tesla_model3.entries.create!( @tesla_model3.entries.create!(
entryable: Valuation.new, entryable: Valuation.new(kind: "current_anchor"),
amount: 4_500, amount: 4_500,
name: "Current Market Value", name: Valuation.build_current_anchor_name(@tesla_model3.accountable_type),
currency: "USD", currency: "USD",
date: Date.current date: Date.current
) )
@jewelry.entries.create!( @jewelry.entries.create!(
entryable: Valuation.new, entryable: Valuation.new(kind: "reconciliation"),
amount: 2000, amount: 2000,
name: "Current Market Value", name: Valuation.build_reconciliation_name(@jewelry.accountable_type),
currency: "USD", currency: "USD",
date: 90.days.ago.to_date date: 90.days.ago.to_date
) )
@personal_loc.entries.create!( @personal_loc.entries.create!(
entryable: Valuation.new, entryable: Valuation.new(kind: "reconciliation"),
amount: 800, amount: 800,
name: "Owed", name: Valuation.build_reconciliation_name(@personal_loc.accountable_type),
currency: "USD", currency: "USD",
date: 120.days.ago.to_date date: 120.days.ago.to_date
) )

View file

@ -51,6 +51,13 @@ class PlaidAccount::Processor
) )
account.save! account.save!
# Create or update the current balance anchor valuation for event-sourced ledger
# Note: This is a partial implementation. In the future, we'll introduce HoldingValuation
# to properly track the holdings vs. cash breakdown, but for now we're only tracking
# the total balance in the current anchor. The cash_balance field on the account model
# is still being used for the breakdown.
account.set_current_balance(balance_calculator.balance)
end end
end end

View file

@ -8,6 +8,13 @@ class Trade < ApplicationRecord
validates :qty, presence: true validates :qty, presence: true
validates :price, :currency, presence: true validates :price, :currency, presence: true
class << self
def build_name(type, qty, ticker)
prefix = type == "buy" ? "Buy" : "Sell"
"#{prefix} #{qty.to_d.abs} shares of #{ticker}"
end
end
def unrealized_gain_loss def unrealized_gain_loss
return nil if qty.negative? return nil if qty.negative?
current_price = security.current_price current_price = security.current_price

View file

@ -29,13 +29,11 @@ class Trade::CreateForm
end end
def create_trade def create_trade
prefix = type == "sell" ? "Sell " : "Buy "
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
signed_qty = type == "sell" ? -qty.to_d : qty.to_d signed_qty = type == "sell" ? -qty.to_d : qty.to_d
signed_amount = signed_qty * price.to_d signed_amount = signed_qty * price.to_d
trade_entry = account.entries.new( trade_entry = account.entries.new(
name: trade_name, name: Trade.build_name(type, qty, security.ticker),
date: date, date: date,
amount: signed_amount, amount: signed_amount,
currency: currency, currency: currency,

View file

@ -1,3 +1,23 @@
class Valuation < ApplicationRecord class Valuation < ApplicationRecord
include Entryable include Entryable
enum :kind, {
reconciliation: "reconciliation",
opening_anchor: "opening_anchor",
current_anchor: "current_anchor"
}, validate: true, default: "reconciliation"
class << self
def build_reconciliation_name(accountable_type)
Valuation::Name.new("reconciliation", accountable_type).to_s
end
def build_opening_anchor_name(accountable_type)
Valuation::Name.new("opening_anchor", accountable_type).to_s
end
def build_current_anchor_name(accountable_type)
Valuation::Name.new("current_anchor", accountable_type).to_s
end
end
end end

View file

@ -0,0 +1,57 @@
class Valuation::Name
def initialize(valuation_kind, accountable_type)
@valuation_kind = valuation_kind
@accountable_type = accountable_type
end
def to_s
case valuation_kind
when "opening_anchor"
opening_anchor_name
when "current_anchor"
current_anchor_name
else
recon_name
end
end
private
attr_reader :valuation_kind, :accountable_type
def opening_anchor_name
case accountable_type
when "Property", "Vehicle"
"Original purchase price"
when "Loan"
"Original principal"
when "Investment", "Crypto", "OtherAsset"
"Opening account value"
else
"Opening balance"
end
end
def current_anchor_name
case accountable_type
when "Property", "Vehicle"
"Current market value"
when "Loan"
"Current loan balance"
when "Investment", "Crypto", "OtherAsset"
"Current account value"
else
"Current balance"
end
end
def recon_name
case accountable_type
when "Property", "Investment", "Vehicle", "Crypto", "OtherAsset"
"Manual value update"
when "Loan"
"Manual principal update"
else
"Manual balance update"
end
end
end

View file

@ -41,7 +41,7 @@
<% end %> <% end %>
<% if account.draft? %> <% if account.draft? %>
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "Complete setup", text: "Complete setup",
href: edit_account_path(account, return_to: return_to), href: edit_account_path(account, return_to: return_to),
variant: :outline, variant: :outline,
@ -49,7 +49,7 @@
) %> ) %>
<% elsif account.active? || account.disabled? %> <% elsif account.active? || account.disabled? %>
<%= form_with model: account, url: toggle_active_account_path(account), method: :patch, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %> <%= form_with model: account, url: toggle_active_account_path(account), method: :patch, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
<%= render ToggleComponent.new( <%= render DS::Toggle.new(
id: "account_#{account.id}_active", id: "account_#{account.id}_active",
name: "active", name: "active",
checked: account.active?, checked: account.active?,

View file

@ -21,7 +21,7 @@
</details> </details>
<% end %> <% end %>
<%= render TabsComponent.new(active_tab: active_tab, session_key: "account_sidebar_tab", testid: "account-sidebar-tabs") do |tabs| %> <%= render DS::Tabs.new(active_tab: active_tab, session_key: "account_sidebar_tab", testid: "account-sidebar-tabs") do |tabs| %>
<% tabs.with_nav do |nav| %> <% tabs.with_nav do |nav| %>
<% nav.with_btn(id: "asset", label: "Assets") %> <% nav.with_btn(id: "asset", label: "Assets") %>
<% nav.with_btn(id: "liability", label: "Debts") %> <% nav.with_btn(id: "liability", label: "Debts") %>
@ -30,7 +30,7 @@
<% tabs.with_panel(tab_id: "asset") do %> <% tabs.with_panel(tab_id: "asset") do %>
<div class="space-y-2"> <div class="space-y-2">
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "New asset", text: "New asset",
variant: "ghost", variant: "ghost",
href: new_account_path(step: "method_select", classification: "asset"), href: new_account_path(step: "method_select", classification: "asset"),
@ -50,7 +50,7 @@
<% tabs.with_panel(tab_id: "liability") do %> <% tabs.with_panel(tab_id: "liability") do %>
<div class="space-y-2"> <div class="space-y-2">
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "New debt", text: "New debt",
variant: "ghost", variant: "ghost",
href: new_account_path(step: "method_select", classification: "liability"), href: new_account_path(step: "method_select", classification: "liability"),
@ -70,7 +70,7 @@
<% tabs.with_panel(tab_id: "all") do %> <% tabs.with_panel(tab_id: "all") do %>
<div class="space-y-2"> <div class="space-y-2">
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "New account", text: "New account",
variant: "ghost", variant: "ghost",
full_width: true, full_width: true,

View file

@ -2,7 +2,7 @@
<%= link_to new_polymorphic_path(accountable, step: "method_select", return_to: params[:return_to]), <%= link_to new_polymorphic_path(accountable, step: "method_select", return_to: params[:return_to]),
class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %> class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %>
<%= render FilledIconComponent.new( <%= render DS::FilledIcon.new(
icon: accountable.icon, icon: accountable.icon,
hex_color: accountable.color, hex_color: accountable.color,
) %> ) %>

View file

@ -2,7 +2,7 @@
<div id="<%= account_group.dom_id(tab: all_tab ? :all : nil, mobile: mobile) %>"> <div id="<%= account_group.dom_id(tab: all_tab ? :all : nil, mobile: mobile) %>">
<% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %> <% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %>
<%= render DisclosureComponent.new(align: :left, open: is_open) do |disclosure| %> <%= render DS::Disclosure.new(align: :left, open: is_open) do |disclosure| %>
<% disclosure.with_summary_content do %> <% disclosure.with_summary_content do %>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
@ -51,7 +51,7 @@
</div> </div>
<div class="my-2"> <div class="my-2">
<%= render LinkComponent.new( <%= render DS::Link.new(
href: new_polymorphic_path(account_group.key, step: "method_select"), href: new_polymorphic_path(account_group.key, step: "method_select"),
text: "New #{account_group.name.downcase.singularize}", text: "New #{account_group.name.downcase.singularize}",
icon: "plus", icon: "plus",

View file

@ -1,7 +0,0 @@
<div class="px-4">
<div class="bg-loader rounded-md h-5 w-32"></div>
</div>
<div class="p-4 h-60 flex items-center justify-center">
<div class="bg-loader rounded-md h-full w-full"></div>
</div>

View file

@ -3,7 +3,7 @@
<%= tag.p t(".no_accounts"), class: "text-primary mb-1 font-medium" %> <%= tag.p t(".no_accounts"), class: "text-primary mb-1 font-medium" %>
<%= tag.p t(".empty_message"), class: "text-secondary mb-4" %> <%= tag.p t(".empty_message"), class: "text-secondary mb-4" %>
<%= render LinkComponent.new( <%= render DS::Link.new(
text: t(".new_account"), text: t(".new_account"),
href: new_account_path, href: new_account_path,
frame: :modal frame: :modal

View file

@ -1,7 +1,7 @@
<%# locals: (account:, url:) %> <%# locals: (account:, url:) %>
<% if @error_message.present? %> <% if @error_message.present? %>
<%= render AlertComponent.new(message: @error_message, variant: :error) %> <%= render DS::Alert.new(message: @error_message, variant: :error) %>
<% end %> <% end %>
<%= styled_form_with model: account, url: url, scope: :account, data: { turbo: false }, class: "flex flex-col gap-4 justify-between grow text-primary" do |form| %> <%= styled_form_with model: account, url: url, scope: :account, data: { turbo: false }, class: "flex flex-col gap-4 justify-between grow text-primary" do |form| %>

View file

@ -12,5 +12,5 @@
<% elsif account.logo.attached? %> <% elsif account.logo.attached? %>
<%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %> <%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %>
<% else %> <% else %>
<%= render FilledIconComponent.new(variant: :text, hex_color: color || account.accountable.color, text: account.name, size: size, rounded: true) %> <%= render DS::FilledIcon.new(variant: :text, hex_color: color || account.accountable.color, text: account.name, size: size, rounded: true) %>
<% end %> <% end %>

View file

@ -1,22 +0,0 @@
<% series = @account.balance_series(period: @period, view: @chart_view) %>
<% trend = series.trend %>
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<div class="px-4">
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
</div>
<div class="h-64 pb-4">
<% if series.any? %>
<div
id="lineChart"
class="w-full h-full"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
</div>
<% end %>
</div>
<% end %>

View file

@ -2,7 +2,7 @@
<h1 class="text-xl"><%= t(".accounts") %></h1> <h1 class="text-xl"><%= t(".accounts") %></h1>
<div class="flex items-center gap-5"> <div class="flex items-center gap-5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "New account", text: "New account",
href: new_account_path(return_to: accounts_path), href: new_account_path(return_to: accounts_path),
variant: "primary", variant: "primary",

View file

@ -25,7 +25,7 @@
<%= button_to imports_path(import: { type: "AccountImport" }), <%= button_to imports_path(import: { type: "AccountImport" }),
data: { turbo_frame: :_top }, data: { turbo_frame: :_top },
class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %> class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %>
<%= render FilledIconComponent.new( <%= render DS::FilledIcon.new(
icon: "download", icon: "download",
hex_color: "#F79009", hex_color: "#F79009",
) %> ) %>

View file

@ -1,11 +1,11 @@
<%# locals: (title:, back_path: nil) %> <%# locals: (title:, back_path: nil) %>
<%= render DialogComponent.new do |dialog| %> <%= render DS::Dialog.new do |dialog| %>
<div class="flex flex-col relative" data-controller="list-keyboard-navigation"> <div class="flex flex-col relative" data-controller="list-keyboard-navigation">
<div class="border-b border-tertiary md:border-alpha-black-25 px-4 pb-4 text-gray-800 flex items-center justify-between gap-2"> <div class="border-b border-tertiary md:border-alpha-black-25 px-4 pb-4 text-gray-800 flex items-center justify-between gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<% if back_path %> <% if back_path %>
<%= render LinkComponent.new( <%= render DS::Link.new(
variant: "icon", variant: "icon",
icon: "arrow-left", icon: "arrow-left",
href: back_path, href: back_path,

View file

@ -0,0 +1,8 @@
<%= render UI::AccountPage.new(
account: @account,
chart_view: @chart_view,
chart_period: @period,
active_tab: @tab
) do |account_page| %>
<%= account_page.with_activity_feed(feed_data: @activity_feed_data, pagy: @pagy, search: @q[:search]) %>
<% end %>

View file

@ -1,11 +1,11 @@
<%# locals: (account:) %> <%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, "entries") do %> <%= turbo_frame_tag dom_id(account, "entries") do %>
<div class="bg-container p-5 shadow-border-xs rounded-xl" data-controller="focus-record" data-focus-record-id-value="<%= @focused_record ? dom_id(@focused_record) : nil %>"> <div class="bg-container p-5 shadow-border-xs rounded-xl">
<div class="flex items-center justify-between mb-4" data-testid="activity-menu"> <div class="flex items-center justify-between mb-4" data-testid="activity-menu">
<%= tag.h2 t(".title"), class: "font-medium text-lg" %> <%= tag.h2 t(".title"), class: "font-medium text-lg" %>
<% unless @account.plaid_account_id.present? %> <% unless @account.plaid_account_id.present? %>
<%= render MenuComponent.new(variant: "button") do |menu| %> <%= render DS::Menu.new(variant: "button") do |menu| %>
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %> <% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
<% menu.with_item( <% menu.with_item(
@ -76,11 +76,9 @@
<div> <div>
<div class="space-y-4"> <div class="space-y-4">
<% calculator = Balance::TrendCalculator.new(@account.balances) %>
<%= entries_by_date(@entries) do |entries| %> <%= entries_by_date(@entries) do |entries| %>
<% entries.each_with_index do |entry, index| %> <% entries.each_with_index do |entry, index| %>
<%= render entry, balance_trend: index == 0 ? calculator.trend_for(entry.date) : nil, view_ctx: "account" %> <%= render entry, view_ctx: "account" %>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>

View file

@ -1,21 +1,16 @@
<%# locals: (account:, title: nil, subtitle: nil) %> <%# locals: (account:, title:, subtitle: nil) %>
<header class="space-y-4"> <header class="space-y-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<% content = yield %>
<% if content.present? %>
<%= content %>
<% else %>
<div class="flex items-center gap-3 overflow-hidden"> <div class="flex items-center gap-3 overflow-hidden">
<%= render "accounts/logo", account: account %> <%= render "accounts/logo", account: account %>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="truncate"> <div class="truncate">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title || account.name %></h2> <h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title %></h2>
<% if account.draft? %> <% if account.draft? %>
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "Complete setup", text: "Complete setup",
href: edit_account_path(account), href: edit_account_path(account),
variant: :outline, variant: :outline,
@ -30,7 +25,6 @@
</div> </div>
</div> </div>
</div> </div>
<% end %>
<div class="flex items-center gap-1 ml-auto"> <div class="flex items-center gap-1 ml-auto">
<% if Rails.env.development? || self_hosted? %> <% if Rails.env.development? || self_hosted? %>

View file

@ -1,3 +0,0 @@
<div class="p-5">
<p class="text-secondary animate-pulse">Loading account...</p>
</div>

View file

@ -1,6 +1,6 @@
<%# locals: (account:) %> <%# locals: (account:) %>
<%= render MenuComponent.new(testid: "account-menu") do |menu| %> <%= render DS::Menu.new(testid: "account-menu") do |menu| %>
<% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %> <% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<% unless account.crypto? %> <% unless account.crypto? %>

View file

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

View file

@ -1,17 +0,0 @@
<%# locals: (account:, tabs:) %>
<% active_tab = tabs.find { |tab| tab[:key] == params[:tab] } || tabs.first %>
<%= render TabsComponent.new(active_tab: active_tab[:key], url_param_key: "tab") do |tabs_container| %>
<% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
<% tabs.each do |tab| %>
<% nav.with_btn(id: tab[:key], label: tab[:key].humanize, classes: "px-6") %>
<% end %>
<% end %>
<% tabs.each do |tab| %>
<% tabs_container.with_panel(tab_id: tab[:key]) do %>
<%= tab[:contents] %>
<% end %>
<% end %>
<% end %>

View file

@ -1,27 +0,0 @@
<%# locals: (account:, header: nil, chart: nil, chart_view: nil, tabs: nil) %>
<%= turbo_stream_from account %>
<%= turbo_frame_tag dom_id(account, :container) do %>
<%= tag.div class: "space-y-4 pb-32" do %>
<% if header.present? %>
<%= header %>
<% else %>
<%= render "accounts/show/header", account: account %>
<% end %>
<% if chart.present? %>
<%= chart %>
<% else %>
<%= render "accounts/show/chart", account: account, chart_view: chart_view %>
<% end %>
<div class="min-h-[800px]" data-testid="account-details">
<% if tabs.present? %>
<%= tabs %>
<% else %>
<%= render "accounts/show/activity", account: account %>
<% end %>
</div>
<% end %>
<% end %>

View file

@ -12,7 +12,7 @@
<% if budget_category.category.lucide_icon %> <% if budget_category.category.lucide_icon %>
<%= icon(budget_category.category.lucide_icon, color: "current") %> <%= icon(budget_category.category.lucide_icon, color: "current") %>
<% else %> <% else %>
<%= render FilledIconComponent.new( <%= render DS::FilledIcon.new(
variant: :text, variant: :text,
hex_color: budget_category.category.color, hex_color: budget_category.category.color,
text: budget_category.category.name, text: budget_category.category.name,

View file

@ -1,5 +1,5 @@
<div id="<%= dom_id(budget, :confirm_button) %>"> <div id="<%= dom_id(budget, :confirm_button) %>">
<%= render ButtonComponent.new( <%= render DS::Button.new(
text: "Confirm", text: "Confirm",
variant: "primary", variant: "primary",
full_width: true, full_width: true,

View file

@ -6,12 +6,12 @@
</p> </p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<%= render ButtonComponent.new( <%= render DS::Button.new(
text: "Use defaults (recommended)", text: "Use defaults (recommended)",
href: bootstrap_categories_path, href: bootstrap_categories_path,
) %> ) %>
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "New category", text: "New category",
variant: "outline", variant: "outline",
icon: "plus", icon: "plus",

View file

@ -1,4 +1,4 @@
<%= render DialogComponent.new(variant: :drawer) do |dialog| %> <%= render DS::Dialog.new(variant: :drawer) do |dialog| %>
<% dialog.with_header do %> <% dialog.with_header do %>
<div> <div>
<p class="text-sm text-secondary">Category</p> <p class="text-sm text-secondary">Category</p>
@ -107,7 +107,7 @@
<%= transaction.entry.date.strftime("%b %d") %> <%= transaction.entry.date.strftime("%b %d") %>
</p> </p>
<%= link_to transaction.entry.name, <%= link_to transaction.entry.name,
transactions_path(focused_record_id: transaction.id), transactions_path,
class: "text-primary hover:underline", class: "text-primary hover:underline",
data: { turbo_frame: :_top } %> data: { turbo_frame: :_top } %>
</div> </div>
@ -119,7 +119,7 @@
<% end %> <% end %>
</ul> </ul>
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "View all category transactions", text: "View all category transactions",
variant: "outline", variant: "outline",
full_width: true, full_width: true,

View file

@ -12,7 +12,7 @@
<%= format_money(budget.actual_spending_money) %> <%= format_money(budget.actual_spending_money) %>
</div> </div>
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "of #{budget.budgeted_spending_money.format}", text: "of #{budget.budgeted_spending_money.format}",
variant: "secondary", variant: "secondary",
icon: "pencil", icon: "pencil",
@ -25,7 +25,7 @@
<span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span> <span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span>
</div> </div>
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "New budget", text: "New budget",
size: "sm", size: "sm",
icon: "plus", icon: "plus",
@ -46,7 +46,7 @@
<%= format_money(bc.actual_spending_money) %> <%= format_money(bc.actual_spending_money) %>
</p> </p>
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "of #{bc.budgeted_spending_money.format(precision: 0)}", text: "of #{bc.budgeted_spending_money.format(precision: 0)}",
variant: "secondary", variant: "secondary",
icon: "pencil", icon: "pencil",

View file

@ -3,7 +3,7 @@
<div class="flex items-center gap-1 mb-4"> <div class="flex items-center gap-1 mb-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<% if budget.previous_budget_param %> <% if budget.previous_budget_param %>
<%= render LinkComponent.new( <%= render DS::Link.new(
variant: "icon", variant: "icon",
icon: "chevron-left", icon: "chevron-left",
href: budget_path(budget.previous_budget_param), href: budget_path(budget.previous_budget_param),
@ -15,7 +15,7 @@
<% end %> <% end %>
<% if budget.next_budget_param %> <% if budget.next_budget_param %>
<%= render LinkComponent.new( <%= render DS::Link.new(
variant: "icon", variant: "icon",
icon: "chevron-right", icon: "chevron-right",
href: budget_path(budget.next_budget_param), href: budget_path(budget.next_budget_param),
@ -27,7 +27,7 @@
<% end %> <% end %>
</div> </div>
<%= render MenuComponent.new(variant: "button") do |menu| %> <%= render DS::Menu.new(variant: "button") do |menu| %>
<% menu.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %> <% menu.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
<span class="text-primary font-medium text-lg lg:text-base"><%= @budget.name %></span> <span class="text-primary font-medium text-lg lg:text-base"><%= @budget.name %></span>
<%= icon("chevron-down") %> <%= icon("chevron-down") %>
@ -39,7 +39,7 @@
<% end %> <% end %>
<div class="ml-auto"> <div class="ml-auto">
<%= render LinkComponent.new( <%= render DS::Link.new(
text: "Today", text: "Today",
variant: "outline", variant: "outline",
href: budget_path(Budget.date_to_param(Date.current)), href: budget_path(Budget.date_to_param(Date.current)),

Some files were not shown because too many files have changed in this diff Show more