mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-22 06:39:39 +02:00
Start and end balance breakdown in activity view (#2466)
* Initial data objects * Remove trend calculator * Fill in balance reconciliation for entry group * Initial tooltip component * Balance trends in activity view * Lint fixes * trade partial alignment fix * Tweaks to balance calculation to acknowledge holdings value better * More lint fixes * Bump brakeman dep * Test fixes * Remove unused class
This commit is contained in:
parent
ab6fdbbb68
commit
e8eb32d2ae
27 changed files with 1088 additions and 119 deletions
|
@ -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)
|
||||||
|
|
9
app/components/DS/tooltip.html.erb
Normal file
9
app/components/DS/tooltip.html.erb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<span data-controller="DS--tooltip" data-DS--tooltip-placement-value="<%= placement %>" data-DS--tooltip-offset-value="<%= offset %>" data-DS--tooltip-cross-axis-value="<%= cross_axis %>" class="inline-flex">
|
||||||
|
<%= helpers.icon icon_name, size: size, color: color %>
|
||||||
|
|
||||||
|
<div role="tooltip" data-DS--tooltip-target="tooltip" class="hidden absolute z-50 bg-gray-700 text-sm px-1.5 py-1 rounded-md">
|
||||||
|
<div class="fg-inverse font-normal max-w-[200px]">
|
||||||
|
<%= tooltip_content %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
17
app/components/DS/tooltip.rb
Normal file
17
app/components/DS/tooltip.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
class DS::Tooltip < ApplicationComponent
|
||||||
|
attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color
|
||||||
|
|
||||||
|
def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
|
||||||
|
@text = text
|
||||||
|
@placement = placement
|
||||||
|
@offset = offset
|
||||||
|
@cross_axis = cross_axis
|
||||||
|
@icon_name = icon
|
||||||
|
@size = size
|
||||||
|
@color = color
|
||||||
|
end
|
||||||
|
|
||||||
|
def tooltip_content
|
||||||
|
content? ? content : @text
|
||||||
|
end
|
||||||
|
end
|
87
app/components/DS/tooltip_controller.js
Normal file
87
app/components/DS/tooltip_controller.js
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import {
|
||||||
|
autoUpdate,
|
||||||
|
computePosition,
|
||||||
|
flip,
|
||||||
|
offset,
|
||||||
|
shift,
|
||||||
|
} from "@floating-ui/dom";
|
||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["tooltip"];
|
||||||
|
static values = {
|
||||||
|
placement: { type: String, default: "top" },
|
||||||
|
offset: { type: Number, default: 10 },
|
||||||
|
crossAxis: { type: Number, default: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this._cleanup = null;
|
||||||
|
this.boundUpdate = this.update.bind(this);
|
||||||
|
this.addEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.removeEventListeners();
|
||||||
|
this.stopAutoUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListeners() {
|
||||||
|
this.element.addEventListener("mouseenter", this.show);
|
||||||
|
this.element.addEventListener("mouseleave", this.hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListeners() {
|
||||||
|
this.element.removeEventListener("mouseenter", this.show);
|
||||||
|
this.element.removeEventListener("mouseleave", this.hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
show = () => {
|
||||||
|
this.tooltipTarget.classList.remove("hidden");
|
||||||
|
this.startAutoUpdate();
|
||||||
|
this.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
hide = () => {
|
||||||
|
this.tooltipTarget.classList.add("hidden");
|
||||||
|
this.stopAutoUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
startAutoUpdate() {
|
||||||
|
if (!this._cleanup) {
|
||||||
|
const reference = this.element.querySelector("[data-icon]");
|
||||||
|
this._cleanup = autoUpdate(
|
||||||
|
reference || this.element,
|
||||||
|
this.tooltipTarget,
|
||||||
|
this.boundUpdate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAutoUpdate() {
|
||||||
|
if (this._cleanup) {
|
||||||
|
this._cleanup();
|
||||||
|
this._cleanup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const reference = this.element.querySelector("[data-icon]");
|
||||||
|
computePosition(reference || this.element, this.tooltipTarget, {
|
||||||
|
placement: this.placementValue,
|
||||||
|
middleware: [
|
||||||
|
offset({
|
||||||
|
mainAxis: this.offsetValue,
|
||||||
|
crossAxis: this.crossAxisValue,
|
||||||
|
}),
|
||||||
|
flip(),
|
||||||
|
shift({ padding: 5 }),
|
||||||
|
],
|
||||||
|
}).then(({ x, y }) => {
|
||||||
|
Object.assign(this.tooltipTarget.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
103
app/components/UI/account/activity_date.html.erb
Normal file
103
app/components/UI/account/activity_date.html.erb
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
<%= tag.div id: id, data: { bulk_select_target: "group" }, class: "bg-container-inset rounded-xl p-1 w-full" do %>
|
||||||
|
<details class="group">
|
||||||
|
<summary>
|
||||||
|
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary">
|
||||||
|
<div class="flex pl-0.5 items-center gap-4">
|
||||||
|
<%= check_box_tag "#{date}_entries_selection",
|
||||||
|
class: ["checkbox checkbox--light", "hidden": entries.size == 0],
|
||||||
|
id: "selection_entry_#{date}",
|
||||||
|
data: { action: "bulk-select#toggleGroupSelection" } %>
|
||||||
|
|
||||||
|
<p class="uppercase space-x-1.5">
|
||||||
|
<%= tag.span I18n.l(date, format: :long) %>
|
||||||
|
<span>·</span>
|
||||||
|
<%= tag.span entries.size %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium"><%= balance_trend.current.format %></span>
|
||||||
|
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
|
||||||
|
</div>
|
||||||
|
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Start of day balance
|
||||||
|
<%= render DS::Tooltip.new(text: "The account balance at the beginning of this day, before any transactions or value changes", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd class="font-bold"><%= start_balance_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<% if account.balance_type == :investment %>
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Δ Cash
|
||||||
|
<%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd><%= cash_change_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Δ Holdings
|
||||||
|
<%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd><%= holdings_change_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
<% else %>
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Δ Cash
|
||||||
|
<%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd><%= cash_change_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
End of day balance
|
||||||
|
<%= render DS::Tooltip.new(text: "The calculated balance after all transactions but before any manual adjustments or reconciliations", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd class="font-medium"><%= end_balance_before_adjustments_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<hr class="border border-primary">
|
||||||
|
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Δ Value adjustments
|
||||||
|
<%= render DS::Tooltip.new(text: "Adjustments are either manual reconciliations made by the user or adjustments due to market price changes throughout the day", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd><%= adjustments_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Closing balance
|
||||||
|
<%= render DS::Tooltip.new(text: "The final account balance for the day, after all transactions and adjustments have been applied", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-primary">
|
||||||
|
<dd class="font-bold"><%= end_balance_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="bg-container shadow-border-xs rounded-lg">
|
||||||
|
<% entries.each do |entry| %>
|
||||||
|
<%= render entry, view_ctx: "account" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
51
app/components/UI/account/activity_date.rb
Normal file
51
app/components/UI/account/activity_date.rb
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
class UI::Account::ActivityDate < ApplicationComponent
|
||||||
|
attr_reader :account, :data
|
||||||
|
|
||||||
|
delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data
|
||||||
|
|
||||||
|
def initialize(account:, data:)
|
||||||
|
@account = account
|
||||||
|
@data = data
|
||||||
|
end
|
||||||
|
|
||||||
|
def id
|
||||||
|
dom_id(account, "entries_#{date}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_channel
|
||||||
|
account
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_balance_money
|
||||||
|
balance_trend.previous
|
||||||
|
end
|
||||||
|
|
||||||
|
def cash_change_money
|
||||||
|
cash_balance_trend.value
|
||||||
|
end
|
||||||
|
|
||||||
|
def holdings_change_money
|
||||||
|
holdings_value_trend.value
|
||||||
|
end
|
||||||
|
|
||||||
|
def end_balance_before_adjustments_money
|
||||||
|
balance_trend.previous + cash_change_money + holdings_change_money
|
||||||
|
end
|
||||||
|
|
||||||
|
def adjustments_money
|
||||||
|
end_balance_money - end_balance_before_adjustments_money
|
||||||
|
end
|
||||||
|
|
||||||
|
def end_balance_money
|
||||||
|
balance_trend.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_refresh!
|
||||||
|
Turbo::StreamsChannel.broadcast_replace_to(
|
||||||
|
broadcast_channel,
|
||||||
|
target: id,
|
||||||
|
renderable: self,
|
||||||
|
layout: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
94
app/components/UI/account/activity_feed.html.erb
Normal file
94
app/components/UI/account/activity_feed.html.erb
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
<%= turbo_frame_tag dom_id(account, "entries") do %>
|
||||||
|
<div class="bg-container p-5 shadow-border-xs rounded-xl">
|
||||||
|
<div class="flex items-center justify-between mb-4" data-testid="activity-menu">
|
||||||
|
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
|
||||||
|
|
||||||
|
<% if account.manual? %>
|
||||||
|
<%= render DS::Menu.new(variant: "button") do |menu| %>
|
||||||
|
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
|
||||||
|
|
||||||
|
<% menu.with_item(
|
||||||
|
variant: "link",
|
||||||
|
text: "New balance",
|
||||||
|
icon: "circle-dollar-sign",
|
||||||
|
href: new_valuation_path(account_id: account.id),
|
||||||
|
data: { turbo_frame: :modal }) %>
|
||||||
|
|
||||||
|
<% unless account.crypto? %>
|
||||||
|
<% menu.with_item(
|
||||||
|
variant: "link",
|
||||||
|
text: "New transaction",
|
||||||
|
icon: "credit-card",
|
||||||
|
href: account.investment? ? new_trade_path(account_id: account.id) : new_transaction_path(account_id: account.id),
|
||||||
|
data: { turbo_frame: :modal }) %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form_with url: account_path(account),
|
||||||
|
id: "entries-search",
|
||||||
|
scope: :q,
|
||||||
|
method: :get,
|
||||||
|
data: { controller: "auto-submit-form" } do |form| %>
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<div class="grow">
|
||||||
|
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
|
||||||
|
<%= helpers.icon("search") %>
|
||||||
|
|
||||||
|
<%= hidden_field_tag :account_id, account.id %>
|
||||||
|
|
||||||
|
<%= form.search_field :search,
|
||||||
|
placeholder: "Search entries by name",
|
||||||
|
value: search,
|
||||||
|
class: "form-field__input placeholder:text-sm placeholder:text-secondary",
|
||||||
|
"data-auto-submit-form-target": "auto" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if activity_dates.empty? %>
|
||||||
|
<p class="text-secondary text-sm p-4">No entries yet</p>
|
||||||
|
<% else %>
|
||||||
|
<%= tag.div id: dom_id(account, "entries_bulk_select"),
|
||||||
|
data: {
|
||||||
|
controller: "bulk-select",
|
||||||
|
bulk_select_singular_label_value: "entry",
|
||||||
|
bulk_select_plural_label_value: "entries"
|
||||||
|
} do %>
|
||||||
|
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
|
||||||
|
<%= render "entries/selection_bar" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid bg-container-inset rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-5 py-3 mb-4">
|
||||||
|
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||||
|
<%= check_box_tag "selection_entry",
|
||||||
|
class: "checkbox checkbox--light",
|
||||||
|
data: { action: "bulk-select#togglePageSelection" } %>
|
||||||
|
<p>Date</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= tag.p "Amount", class: "col-span-4 justify-self-end" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% activity_dates.each do |activity_date_data| %>
|
||||||
|
<%= render UI::Account::ActivityDate.new(
|
||||||
|
account: account,
|
||||||
|
data: activity_date_data
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 bg-container rounded-bl-lg rounded-br-lg">
|
||||||
|
<%= render "shared/pagination", pagy: pagy %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
35
app/components/UI/account/activity_feed.rb
Normal file
35
app/components/UI/account/activity_feed.rb
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
class UI::Account::ActivityFeed < ApplicationComponent
|
||||||
|
attr_reader :feed_data, :pagy, :search
|
||||||
|
|
||||||
|
def initialize(feed_data:, pagy:, search: nil)
|
||||||
|
@feed_data = feed_data
|
||||||
|
@pagy = pagy
|
||||||
|
@search = search
|
||||||
|
end
|
||||||
|
|
||||||
|
def id
|
||||||
|
dom_id(account, :activity_feed)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_channel
|
||||||
|
account
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_refresh!
|
||||||
|
Turbo::StreamsChannel.broadcast_replace_to(
|
||||||
|
broadcast_channel,
|
||||||
|
target: id,
|
||||||
|
renderable: self,
|
||||||
|
layout: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def activity_dates
|
||||||
|
feed_data.entries_by_date
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def account
|
||||||
|
feed_data.account
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,6 +1,6 @@
|
||||||
<%= turbo_stream_from account %>
|
<%= turbo_stream_from account %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(account, :container) do %>
|
<%= turbo_frame_tag id do %>
|
||||||
<%= tag.div class: "space-y-4 pb-32" do %>
|
<%= tag.div class: "space-y-4 pb-32" do %>
|
||||||
<%= render "accounts/show/header", account: account, title: title, subtitle: subtitle %>
|
<%= render "accounts/show/header", account: account, title: title, subtitle: subtitle %>
|
||||||
|
|
||||||
|
@ -17,12 +17,12 @@
|
||||||
|
|
||||||
<% tabs.each do |tab| %>
|
<% tabs.each do |tab| %>
|
||||||
<% tabs_container.with_panel(tab_id: tab) do %>
|
<% tabs_container.with_panel(tab_id: tab) do %>
|
||||||
<%= render tab_partial_name(tab), account: account %>
|
<%= tab_content_for(tab) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= render tab_partial_name(tabs.first), account: account %>
|
<%= tab_content_for(tabs.first) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
class UI::AccountPage < ApplicationComponent
|
class UI::AccountPage < ApplicationComponent
|
||||||
attr_reader :account, :chart_view, :chart_period
|
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)
|
def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil)
|
||||||
@account = account
|
@account = account
|
||||||
@chart_view = chart_view
|
@chart_view = chart_view
|
||||||
|
@ -8,6 +10,18 @@ class UI::AccountPage < ApplicationComponent
|
||||||
@active_tab = active_tab
|
@active_tab = active_tab
|
||||||
end
|
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
|
def title
|
||||||
account.name
|
account.name
|
||||||
end
|
end
|
||||||
|
@ -33,13 +47,13 @@ class UI::AccountPage < ApplicationComponent
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def tab_partial_name(tab)
|
def tab_content_for(tab)
|
||||||
case tab
|
case tab
|
||||||
when :activity
|
when :activity
|
||||||
"accounts/show/activity"
|
activity_feed
|
||||||
when :holdings, :overview
|
when :holdings, :overview
|
||||||
# Accountable is responsible for implementing the partial in the correct folder
|
# Accountable is responsible for implementing the partial in the correct folder
|
||||||
"#{account.accountable_type.downcase.pluralize}/tabs/#{tab}"
|
render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,6 +16,8 @@ class AccountsController < ApplicationController
|
||||||
entries = @account.entries.search(@q).reverse_chronological
|
entries = @account.entries.search(@q).reverse_chronological
|
||||||
|
|
||||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
|
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
|
||||||
|
|
||||||
|
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync
|
def sync
|
||||||
|
|
219
app/models/account/activity_feed_data.rb
Normal file
219
app/models/account/activity_feed_data.rb
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
# Data used to build the paginated feed of account "activity" (events like transfers, deposits, withdrawals, etc.)
|
||||||
|
# This data object is useful for avoiding N+1 queries and having an easy way to pass around the required data to the
|
||||||
|
# activity feed component in controllers and background jobs that refresh it.
|
||||||
|
class Account::ActivityFeedData
|
||||||
|
ActivityDateData = Data.define(:date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers)
|
||||||
|
|
||||||
|
attr_reader :account, :entries
|
||||||
|
|
||||||
|
def initialize(account, entries)
|
||||||
|
@account = account
|
||||||
|
@entries = entries.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def entries_by_date
|
||||||
|
@entries_by_date_objects ||= begin
|
||||||
|
grouped_entries.map do |date, date_entries|
|
||||||
|
ActivityDateData.new(
|
||||||
|
date: date,
|
||||||
|
entries: date_entries,
|
||||||
|
balance_trend: balance_trend_for_date(date),
|
||||||
|
cash_balance_trend: cash_balance_trend_for_date(date),
|
||||||
|
holdings_value_trend: holdings_value_trend_for_date(date),
|
||||||
|
transfers: transfers_for_date(date)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def balance_trend_for_date(date)
|
||||||
|
build_trend_for_date(date, :balance_money)
|
||||||
|
end
|
||||||
|
|
||||||
|
def cash_balance_trend_for_date(date)
|
||||||
|
date_entries = grouped_entries[date] || []
|
||||||
|
has_valuation = date_entries.any?(&:valuation?)
|
||||||
|
|
||||||
|
if has_valuation
|
||||||
|
# When there's a valuation, calculate cash change from transaction entries only
|
||||||
|
transactions = date_entries.select { |e| e.transaction? }
|
||||||
|
cash_change = sum_entries_with_exchange_rates(transactions, date) * -1
|
||||||
|
|
||||||
|
start_balance = start_balance_for_date(date)
|
||||||
|
Trend.new(
|
||||||
|
current: start_balance.cash_balance_money + cash_change,
|
||||||
|
previous: start_balance.cash_balance_money
|
||||||
|
)
|
||||||
|
else
|
||||||
|
build_trend_for_date(date, :cash_balance_money)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def holdings_value_trend_for_date(date)
|
||||||
|
date_entries = grouped_entries[date] || []
|
||||||
|
has_valuation = date_entries.any?(&:valuation?)
|
||||||
|
|
||||||
|
if has_valuation
|
||||||
|
# When there's a valuation, calculate holdings change from trade entries only
|
||||||
|
trades = date_entries.select { |e| e.trade? }
|
||||||
|
holdings_change = sum_entries_with_exchange_rates(trades, date)
|
||||||
|
|
||||||
|
start_balance = start_balance_for_date(date)
|
||||||
|
start_holdings = start_balance.balance_money - start_balance.cash_balance_money
|
||||||
|
Trend.new(
|
||||||
|
current: start_holdings + holdings_change,
|
||||||
|
previous: start_holdings
|
||||||
|
)
|
||||||
|
else
|
||||||
|
build_trend_for_date(date) do |balance|
|
||||||
|
balance.balance_money - balance.cash_balance_money
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfers_for_date(date)
|
||||||
|
date_entries = grouped_entries[date] || []
|
||||||
|
return [] if date_entries.empty?
|
||||||
|
|
||||||
|
date_transaction_ids = date_entries.select(&:transaction?).map(&:entryable_id)
|
||||||
|
return [] if date_transaction_ids.empty?
|
||||||
|
|
||||||
|
# Convert to Set for O(1) lookups
|
||||||
|
date_transaction_id_set = Set.new(date_transaction_ids)
|
||||||
|
|
||||||
|
transfers.select { |txfr|
|
||||||
|
date_transaction_id_set.include?(txfr.inflow_transaction_id) ||
|
||||||
|
date_transaction_id_set.include?(txfr.outflow_transaction_id)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_trend_for_date(date, method = nil)
|
||||||
|
start_balance = start_balance_for_date(date)
|
||||||
|
end_balance = end_balance_for_date(date)
|
||||||
|
|
||||||
|
if block_given?
|
||||||
|
Trend.new(
|
||||||
|
current: yield(end_balance),
|
||||||
|
previous: yield(start_balance)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Trend.new(
|
||||||
|
current: end_balance.send(method),
|
||||||
|
previous: start_balance.send(method)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Finds the balance on date, or the most recent balance before it ("last observation carried forward")
|
||||||
|
def start_balance_for_date(date)
|
||||||
|
@start_balance_for_date ||= {}
|
||||||
|
@start_balance_for_date[date] ||= last_observed_balance_before_date(date.prev_day) || generate_fallback_balance(date)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Finds the balance on date, or the most recent balance before it ("last observation carried forward")
|
||||||
|
def end_balance_for_date(date)
|
||||||
|
@end_balance_for_date ||= {}
|
||||||
|
@end_balance_for_date[date] ||= last_observed_balance_before_date(date) || generate_fallback_balance(date)
|
||||||
|
end
|
||||||
|
|
||||||
|
RequiredExchangeRate = Data.define(:date, :from, :to)
|
||||||
|
|
||||||
|
def grouped_entries
|
||||||
|
@grouped_entries ||= entries.group_by(&:date)
|
||||||
|
end
|
||||||
|
|
||||||
|
def needs_exchange_rates?
|
||||||
|
entries.any? { |entry| entry.currency != account.currency }
|
||||||
|
end
|
||||||
|
|
||||||
|
def required_exchange_rates
|
||||||
|
multi_currency_entries = entries.select { |entry| entry.currency != account.currency }
|
||||||
|
|
||||||
|
multi_currency_entries.map do |entry|
|
||||||
|
RequiredExchangeRate.new(date: entry.date, from: entry.currency, to: account.currency)
|
||||||
|
end.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
# If the account has entries denominated in a different currency than the main account, we attach necessary
|
||||||
|
# exchange rates required to "roll up" the entry group balance into the normal account currency.
|
||||||
|
def exchange_rates
|
||||||
|
return [] unless needs_exchange_rates?
|
||||||
|
|
||||||
|
@exchange_rates ||= begin
|
||||||
|
rate_requirements = required_exchange_rates
|
||||||
|
return [] if rate_requirements.empty?
|
||||||
|
|
||||||
|
# Use ActiveRecord's or chain for better performance
|
||||||
|
conditions = rate_requirements.map do |req|
|
||||||
|
ExchangeRate.where(date: req.date, from_currency: req.from, to_currency: req.to)
|
||||||
|
end.reduce(:or)
|
||||||
|
|
||||||
|
conditions.to_a
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def exchange_rate_for(date, from_currency, to_currency)
|
||||||
|
return 1.0 if from_currency == to_currency
|
||||||
|
|
||||||
|
rate = exchange_rates.find { |r| r.date == date && r.from_currency == from_currency && r.to_currency == to_currency }
|
||||||
|
rate&.rate || 1.0 # Fallback to 1:1 if no rate found
|
||||||
|
end
|
||||||
|
|
||||||
|
def sum_entries_with_exchange_rates(entries, date)
|
||||||
|
return Money.new(0, account.currency) if entries.empty?
|
||||||
|
|
||||||
|
entries.sum do |entry|
|
||||||
|
amount = entry.amount_money
|
||||||
|
if entry.currency != account.currency
|
||||||
|
rate = exchange_rate_for(date, entry.currency, account.currency)
|
||||||
|
Money.new(amount.amount * rate, account.currency)
|
||||||
|
else
|
||||||
|
amount
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# We read balances so we can show "start of day" -> "end of day" balances for each entry date group in the feed
|
||||||
|
def balances
|
||||||
|
@balances ||= begin
|
||||||
|
return [] if entries.empty?
|
||||||
|
|
||||||
|
min_date = entries.min_by(&:date).date.prev_day
|
||||||
|
max_date = entries.max_by(&:date).date
|
||||||
|
|
||||||
|
account.balances.where(date: min_date..max_date, currency: account.currency).order(:date).to_a
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def transaction_ids
|
||||||
|
entries.select { |entry| entry.transaction? }.map(&:entryable_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfers
|
||||||
|
return [] if entries.select { |e| e.transaction? && e.transaction.transfer? }.empty?
|
||||||
|
return [] if transaction_ids.empty?
|
||||||
|
|
||||||
|
@transfers ||= Transfer.where(inflow_transaction_id: transaction_ids).or(Transfer.where(outflow_transaction_id: transaction_ids)).to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
# Use binary search since balances are sorted by date
|
||||||
|
def last_observed_balance_before_date(date)
|
||||||
|
idx = balances.bsearch_index { |b| b.date > date }
|
||||||
|
|
||||||
|
if idx
|
||||||
|
idx > 0 ? balances[idx - 1] : nil
|
||||||
|
else
|
||||||
|
balances.last
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_fallback_balance(date)
|
||||||
|
Balance.new(
|
||||||
|
account: account,
|
||||||
|
date: date,
|
||||||
|
balance: 0,
|
||||||
|
currency: account.currency
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,13 +3,13 @@ module Account::Reconcileable
|
||||||
|
|
||||||
def create_reconciliation(balance:, date:, dry_run: false)
|
def create_reconciliation(balance:, date:, dry_run: false)
|
||||||
result = reconciliation_manager.reconcile_balance(balance: balance, date: date, dry_run: dry_run)
|
result = reconciliation_manager.reconcile_balance(balance: balance, date: date, dry_run: dry_run)
|
||||||
sync_later if result.success?
|
sync_later if result.success? && !dry_run
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_reconciliation(existing_valuation_entry, balance:, date:, dry_run: false)
|
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)
|
result = reconciliation_manager.reconcile_balance(balance: balance, date: date, existing_valuation_entry: existing_valuation_entry, dry_run: dry_run)
|
||||||
sync_later if result.success?
|
sync_later if result.success? && !dry_run
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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: prior_balance.present? ? Money.new(prior_balance.balance, balance.currency) : nil,
|
|
||||||
favorable_direction: balance.account.favorable_direction
|
|
||||||
),
|
|
||||||
cash: Money.new(balance.cash_balance, balance.currency),
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
attr_reader :balances
|
|
||||||
end
|
|
|
@ -3,4 +3,6 @@
|
||||||
chart_view: @chart_view,
|
chart_view: @chart_view,
|
||||||
chart_period: @period,
|
chart_period: @period,
|
||||||
active_tab: @tab
|
active_tab: @tab
|
||||||
) %>
|
) do |account_page| %>
|
||||||
|
<%= account_page.with_activity_feed(feed_data: @activity_feed_data, pagy: @pagy, search: @q[:search]) %>
|
||||||
|
<% end %>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<%= turbo_frame_tag dom_id(entry) do %>
|
<%= turbo_frame_tag dom_id(entry) do %>
|
||||||
<%= turbo_frame_tag dom_id(trade) do %>
|
<%= turbo_frame_tag dom_id(trade) do %>
|
||||||
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-primary" %> text-sm font-medium p-4">
|
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-primary" %> text-sm font-medium p-4">
|
||||||
<div class="col-span-6 flex items-center gap-4">
|
<div class="col-span-8 flex items-center gap-4">
|
||||||
<%= check_box_tag dom_id(entry, "selection"),
|
<%= check_box_tag dom_id(entry, "selection"),
|
||||||
class: "checkbox checkbox--light",
|
class: "checkbox checkbox--light",
|
||||||
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
||||||
|
@ -38,16 +38,6 @@
|
||||||
format_money(-entry.amount_money),
|
format_money(-entry.amount_money),
|
||||||
class: ["text-green-600": entry.amount.negative?] %>
|
class: ["text-green-600": entry.amount.negative?] %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 justify-self-end">
|
|
||||||
<% if balance_trend&.trend %>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-primary" %>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<%= turbo_frame_tag dom_id(transaction) do %>
|
<%= turbo_frame_tag dom_id(transaction) do %>
|
||||||
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4 lg:p-4 <%= entry.excluded ? "opacity-50 text-gray-400" : "" %>">
|
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4 lg:p-4 <%= entry.excluded ? "opacity-50 text-gray-400" : "" %>">
|
||||||
|
|
||||||
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 <%= view_ctx == "global" ? "lg:col-span-8" : "lg:col-span-6" %>">
|
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8">
|
||||||
<%= check_box_tag dom_id(entry, "selection"),
|
<%= check_box_tag dom_id(entry, "selection"),
|
||||||
disabled: transaction.transfer.present?,
|
disabled: transaction.transfer.present?,
|
||||||
class: "checkbox checkbox--light",
|
class: "checkbox checkbox--light",
|
||||||
|
@ -93,22 +93,11 @@
|
||||||
<%= render "transactions/transaction_category", transaction: transaction %>
|
<%= render "transactions/transaction_category", transaction: transaction %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-4 lg:col-span-2 ml-auto text-right">
|
<div class="col-span-2 ml-auto text-right">
|
||||||
<%= content_tag :p,
|
<%= content_tag :p,
|
||||||
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
|
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
|
||||||
class: ["text-green-600": entry.amount.negative?] %>
|
class: ["text-green-600": entry.amount.negative?] %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if view_ctx != "global" %>
|
|
||||||
<div class="col-span-2 justify-self-end hidden lg:block">
|
|
||||||
<% if balance_trend&.trend %>
|
|
||||||
<%= tag.p format_money(balance_trend.trend.current),
|
|
||||||
class: "font-medium text-sm text-primary" %>
|
|
||||||
<% else %>
|
|
||||||
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
<div class="space-y-4 text-sm text-secondary">
|
<div class="space-y-4 text-sm text-secondary">
|
||||||
<% if account.investment? %>
|
<% if account.investment? %>
|
||||||
<% holdings_value = reconciliation_dry_run.new_balance - reconciliation_dry_run.new_cash_balance %>
|
<% brokerage_cash = reconciliation_dry_run.new_cash_balance || 0 %>
|
||||||
<% brokerage_cash = reconciliation_dry_run.new_cash_balance %>
|
<% holdings_value = reconciliation_dry_run.new_balance - brokerage_cash %>
|
||||||
|
|
||||||
<p>This will <%= action_verb %> the account value on <span class="font-medium text-primary"><%= entry.date.strftime("%B %d, %Y") %></span> to:</p>
|
<p>This will <%= action_verb %> the account value on <span class="font-medium text-primary"><%= entry.date.strftime("%B %d, %Y") %></span> to:</p>
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<%# locals: (entry:, balance_trend: nil, **) %>
|
<%# locals: (entry:, **) %>
|
||||||
|
|
||||||
<% valuation = entry.entryable %>
|
<% valuation = entry.entryable %>
|
||||||
|
|
||||||
<% color = balance_trend&.trend&.color || "#D444F1" %>
|
<% color = valuation.opening_anchor? ? "#D444F1" : "var(--color-gray)" %>
|
||||||
<% icon = balance_trend&.trend&.icon || "plus" %>
|
<% icon = valuation.opening_anchor? ? "plus" : "minus" %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(entry) do %>
|
<%= turbo_frame_tag dom_id(entry) do %>
|
||||||
<%= turbo_frame_tag dom_id(valuation) do %>
|
<%= turbo_frame_tag dom_id(valuation) do %>
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-4 justify-self-end">
|
<div class="col-span-4 justify-self-end">
|
||||||
<%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-primary" %>
|
<%= tag.p format_money(entry.amount_money), class: "font-bold text-sm text-primary" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
max: Date.current %>
|
max: Date.current %>
|
||||||
|
|
||||||
<%= f.money_field :amount,
|
<%= f.money_field :amount,
|
||||||
label: t(".amount"),
|
label: "Account value on date",
|
||||||
disable_currency: true %>
|
disable_currency: true %>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
|
|
32
test/components/previews/tooltip_component_preview.rb
Normal file
32
test/components/previews/tooltip_component_preview.rb
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
class TooltipComponentPreview < ViewComponent::Preview
|
||||||
|
# @param text text
|
||||||
|
# @param placement select [top, right, bottom, left]
|
||||||
|
# @param offset number
|
||||||
|
# @param cross_axis number
|
||||||
|
# @param icon text
|
||||||
|
# @param size select [xs, sm, md, lg, xl, 2xl]
|
||||||
|
# @param color select [default, white, success, warning, destructive, current]
|
||||||
|
def default(text: "This is helpful information", placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
|
||||||
|
render DS::Tooltip.new(
|
||||||
|
text: text,
|
||||||
|
placement: placement,
|
||||||
|
offset: offset,
|
||||||
|
cross_axis: cross_axis,
|
||||||
|
icon: icon,
|
||||||
|
size: size,
|
||||||
|
color: color
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_block_content
|
||||||
|
render DS::Tooltip.new(icon: "help-circle", color: "warning") do
|
||||||
|
tag.div do
|
||||||
|
tag.p("Custom content with formatting:", class: "font-medium mb-1") +
|
||||||
|
tag.ul(class: "list-disc list-inside text-xs") do
|
||||||
|
tag.li("First item") +
|
||||||
|
tag.li("Second item")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
355
test/models/account/activity_feed_data_test.rb
Normal file
355
test/models/account/activity_feed_data_test.rb
Normal file
|
@ -0,0 +1,355 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Account::ActivityFeedDataTest < ActiveSupport::TestCase
|
||||||
|
include EntriesTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@family = families(:empty)
|
||||||
|
@checking = @family.accounts.create!(name: "Test Checking", accountable: Depository.new, currency: "USD", balance: 0)
|
||||||
|
@savings = @family.accounts.create!(name: "Test Savings", accountable: Depository.new, currency: "USD", balance: 0)
|
||||||
|
@investment = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
|
||||||
|
|
||||||
|
@test_period_start = Date.current - 4.days
|
||||||
|
|
||||||
|
setup_test_data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates balance trend with complete balance history" do
|
||||||
|
entries = @checking.entries.includes(:entryable).to_a
|
||||||
|
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||||
|
|
||||||
|
activities = feed_data.entries_by_date
|
||||||
|
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||||
|
|
||||||
|
assert_not_nil day2_activity
|
||||||
|
trend = day2_activity.balance_trend
|
||||||
|
assert_equal 1100, trend.current.amount.to_i # End of day 2
|
||||||
|
assert_equal 1000, trend.previous.amount.to_i # End of day 1
|
||||||
|
assert_equal 100, trend.value.amount.to_i
|
||||||
|
assert_equal "up", trend.direction.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates balance trend for first day with zero starting balance" do
|
||||||
|
entries = @checking.entries.includes(:entryable).to_a
|
||||||
|
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||||
|
|
||||||
|
activities = feed_data.entries_by_date
|
||||||
|
day1_activity = find_activity_for_date(activities, @test_period_start)
|
||||||
|
|
||||||
|
assert_not_nil day1_activity
|
||||||
|
trend = day1_activity.balance_trend
|
||||||
|
assert_equal 1000, trend.current.amount.to_i # End of first day
|
||||||
|
assert_equal 0, trend.previous.amount.to_i # Fallback to 0
|
||||||
|
assert_equal 1000, trend.value.amount.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses last observed balance when intermediate balances are missing" do
|
||||||
|
@checking.balances.where(date: [ @test_period_start + 1.day, @test_period_start + 3.days ]).destroy_all
|
||||||
|
|
||||||
|
entries = @checking.entries.includes(:entryable).to_a
|
||||||
|
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||||
|
|
||||||
|
activities = feed_data.entries_by_date
|
||||||
|
|
||||||
|
# When day 2 balance is missing, both start and end use day 1 balance
|
||||||
|
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||||
|
assert_not_nil day2_activity
|
||||||
|
trend = day2_activity.balance_trend
|
||||||
|
assert_equal 1000, trend.current.amount.to_i # LOCF from day 1
|
||||||
|
assert_equal 1000, trend.previous.amount.to_i # LOCF from day 1
|
||||||
|
assert_equal 0, trend.value.amount.to_i
|
||||||
|
assert_equal "flat", trend.direction.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns zero balance when no balance history exists" do
|
||||||
|
@checking.balances.destroy_all
|
||||||
|
|
||||||
|
entries = @checking.entries.includes(:entryable).to_a
|
||||||
|
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||||
|
|
||||||
|
activities = feed_data.entries_by_date
|
||||||
|
# Use first day which has a transaction
|
||||||
|
day1_activity = find_activity_for_date(activities, @test_period_start)
|
||||||
|
|
||||||
|
assert_not_nil day1_activity
|
||||||
|
trend = day1_activity.balance_trend
|
||||||
|
assert_equal 0, trend.current.amount.to_i # Fallback to 0
|
||||||
|
assert_equal 0, trend.previous.amount.to_i # Fallback to 0
|
||||||
|
assert_equal 0, trend.value.amount.to_i
|
||||||
|
assert_equal "flat", trend.direction.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates cash and holdings trends for investment accounts" do
|
||||||
|
entries = @investment.entries.includes(:entryable).to_a
|
||||||
|
feed_data = Account::ActivityFeedData.new(@investment, entries)
|
||||||
|
|
||||||
|
activities = feed_data.entries_by_date
|
||||||
|
day3_activity = find_activity_for_date(activities, @test_period_start + 2.days)
|
||||||
|
|
||||||
|
assert_not_nil day3_activity
|
||||||
|
|
||||||
|
# Cash trend for day 3 (after foreign currency transaction)
|
||||||
|
cash_trend = day3_activity.cash_balance_trend
|
||||||
|
assert_equal 400, cash_trend.current.amount.to_i # End of day 3 cash balance
|
||||||
|
assert_equal 500, cash_trend.previous.amount.to_i # End of day 2 cash balance
|
||||||
|
assert_equal(-100, cash_trend.value.amount.to_i)
|
||||||
|
assert_equal "down", cash_trend.direction.to_s
|
||||||
|
|
||||||
|
# Holdings trend for day 3 (after trade)
|
||||||
|
holdings_trend = day3_activity.holdings_value_trend
|
||||||
|
assert_equal 1500, holdings_trend.current.amount.to_i # Total balance - cash balance
|
||||||
|
assert_equal 0, holdings_trend.previous.amount.to_i # No holdings before trade
|
||||||
|
assert_equal 1500, holdings_trend.value.amount.to_i
|
||||||
|
assert_equal "up", holdings_trend.direction.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
test "identifies transfers for a specific date" do
|
||||||
|
entries = @checking.entries.includes(:entryable).to_a
|
||||||
|
feed_data = Account::ActivityFeedData.new(@checking, entries)
|
||||||
|
|
||||||
|
activities = feed_data.entries_by_date
|
||||||
|
|
||||||
|
# Day 2 has the transfer
|
||||||
|
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||||
|
assert_not_nil day2_activity
|
||||||
|
assert_equal 1, day2_activity.transfers.size
|
||||||
|
assert_equal @transfer, day2_activity.transfers.first
|
||||||
|
|
||||||
|
# Other days have no transfers
|
||||||
|
day1_activity = find_activity_for_date(activities, @test_period_start)
|
||||||
|
assert_not_nil day1_activity
|
||||||
|
assert_empty day1_activity.transfers
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns complete ActivityDateData objects with all required fields" do
|
||||||
|
entries = @investment.entries.includes(:entryable).to_a
|
||||||
|
feed_data = Account::ActivityFeedData.new(@investment, entries)
|
||||||
|
|
||||||
|
activities = feed_data.entries_by_date
|
||||||
|
|
||||||
|
# Check that we get ActivityDateData objects
|
||||||
|
assert activities.all? { |a| a.is_a?(Account::ActivityFeedData::ActivityDateData) }
|
||||||
|
|
||||||
|
# Check that each ActivityDate has the required fields
|
||||||
|
activities.each do |activity|
|
||||||
|
assert_respond_to activity, :date
|
||||||
|
assert_respond_to activity, :entries
|
||||||
|
assert_respond_to activity, :balance_trend
|
||||||
|
assert_respond_to activity, :cash_balance_trend
|
||||||
|
assert_respond_to activity, :holdings_value_trend
|
||||||
|
assert_respond_to activity, :transfers
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles valuations correctly by summing entry changes" do
|
||||||
|
# Create account with known balances
|
||||||
|
account = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
|
||||||
|
|
||||||
|
# Day 1: Starting balance
|
||||||
|
account.balances.create!(
|
||||||
|
date: @test_period_start,
|
||||||
|
balance: 7321.56,
|
||||||
|
cash_balance: 1000,
|
||||||
|
currency: "USD"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Day 2: Add transactions, trades and a valuation
|
||||||
|
account.balances.create!(
|
||||||
|
date: @test_period_start + 1.day,
|
||||||
|
balance: 8500, # Valuation sets this
|
||||||
|
cash_balance: 1070, # Cash increased by transactions
|
||||||
|
currency: "USD"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create transactions
|
||||||
|
create_transaction(
|
||||||
|
account: account,
|
||||||
|
date: @test_period_start + 1.day,
|
||||||
|
amount: -50,
|
||||||
|
name: "Interest payment"
|
||||||
|
)
|
||||||
|
create_transaction(
|
||||||
|
account: account,
|
||||||
|
date: @test_period_start + 1.day,
|
||||||
|
amount: -20,
|
||||||
|
name: "Interest payment"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a trade
|
||||||
|
create_trade(
|
||||||
|
securities(:aapl),
|
||||||
|
account: account,
|
||||||
|
qty: 5,
|
||||||
|
date: @test_period_start + 1.day,
|
||||||
|
price: 150 # 5 * 150 = 750
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create valuation
|
||||||
|
create_valuation(
|
||||||
|
account: account,
|
||||||
|
date: @test_period_start + 1.day,
|
||||||
|
amount: 8500
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = account.entries.includes(:entryable).to_a
|
||||||
|
feed_data = Account::ActivityFeedData.new(account, entries)
|
||||||
|
|
||||||
|
activities = feed_data.entries_by_date
|
||||||
|
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||||
|
|
||||||
|
assert_not_nil day2_activity
|
||||||
|
|
||||||
|
# Cash change should be $70 (50 + 20 from transactions only, not trades)
|
||||||
|
assert_equal 70, day2_activity.cash_balance_trend.value.amount.to_i
|
||||||
|
|
||||||
|
# Holdings change should be 750 (from the trade)
|
||||||
|
assert_equal 750, day2_activity.holdings_value_trend.value.amount.to_i
|
||||||
|
|
||||||
|
# Total balance change
|
||||||
|
assert_in_delta 1178.44, day2_activity.balance_trend.value.amount.to_f, 0.01
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normalizes multi-currency entries on valuation days" do
|
||||||
|
# Create EUR account
|
||||||
|
eur_account = @family.accounts.create!(name: "EUR Investment", accountable: Investment.new, currency: "EUR", balance: 0)
|
||||||
|
|
||||||
|
# Day 1: Starting balance
|
||||||
|
eur_account.balances.create!(
|
||||||
|
date: @test_period_start,
|
||||||
|
balance: 1000,
|
||||||
|
cash_balance: 500,
|
||||||
|
currency: "EUR"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Day 2: Multi-currency transactions and valuation
|
||||||
|
eur_account.balances.create!(
|
||||||
|
date: @test_period_start + 1.day,
|
||||||
|
balance: 2000,
|
||||||
|
cash_balance: 600,
|
||||||
|
currency: "EUR"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create USD transaction (should be converted to EUR)
|
||||||
|
create_transaction(
|
||||||
|
account: eur_account,
|
||||||
|
date: @test_period_start + 1.day,
|
||||||
|
amount: -100,
|
||||||
|
currency: "USD",
|
||||||
|
name: "USD Payment"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create exchange rate: 1 USD = 0.9 EUR
|
||||||
|
ExchangeRate.create!(
|
||||||
|
date: @test_period_start + 1.day,
|
||||||
|
from_currency: "USD",
|
||||||
|
to_currency: "EUR",
|
||||||
|
rate: 0.9
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create valuation
|
||||||
|
create_valuation(
|
||||||
|
account: eur_account,
|
||||||
|
date: @test_period_start + 1.day,
|
||||||
|
amount: 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = eur_account.entries.includes(:entryable).to_a
|
||||||
|
feed_data = Account::ActivityFeedData.new(eur_account, entries)
|
||||||
|
|
||||||
|
activities = feed_data.entries_by_date
|
||||||
|
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
|
||||||
|
|
||||||
|
assert_not_nil day2_activity
|
||||||
|
|
||||||
|
# Cash change should be 90 EUR (100 USD * 0.9)
|
||||||
|
# The transaction is -100 USD, which becomes +100 when inverted, then 100 * 0.9 = 90 EUR
|
||||||
|
assert_equal 90, day2_activity.cash_balance_trend.value.amount.to_i
|
||||||
|
assert_equal "EUR", day2_activity.cash_balance_trend.value.currency.iso_code
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def find_activity_for_date(activities, date)
|
||||||
|
activities.find { |a| a.date == date }
|
||||||
|
end
|
||||||
|
|
||||||
|
def setup_test_data
|
||||||
|
# Create daily balances for checking account
|
||||||
|
5.times do |i|
|
||||||
|
date = @test_period_start + i.days
|
||||||
|
@checking.balances.create!(
|
||||||
|
date: date,
|
||||||
|
balance: 1000 + (i * 100),
|
||||||
|
currency: "USD"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create daily balances for investment account with cash_balance
|
||||||
|
@investment.balances.create!(
|
||||||
|
date: @test_period_start,
|
||||||
|
balance: 500,
|
||||||
|
cash_balance: 500,
|
||||||
|
currency: "USD"
|
||||||
|
)
|
||||||
|
@investment.balances.create!(
|
||||||
|
date: @test_period_start + 1.day,
|
||||||
|
balance: 500,
|
||||||
|
cash_balance: 500,
|
||||||
|
currency: "USD"
|
||||||
|
)
|
||||||
|
@investment.balances.create!(
|
||||||
|
date: @test_period_start + 2.days,
|
||||||
|
balance: 1900, # 1500 holdings + 400 cash
|
||||||
|
cash_balance: 400, # After -100 EUR transaction
|
||||||
|
currency: "USD"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Day 1: Regular transaction
|
||||||
|
create_transaction(
|
||||||
|
account: @checking,
|
||||||
|
date: @test_period_start,
|
||||||
|
amount: -50,
|
||||||
|
name: "Grocery Store"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Day 2: Transfer between accounts
|
||||||
|
@transfer = create_transfer(
|
||||||
|
from_account: @checking,
|
||||||
|
to_account: @savings,
|
||||||
|
amount: 200,
|
||||||
|
date: @test_period_start + 1.day
|
||||||
|
)
|
||||||
|
|
||||||
|
# Day 3: Trade in investment account
|
||||||
|
create_trade(
|
||||||
|
securities(:aapl),
|
||||||
|
account: @investment,
|
||||||
|
qty: 10,
|
||||||
|
date: @test_period_start + 2.days,
|
||||||
|
price: 150
|
||||||
|
)
|
||||||
|
|
||||||
|
# Day 3: Foreign currency transaction
|
||||||
|
create_transaction(
|
||||||
|
account: @investment,
|
||||||
|
date: @test_period_start + 2.days,
|
||||||
|
amount: -100,
|
||||||
|
currency: "EUR",
|
||||||
|
name: "International Wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create exchange rate for foreign currency
|
||||||
|
ExchangeRate.create!(
|
||||||
|
date: @test_period_start + 2.days,
|
||||||
|
from_currency: "EUR",
|
||||||
|
to_currency: "USD",
|
||||||
|
rate: 1.1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Day 4: Valuation
|
||||||
|
create_valuation(
|
||||||
|
account: @investment,
|
||||||
|
date: @test_period_start + 3.days,
|
||||||
|
amount: 25
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -15,33 +15,6 @@ module EntriesTestHelper
|
||||||
Entry.create! entry_defaults.merge(entry_attributes)
|
Entry.create! entry_defaults.merge(entry_attributes)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_opening_anchor_valuation(account:, balance:, date:)
|
|
||||||
create_valuation(
|
|
||||||
account: account,
|
|
||||||
kind: "opening_anchor",
|
|
||||||
amount: balance,
|
|
||||||
date: date
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_reconciliation_valuation(account:, balance:, date:)
|
|
||||||
create_valuation(
|
|
||||||
account: account,
|
|
||||||
kind: "reconciliation",
|
|
||||||
amount: balance,
|
|
||||||
date: date
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_current_anchor_valuation(account:, balance:, date: Date.current)
|
|
||||||
create_valuation(
|
|
||||||
account: account,
|
|
||||||
kind: "current_anchor",
|
|
||||||
amount: balance,
|
|
||||||
date: date
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_valuation(attributes = {})
|
def create_valuation(attributes = {})
|
||||||
entry_attributes = attributes.except(:kind)
|
entry_attributes = attributes.except(:kind)
|
||||||
valuation_attributes = attributes.slice(:kind)
|
valuation_attributes = attributes.slice(:kind)
|
||||||
|
@ -77,4 +50,33 @@ module EntriesTestHelper
|
||||||
currency: currency,
|
currency: currency,
|
||||||
entryable: trade
|
entryable: trade
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_transfer(from_account:, to_account:, amount:, date: Date.current, currency: "USD")
|
||||||
|
outflow_transaction = Transaction.create!(kind: "funds_movement")
|
||||||
|
inflow_transaction = Transaction.create!(kind: "funds_movement")
|
||||||
|
|
||||||
|
transfer = Transfer.create!(
|
||||||
|
outflow_transaction: outflow_transaction,
|
||||||
|
inflow_transaction: inflow_transaction
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create entries for both accounts
|
||||||
|
from_account.entries.create!(
|
||||||
|
name: "Transfer to #{to_account.name}",
|
||||||
|
date: date,
|
||||||
|
amount: -amount.abs,
|
||||||
|
currency: currency,
|
||||||
|
entryable: outflow_transaction
|
||||||
|
)
|
||||||
|
|
||||||
|
to_account.entries.create!(
|
||||||
|
name: "Transfer from #{from_account.name}",
|
||||||
|
date: date,
|
||||||
|
amount: amount.abs,
|
||||||
|
currency: currency,
|
||||||
|
entryable: inflow_transaction
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -124,17 +124,14 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
|
||||||
# Click the revoke button to open the modal
|
# Click the revoke button to open the modal
|
||||||
click_button "Revoke Key"
|
click_button "Revoke Key"
|
||||||
|
|
||||||
# Wait for the modal to appear and click Confirm
|
# Wait for the dialog and then confirm
|
||||||
# The dialog might take a moment to appear
|
assert_selector "#confirm-dialog", visible: true
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
within "#confirm-dialog" do
|
within "#confirm-dialog" do
|
||||||
assert_text "Are you sure you want to revoke this API key?"
|
|
||||||
click_button "Confirm"
|
click_button "Confirm"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Wait for the page to update after revoke
|
# Wait for redirect after revoke
|
||||||
sleep 0.5
|
assert_no_selector "#confirm-dialog"
|
||||||
|
|
||||||
assert_text "Create Your API Key"
|
assert_text "Create Your API Key"
|
||||||
assert_text "Get programmatic access to your Maybe data"
|
assert_text "Get programmatic access to your Maybe data"
|
||||||
|
|
|
@ -118,22 +118,25 @@ class TransactionsTest < ApplicationSystemTestCase
|
||||||
|
|
||||||
assert_text "No entries found"
|
assert_text "No entries found"
|
||||||
|
|
||||||
|
# Wait for Turbo to finish updating the DOM
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
# Page reload doesn't affect results
|
# Page reload doesn't affect results
|
||||||
visit current_url
|
visit current_url
|
||||||
|
|
||||||
assert_text "No entries found"
|
assert_text "No entries found"
|
||||||
|
|
||||||
within "ul#transaction-search-filters" do
|
# Remove all filters by clicking their X buttons
|
||||||
find("li", text: account.name).first("button").click
|
# Get all the filter buttons at once to avoid stale elements
|
||||||
find("li", text: "on or after #{10.days.ago.to_date}").first("button").click
|
filter_count = page.all("ul#transaction-search-filters li button").count
|
||||||
find("li", text: "on or before #{1.day.ago.to_date}").first("button").click
|
|
||||||
find("li", text: "Income").first("button").click
|
# Click each one with a small delay to let Turbo update
|
||||||
find("li", text: "less than 200").first("button").click
|
filter_count.times do
|
||||||
find("li", text: category.name).first("button").click
|
page.all("ul#transaction-search-filters li button").first.click
|
||||||
find("li", text: merchant.name).first("button").click
|
sleep 0.1
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_selector "#" + dom_id(@transaction), count: 1
|
assert_text @transaction.name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can select and deselect entire page of transactions" do
|
test "can select and deselect entire page of transactions" do
|
||||||
|
@ -191,7 +194,7 @@ class TransactionsTest < ApplicationSystemTestCase
|
||||||
fill_in "Date", with: transfer_date
|
fill_in "Date", with: transfer_date
|
||||||
fill_in "model[amount]", with: 175.25
|
fill_in "model[amount]", with: 175.25
|
||||||
click_button "Add transaction"
|
click_button "Add transaction"
|
||||||
within "#entry-group-" + transfer_date.to_s do
|
within "#" + dom_id(investment_account, "entries_#{transfer_date}") do
|
||||||
assert_text "175.25"
|
assert_text "175.25"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue