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

View file

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

View file

@ -1,6 +1,6 @@
<%= container do %>
<% if icon && (icon_position != :right) %>
<%= helpers.icon(icon, size: size, color: icon_color) %>
<%= helpers.icon(icon, size: size, color: icon_color, class: icon_classes) %>
<% end %>
<% unless icon_only? %>

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
# options available.
class ButtonComponent < ButtonishComponent
class DS::Button < DS::Buttonish
attr_reader :confirm
def initialize(confirm: nil, **opts)

View file

@ -1,11 +1,11 @@
class ButtonishComponent < ViewComponent::Base
class DS::Buttonish < DesignSystemComponent
VARIANTS = {
primary: {
container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
icon_classes: "fg-inverse"
},
secondary: {
container_classes: "text-secondary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
container_classes: "text-primary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
icon_classes: "fg-primary"
},
destructive: {
@ -71,7 +71,7 @@ class ButtonishComponent < ViewComponent::Base
end
def call
raise NotImplementedError, "ButtonishComponent is an abstract class and cannot be instantiated directly."
raise NotImplementedError, "Buttonish is an abstract class and cannot be instantiated directly."
end
def container_classes(override_classes = nil)

View file

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

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
content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do
title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do
title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
close_icon = render ButtonComponent.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "dialog#close" }) unless hide_close_icon
close_icon = render DS::Button.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "DS--dialog#close" }) unless hide_close_icon
safe_join([ title, close_icon ].compact)
end
@ -19,16 +19,16 @@ class DialogComponent < ViewComponent::Base
renders_many :actions, ->(cancel_action: false, **button_opts) do
merged_opts = if cancel_action
button_opts.merge(type: "button", data: { action: "modal#close" })
button_opts.merge(type: "button", data: { action: "DS--dialog#close" })
else
button_opts
end
render ButtonComponent.new(**merged_opts)
render DS::Button.new(**merged_opts)
end
renders_many :sections, ->(title:, **disclosure_opts, &block) do
render DisclosureComponent.new(title: title, align: :right, **disclosure_opts) do
render DS::Disclosure.new(title: title, align: :right, **disclosure_opts) do
block.call
end
end
@ -99,11 +99,11 @@ class DialogComponent < ViewComponent::Base
merged_opts = opts.dup
data = merged_opts.delete(:data) || {}
data[:controller] = [ "dialog", "hotkey", data[:controller] ].compact.join(" ")
data[:dialog_auto_open_value] = auto_open
data[:dialog_reload_on_close_value] = reload_on_close
data[:action] = [ "mousedown->dialog#clickOutside", data[:action] ].compact.join(" ")
data[:hotkey] = "esc:dialog#close"
data[:controller] = [ "DS--dialog", "hotkey", data[:controller] ].compact.join(" ")
data[:DS__dialog_auto_open_value] = auto_open
data[:DS__dialog_reload_on_close_value] = reload_on_close
data[:action] = [ "mousedown->DS--dialog#clickOutside", data[:action] ].compact.join(" ")
data[:hotkey] = "esc:DS--dialog#close"
merged_opts[:data] = data
merged_opts

View file

@ -1,4 +1,4 @@
class DisclosureComponent < ViewComponent::Base
class DS::Disclosure < DesignSystemComponent
renders_one :summary_content
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
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
# options available.
class LinkComponent < ButtonishComponent
class DS::Link < DS::Buttonish
attr_reader :frame
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 %>
<%= render ButtonComponent.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { menu_target: "button" }) %>
<%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %>
<% elsif variant == :button %>
<%= button %>
<% elsif variant == :avatar %>
<button data-menu-target="button">
<button data-DS--menu-target="button">
<div class="w-9 h-9 cursor-pointer">
<%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %>
</div>
</button>
<% end %>
<div data-menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
<%= header %>

View file

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

View file

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

View file

@ -1,4 +1,4 @@
class TabComponent < ViewComponent::Base
class DS::Tab < DesignSystemComponent
attr_reader :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
Tabs::NavComponent.new(
DS::Tabs::Nav.new(
active_tab: active_tab,
active_btn_classes: active_btn_classes,
inactive_btn_classes: inactive_btn_classes,
@ -13,7 +13,7 @@ class TabsComponent < ViewComponent::Base
content_tag(
:div,
class: ("hidden" unless tab_id == active_tab),
data: { id: tab_id, tabs_target: "panel" },
data: { id: tab_id, DS__tabs_target: "panel" },
&block
)
end

View file

@ -1,4 +1,4 @@
class Tabs::NavComponent < ViewComponent::Base
class DS::Tabs::Nav < DesignSystemComponent
erb_template <<~ERB
<%= tag.nav class: classes do %>
<% btns.each do |btn| %>
@ -12,7 +12,7 @@ class Tabs::NavComponent < ViewComponent::Base
:button, label, id: id,
type: "button",
class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes),
data: { id: id, action: "tabs#show", tabs_target: "navBtn" },
data: { id: id, action: "DS--tabs#show", DS__tabs_target: "navBtn" },
&block
)
end

View file

@ -1,4 +1,4 @@
class Tabs::PanelComponent < ViewComponent::Base
class DS::Tabs::Panel < DesignSystemComponent
attr_reader :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
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 class="flex justify-between flex-col-reverse lg:flex-row gap-2 px-4 pt-4 mb-2">
<div class="space-y-2 w-full">
<div class="flex items-center gap-1">
<%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %>
<%= tag.p title, class: "text-sm font-medium text-secondary" %>
<% if account.investment? %>
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: account.balance_money - account.cash_balance_money, cash: account.cash_balance_money %>
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: holdings_value_money, cash: account.cash_balance_money %>
<% end %>
</div>
<div class="flex flex-row gap-2 items-baseline">
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
<% if account.currency != Current.family.currency %>
<%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %>
<%= tag.p view_balance_money.format, class: "text-primary text-3xl font-medium truncate" %>
<% if converted_balance_money %>
<%= tag.p converted_balance_money.format, class: "text-sm font-medium text-secondary" %>
<% end %>
</div>
</div>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
<%= form_with url: account_path(account), method: :get, data: { controller: "auto-submit-form" } do |form| %>
<div class="flex items-center gap-2">
<% if chart_view.present? %>
<% if account.investment? %>
<%= form.select :chart_view,
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
{ selected: chart_view },
{ selected: view },
class: "bg-container border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
data: { "auto-submit-form-target": "auto" } %>
<% end %>
@ -40,7 +36,23 @@
<% end %>
</div>
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key, chart_view: chart_view) do %>
<%= render "accounts/chart_loader" %>
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<div class="px-4">
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: period.comparison_label } %>
</div>
<div class="h-64 pb-4">
<% if series.any? %>
<div
id="lineChart"
class="w-full h-full"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm">No data available</p>
</div>
<% end %>
</div>
<% end %>
</div>

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
before_action :set_account, only: %i[sync chart sparkline toggle_active]
before_action :set_account, only: %i[sync sparkline toggle_active show destroy]
include Periodable
def index
@ -9,6 +9,17 @@ class AccountsController < ApplicationController
render layout: "settings"
end
def show
@chart_view = params[:chart_view] || "balance"
@tab = params[:tab]
@q = params.fetch(:q, {}).permit(:search)
entries = @account.entries.search(@q).reverse_chronological
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
end
def sync
unless @account.syncing?
@account.sync_later
@ -17,11 +28,6 @@ class AccountsController < ApplicationController
redirect_to account_path(@account)
end
def chart
@chart_view = params[:chart_view] || "balance"
render layout: "application"
end
def sparkline
etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true)
@ -42,6 +48,15 @@ class AccountsController < ApplicationController
redirect_to accounts_path
end
def destroy
if @account.linked?
redirect_to account_path(@account), alert: "Cannot delete a linked account"
else
@account.destroy_later
redirect_to accounts_path, notice: "Account scheduled for deletion"
end
end
private
def family
Current.family

View file

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

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
def update_balances
result = @account.update_balance(balance: balance_params[:balance], currency: balance_params[:currency])
result = @account.set_current_balance(balance_params[:balance].to_d)
if result.success?
@success_message = result.updated? ? "Balance updated successfully." : "No changes made. Account is already up to date."
@success_message = "Balance updated successfully."
if @account.active?
render :balances

View file

@ -1,5 +1,5 @@
class TransactionsController < ApplicationController
include ScrollFocusable, EntryableResource
include EntryableResource
before_action :store_params!, only: :index
@ -21,12 +21,7 @@ class TransactionsController < ApplicationController
:transfer_as_inflow, :transfer_as_outflow
)
@pagy, @transactions = pagy(base_scope, limit: per_page, params: ->(p) { p.except(:focused_record_id) })
# No performance penalty by default. Only runs queries if the record is set.
if params[:focused_record_id].present?
set_focused_record(base_scope, params[:focused_record_id], default_per_page: per_page)
end
@pagy, @transactions = pagy(base_scope, limit: per_page)
end
def clear_filter

View file

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

View file

@ -21,7 +21,7 @@ module ApplicationHelper
if custom
inline_svg_tag("#{key}.svg", class: icon_classes, **opts)
elsif as_button
render ButtonComponent.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts)
render DS::Button.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts)
else
lucide_icon(key, class: icon_classes, **opts)
end

View file

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

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
include Syncable, Monetizable, Chartable, Linkable, Enrichable
include AASM
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable
validates :name, :balance, :currency, presence: true
@ -59,26 +58,14 @@ class Account < ApplicationRecord
def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes.merge(cash_balance: attributes[:balance]))
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d
transaction do
# Create 2 valuations for new accounts to establish a value history for users to see
account.entries.build(
name: "Current Balance",
date: Date.current,
amount: account.balance,
currency: account.currency,
entryable: Valuation.new
)
account.entries.build(
name: "Initial Balance",
date: 1.day.ago.to_date,
amount: initial_balance,
currency: account.currency,
entryable: Valuation.new
)
account.save!
manager = Account::OpeningBalanceManager.new(account)
result = manager.set_opening_balance(balance: initial_balance || account.balance)
raise result.error if result.error
end
account.sync_later
@ -127,11 +114,6 @@ class Account < ApplicationRecord
.order(amount: :desc)
end
def update_balance(balance:, date: Date.current, currency: nil, notes: nil)
Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:).update
end
def start_date
first_entry_date = entries.minimum(:date) || Date.current
first_entry_date - 1.day
@ -159,4 +141,23 @@ class Account < ApplicationRecord
def long_subtype_label
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
end
# The balance type determines which "component" of balance is being tracked.
# This is primarily used for balance related calculations and updates.
#
# "Cash" = "Liquid"
# "Non-cash" = "Illiquid"
# "Investment" = A mix of both, including brokerage cash (liquid) and holdings (illiquid)
def balance_type
case accountable_type
when "Depository", "CreditCard"
:cash
when "Property", "Vehicle", "OtherAsset", "Loan", "OtherLiability"
:non_cash
when "Investment", "Crypto"
:investment
else
raise "Unknown account type: #{accountable_type}"
end
end
end

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

View file

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

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
attr_reader :account
def initialize(account)
@account = account
end
class Balance::ForwardCalculator < Balance::BaseCalculator
def calculate
Rails.logger.tagged("Balance::ForwardCalculator") do
calculate_balances
start_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
date: account.opening_anchor_date
)
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
calc_start_date.upto(calc_end_date).map do |date|
valuation = sync_cache.get_reconciliation_valuation(date)
if valuation
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: valuation.amount,
date: date
)
end_non_cash_balance = valuation.amount - end_cash_balance
else
end_cash_balance = derive_end_cash_balance(start_cash_balance: start_cash_balance, date: date)
end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date)
end
output_balance = build_balance(
date: date,
cash_balance: end_cash_balance,
non_cash_balance: end_non_cash_balance
)
# Set values for the next iteration
start_cash_balance = end_cash_balance
start_non_cash_balance = end_non_cash_balance
output_balance
end
end
end
private
def calculate_balances
current_cash_balance = 0
next_cash_balance = nil
@balances = []
account.start_date.upto(Date.current).each do |date|
entries = sync_cache.get_entries(date)
holdings = sync_cache.get_holdings(date)
holdings_value = holdings.sum(&:amount)
valuation = sync_cache.get_valuation(date)
next_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :forward)
end
@balances << build_balance(date, next_cash_balance, holdings_value)
current_cash_balance = next_cash_balance
end
@balances
def calc_start_date
account.opening_anchor_date
end
def sync_cache
@sync_cache ||= Balance::SyncCache.new(account)
def calc_end_date
[ account.entries.order(:date).last&.date, account.holdings.order(:date).last&.date ].compact.max || Date.current
end
def build_balance(date, cash_balance, holdings_value)
Balance.new(
account_id: account.id,
date: date,
balance: holdings_value + cash_balance,
cash_balance: cash_balance,
currency: account.currency
)
# Negative entries amount on an "asset" account means, "account value has increased"
# Negative entries amount on a "liability" account means, "account debt has decreased"
# Positive entries amount on an "asset" account means, "account value has decreased"
# Positive entries amount on a "liability" account means, "account debt has increased"
def signed_entry_flows(entries)
entry_flows = entries.sum(&:amount)
account.asset? ? -entry_flows : entry_flows
end
def calculate_next_balance(prior_balance, transactions, direction: :forward)
flows = transactions.sum(&:amount)
negated = direction == :forward ? account.asset? : account.liability?
flows *= -1 if negated
prior_balance + flows
# Derives cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
def derive_end_cash_balance(start_cash_balance:, date:)
derive_cash_balance(start_cash_balance, date)
end
# Derives non-cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance
def derive_end_non_cash_balance(start_non_cash_balance:, date:)
derive_non_cash_balance(start_non_cash_balance, date, direction: :forward)
end
end

View file

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

View file

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

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
def sync_error
latest_sync&.error
latest_sync&.error || latest_sync&.children&.map(&:error)&.compact&.first
end
def last_synced_at

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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(".empty_message"), class: "text-secondary mb-4" %>
<%= render LinkComponent.new(
<%= render DS::Link.new(
text: t(".new_account"),
href: new_account_path,
frame: :modal

View file

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

View file

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

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>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= render LinkComponent.new(
<%= render DS::Link.new(
text: "New account",
href: new_account_path(return_to: accounts_path),
variant: "primary",

View file

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

View file

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

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

View file

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

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:) %>
<%= render MenuComponent.new(testid: "account-menu") do |menu| %>
<%= render DS::Menu.new(testid: "account-menu") do |menu| %>
<% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<% unless account.crypto? %>

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 %>
<%= icon(budget_category.category.lucide_icon, color: "current") %>
<% else %>
<%= render FilledIconComponent.new(
<%= render DS::FilledIcon.new(
variant: :text,
hex_color: budget_category.category.color,
text: budget_category.category.name,

View file

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

View file

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

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

View file

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

View file

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

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