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

Merge branch 'maybe-finance:main' into bugfix/ui-pwa-ios

This commit is contained in:
Kenrick Tandrian 2025-07-27 16:32:16 +07:00 committed by GitHub
commit 3fcae9dd0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 2872 additions and 760 deletions

View file

@ -72,6 +72,7 @@ gem "plaid"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 3.0"
gem "activerecord-import"
gem "rubyzip", "~> 2.3"
# State machines
gem "aasm"

View file

@ -672,6 +672,7 @@ DEPENDENCIES
rubocop-rails-omakase
ruby-lsp-rails
ruby-openai
rubyzip (~> 2.3)
selenium-webdriver
sentry-rails
sentry-ruby

View file

@ -1,50 +1,21 @@
<img width="1190" alt="maybe_hero" src="https://github.com/user-attachments/assets/13fc5ef4-ce0f-4073-a163-9dbc3eb4c8e5" />
<img width="1190" alt="maybe_hero" src="https://github.com/user-attachments/assets/5ed08763-a9ee-42b2-a436-e05038fcf573" />
# Maybe: The personal finance app for everyone
<b>Get
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
## Backstory
We spent the better part of 2021/2022 building a personal finance + wealth
management app called, Maybe. Very full-featured, including an "Ask an Advisor"
feature which connected users with an actual CFP/CFA to help them with their
finances (all included in your subscription).
The business end of things didn't work out, and so we shut things down mid-2023.
We spent the better part of $1,000,000 building the app (employees +
contractors, data providers/services, infrastructure, etc.).
We're now reviving the product as a fully open-source project. The goal is to
let you run the app yourself, for free, and use it to manage your own finances
and eventually offer a hosted version of the app for a small monthly fee.
> [!IMPORTANT]
> This repository is no longer actively maintained. You can read more about this in our [final release](https://github.com/maybe-finance/maybe/releases/tag/v0.6.0).
## Maybe Hosting
There are 2 primary ways to use the Maybe app:
Maybe is a fully working personal finance app that can be [self hosted with Docker](docs/hosting/docker.md).
1. Managed (easiest) - we're in alpha and release invites in our Discord
2. [Self-host with Docker](docs/hosting/docker.md)
## Forking and Attribution
## Contributing
This repo is no longer maintained. Youre free to fork it under the AGPLv3. To stay compliant and avoid trademark issues:
Before contributing, you'll likely find it helpful
to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
Once you've done that, please visit
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
to get started!
### Performance Issues
With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests, along with the stacktraces to help debug them.
Any contributions that help improve performance are very much welcome.
https://oss.skylight.io/app/applications/XDpPIXEX52oi/recent/6h/endpoints
- Be sure to include the original [AGPLv3 license](https://github.com/maybe-finance/maybe/blob/main/LICENSE) and clearly state in your README that your fork is based on Maybe Finance but is **not affiliated with or endorsed by** Maybe Finance Inc.
- "Maybe" is a trademark of Maybe Finance Inc. and therefore, use of it is NOT allowed in forked repositories (or the logo)
## Local Development Setup
@ -78,14 +49,6 @@ credentials to log in (generated by DB seed):
For further instructions, see guides below.
### Multi-currency support
If you'd like multi-currency support, there are a few extra steps to follow.
1. Sign up for an API key at [Synth](https://synthfinance.com). It's a Maybe
product and the free plan is sufficient for basic multi-currency support.
2. Add your API key to your `.env` file.
### Setup Guides
- [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide)
@ -93,10 +56,6 @@ If you'd like multi-currency support, there are a few extra steps to follow.
- [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide)
- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more
## Repo Activity
![Repo Activity](https://repobeats.axiom.co/api/embed/7866c9790deba0baf63ca1688b209130b306ea4e.svg "Repobeats analytics image")
## Copyright & license
Maybe is distributed under

View file

@ -17,7 +17,7 @@
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="font-medium"><%= balance_trend.current.format %></span>
<span class="font-medium"><%= end_balance_money.format %></span>
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
</div>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
@ -25,73 +25,12 @@
</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>
<div class="p-4">
<% if balance %>
<%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %>
<% 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>
<p class="text-sm text-secondary">No balance data available for this date</p>
<% 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>

View file

@ -1,7 +1,7 @@
class UI::Account::ActivityDate < ApplicationComponent
attr_reader :account, :data
delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data
delegate :date, :entries, :balance, :transfers, to: :data
def initialize(account:, data:)
@account = account
@ -16,28 +16,8 @@ class UI::Account::ActivityDate < ApplicationComponent
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
balance&.end_balance_money || Money.new(0, account.currency)
end
def broadcast_refresh!

View file

@ -0,0 +1,22 @@
<div class="space-y-3">
<% reconciliation_items.each_with_index do |item, index| %>
<% if item[:style] == :subtotal %>
<hr class="border border-primary">
<% end %>
<dl class="flex gap-4 items-center text-sm text-primary">
<dt class="flex items-center gap-2">
<%= item[:label] %>
<%= render DS::Tooltip.new(text: item[:tooltip], placement: "left", size: "sm") %>
</dt>
<hr class="grow border-dashed <%= item[:style] == :final ? "border-primary" : "border-secondary" %>">
<dd class="<%= item[:style] == :start || item[:style] == :final ? "font-bold" : item[:style] == :subtotal ? "font-medium" : "" %>">
<%= item[:value].format %>
</dd>
</dl>
<% if item[:style] == :adjustment %>
<hr class="border border-primary">
<% end %>
<% end %>
</div>

View file

@ -0,0 +1,155 @@
class UI::Account::BalanceReconciliation < ApplicationComponent
attr_reader :balance, :account
def initialize(balance:, account:)
@balance = balance
@account = account
end
def reconciliation_items
case account.accountable_type
when "Depository", "OtherAsset", "OtherLiability"
default_items
when "CreditCard"
credit_card_items
when "Investment"
investment_items
when "Loan"
loan_items
when "Property", "Vehicle"
asset_items
when "Crypto"
crypto_items
else
default_items
end
end
private
def default_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The account balance at the beginning of this day", style: :start },
{ label: "Net cash flow", value: net_cash_flow, tooltip: "Net change in balance from all transactions during the day", style: :flow }
]
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final account balance for the day", style: :final }
items
end
def credit_card_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The balance owed at the beginning of this day", style: :start },
{ label: "Charges", value: balance.cash_outflows_money, tooltip: "New charges made during the day", style: :flow },
{ label: "Payments", value: balance.cash_inflows_money * -1, tooltip: "Payments made to the card during the day", style: :flow }
]
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final balance owed for the day", style: :final }
items
end
def investment_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The total portfolio value at the beginning of this day", style: :start }
]
# Change in brokerage cash (includes deposits, withdrawals, and cash from trades)
items << { label: "Change in brokerage cash", value: net_cash_flow, tooltip: "Net change in cash from deposits, withdrawals, and trades", style: :flow }
# Change in holdings from trading activity
items << { label: "Change in holdings (buys/sells)", value: net_non_cash_flow, tooltip: "Impact on holdings from buying and selling securities", style: :flow }
# Market price changes
items << { label: "Change in holdings (market price activity)", value: balance.net_market_flows_money, tooltip: "Change in holdings value from market price movements", style: :flow }
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final portfolio value for the day", style: :final }
items
end
def loan_items
items = [
{ label: "Start principal", value: balance.start_balance_money, tooltip: "The principal balance at the beginning of this day", style: :start },
{ label: "Net principal change", value: net_non_cash_flow, tooltip: "Principal payments and new borrowing during the day", style: :flow }
]
if has_adjustments?
items << { label: "End principal", value: end_balance_before_adjustments, tooltip: "The calculated principal after all transactions", style: :subtotal }
items << { label: "Adjustments", value: balance.non_cash_adjustments_money, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
end
items << { label: "Final principal", value: balance.end_balance_money, tooltip: "The final principal balance for the day", style: :final }
items
end
def asset_items # Property/Vehicle
items = [
{ label: "Start value", value: balance.start_balance_money, tooltip: "The asset value at the beginning of this day", style: :start },
{ label: "Net value change", value: net_total_flow, tooltip: "All value changes including improvements and depreciation", style: :flow }
]
if has_adjustments?
items << { label: "End value", value: end_balance_before_adjustments, tooltip: "The calculated value after all changes", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual value adjustments or appraisals", style: :adjustment }
end
items << { label: "Final value", value: balance.end_balance_money, tooltip: "The final asset value for the day", style: :final }
items
end
def crypto_items
items = [
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The crypto holdings value at the beginning of this day", style: :start }
]
items << { label: "Buys", value: balance.cash_outflows_money * -1, tooltip: "Crypto purchases during the day", style: :flow } if balance.cash_outflows != 0
items << { label: "Sells", value: balance.cash_inflows_money, tooltip: "Crypto sales during the day", style: :flow } if balance.cash_inflows != 0
items << { label: "Market changes", value: balance.net_market_flows_money, tooltip: "Value changes from market price movements", style: :flow } if balance.net_market_flows != 0
if has_adjustments?
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
end
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final crypto holdings value for the day", style: :final }
items
end
def net_cash_flow
balance.cash_inflows_money - balance.cash_outflows_money
end
def net_non_cash_flow
balance.non_cash_inflows_money - balance.non_cash_outflows_money
end
def net_total_flow
net_cash_flow + net_non_cash_flow + balance.net_market_flows_money
end
def total_adjustments
balance.cash_adjustments_money + balance.non_cash_adjustments_money
end
def has_adjustments?
balance.cash_adjustments != 0 || balance.non_cash_adjustments != 0
end
def end_balance_before_adjustments
balance.end_balance_money - total_adjustments
end
end

View file

@ -9,6 +9,11 @@ class AccountsController < ApplicationController
render layout: "settings"
end
def sync_all
family.sync_later
redirect_to accounts_path, notice: "Syncing accounts..."
end
def show
@chart_view = params[:chart_view] || "balance"
@tab = params[:tab]

View file

@ -98,7 +98,7 @@ class Api::V1::BaseController < ApplicationController
@current_user = @api_key.user
@api_key.update_last_used!
@authentication_method = :api_key
@rate_limiter = ApiRateLimiter.new(@api_key)
@rate_limiter = ApiRateLimiter.limit(@api_key)
setup_current_context_for_api
true
end

View file

@ -0,0 +1,47 @@
class FamilyExportsController < ApplicationController
include StreamExtensions
before_action :require_admin
before_action :set_export, only: [ :download ]
def new
# Modal view for initiating export
end
def create
@export = Current.family.family_exports.create!
FamilyDataExportJob.perform_later(@export)
respond_to do |format|
format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." }
format.turbo_stream {
stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly."
}
end
end
def index
@exports = Current.family.family_exports.ordered.limit(10)
render layout: false # For turbo frame
end
def download
if @export.downloadable?
redirect_to @export.export_file, allow_other_host: true
else
redirect_to settings_profile_path, alert: "Export not ready for download"
end
end
private
def set_export
@export = Current.family.family_exports.find(params[:id])
end
def require_admin
unless Current.user.admin?
redirect_to root_path, alert: "Access denied"
end
end
end

View file

@ -110,7 +110,7 @@ class TransactionsController < ApplicationController
private
def per_page
params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
params[:per_page].to_i.positive? ? params[:per_page].to_i : 20
end
def needs_rule_notification?(transaction)
@ -154,10 +154,6 @@ class TransactionsController < ApplicationController
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
# Only add default start_date if params are blank AND filters weren't explicitly cleared
if cleaned_params.blank? && params[:filter_cleared].blank?
cleaned_params[:start_date] = 30.days.ago.to_date
end
cleaned_params
end

View file

@ -0,0 +1,59 @@
class BalanceComponentMigrator
def self.run
ActiveRecord::Base.transaction do
# Step 1: Update flows factor
ActiveRecord::Base.connection.execute <<~SQL
UPDATE balances SET
flows_factor = CASE WHEN a.classification = 'asset' THEN 1 ELSE -1 END
FROM accounts a
WHERE a.id = balances.account_id
SQL
# Step 2: Set start values using LOCF (Last Observation Carried Forward)
ActiveRecord::Base.connection.execute <<~SQL
UPDATE balances b1
SET
start_cash_balance = COALESCE(prev.cash_balance, 0),
start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0)
FROM balances b1_inner
LEFT JOIN LATERAL (
SELECT
b2.cash_balance,
b2.balance
FROM balances b2
WHERE b2.account_id = b1_inner.account_id
AND b2.currency = b1_inner.currency
AND b2.date < b1_inner.date
ORDER BY b2.date DESC
LIMIT 1
) prev ON true
WHERE b1.id = b1_inner.id
SQL
# Step 3: Calculate net inflows
# A slight workaround to the fact that we can't easily derive inflows/outflows from our current data model, and
# the tradeoff not worth it since each new sync will fix it. So instead, we sum up *net* flows, and throw the signed
# amount in the "inflows" column, and zero-out the "outflows" column so our math works correctly with incomplete data.
ActiveRecord::Base.connection.execute <<~SQL
UPDATE balances SET
cash_inflows = (cash_balance - start_cash_balance) * flows_factor,
cash_outflows = 0,
non_cash_inflows = ((balance - cash_balance) - start_non_cash_balance) * flows_factor,
non_cash_outflows = 0,
net_market_flows = 0
SQL
# Verify data integrity
# All end_balance values should match the original balance
invalid_count = ActiveRecord::Base.connection.select_value(<<~SQL)
SELECT COUNT(*)
FROM balances b
WHERE ABS(b.balance - b.end_balance) > 0.0001
SQL
if invalid_count > 0
raise "Data migration failed validation: #{invalid_count} balances have incorrect end_balance values"
end
end
end
end

View file

@ -10,16 +10,14 @@ export default class extends Controller {
connect() {
this.autoTargets.forEach((element) => {
const event =
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
const event = this.#getTriggerEvent(element);
element.addEventListener(event, this.handleInput);
});
}
disconnect() {
this.autoTargets.forEach((element) => {
const event =
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
const event = this.#getTriggerEvent(element);
element.removeEventListener(event, this.handleInput);
});
}
@ -33,6 +31,50 @@ export default class extends Controller {
}, this.#debounceTimeout(target));
};
#getTriggerEvent(element) {
// Check if element has explicit trigger event set
if (element.dataset.autosubmitTriggerEvent) {
return element.dataset.autosubmitTriggerEvent;
}
// Check if form has explicit trigger event set
if (this.triggerEventValue !== "input") {
return this.triggerEventValue;
}
// Otherwise, choose trigger event based on element type
const type = element.type || element.tagName;
switch (type.toLowerCase()) {
case "text":
case "email":
case "password":
case "search":
case "tel":
case "url":
case "textarea":
return "blur";
case "number":
case "date":
case "datetime-local":
case "month":
case "time":
case "week":
case "color":
return "change";
case "checkbox":
case "radio":
case "select":
case "select-one":
case "select-multiple":
return "change";
case "range":
return "input";
default:
return "blur";
}
}
#debounceTimeout(element) {
if (element.dataset.autosubmitDebounceTimeout) {
return Number.parseInt(element.dataset.autosubmitDebounceTimeout);

View file

@ -0,0 +1,22 @@
class FamilyDataExportJob < ApplicationJob
queue_as :default
def perform(family_export)
family_export.update!(status: :processing)
exporter = Family::DataExporter.new(family_export.family)
zip_file = exporter.generate_export
family_export.export_file.attach(
io: zip_file,
filename: family_export.filename,
content_type: "application/zip"
)
family_export.update!(status: :completed)
rescue => e
Rails.logger.error "Family export failed: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
family_export.update!(status: :failed)
end
end

View file

@ -2,7 +2,7 @@
# 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)
ActivityDateData = Data.define(:date, :entries, :balance, :transfers)
attr_reader :account, :entries
@ -17,9 +17,7 @@ class Account::ActivityFeedData
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),
balance: balance_for_date(date),
transfers: transfers_for_date(date)
)
end
@ -27,193 +25,61 @@ class Account::ActivityFeedData
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
def balance_for_date(date)
balances_by_date[date]
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)
}
transfers_by_date[date] || []
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 balances_by_date
@balances_by_date ||= begin
return {} if entries.empty?
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
dates = grouped_entries.keys
account.balances
.where(date: dates, currency: account.currency)
.index_by(&:date)
end
end
def exchange_rate_for(date, from_currency, to_currency)
return 1.0 if from_currency == to_currency
def transfers_by_date
@transfers_by_date ||= begin
return {} if transaction_ids.empty?
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
transfers = Transfer
.where(inflow_transaction_id: transaction_ids)
.or(Transfer.where(outflow_transaction_id: transaction_ids))
.to_a
def sum_entries_with_exchange_rates(entries, date)
return Money.new(0, account.currency) if entries.empty?
# Group transfers by the date of their transaction entries
result = Hash.new { |h, k| h[k] = [] }
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
entries.each do |entry|
next unless entry.transaction? && transaction_ids.include?(entry.entryable_id)
transfers.each do |transfer|
if transfer.inflow_transaction_id == entry.entryable_id ||
transfer.outflow_transaction_id == entry.entryable_id
result[entry.date] << transfer
end
end
end
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
# Remove duplicates
result.transform_values(&:uniq)
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
)
@transaction_ids ||= entries
.select(&:transaction?)
.map(&:entryable_id)
.compact
end
end

View file

@ -82,8 +82,8 @@ class Account::ReconciliationManager
balance_record = account.balances.find_by(date: date, currency: account.currency)
{
cash_balance: balance_record&.cash_balance,
balance: balance_record&.balance
cash_balance: balance_record&.end_cash_balance,
balance: balance_record&.end_balance
}
end
end

View file

@ -134,7 +134,8 @@ class Assistant::Function::GetTransactions < Assistant::Function
def call(params = {})
search_params = params.except("order", "page")
transactions_query = family.transactions.visible.search(search_params)
search = Transaction::Search.new(family, filters: search_params)
transactions_query = search.transactions_scope
pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological
# By default, we give a small page size to force the AI to use filters effectively and save on tokens
@ -149,7 +150,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
limit: default_page_size
)
totals = family.income_statement.totals(transactions_scope: transactions_query)
totals = search.totals
normalized_transactions = paginated_transactions.map do |txn|
entry = txn.entry

View file

@ -2,8 +2,30 @@ class Balance < ApplicationRecord
include Monetizable
belongs_to :account
validates :account, :date, :balance, presence: true
monetize :balance, :cash_balance
validates :flows_factor, inclusion: { in: [ -1, 1 ] }
monetize :balance, :cash_balance,
:start_cash_balance, :start_non_cash_balance, :start_balance,
:cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows,
:cash_adjustments, :non_cash_adjustments,
:end_cash_balance, :end_non_cash_balance, :end_balance
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
scope :chronological, -> { order(:date) }
def balance_trend
Trend.new(
current: end_balance_money,
previous: start_balance_money,
favorable_direction: favorable_direction
)
end
private
def favorable_direction
flows_factor == -1 ? "down" : "up"
end
end

View file

@ -15,8 +15,8 @@ class Balance::BaseCalculator
end
def holdings_value_for_date(date)
holdings = sync_cache.get_holdings(date)
holdings.sum(&:amount)
@holdings_value_for_date ||= {}
@holdings_value_for_date[date] ||= sync_cache.get_holdings(date).sum(&:amount)
end
def derive_cash_balance_on_date_from_total(total_balance:, date:)
@ -29,6 +29,67 @@ class Balance::BaseCalculator
end
end
def cash_adjustments_for_date(start_cash, end_cash, net_cash_flows)
return 0 unless account.balance_type != :non_cash
end_cash - start_cash - net_cash_flows
end
def non_cash_adjustments_for_date(start_non_cash, end_non_cash, non_cash_flows)
return 0 unless account.balance_type == :non_cash
end_non_cash - start_non_cash - non_cash_flows
end
# If holdings value goes from $100 -> $200 (change_holdings_value is $100)
# And non-cash flows (i.e. "buys") for day are +$50 (net_buy_sell_value is $50)
# That means value increased by $100, where $50 of that is due to the change in holdings value, and $50 is due to the buy/sell
def market_value_change_on_date(date, flows)
return 0 unless account.balance_type == :investment
start_of_day_holdings_value = holdings_value_for_date(date.prev_day)
end_of_day_holdings_value = holdings_value_for_date(date)
change_holdings_value = end_of_day_holdings_value - start_of_day_holdings_value
net_buy_sell_value = flows[:non_cash_inflows] - flows[:non_cash_outflows]
change_holdings_value - net_buy_sell_value
end
def flows_for_date(date)
entries = sync_cache.get_entries(date)
cash_inflows = 0
cash_outflows = 0
non_cash_inflows = 0
non_cash_outflows = 0
txn_inflow_sum = entries.select { |e| e.amount < 0 && e.transaction? }.sum(&:amount)
txn_outflow_sum = entries.select { |e| e.amount >= 0 && e.transaction? }.sum(&:amount)
trade_cash_inflow_sum = entries.select { |e| e.amount < 0 && e.trade? }.sum(&:amount)
trade_cash_outflow_sum = entries.select { |e| e.amount >= 0 && e.trade? }.sum(&:amount)
if account.balance_type == :non_cash && account.accountable_type == "Loan"
non_cash_inflows = txn_inflow_sum.abs
non_cash_outflows = txn_outflow_sum
elsif account.balance_type != :non_cash
cash_inflows = txn_inflow_sum.abs + trade_cash_inflow_sum.abs
cash_outflows = txn_outflow_sum + trade_cash_outflow_sum
# Trades are inverse (a "buy" is outflow of cash, but "inflow" of non-cash, aka "holdings")
non_cash_outflows = trade_cash_inflow_sum.abs
non_cash_inflows = trade_cash_outflow_sum
end
{
cash_inflows: cash_inflows,
cash_outflows: cash_outflows,
non_cash_inflows: non_cash_inflows,
non_cash_outflows: non_cash_outflows
}
end
def derive_cash_balance(cash_balance, date)
entries = sync_cache.get_entries(date)
@ -57,13 +118,23 @@ class Balance::BaseCalculator
raise NotImplementedError, "Directional calculators must implement this method"
end
def build_balance(date:, cash_balance:, non_cash_balance:)
def build_balance(date:, **args)
Balance.new(
account_id: account.id,
currency: account.currency,
date: date,
balance: non_cash_balance + cash_balance,
cash_balance: cash_balance,
currency: account.currency
balance: args[:balance],
cash_balance: args[:cash_balance],
start_cash_balance: args[:start_cash_balance] || 0,
start_non_cash_balance: args[:start_non_cash_balance] || 0,
cash_inflows: args[:cash_inflows] || 0,
cash_outflows: args[:cash_outflows] || 0,
non_cash_inflows: args[:non_cash_inflows] || 0,
non_cash_outflows: args[:non_cash_outflows] || 0,
cash_adjustments: args[:cash_adjustments] || 0,
non_cash_adjustments: args[:non_cash_adjustments] || 0,
net_market_flows: args[:net_market_flows] || 0,
flows_factor: account.classification == "asset" ? 1 : -1
)
end
end

View file

@ -8,21 +8,21 @@ class Balance::ChartSeriesBuilder
end
def balance_series
build_series_for(:balance)
build_series_for(:end_balance)
rescue => e
Rails.logger.error "Balance series error: #{e.message} for accounts #{@account_ids}"
raise
end
def cash_balance_series
build_series_for(:cash_balance)
build_series_for(:end_cash_balance)
rescue => e
Rails.logger.error "Cash balance series error: #{e.message} for accounts #{@account_ids}"
raise
end
def holdings_balance_series
build_series_for(:holdings_balance)
build_series_for(:end_holdings_balance)
rescue => e
Rails.logger.error "Holdings balance series error: #{e.message} for accounts #{@account_ids}"
raise
@ -37,13 +37,20 @@ class Balance::ChartSeriesBuilder
def build_series_for(column)
values = query_data.map do |datum|
# Map column names to their start equivalents
previous_column = case column
when :end_balance then :start_balance
when :end_cash_balance then :start_cash_balance
when :end_holdings_balance then :start_holdings_balance
end
Series::Value.new(
date: datum.date,
date_formatted: I18n.l(datum.date, format: :long),
value: Money.new(datum.send(column), currency),
trend: Trend.new(
current: Money.new(datum.send(column), currency),
previous: Money.new(datum.send("previous_#{column}"), currency),
previous: Money.new(datum.send(previous_column), currency),
favorable_direction: favorable_direction
)
)
@ -88,66 +95,57 @@ class Balance::ChartSeriesBuilder
WITH dates AS (
SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date
UNION DISTINCT
SELECT :end_date::date -- Pass in date to ensure timezone-aware "today" date
), aggregated_balances AS (
SELECT
d.date,
-- Total balance (assets positive, liabilities negative)
SUM(
CASE WHEN accounts.classification = 'asset'
THEN COALESCE(last_bal.balance, 0)
ELSE -COALESCE(last_bal.balance, 0)
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
) AS balance,
-- Cash-only balance
SUM(
CASE WHEN accounts.classification = 'asset'
THEN COALESCE(last_bal.cash_balance, 0)
ELSE -COALESCE(last_bal.cash_balance, 0)
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
) AS cash_balance,
-- Holdings value (balance cash)
SUM(
CASE WHEN accounts.classification = 'asset'
THEN COALESCE(last_bal.balance, 0) - COALESCE(last_bal.cash_balance, 0)
ELSE 0
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
) AS holdings_balance
FROM dates d
JOIN accounts ON accounts.id = ANY(array[:account_ids]::uuid[])
-- Last observation carried forward (LOCF), use the most recent balance on or before the chart date
LEFT JOIN LATERAL (
SELECT b.balance, b.cash_balance
FROM balances b
WHERE b.account_id = accounts.id
AND b.date <= d.date
ORDER BY b.date DESC
LIMIT 1
) last_bal ON TRUE
-- Last observation carried forward (LOCF), use the most recent exchange rate on or before the chart date
LEFT JOIN LATERAL (
SELECT er.rate
FROM exchange_rates er
WHERE er.from_currency = accounts.currency
AND er.to_currency = :target_currency
AND er.date <= d.date
ORDER BY er.date DESC
LIMIT 1
) er ON TRUE
GROUP BY d.date
SELECT :end_date::date -- Ensure end date is included
)
SELECT
date,
balance,
cash_balance,
holdings_balance,
COALESCE(LAG(balance) OVER (ORDER BY date), 0) AS previous_balance,
COALESCE(LAG(cash_balance) OVER (ORDER BY date), 0) AS previous_cash_balance,
COALESCE(LAG(holdings_balance) OVER (ORDER BY date), 0) AS previous_holdings_balance
FROM aggregated_balances
ORDER BY date
d.date,
-- Use flows_factor: already handles asset (+1) vs liability (-1)
COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance,
COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance,
-- Holdings only for assets (flows_factor = 1)
COALESCE(SUM(
CASE WHEN last_bal.flows_factor = 1
THEN last_bal.end_non_cash_balance
ELSE 0
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
), 0) AS end_holdings_balance,
-- Previous balances
COALESCE(SUM(last_bal.start_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_balance,
COALESCE(SUM(last_bal.start_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_cash_balance,
COALESCE(SUM(
CASE WHEN last_bal.flows_factor = 1
THEN last_bal.start_non_cash_balance
ELSE 0
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
), 0) AS start_holdings_balance
FROM dates d
CROSS JOIN accounts
LEFT JOIN LATERAL (
SELECT b.end_balance,
b.end_cash_balance,
b.end_non_cash_balance,
b.start_balance,
b.start_cash_balance,
b.start_non_cash_balance,
b.flows_factor
FROM balances b
WHERE b.account_id = accounts.id
AND b.date <= d.date
ORDER BY b.date DESC
LIMIT 1
) last_bal ON TRUE
LEFT JOIN LATERAL (
SELECT er.rate
FROM exchange_rates er
WHERE er.from_currency = accounts.currency
AND er.to_currency = :target_currency
AND er.date <= d.date
ORDER BY er.date DESC
LIMIT 1
) er ON TRUE
WHERE accounts.id = ANY(array[:account_ids]::uuid[])
GROUP BY d.date
ORDER BY d.date
SQL
end
end

View file

@ -8,7 +8,7 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
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)
valuation = sync_cache.get_valuation(date)
if valuation
end_cash_balance = derive_cash_balance_on_date_from_total(
@ -21,10 +21,25 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date)
end
flows = flows_for_date(date)
market_value_change = market_value_change_on_date(date, flows)
cash_adjustments = cash_adjustments_for_date(start_cash_balance, end_cash_balance, (flows[:cash_inflows] - flows[:cash_outflows]) * flows_factor)
non_cash_adjustments = non_cash_adjustments_for_date(start_non_cash_balance, end_non_cash_balance, (flows[:non_cash_inflows] - flows[:non_cash_outflows]) * flows_factor)
output_balance = build_balance(
date: date,
balance: end_cash_balance + end_non_cash_balance,
cash_balance: end_cash_balance,
non_cash_balance: end_non_cash_balance
start_cash_balance: start_cash_balance,
start_non_cash_balance: start_non_cash_balance,
cash_inflows: flows[:cash_inflows],
cash_outflows: flows[:cash_outflows],
non_cash_inflows: flows[:non_cash_inflows],
non_cash_outflows: flows[:non_cash_outflows],
cash_adjustments: cash_adjustments,
non_cash_adjustments: non_cash_adjustments,
net_market_flows: market_value_change
)
# Set values for the next iteration
@ -63,4 +78,8 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
def derive_end_non_cash_balance(start_non_cash_balance:, date:)
derive_non_cash_balance(start_non_cash_balance, date, direction: :forward)
end
def flows_factor
account.asset? ? 1 : -1
end
end

View file

@ -28,9 +28,20 @@ class Balance::Materializer
end
def update_account_info
calculated_balance = @balances.sort_by(&:date).last&.balance || 0
calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
calculated_cash_balance = calculated_balance - calculated_holdings_value
# Query fresh balance from DB to get generated column values
current_balance = account.balances
.where(currency: account.currency)
.order(date: :desc)
.first
if current_balance
calculated_balance = current_balance.end_balance
calculated_cash_balance = current_balance.end_cash_balance
else
# Fallback if no balance exists
calculated_balance = 0
calculated_cash_balance = 0
end
Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")
@ -48,14 +59,23 @@ class Balance::Materializer
current_time = Time.now
account.balances.upsert_all(
@balances.map { |b| b.attributes
.slice("date", "balance", "cash_balance", "currency")
.slice("date", "balance", "cash_balance", "currency",
"start_cash_balance", "start_non_cash_balance",
"cash_inflows", "cash_outflows",
"non_cash_inflows", "non_cash_outflows",
"net_market_flows",
"cash_adjustments", "non_cash_adjustments",
"flows_factor")
.merge("updated_at" => current_time) },
unique_by: %i[account_id date currency]
)
end
def purge_stale_balances
deleted_count = account.balances.delete_by("date < ?", account.start_date)
sorted_balances = @balances.sort_by(&:date)
oldest_calculated_balance_date = sorted_balances.first&.date
newest_calculated_balance_date = sorted_balances.last&.date
deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_calculated_balance_date, newest_calculated_balance_date)
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
end

View file

@ -11,6 +11,8 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
# Calculates in reverse-chronological order (End of day -> Start of day)
account.current_anchor_date.downto(account.opening_anchor_date).map do |date|
flows = flows_for_date(date)
if use_opening_anchor_for_date?(date)
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
@ -20,29 +22,30 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
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
)
market_value_change = 0
else
start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)
start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)
# 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
market_value_change = market_value_change_on_date(date, flows)
end
output_balance = build_balance(
date: date,
balance: end_cash_balance + end_non_cash_balance,
cash_balance: end_cash_balance,
start_cash_balance: start_cash_balance,
start_non_cash_balance: start_non_cash_balance,
cash_inflows: flows[:cash_inflows],
cash_outflows: flows[:cash_outflows],
non_cash_inflows: flows[:non_cash_inflows],
non_cash_outflows: flows[:non_cash_outflows],
net_market_flows: market_value_change
)
end_cash_balance = start_cash_balance
end_non_cash_balance = start_non_cash_balance
output_balance
end
end
end
@ -58,13 +61,6 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
account.asset? ? entry_flows : -entry_flows
end
# 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
# 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:)
@ -76,4 +72,11 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
def derive_start_non_cash_balance(end_non_cash_balance:, date:)
derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
end
# Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
# to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed
# explanation, see the test suite.
def use_opening_anchor_for_date?(date)
account.has_opening_anchor? && date == account.opening_anchor_date
end
end

View file

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

View file

@ -49,7 +49,10 @@ class Budget < ApplicationRecord
private
def oldest_valid_budget_date(family)
@oldest_valid_budget_date ||= family.oldest_entry_date.beginning_of_month
# Allow going back to either the earliest entry date OR 2 years ago, whichever is earlier
two_years_ago = 2.years.ago.beginning_of_month
oldest_entry_date = family.oldest_entry_date.beginning_of_month
[ two_years_ago, oldest_entry_date ].min
end
end

View file

@ -18,6 +18,7 @@ class Family < ApplicationRecord
has_many :invitations, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :family_exports, dependent: :destroy
has_many :entries, through: :accounts
has_many :transactions, through: :accounts

View file

@ -0,0 +1,238 @@
require "zip"
require "csv"
class Family::DataExporter
def initialize(family)
@family = family
end
def generate_export
# Create a StringIO to hold the zip data in memory
zip_data = Zip::OutputStream.write_buffer do |zipfile|
# Add accounts.csv
zipfile.put_next_entry("accounts.csv")
zipfile.write generate_accounts_csv
# Add transactions.csv
zipfile.put_next_entry("transactions.csv")
zipfile.write generate_transactions_csv
# Add trades.csv
zipfile.put_next_entry("trades.csv")
zipfile.write generate_trades_csv
# Add categories.csv
zipfile.put_next_entry("categories.csv")
zipfile.write generate_categories_csv
# Add all.ndjson
zipfile.put_next_entry("all.ndjson")
zipfile.write generate_ndjson
end
# Rewind and return the StringIO
zip_data.rewind
zip_data
end
private
def generate_accounts_csv
CSV.generate do |csv|
csv << [ "id", "name", "type", "subtype", "balance", "currency", "created_at" ]
# Only export accounts belonging to this family
@family.accounts.includes(:accountable).find_each do |account|
csv << [
account.id,
account.name,
account.accountable_type,
account.subtype,
account.balance.to_s,
account.currency,
account.created_at.iso8601
]
end
end
end
def generate_transactions_csv
CSV.generate do |csv|
csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ]
# Only export transactions from accounts belonging to this family
@family.transactions
.includes(:category, :tags, entry: :account)
.find_each do |transaction|
csv << [
transaction.entry.date.iso8601,
transaction.entry.account.name,
transaction.entry.amount.to_s,
transaction.entry.name,
transaction.category&.name,
transaction.tags.pluck(:name).join(","),
transaction.entry.notes,
transaction.entry.currency
]
end
end
end
def generate_trades_csv
CSV.generate do |csv|
csv << [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ]
# Only export trades from accounts belonging to this family
@family.trades
.includes(:security, entry: :account)
.find_each do |trade|
csv << [
trade.entry.date.iso8601,
trade.entry.account.name,
trade.security.ticker,
trade.qty.to_s,
trade.price.to_s,
trade.entry.amount.to_s,
trade.currency
]
end
end
end
def generate_categories_csv
CSV.generate do |csv|
csv << [ "name", "color", "parent_category", "classification" ]
# Only export categories belonging to this family
@family.categories.includes(:parent).find_each do |category|
csv << [
category.name,
category.color,
category.parent&.name,
category.classification
]
end
end
end
def generate_ndjson
lines = []
# Export accounts with full accountable data
@family.accounts.includes(:accountable).find_each do |account|
lines << {
type: "Account",
data: account.as_json(
include: {
accountable: {}
}
)
}.to_json
end
# Export categories
@family.categories.find_each do |category|
lines << {
type: "Category",
data: category.as_json
}.to_json
end
# Export tags
@family.tags.find_each do |tag|
lines << {
type: "Tag",
data: tag.as_json
}.to_json
end
# Export merchants (only family merchants)
@family.merchants.find_each do |merchant|
lines << {
type: "Merchant",
data: merchant.as_json
}.to_json
end
# Export transactions with full data
@family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction|
lines << {
type: "Transaction",
data: {
id: transaction.id,
entry_id: transaction.entry.id,
account_id: transaction.entry.account_id,
date: transaction.entry.date,
amount: transaction.entry.amount,
currency: transaction.entry.currency,
name: transaction.entry.name,
notes: transaction.entry.notes,
excluded: transaction.entry.excluded,
category_id: transaction.category_id,
merchant_id: transaction.merchant_id,
tag_ids: transaction.tag_ids,
kind: transaction.kind,
created_at: transaction.created_at,
updated_at: transaction.updated_at
}
}.to_json
end
# Export trades with full data
@family.trades.includes(:security, entry: :account).find_each do |trade|
lines << {
type: "Trade",
data: {
id: trade.id,
entry_id: trade.entry.id,
account_id: trade.entry.account_id,
security_id: trade.security_id,
ticker: trade.security.ticker,
date: trade.entry.date,
qty: trade.qty,
price: trade.price,
amount: trade.entry.amount,
currency: trade.currency,
created_at: trade.created_at,
updated_at: trade.updated_at
}
}.to_json
end
# Export valuations
@family.entries.valuations.includes(:account, :entryable).find_each do |entry|
lines << {
type: "Valuation",
data: {
id: entry.entryable.id,
entry_id: entry.id,
account_id: entry.account_id,
date: entry.date,
amount: entry.amount,
currency: entry.currency,
name: entry.name,
created_at: entry.created_at,
updated_at: entry.updated_at
}
}.to_json
end
# Export budgets
@family.budgets.find_each do |budget|
lines << {
type: "Budget",
data: budget.as_json
}.to_json
end
# Export budget categories
@family.budget_categories.includes(:budget, :category).find_each do |budget_category|
lines << {
type: "BudgetCategory",
data: budget_category.as_json
}.to_json
end
lines.join("\n")
end
end

View file

@ -0,0 +1,22 @@
class FamilyExport < ApplicationRecord
belongs_to :family
has_one_attached :export_file
enum :status, {
pending: "pending",
processing: "processing",
completed: "completed",
failed: "failed"
}, default: :pending, validate: true
scope :ordered, -> { order(created_at: :desc) }
def filename
"maybe_export_#{created_at.strftime('%Y%m%d_%H%M%S')}.zip"
end
def downloadable?
completed? && export_file.attached?
end
end

View file

@ -88,7 +88,7 @@ class Import < ApplicationRecord
entries.destroy_all
end
family.sync
family.sync_later
update! status: :pending
rescue => error

View file

@ -47,8 +47,8 @@ class Transaction::Search
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
result = transactions_scope
.select(
"COALESCE(SUM(CASE WHEN entries.amount >= 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COUNT(entries.id) as transactions_count"
)
.joins(
@ -61,8 +61,8 @@ class Transaction::Search
Totals.new(
count: result.transactions_count.to_i,
income_money: Money.new(result.income_total.to_i, family.currency),
expense_money: Money.new(result.expense_total.to_i, family.currency)
income_money: Money.new(result.income_total.round, family.currency),
expense_money: Money.new(result.expense_total.round, family.currency)
)
end
end

View file

@ -67,7 +67,17 @@ class ApiRateLimiter
# Class method to get usage for an API key without incrementing
def self.usage_for(api_key)
new(api_key).usage_info
limit(api_key).usage_info
end
def self.limit(api_key)
if Rails.application.config.app_mode.self_hosted?
# Use NoopApiRateLimiter for self-hosted mode
# This means no rate limiting is applied
NoopApiRateLimiter.new(api_key)
else
new(api_key)
end
end
private

View file

@ -0,0 +1,39 @@
class NoopApiRateLimiter
def initialize(api_key)
@api_key = api_key
end
def rate_limit_exceeded?
false
end
def increment_request_count!
# No operation
end
def current_count
0
end
def rate_limit
Float::INFINITY
end
def reset_time
0
end
def usage_info
{
current_count: 0,
rate_limit: Float::INFINITY,
remaining: Float::INFINITY,
reset_time: 0,
tier: :noop
}
end
def self.usage_for(api_key)
new(api_key).usage_info
end
end

View file

@ -2,6 +2,14 @@
<h1 class="text-xl"><%= t(".accounts") %></h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= icon(
"refresh-cw",
as_button: true,
size: "sm",
href: sync_all_accounts_path,
disabled: Current.family.syncing?,
frame: :_top
) %>
<%= render DS::Link.new(
text: "New account",
href: new_account_path(return_to: accounts_path),

View file

@ -0,0 +1,39 @@
<%= turbo_frame_tag "family_exports",
data: exports.any? { |e| e.pending? || e.processing? } ? {
turbo_refresh_url: family_exports_path,
turbo_refresh_interval: 3000
} : {} do %>
<div class="mt-4 space-y-3 max-h-96 overflow-y-auto">
<% if exports.any? %>
<% exports.each do |export| %>
<div class="flex items-center justify-between bg-container p-4 rounded-lg border border-primary">
<div>
<p class="text-sm font-medium text-primary">Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %></p>
<p class="text-xs text-secondary"><%= export.filename %></p>
</div>
<% if export.processing? || export.pending? %>
<div class="flex items-center gap-2 text-secondary">
<div class="animate-spin h-4 w-4 border-2 border-secondary border-t-transparent rounded-full"></div>
<span class="text-sm">Exporting...</span>
</div>
<% elsif export.completed? %>
<%= link_to download_family_export_path(export),
class: "flex items-center gap-2 text-primary hover:text-primary-hover",
data: { turbo_frame: "_top" } do %>
<%= icon "download", class: "w-5 h-5" %>
<span class="text-sm font-medium">Download</span>
<% end %>
<% elsif export.failed? %>
<div class="flex items-center gap-2 text-destructive">
<%= icon "alert-circle", class: "w-4 h-4" %>
<span class="text-sm">Failed</span>
</div>
<% end %>
</div>
<% end %>
<% else %>
<p class="text-sm text-secondary text-center py-4">No exports yet</p>
<% end %>
</div>
<% end %>

View file

@ -0,0 +1 @@
<%= render "list", exports: @exports %>

View file

@ -0,0 +1,42 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Export your data", subtitle: "Download all your financial data") %>
<% dialog.with_body do %>
<div class="space-y-4">
<div class="bg-container-inset rounded-lg p-4 space-y-3">
<h3 class="font-medium text-primary">What's included:</h3>
<ul class="space-y-2 text-sm text-secondary">
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>All accounts and balances</span>
</li>
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>Transaction history</span>
</li>
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>Investment trades</span>
</li>
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>Categories and tags</span>
</li>
</ul>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p class="text-sm text-amber-800">
<strong>Note:</strong> This export includes all of your data, but only some of the data can be imported back into Maybe via the CSV import feature. We support account, transaction (with category and tags), and trade imports. Other account data cannot be imported and is for your records only.
</p>
</div>
<%= form_with url: family_exports_path, method: :post, class: "space-y-4" do |form| %>
<div class="flex gap-3">
<%= link_to "Cancel", "#", class: "flex-1 text-center px-4 py-2 border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %>
<%= form.submit "Export data", class: "flex-1 bg-inverse fg-inverse rounded-lg px-4 py-2 cursor-pointer" %>
</div>
<% end %>
</div>
<% end %>
<% end %>

View file

@ -1,5 +1,20 @@
<% content_for :page_header do %>
<% unless Current.family.self_hoster? %>
<div class="bg-gray-100 mb-4 rounded-xl p-4 flex gap-2 items-start">
<%= icon "triangle-alert", color: "warning" %>
<div class="text-sm space-y-2">
<p class="font-medium">We've made a tough decision to shut down the hosted version of Maybe. Here's what's happening next:</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li><%= link_to "Read why we're doing this here", "https://x.com/Shpigford/status/1947725345244709240", class: "underline" %></li>
<li>You will be refunded in full.</li>
<li>You have until July 31, 2025 to export your data. You can do that <%= link_to "here", settings_profile_path, class: "underline" %>.</li>
</ul>
</div>
</div>
<% end %>
<div class="space-y-1 mb-6 flex gap-4 justify-between items-center lg:items-start">
<div class="space-y-1">
<h1 class="text-xl lg:text-3xl font-medium text-primary">Welcome back, <%= Current.user.first_name %></h1>
<p class="text-sm lg:text-base text-secondary">Here's what's happening with your finances</p>

View file

@ -57,7 +57,7 @@
</div>
</div>
<div class="flex items-center gap-4">
<%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %>
<%= styled_form_with model: rule, namespace: "rule_#{rule.id}", data: { controller: "auto-submit-form" } do |f| %>
<%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %>
<% end %>
<%= render DS::Menu.new do |menu| %>

View file

@ -122,6 +122,29 @@
</div>
<% end %>
<% if Current.user.admin? %>
<%= settings_section title: "Data Import/Export" do %>
<div class="space-y-4">
<div class="flex gap-4 items-center">
<%= render DS::Link.new(
text: "Export data",
icon: "database",
href: new_family_export_path,
variant: "secondary",
full_width: true,
data: { turbo_frame: :modal }
) %>
</div>
<%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %>
<div class="mt-4 text-center text-secondary">
<div class="animate-spin inline-block h-4 w-4 border-2 border-secondary border-t-transparent rounded-full"></div>
</div>
<% end %>
</div>
<% end %>
<% end %>
<%= settings_section title: t(".danger_zone_title") do %>
<div class="space-y-4">
<% if Current.user.admin? %>

View file

@ -18,7 +18,7 @@
<% if @entry.account_id %>
<%= f.hidden_field :account_id %>
<% else %>
<%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
<%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
<% end %>
<%= f.money_field :amount, label: t(".amount"), required: true %>

View file

@ -1,5 +1,28 @@
{
"ignored_warnings": [
{
"warning_type": "Redirect",
"warning_code": 18,
"fingerprint": "723b1970ca6bf16ea0c2c1afa0c00d3c54854a16568d6cb933e497947565d9ab",
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/family_exports_controller.rb",
"line": 30,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(Current.family.family_exports.find(params[:id]).export_file, :allow_other_host => true)",
"render_path": null,
"location": {
"type": "method",
"class": "FamilyExportsController",
"method": "download"
},
"user_input": "Current.family.family_exports.find(params[:id]).export_file",
"confidence": "Weak",
"cwe_id": [
601
],
"note": ""
},
{
"warning_type": "Mass Assignment",
"warning_code": 105,
@ -105,5 +128,5 @@
"note": ""
}
],
"brakeman_version": "7.0.2"
"brakeman_version": "7.1.0"
}

View file

@ -9,8 +9,11 @@ class Rack::Attack
request.ip if request.path == "/oauth/token"
end
# Determine limits based on self-hosted mode
self_hosted = Rails.application.config.app_mode.self_hosted?
# Throttle API requests per access token
throttle("api/requests", limit: 100, period: 1.hour) do |request|
throttle("api/requests", limit: self_hosted ? 10_000 : 100, period: 1.hour) do |request|
if request.path.start_with?("/api/")
# Extract access token from Authorization header
auth_header = request.get_header("HTTP_AUTHORIZATION")
@ -25,7 +28,7 @@ class Rack::Attack
end
# More permissive throttling for API requests by IP (for development/testing)
throttle("api/ip", limit: 200, period: 1.hour) do |request|
throttle("api/ip", limit: self_hosted ? 20_000 : 200, period: 1.hour) do |request|
request.ip if request.path.start_with?("/api/")
end

View file

@ -14,7 +14,7 @@ module Maybe
private
def semver
"0.5.0"
"0.6.0"
end
end
end

View file

@ -24,6 +24,12 @@ Rails.application.routes.draw do
end
end
resources :family_exports, only: %i[new create index] do
member do
get :download
end
end
get "changelog", to: "pages#changelog"
get "feedback", to: "pages#feedback"
@ -156,6 +162,10 @@ Rails.application.routes.draw do
get :sparkline
patch :toggle_active
end
collection do
post :sync_all
end
end
# Convenience routes for polymorphic paths

View file

@ -0,0 +1,72 @@
class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2]
def up
# Add new columns for balance tracking
add_column :balances, :start_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0
add_column :balances, :start_non_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0
# Flow tracking columns (absolute values)
add_column :balances, :cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
add_column :balances, :cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
add_column :balances, :non_cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
add_column :balances, :non_cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
# Market value changes
add_column :balances, :net_market_flows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
# Manual adjustments from valuations
add_column :balances, :cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0
add_column :balances, :non_cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0
# Flows factor determines *how* the flows affect the balance.
# Inflows increase asset accounts, while inflows decrease liability accounts (reducing debt via "payment")
add_column :balances, :flows_factor, :integer, null: false, default: 1
# Add generated columns
change_table :balances do |t|
t.virtual :start_balance, type: :decimal, precision: 19, scale: 4, stored: true,
as: "start_cash_balance + start_non_cash_balance"
t.virtual :end_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true,
as: "start_cash_balance + ((cash_inflows - cash_outflows) * flows_factor) + cash_adjustments"
t.virtual :end_non_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true,
as: "start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * flows_factor) + net_market_flows + non_cash_adjustments"
# Postgres doesn't support generated columns depending on other generated columns,
# but we want the integrity of the data to happen at the DB level, so this is the full formula.
# Formula: (cash components) + (non-cash components)
t.virtual :end_balance, type: :decimal, precision: 19, scale: 4, stored: true,
as: <<~SQL.squish
(
start_cash_balance +
((cash_inflows - cash_outflows) * flows_factor) +
cash_adjustments
) + (
start_non_cash_balance +
((non_cash_inflows - non_cash_outflows) * flows_factor) +
net_market_flows +
non_cash_adjustments
)
SQL
end
end
def down
# Remove generated columns first (PostgreSQL requirement)
remove_column :balances, :start_balance
remove_column :balances, :end_cash_balance
remove_column :balances, :end_non_cash_balance
remove_column :balances, :end_balance
# Remove new columns
remove_column :balances, :start_cash_balance
remove_column :balances, :start_non_cash_balance
remove_column :balances, :cash_inflows
remove_column :balances, :cash_outflows
remove_column :balances, :non_cash_inflows
remove_column :balances, :non_cash_outflows
remove_column :balances, :net_market_flows
remove_column :balances, :cash_adjustments
remove_column :balances, :non_cash_adjustments
end
end

View file

@ -0,0 +1,10 @@
class CreateFamilyExports < ActiveRecord::Migration[7.2]
def change
create_table :family_exports, id: :uuid do |t|
t.references :family, null: false, foreign_key: true, type: :uuid
t.string :status, default: "pending", null: false
t.timestamps
end
end
end

25
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do
ActiveRecord::Schema[7.2].define(version: 2025_07_24_115507) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -115,6 +115,20 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false
t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false
t.integer "flows_factor", default: 1, null: false
t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true
t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true
t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true
t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true
t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true
t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc }
t.index ["account_id"], name: "index_balances_on_account_id"
@ -256,6 +270,14 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do
t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" }
end
create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "status", default: "pending", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["family_id"], name: "index_family_exports_on_family_id"
end
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "security_id", null: false
@ -816,6 +838,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do
add_foreign_key "chats", "users"
add_foreign_key "entries", "accounts"
add_foreign_key "entries", "imports"
add_foreign_key "family_exports", "families"
add_foreign_key "holdings", "accounts"
add_foreign_key "holdings", "securities"
add_foreign_key "impersonation_session_logs", "impersonation_sessions"

View file

@ -154,4 +154,19 @@ namespace :data_migration do
puts " Processed: #{accounts_processed} accounts"
puts " Opening anchors set: #{opening_anchors_set}"
end
desc "Migrate balance components"
# 2025-07-20: Migrate balance components to support event-sourced ledger model.
# This task:
# 1. Sets the flows_factor for each account based on the account's classification
# 2. Sets the start_cash_balance, start_non_cash_balance, and start_balance for each balance
# 3. Sets the cash_inflows, cash_outflows, non_cash_inflows, non_cash_outflows, net_market_flows, cash_adjustments, and non_cash_adjustments for each balance
# 4. Sets the end_cash_balance, end_non_cash_balance, and end_balance for each balance
task migrate_balance_components: :environment do
puts "==> Migrating balance components..."
BalanceComponentMigrator.run
puts "✅ Balance component migration complete."
end
end

View file

@ -0,0 +1,73 @@
require "test_helper"
class FamilyExportsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:family_admin)
@non_admin = users(:family_member)
@family = @admin.family
sign_in @admin
end
test "non-admin cannot access exports" do
sign_in @non_admin
get new_family_export_path
assert_redirected_to root_path
post family_exports_path
assert_redirected_to root_path
get family_exports_path
assert_redirected_to root_path
end
test "admin can view export modal" do
get new_family_export_path
assert_response :success
assert_select "h2", text: "Export your data"
end
test "admin can create export" do
assert_enqueued_with(job: FamilyDataExportJob) do
post family_exports_path
end
assert_redirected_to settings_profile_path
assert_equal "Export started. You'll be able to download it shortly.", flash[:notice]
export = @family.family_exports.last
assert_equal "pending", export.status
end
test "admin can view export list" do
export1 = @family.family_exports.create!(status: "completed")
export2 = @family.family_exports.create!(status: "processing")
get family_exports_path
assert_response :success
assert_match export1.filename, response.body
assert_match "Exporting...", response.body
end
test "admin can download completed export" do
export = @family.family_exports.create!(status: "completed")
export.export_file.attach(
io: StringIO.new("test zip content"),
filename: "test.zip",
content_type: "application/zip"
)
get download_family_export_path(export)
assert_redirected_to(/rails\/active_storage/)
end
test "cannot download incomplete export" do
export = @family.family_exports.create!(status: "processing")
get download_family_export_path(export)
assert_redirected_to settings_profile_path
assert_equal "Export not ready for download", flash[:alert]
end
end

View file

@ -162,8 +162,7 @@ end
income_money: Money.new(0, "USD")
)
expected_filters = { "start_date" => 30.days.ago.to_date }
Transaction::Search.expects(:new).with(family, filters: expected_filters).returns(search)
Transaction::Search.expects(:new).with(family, filters: {}).returns(search)
search.expects(:totals).once.returns(totals)
get transactions_url

View file

@ -0,0 +1,160 @@
require "test_helper"
class BalanceComponentMigratorTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@depository = accounts(:depository)
@investment = accounts(:investment)
@loan = accounts(:loan)
# Start fresh
Balance.delete_all
end
test "depository account with no gaps" do
create_balance_history(@depository, [
{ date: 5.days.ago, cash_balance: 1000, balance: 1000 },
{ date: 4.days.ago, cash_balance: 1100, balance: 1100 },
{ date: 3.days.ago, cash_balance: 1050, balance: 1050 },
{ date: 2.days.ago, cash_balance: 1200, balance: 1200 },
{ date: 1.day.ago, cash_balance: 1150, balance: 1150 }
])
BalanceComponentMigrator.run
assert_migrated_balances @depository, [
{ date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
{ date: 4.days.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 100, non_cash_inflows: 0, end_cash: 1100, end_non_cash: 0, end: 1100 },
{ date: 3.days.ago, start_cash: 1100, start_non_cash: 0, start: 1100, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1050, end_non_cash: 0, end: 1050 },
{ date: 2.days.ago, start_cash: 1050, start_non_cash: 0, start: 1050, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1200, end_non_cash: 0, end: 1200 },
{ date: 1.day.ago, start_cash: 1200, start_non_cash: 0, start: 1200, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }
]
end
test "depository account with gaps" do
create_balance_history(@depository, [
{ date: 5.days.ago, cash_balance: 1000, balance: 1000 },
{ date: 1.day.ago, cash_balance: 1150, balance: 1150 }
])
BalanceComponentMigrator.run
assert_migrated_balances @depository, [
{ date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
{ date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }
]
end
test "investment account with no gaps" do
create_balance_history(@investment, [
{ date: 3.days.ago, cash_balance: 100, balance: 200 },
{ date: 2.days.ago, cash_balance: 200, balance: 300 },
{ date: 1.day.ago, cash_balance: 0, balance: 300 }
])
BalanceComponentMigrator.run
assert_migrated_balances @investment, [
{ date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 100, non_cash_inflows: 100, end_cash: 100, end_non_cash: 100, end: 200 },
{ date: 2.days.ago, start_cash: 100, start_non_cash: 100, start: 200, cash_inflows: 100, non_cash_inflows: 0, end_cash: 200, end_non_cash: 100, end: 300 },
{ date: 1.day.ago, start_cash: 200, start_non_cash: 100, start: 300, cash_inflows: -200, non_cash_inflows: 200, end_cash: 0, end_non_cash: 300, end: 300 }
]
end
test "investment account with gaps" do
create_balance_history(@investment, [
{ date: 5.days.ago, cash_balance: 1000, balance: 1000 },
{ date: 1.day.ago, cash_balance: 1150, balance: 1150 }
])
BalanceComponentMigrator.run
assert_migrated_balances @investment, [
{ date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
{ date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }
]
end
# Negative flows factor test
test "loan account with no gaps" do
create_balance_history(@loan, [
{ date: 3.days.ago, cash_balance: 0, balance: 200 },
{ date: 2.days.ago, cash_balance: 0, balance: 300 },
{ date: 1.day.ago, cash_balance: 0, balance: 500 }
])
BalanceComponentMigrator.run
assert_migrated_balances @loan, [
{ date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 200, end: 200 },
{ date: 2.days.ago, start_cash: 0, start_non_cash: 200, start: 200, cash_inflows: 0, non_cash_inflows: -100, end_cash: 0, end_non_cash: 300, end: 300 },
{ date: 1.day.ago, start_cash: 0, start_non_cash: 300, start: 300, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 500, end: 500 }
]
end
test "loan account with gaps" do
create_balance_history(@loan, [
{ date: 5.days.ago, cash_balance: 0, balance: 1000 },
{ date: 1.day.ago, cash_balance: 0, balance: 2000 }
])
BalanceComponentMigrator.run
assert_migrated_balances @loan, [
{ date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
{ date: 1.day.ago, start_cash: 0, start_non_cash: 1000, start: 1000, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 2000, end: 2000 }
]
end
private
def create_balance_history(account, balances)
balances.each do |balance|
account.balances.create!(
date: balance[:date].to_date,
balance: balance[:balance],
cash_balance: balance[:cash_balance],
currency: account.currency
)
end
end
def assert_migrated_balances(account, expected)
balances = account.balances.order(:date)
expected.each_with_index do |expected_values, index|
balance = balances.find { |b| b.date == expected_values[:date].to_date }
assert balance, "Expected balance for #{expected_values[:date].to_date} but none found"
# Assert expected values
assert_equal expected_values[:start_cash], balance.start_cash_balance,
"start_cash_balance mismatch for #{balance.date}"
assert_equal expected_values[:start_non_cash], balance.start_non_cash_balance,
"start_non_cash_balance mismatch for #{balance.date}"
assert_equal expected_values[:start], balance.start_balance,
"start_balance mismatch for #{balance.date}"
assert_equal expected_values[:cash_inflows], balance.cash_inflows,
"cash_inflows mismatch for #{balance.date}"
assert_equal expected_values[:non_cash_inflows], balance.non_cash_inflows,
"non_cash_inflows mismatch for #{balance.date}"
assert_equal expected_values[:end_cash], balance.end_cash_balance,
"end_cash_balance mismatch for #{balance.date}"
assert_equal expected_values[:end_non_cash], balance.end_non_cash_balance,
"end_non_cash_balance mismatch for #{balance.date}"
assert_equal expected_values[:end], balance.end_balance,
"end_balance mismatch for #{balance.date}"
# Assert zeros for other fields
assert_equal 0, balance.cash_outflows,
"cash_outflows should be zero for #{balance.date}"
assert_equal 0, balance.non_cash_outflows,
"non_cash_outflows should be zero for #{balance.date}"
assert_equal 0, balance.cash_adjustments,
"cash_adjustments should be zero for #{balance.date}"
assert_equal 0, balance.non_cash_adjustments,
"non_cash_adjustments should be zero for #{balance.date}"
assert_equal 0, balance.net_market_flows,
"net_market_flows should be zero for #{balance.date}"
end
end
end

3
test/fixtures/family_exports.yml vendored Normal file
View file

@ -0,0 +1,3 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# Empty file - no fixtures needed, tests create them dynamically

View file

@ -0,0 +1,32 @@
require "test_helper"
class FamilyDataExportJobTest < ActiveJob::TestCase
setup do
@family = families(:dylan_family)
@export = @family.family_exports.create!
end
test "marks export as processing then completed" do
assert_equal "pending", @export.status
perform_enqueued_jobs do
FamilyDataExportJob.perform_later(@export)
end
@export.reload
assert_equal "completed", @export.status
assert @export.export_file.attached?
end
test "marks export as failed on error" do
# Mock the exporter to raise an error
Family::DataExporter.any_instance.stubs(:generate_export).raises(StandardError, "Export failed")
perform_enqueued_jobs do
FamilyDataExportJob.perform_later(@export)
end
@export.reload
assert_equal "failed", @export.status
end
end

View file

@ -14,7 +14,7 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
setup_test_data
end
test "calculates balance trend with complete balance history" do
test "returns balance for date with complete balance history" do
entries = @checking.entries.includes(:entryable).to_a
feed_data = Account::ActivityFeedData.new(@checking, entries)
@ -22,14 +22,11 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
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
assert_not_nil day2_activity.balance
assert_equal 1100, day2_activity.balance.end_balance # End of day 2
end
test "calculates balance trend for first day with zero starting balance" do
test "returns balance for first day" do
entries = @checking.entries.includes(:entryable).to_a
feed_data = Account::ActivityFeedData.new(@checking, entries)
@ -37,49 +34,24 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
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
assert_not_nil day1_activity.balance
assert_equal 1000, day1_activity.balance.end_balance # End of first day
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
test "returns nil balance when no balance exists for date" 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
assert_nil day1_activity.balance
end
test "calculates cash and holdings trends for investment accounts" do
test "returns cash and holdings data for investment accounts" do
entries = @investment.entries.includes(:entryable).to_a
feed_data = Account::ActivityFeedData.new(@investment, entries)
@ -87,20 +59,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
day3_activity = find_activity_for_date(activities, @test_period_start + 2.days)
assert_not_nil day3_activity
assert_not_nil day3_activity.balance
# 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
# Balance should have the new schema fields
assert_equal 400, day3_activity.balance.end_cash_balance # End of day 3 cash balance
assert_equal 1500, day3_activity.balance.end_non_cash_balance # Holdings value
assert_equal 1900, day3_activity.balance.end_balance # Total balance
end
test "identifies transfers for a specific date" do
@ -134,30 +98,46 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
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, :balance
assert_respond_to activity, :transfers
end
end
test "handles valuations correctly by summing entry changes" do
test "handles valuations correctly with new balance schema" 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,
balance: 7321.56, # Keep old field for now
cash_balance: 1000, # Keep old field for now
start_cash_balance: 0,
start_non_cash_balance: 0,
cash_inflows: 1000,
cash_outflows: 0,
non_cash_inflows: 6321.56,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
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
balance: 8500, # Keep old field for now
cash_balance: 1070, # Keep old field for now
start_cash_balance: 1000,
start_non_cash_balance: 6321.56,
cash_inflows: 70,
cash_outflows: 0,
non_cash_inflows: 750,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 358.44,
currency: "USD"
)
@ -198,73 +178,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
assert_not_nil day2_activity
assert_not_nil day2_activity.balance
# 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
# Check new balance fields
assert_equal 1070, day2_activity.balance.end_cash_balance
assert_equal 7430, day2_activity.balance.end_non_cash_balance
assert_equal 8500, day2_activity.balance.end_balance
end
private
@ -273,12 +192,25 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
end
def setup_test_data
# Create daily balances for checking account
# Create daily balances for checking account with new schema
5.times do |i|
date = @test_period_start + i.days
prev_balance = i > 0 ? 1000 + ((i - 1) * 100) : 0
@checking.balances.create!(
date: date,
balance: 1000 + (i * 100),
balance: 1000 + (i * 100), # Keep old field for now
cash_balance: 1000 + (i * 100), # Keep old field for now
start_balance: prev_balance,
start_cash_balance: prev_balance,
start_non_cash_balance: 0,
cash_inflows: i == 0 ? 1000 : 100,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
currency: "USD"
)
end
@ -286,20 +218,50 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
# Create daily balances for investment account with cash_balance
@investment.balances.create!(
date: @test_period_start,
balance: 500,
cash_balance: 500,
balance: 500, # Keep old field for now
cash_balance: 500, # Keep old field for now
start_balance: 0,
start_cash_balance: 0,
start_non_cash_balance: 0,
cash_inflows: 500,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
currency: "USD"
)
@investment.balances.create!(
date: @test_period_start + 1.day,
balance: 500,
cash_balance: 500,
balance: 500, # Keep old field for now
cash_balance: 500, # Keep old field for now
start_balance: 500,
start_cash_balance: 500,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
currency: "USD"
)
@investment.balances.create!(
date: @test_period_start + 2.days,
balance: 1900, # 1500 holdings + 400 cash
cash_balance: 400, # After -100 EUR transaction
balance: 1900, # Keep old field for now
cash_balance: 400, # Keep old field for now
start_balance: 500,
start_cash_balance: 500,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 100,
non_cash_inflows: 1500,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
currency: "USD"
)

View file

@ -1,18 +1,15 @@
require "test_helper"
class Account::ReconciliationManagerTest < ActiveSupport::TestCase
include BalanceTestHelper
setup do
@account = accounts(:investment)
@manager = Account::ReconciliationManager.new(@account)
end
test "new reconciliation" do
@account.balances.create!(
date: Date.current,
balance: 1000,
cash_balance: 500,
currency: @account.currency
)
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
result = @manager.reconcile_balance(balance: 1200, date: Date.current)
@ -24,7 +21,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
end
test "updates existing reconciliation without date change" do
@account.balances.create!(date: Date.current, balance: 1000, cash_balance: 500, currency: @account.currency)
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
# Existing reconciliation entry
existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: Date.current, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency)
@ -39,8 +36,8 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
end
test "updates existing reconciliation with date and amount change" do
@account.balances.create!(date: 5.days.ago, balance: 1000, cash_balance: 500, currency: @account.currency)
@account.balances.create!(date: Date.current, balance: 1200, cash_balance: 700, currency: @account.currency)
create_balance(account: @account, date: 5.days.ago, balance: 1000, cash_balance: 500)
create_balance(account: @account, date: Date.current, balance: 1200, cash_balance: 700)
# Existing reconciliation entry (5 days ago)
existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: 5.days.ago, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency)
@ -63,12 +60,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
end
test "handles date conflicts" do
@account.balances.create!(
date: Date.current,
balance: 1000,
cash_balance: 1000,
currency: @account.currency
)
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 1000)
# Existing reconciliation entry
@account.entries.create!(
@ -89,7 +81,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
end
test "dry run does not persist account" do
@account.balances.create!(date: Date.current, balance: 1000, cash_balance: 500, currency: @account.currency)
create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
assert_no_difference "Valuation.count" do
@manager.reconcile_balance(balance: 1200, date: Date.current, dry_run: true)

View file

@ -1,6 +1,8 @@
require "test_helper"
class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
include BalanceTestHelper
setup do
end
@ -9,9 +11,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
account.balances.destroy_all
# With gaps
account.balances.create!(date: 3.days.ago.to_date, balance: 1000, currency: "USD")
account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD")
account.balances.create!(date: Date.current, balance: 1200, currency: "USD")
create_balance(account: account, date: 3.days.ago.to_date, balance: 1000)
create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
create_balance(account: account, date: Date.current, balance: 1200)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],
@ -38,9 +40,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
account = accounts(:depository)
account.balances.destroy_all
account.balances.create!(date: 2.days.ago.to_date, balance: 1000, currency: "USD")
account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD")
account.balances.create!(date: Date.current, balance: 1200, currency: "USD")
create_balance(account: account, date: 2.days.ago.to_date, balance: 1000)
create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
create_balance(account: account, date: Date.current, balance: 1200)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],
@ -68,13 +70,13 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
Balance.destroy_all
asset_account.balances.create!(date: 3.days.ago.to_date, balance: 500, currency: "USD")
asset_account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD")
asset_account.balances.create!(date: Date.current, balance: 1000, currency: "USD")
create_balance(account: asset_account, date: 3.days.ago.to_date, balance: 500)
create_balance(account: asset_account, date: 1.day.ago.to_date, balance: 1000)
create_balance(account: asset_account, date: Date.current, balance: 1000)
liability_account.balances.create!(date: 3.days.ago.to_date, balance: 200, currency: "USD")
liability_account.balances.create!(date: 2.days.ago.to_date, balance: 200, currency: "USD")
liability_account.balances.create!(date: Date.current, balance: 100, currency: "USD")
create_balance(account: liability_account, date: 3.days.ago.to_date, balance: 200)
create_balance(account: liability_account, date: 2.days.ago.to_date, balance: 200)
create_balance(account: liability_account, date: Date.current, balance: 100)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ asset_account.id, liability_account.id ],
@ -98,8 +100,8 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
account = accounts(:credit_card)
account.balances.destroy_all
account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD")
account.balances.create!(date: Date.current, balance: 500, currency: "USD")
create_balance(account: account, date: 1.day.ago.to_date, balance: 1000)
create_balance(account: account, date: Date.current, balance: 500)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],

View file

@ -11,7 +11,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
test "no entries sync" do
account = create_account_with_ledger(
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
account: { type: Depository, currency: "USD" },
entries: []
)
@ -21,8 +21,14 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 0, cash_balance: 0 } ]
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 0, cash_balance: 0 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 },
flows: 0,
adjustments: 0
}
]
)
end
@ -30,7 +36,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# Our system ensures all manual accounts have an opening anchor (for UX), but we should be able to handle a missing anchor by starting at 0 (i.e. "fresh account with no history")
test "account without opening anchor starts at zero balance" do
account = create_account_with_ledger(
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
account: { type: Depository, currency: "USD" },
entries: [
{ type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
]
@ -41,16 +47,28 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# Since we start at 0, this transaction (inflow) simply increases balance from 0 -> 1000
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 3.days.ago.to_date, { balance: 0, cash_balance: 0 } ],
[ 2.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 0, cash_balance: 0 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 1000, cash_balance: 1000 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
flows: { cash_inflows: 1000, cash_outflows: 0 },
adjustments: 0
}
]
)
end
test "reconciliation valuation sets absolute balance before applying subsequent transactions" do
account = create_account_with_ledger(
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
account: { type: Depository, currency: "USD" },
entries: [
{ type: "reconciliation", date: 3.days.ago.to_date, balance: 18000 },
{ type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
@ -62,9 +80,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# First valuation sets balance to 18000, then transaction increases balance to 19000
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 3.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ],
[ 2.days.ago.to_date, { balance: 19000, cash_balance: 19000 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 18000, cash_balance: 18000 },
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },
flows: 0,
adjustments: { cash_adjustments: 18000, non_cash_adjustments: 0 }
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 19000, cash_balance: 19000 },
balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 19000, end_non_cash: 0, end: 19000 },
flows: { cash_inflows: 1000, cash_outflows: 0 },
adjustments: 0
}
]
)
end
@ -72,7 +102,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
test "cash-only accounts (depository, credit card) use valuations where cash balance equals total balance" do
[ Depository, CreditCard ].each do |account_type|
account = create_account_with_ledger(
account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" },
account: { type: account_type, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
@ -83,9 +113,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
[ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 17000, cash_balance: 17000 },
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 18000, cash_balance: 18000 },
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },
flows: 0,
adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 }
}
]
)
end
@ -94,7 +136,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
test "non-cash accounts (property, loan) use valuations where cash balance is always zero" do
[ Property, Loan ].each do |account_type|
account = create_account_with_ledger(
account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" },
account: { type: account_type, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
@ -105,9 +147,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 0.0 } ],
[ 2.days.ago.to_date, { balance: 18000, cash_balance: 0.0 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 17000, cash_balance: 0.0 },
balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 17000, end: 17000 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 18000, cash_balance: 0.0 },
balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 18000, end: 18000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 1000 }
}
]
)
end
@ -115,7 +169,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
test "mixed accounts (investment) use valuations where cash balance is total minus holdings" do
account = create_account_with_ledger(
account: { type: Investment, balance: 10000, cash_balance: 10000, currency: "USD" },
account: { type: Investment, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
@ -127,9 +181,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
[ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 17000, cash_balance: 17000 },
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
flows: { market_flows: 0 },
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 18000, cash_balance: 18000 },
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },
flows: { market_flows: 0 },
adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash
}
]
)
end
@ -140,7 +206,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
test "transactions on depository accounts affect cash balance" do
account = create_account_with_ledger(
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
account: { type: Depository, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 5.days.ago.to_date, balance: 20000 },
{ type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # income
@ -152,11 +218,35 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 5.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
[ 4.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ],
[ 3.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ],
[ 2.days.ago.to_date, { balance: 20400, cash_balance: 20400 } ]
expected_data: [
{
date: 5.days.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
},
{
date: 4.days.ago.to_date,
legacy_balances: { balance: 20500, cash_balance: 20500 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
},
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 20500, cash_balance: 20500 },
balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 20400, cash_balance: 20400 },
balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20400, end_non_cash: 0, end: 20400 },
flows: { cash_inflows: 0, cash_outflows: 100 },
adjustments: 0
}
]
)
end
@ -164,7 +254,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
test "transactions on credit card accounts affect cash balance inversely" do
account = create_account_with_ledger(
account: { type: CreditCard, balance: 10000, cash_balance: 10000, currency: "USD" },
account: { type: CreditCard, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 5.days.ago.to_date, balance: 1000 },
{ type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # CC payment
@ -176,26 +266,47 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 5.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ],
[ 4.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
[ 3.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
[ 2.days.ago.to_date, { balance: 600, cash_balance: 600 } ]
expected_data: [
{
date: 5.days.ago.to_date,
legacy_balances: { balance: 1000, cash_balance: 1000 },
balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
flows: 0,
adjustments: 0
},
{
date: 4.days.ago.to_date,
legacy_balances: { balance: 500, cash_balance: 500 },
balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
},
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 500, cash_balance: 500 },
balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 600, cash_balance: 600 },
balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 600, end_non_cash: 0, end: 600 },
flows: { cash_inflows: 0, cash_outflows: 100 },
adjustments: 0
}
]
)
end
test "depository account with transactions and balance reconciliations" do
account = create_account_with_ledger(
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
account: { type: Depository, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 10.days.ago.to_date, balance: 20000 },
{ type: "transaction", date: 8.days.ago.to_date, amount: -5000 },
{ type: "reconciliation", date: 6.days.ago.to_date, balance: 17000 },
{ type: "transaction", date: 6.days.ago.to_date, amount: -500 },
{ type: "transaction", date: 4.days.ago.to_date, amount: -500 },
{ type: "reconciliation", date: 3.days.ago.to_date, balance: 17000 },
{ type: "transaction", date: 1.day.ago.to_date, amount: 100 }
{ type: "opening_anchor", date: 4.days.ago.to_date, balance: 20000 },
{ type: "transaction", date: 3.days.ago.to_date, amount: -5000 },
{ type: "reconciliation", date: 2.days.ago.to_date, balance: 17000 },
{ type: "transaction", date: 1.day.ago.to_date, amount: -500 }
]
)
@ -203,24 +314,42 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 10.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
[ 9.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
[ 8.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ],
[ 7.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ],
[ 6.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
[ 5.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
[ 4.days.ago.to_date, { balance: 17500, cash_balance: 17500 } ],
[ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
[ 2.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
[ 1.day.ago.to_date, { balance: 16900, cash_balance: 16900 } ]
expected_data: [
{
date: 4.days.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
},
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 25000, cash_balance: 25000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 25000, end_non_cash: 0, end: 25000 },
flows: { cash_inflows: 5000, cash_outflows: 0 },
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 17000, cash_balance: 17000 },
balances: { start: 25000, start_cash: 25000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
flows: 0,
adjustments: { cash_adjustments: -8000, non_cash_adjustments: 0 }
},
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 17500, cash_balance: 17500 },
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17500, end_non_cash: 0, end: 17500 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
}
]
)
end
test "accounts with transactions in multiple currencies convert to the account currency" do
test "accounts with transactions in multiple currencies convert to the account currency and flows are stored in account currency" do
account = create_account_with_ledger(
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
account: { type: Depository, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 4.days.ago.to_date, balance: 100 },
{ type: "transaction", date: 3.days.ago.to_date, amount: -100 },
@ -237,11 +366,35 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 4.days.ago.to_date, { balance: 100, cash_balance: 100 } ],
[ 3.days.ago.to_date, { balance: 200, cash_balance: 200 } ],
[ 2.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
[ 1.day.ago.to_date, { balance: 1100, cash_balance: 1100 } ]
expected_data: [
{
date: 4.days.ago.to_date,
legacy_balances: { balance: 100, cash_balance: 100 },
balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 },
flows: 0,
adjustments: 0
},
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 200, cash_balance: 200 },
balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 200, end_non_cash: 0, end: 200 },
flows: { cash_inflows: 100, cash_outflows: 0 },
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 500, cash_balance: 500 },
balances: { start: 200, start_cash: 200, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },
flows: { cash_inflows: 300, cash_outflows: 0 },
adjustments: 0
},
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 1100, cash_balance: 1100 },
balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 1100, end_non_cash: 0, end: 1100 },
flows: { cash_inflows: 600, cash_outflows: 0 }, # Cash inflow is the USD equivalent of €500 (converted for balances table)
adjustments: 0
}
]
)
end
@ -249,7 +402,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# A loan is a special case where despite being a "non-cash" account, it is typical to have "payment" transactions that reduce the loan principal (non cash balance)
test "loan payment transactions affect non cash balance" do
account = create_account_with_ledger(
account: { type: Loan, balance: 10000, cash_balance: 0, currency: "USD" },
account: { type: Loan, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 2.days.ago.to_date, balance: 20000 },
# "Loan payment" of $2000, which reduces the principal
@ -263,9 +416,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 2.days.ago.to_date, { balance: 20000, cash_balance: 0 } ],
[ 1.day.ago.to_date, { balance: 18000, cash_balance: 0 } ]
expected_data: [
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 0 },
balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 20000, end: 20000 },
flows: 0,
adjustments: 0
},
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 18000, cash_balance: 0 },
balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 18000, end: 18000 },
flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 }, # Loans are "special cases" where transactions do affect non-cash balance
adjustments: 0
}
]
)
end
@ -273,7 +438,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
test "non cash accounts can only use valuations and transactions will be recorded but ignored for balance calculation" do
[ Property, Vehicle, OtherAsset, OtherLiability ].each do |account_type|
account = create_account_with_ledger(
account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" },
account: { type: account_type, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 500000 },
@ -286,9 +451,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 3.days.ago.to_date, { balance: 500000, cash_balance: 0 } ],
[ 2.days.ago.to_date, { balance: 500000, cash_balance: 0 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 500000, cash_balance: 0 },
balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 500000, cash_balance: 0 },
balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 },
flows: 0, # Despite having a transaction, non-cash accounts ignore it for balance calculation
adjustments: 0
}
]
)
end
@ -304,7 +481,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# Holdings are calculated separately and fed into the balance calculator; treated as "non-cash"
test "investment account calculates balance from transactions and trades and treats holdings as non-cash, additive to balance" do
account = create_account_with_ledger(
account: { type: Investment, balance: 10000, cash_balance: 10000, currency: "USD" },
account: { type: Investment, currency: "USD" },
entries: [
# Account starts with brokerage cash of $5000 and no holdings
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 5000 },
@ -314,7 +491,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
holdings: [
# Holdings calculator will calculate $1000 worth of holdings
{ date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }
{ date: Date.current, ticker: "AAPL", qty: 10, price: 110, amount: 1100 } # Price increased by 10%, so holdings value goes up by $100 without a trade
]
)
@ -324,17 +501,87 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ 3.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ],
[ 2.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ],
[ 1.day.ago.to_date, { balance: 5000, cash_balance: 4000 } ],
[ Date.current, { balance: 5000, cash_balance: 4000 } ]
expected_data: [
{
date: 3.days.ago.to_date,
legacy_balances: { balance: 5000, cash_balance: 5000 },
balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 5000, cash_balance: 5000 },
balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
flows: 0,
adjustments: 0
},
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 5000, cash_balance: 4000 },
balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 4000, end_non_cash: 1000, end: 5000 },
flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 }, # Decrease cash by 1000, increase holdings by 1000 (i.e. "buy" of $1000 worth of AAPL)
adjustments: 0
},
{
date: Date.current,
legacy_balances: { balance: 5100, cash_balance: 4000 },
balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1100, end: 5100 },
flows: { net_market_flows: 100 }, # Holdings value increased by 100, despite no change in portfolio quantities
adjustments: 0
}
]
)
end
test "investment account can have valuations that override balance" do
account = create_account_with_ledger(
account: { type: Investment, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 2.days.ago.to_date, balance: 5000 },
{ type: "reconciliation", date: 1.day.ago.to_date, balance: 10000 }
],
holdings: [
{ date: 3.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: 2.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 110, amount: 1100 },
{ date: Date.current, ticker: "AAPL", qty: 10, price: 120, amount: 1200 }
]
)
# Given constant prices, overall balance (account value) should be constant
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
calculated = Balance::ForwardCalculator.new(account).calculate
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_data: [
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 5000, cash_balance: 4000 },
balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1000, end: 5000 },
flows: 0,
adjustments: 0
},
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 10000, cash_balance: 8900 },
balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 8900, end_non_cash: 1100, end: 10000 },
flows: { net_market_flows: 100 },
adjustments: { cash_adjustments: 4900, non_cash_adjustments: 0 }
},
{
date: Date.current,
legacy_balances: { balance: 10100, cash_balance: 8900 },
balances: { start: 10000, start_cash: 8900, start_non_cash: 1100, end_cash: 8900, end_non_cash: 1200, end: 10100 },
flows: { net_market_flows: 100 },
adjustments: 0
}
]
)
end
private
def assert_balances(calculated_data:, expected_balances:)
# Sort calculated data by date to ensure consistent ordering
sorted_data = calculated_data.sort_by(&:date)

View file

@ -2,6 +2,7 @@ require "test_helper"
class Balance::MaterializerTest < ActiveSupport::TestCase
include EntriesTestHelper
include BalanceTestHelper
setup do
@account = families(:empty).accounts.create!(
@ -16,36 +17,143 @@ class Balance::MaterializerTest < ActiveSupport::TestCase
test "syncs balances" do
Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once
@account.expects(:start_date).returns(2.days.ago.to_date)
expected_balances = [
Balance.new(
date: 1.day.ago.to_date,
balance: 1000,
cash_balance: 1000,
currency: "USD",
start_cash_balance: 500,
start_non_cash_balance: 0,
cash_inflows: 500,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
),
Balance.new(
date: Date.current,
balance: 1000,
cash_balance: 1000,
currency: "USD",
start_cash_balance: 1000,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
)
]
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
[
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
]
)
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)
assert_difference "@account.balances.count", 2 do
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
end
assert_balance_fields_persisted(expected_balances)
end
test "purges stale balances and holdings" do
# Balance before start date is stale
@account.expects(:start_date).returns(2.days.ago.to_date).twice
stale_balance = Balance.new(date: 3.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD")
test "purges stale balances outside calculated range" do
# Create existing balances that will be stale
stale_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 5000)
stale_future = create_balance(account: @account, date: 2.days.from_now.to_date, balance: 15000)
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
[
stale_balance,
Balance.new(date: 2.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD"),
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
]
)
# Calculator will return balances for only these dates
expected_balances = [
Balance.new(
date: 2.days.ago.to_date,
balance: 10000,
cash_balance: 10000,
currency: "USD",
start_cash_balance: 10000,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
),
Balance.new(
date: 1.day.ago.to_date,
balance: 1000,
cash_balance: 1000,
currency: "USD",
start_cash_balance: 10000,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 9000,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
),
Balance.new(
date: Date.current,
balance: 1000,
cash_balance: 1000,
currency: "USD",
start_cash_balance: 1000,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
)
]
assert_difference "@account.balances.count", 3 do
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)
Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once
# Should end up with 3 balances (stale ones deleted, new ones created)
assert_difference "@account.balances.count", 1 do
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
end
# Verify stale balances were deleted
assert_nil @account.balances.find_by(id: stale_old.id)
assert_nil @account.balances.find_by(id: stale_future.id)
# Verify expected balances were persisted
assert_balance_fields_persisted(expected_balances)
end
private
def assert_balance_fields_persisted(expected_balances)
expected_balances.each do |expected|
persisted = @account.balances.find_by(date: expected.date)
assert_not_nil persisted, "Balance for #{expected.date} should be persisted"
# Check all balance component fields
assert_equal expected.balance, persisted.balance
assert_equal expected.cash_balance, persisted.cash_balance
assert_equal expected.start_cash_balance, persisted.start_cash_balance
assert_equal expected.start_non_cash_balance, persisted.start_non_cash_balance
assert_equal expected.cash_inflows, persisted.cash_inflows
assert_equal expected.cash_outflows, persisted.cash_outflows
assert_equal expected.non_cash_inflows, persisted.non_cash_inflows
assert_equal expected.non_cash_outflows, persisted.non_cash_outflows
assert_equal expected.net_market_flows, persisted.net_market_flows
assert_equal expected.cash_adjustments, persisted.cash_adjustments
assert_equal expected.non_cash_adjustments, persisted.non_cash_adjustments
assert_equal expected.flows_factor, persisted.flows_factor
end
end
end

View file

@ -16,8 +16,14 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 20000, cash_balance: 20000 } ]
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
}
]
)
end
@ -47,12 +53,42 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
# a 100% full entries history.
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current anchor
[ 1.day.ago, { balance: 20000, cash_balance: 20000 } ],
[ 2.days.ago, { balance: 20000, cash_balance: 20000 } ],
[ 3.days.ago, { balance: 20000, cash_balance: 20000 } ],
[ 4.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
}, # Current anchor
{
date: 1.day.ago,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
},
{
date: 3.days.ago,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
},
{
date: 4.days.ago,
legacy_balances: { balance: 15000, cash_balance: 15000 },
balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 },
flows: 0,
adjustments: 0
} # Opening anchor
]
)
end
@ -75,9 +111,21 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 20000, cash_balance: 10000 } ], # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value
[ 1.day.ago, { balance: 15000, cash_balance: 5000 } ] # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 10000 },
balances: { start: 20000, start_cash: 10000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 },
flows: { market_flows: 0 },
adjustments: 0
}, # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value
{
date: 1.day.ago,
legacy_balances: { balance: 15000, cash_balance: 5000 },
balances: { start: 15000, start_cash: 5000, start_non_cash: 10000, end_cash: 5000, end_non_cash: 10000, end: 15000 },
flows: { market_flows: 0 },
adjustments: 0
} # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value
]
)
end
@ -87,8 +135,8 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
entries: [
{ type: "current_anchor", date: Date.current, balance: 20000 },
{ type: "transaction", date: 4.days.ago, amount: -500 }, # income
{ type: "transaction", date: 2.days.ago, amount: 100 } # expense
{ type: "transaction", date: 2.days.ago, amount: 100 }, # expense
{ type: "transaction", date: 4.days.ago, amount: -500 } # income
]
)
@ -96,13 +144,49 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current balance
[ 1.day.ago, { balance: 20000, cash_balance: 20000 } ], # No change
[ 2.days.ago, { balance: 20000, cash_balance: 20000 } ], # After expense (+100)
[ 3.days.ago, { balance: 20100, cash_balance: 20100 } ], # Before expense
[ 4.days.ago, { balance: 20100, cash_balance: 20100 } ], # After income (-500)
[ 5.days.ago, { balance: 19600, cash_balance: 19600 } ] # After income (-500)
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
}, # Current balance
{
date: 1.day.ago,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: 0,
adjustments: 0
}, # No change
{
date: 2.days.ago,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: { cash_inflows: 0, cash_outflows: 100 },
adjustments: 0
}, # After expense (+100)
{
date: 3.days.ago,
legacy_balances: { balance: 20100, cash_balance: 20100 },
balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 },
flows: 0,
adjustments: 0
}, # Before expense
{
date: 4.days.ago,
legacy_balances: { balance: 20100, cash_balance: 20100 },
balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
}, # After income (-500)
{
date: 5.days.ago,
legacy_balances: { balance: 19600, cash_balance: 19600 },
balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 19600, end_non_cash: 0, end: 19600 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
} # After income (-500)
]
)
end
@ -122,13 +206,49 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
# Reversed order: showing how we work backwards
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 2000, cash_balance: 2000 } ], # Current balance
[ 1.day.ago, { balance: 2000, cash_balance: 2000 } ], # No change
[ 2.days.ago, { balance: 2000, cash_balance: 2000 } ], # After expense (+100)
[ 3.days.ago, { balance: 1900, cash_balance: 1900 } ], # Before expense
[ 4.days.ago, { balance: 1900, cash_balance: 1900 } ], # After CC payment (-500)
[ 5.days.ago, { balance: 2400, cash_balance: 2400 } ]
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 2000, cash_balance: 2000 },
balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },
flows: 0,
adjustments: 0
}, # Current balance
{
date: 1.day.ago,
legacy_balances: { balance: 2000, cash_balance: 2000 },
balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },
flows: 0,
adjustments: 0
}, # No change
{
date: 2.days.ago,
legacy_balances: { balance: 2000, cash_balance: 2000 },
balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },
flows: { cash_inflows: 0, cash_outflows: 100 },
adjustments: 0
}, # After expense (+100)
{
date: 3.days.ago,
legacy_balances: { balance: 1900, cash_balance: 1900 },
balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 },
flows: 0,
adjustments: 0
}, # Before expense
{
date: 4.days.ago,
legacy_balances: { balance: 1900, cash_balance: 1900 },
balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
}, # After CC payment (-500)
{
date: 5.days.ago,
legacy_balances: { balance: 2400, cash_balance: 2400 },
balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 2400, end_non_cash: 0, end: 2400 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
}
]
)
end
@ -150,10 +270,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 198000, cash_balance: 0 } ],
[ 1.day.ago, { balance: 198000, cash_balance: 0 } ],
[ 2.days.ago, { balance: 200000, cash_balance: 0 } ]
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 198000, cash_balance: 0 },
balances: { start: 198000, start_cash: 0, start_non_cash: 198000, end_cash: 0, end_non_cash: 198000, end: 198000 },
flows: 0,
adjustments: 0
},
{
date: 1.day.ago,
legacy_balances: { balance: 198000, cash_balance: 0 },
balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 198000, end: 198000 },
flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 },
adjustments: 0
},
{
date: 2.days.ago,
legacy_balances: { balance: 200000, cash_balance: 0 },
balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 200000, end: 200000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
}
]
)
end
@ -174,10 +312,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 1000, cash_balance: 0 } ],
[ 1.day.ago, { balance: 1000, cash_balance: 0 } ],
[ 2.days.ago, { balance: 1000, cash_balance: 0 } ]
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 1000, cash_balance: 0 },
balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
flows: 0,
adjustments: 0
},
{
date: 1.day.ago,
legacy_balances: { balance: 1000, cash_balance: 0 },
balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
flows: 0,
adjustments: 0
},
{
date: 2.days.ago,
legacy_balances: { balance: 1000, cash_balance: 0 },
balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
flows: 0,
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
}
]
)
end
@ -206,10 +362,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings (anchor)
[ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After trade: $19k cash + $1k holdings
[ 2.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ] # At first, account is 100% cash, no holdings (no trades)
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { market_flows: 0 },
adjustments: 0
}, # Current: $19k cash + $1k holdings (anchor)
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 },
adjustments: 0
}, # After trade: $19k cash + $1k holdings
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 20000 },
balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
flows: { market_flows: 0 },
adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
} # At first, account is 100% cash, no holdings (no trades)
]
)
end
@ -240,10 +414,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
[ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL)
[ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After AAPL trade: $19k cash + $1k holdings
[ 2.days.ago.to_date, { balance: 20000, cash_balance: 19500 } ] # Before AAPL trade: $19.5k cash + $500 MSFT
expected_data: [
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { market_flows: 0 },
adjustments: 0
}, # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL)
{
date: 1.day.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { cash_inflows: 0, cash_outflows: 500, non_cash_inflows: 500, non_cash_outflows: 0, market_flows: 0 },
adjustments: 0
}, # After AAPL trade: $19k cash + $1k holdings
{
date: 2.days.ago.to_date,
legacy_balances: { balance: 20000, cash_balance: 19500 },
balances: { start: 19500, start_cash: 19500, start_non_cash: 0, end_cash: 19500, end_non_cash: 500, end: 20000 },
flows: { market_flows: -500 },
adjustments: 0
} # Before AAPL trade: $19.5k cash + $500 MSFT
]
)
end
@ -258,8 +450,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
],
holdings: [
# Create holdings that differ in value from provider ($2,000 vs. the $1,000 reported by provider)
{ date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 2000 },
{ date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 2000 }
{ date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
{ date: 2.days.ago, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }
]
)
@ -267,12 +460,30 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
expected_balances: [
expected_data: [
# No matter what, we force current day equal to the "anchor" balance (what provider gave us), and let "cash" float based on holdings value
# This ensures the user sees the same top-line number reported by the provider (even if it creates a discrepancy in the cash balance)
[ Date.current, { balance: 20000, cash_balance: 18000 } ],
[ 1.day.ago, { balance: 20000, cash_balance: 18000 } ],
[ 2.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor sets absolute balance
{
date: Date.current,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { market_flows: 0 },
adjustments: 0
},
{
date: 1.day.ago,
legacy_balances: { balance: 20000, cash_balance: 19000 },
balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
flows: { market_flows: 0 },
adjustments: 0
},
{
date: 2.days.ago,
legacy_balances: { balance: 15000, cash_balance: 14000 },
balances: { start: 15000, start_cash: 14000, start_non_cash: 1000, end_cash: 14000, end_non_cash: 1000, end: 15000 },
flows: { market_flows: 0 },
adjustments: 0
} # Opening anchor sets absolute balance
]
)
end

View file

@ -0,0 +1,88 @@
require "test_helper"
class BudgetTest < ActiveSupport::TestCase
setup do
@family = families(:empty)
end
test "budget_date_valid? allows going back 2 years even without entries" do
two_years_ago = 2.years.ago.beginning_of_month
assert Budget.budget_date_valid?(two_years_ago, family: @family)
end
test "budget_date_valid? allows going back to earliest entry date if more than 2 years ago" do
# Create an entry 3 years ago
old_account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Old Account",
status: "active",
currency: "USD",
balance: 1000
)
old_entry = Entry.create!(
account: old_account,
entryable: Transaction.new(category: categories(:income)),
date: 3.years.ago,
name: "Old Transaction",
amount: 100,
currency: "USD"
)
# Should allow going back to the old entry date
assert Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family)
end
test "budget_date_valid? does not allow dates before earliest entry or 2 years ago" do
# Create an entry 1 year ago
account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Test Account",
status: "active",
currency: "USD",
balance: 500
)
Entry.create!(
account: account,
entryable: Transaction.new(category: categories(:income)),
date: 1.year.ago,
name: "Recent Transaction",
amount: 100,
currency: "USD"
)
# Should not allow going back more than 2 years
refute Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family)
end
test "budget_date_valid? does not allow future dates beyond current month" do
refute Budget.budget_date_valid?(2.months.from_now, family: @family)
end
test "previous_budget_param returns nil when date is too old" do
# Create a budget at the oldest allowed date
two_years_ago = 2.years.ago.beginning_of_month
budget = Budget.create!(
family: @family,
start_date: two_years_ago,
end_date: two_years_ago.end_of_month,
currency: "USD"
)
assert_nil budget.previous_budget_param
end
test "previous_budget_param returns param when date is valid" do
budget = Budget.create!(
family: @family,
start_date: Date.current.beginning_of_month,
end_date: Date.current.end_of_month,
currency: "USD"
)
assert_not_nil budget.previous_budget_param
end
end

View file

@ -0,0 +1,115 @@
require "test_helper"
class Family::DataExporterTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@other_family = families(:empty)
@exporter = Family::DataExporter.new(@family)
# Create some test data for the family
@account = @family.accounts.create!(
name: "Test Account",
accountable: Depository.new,
balance: 1000,
currency: "USD"
)
@category = @family.categories.create!(
name: "Test Category",
color: "#FF0000"
)
@tag = @family.tags.create!(
name: "Test Tag",
color: "#00FF00"
)
end
test "generates a zip file with all required files" do
zip_data = @exporter.generate_export
assert zip_data.is_a?(StringIO)
# Check that the zip contains all expected files
expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "all.ndjson" ]
Zip::File.open_buffer(zip_data) do |zip|
actual_files = zip.entries.map(&:name)
assert_equal expected_files.sort, actual_files.sort
end
end
test "generates valid CSV files" do
zip_data = @exporter.generate_export
Zip::File.open_buffer(zip_data) do |zip|
# Check accounts.csv
accounts_csv = zip.read("accounts.csv")
assert accounts_csv.include?("id,name,type,subtype,balance,currency,created_at")
# Check transactions.csv
transactions_csv = zip.read("transactions.csv")
assert transactions_csv.include?("date,account_name,amount,name,category,tags,notes,currency")
# Check trades.csv
trades_csv = zip.read("trades.csv")
assert trades_csv.include?("date,account_name,ticker,quantity,price,amount,currency")
# Check categories.csv
categories_csv = zip.read("categories.csv")
assert categories_csv.include?("name,color,parent_category,classification")
end
end
test "generates valid NDJSON file" do
zip_data = @exporter.generate_export
Zip::File.open_buffer(zip_data) do |zip|
ndjson_content = zip.read("all.ndjson")
lines = ndjson_content.split("\n")
lines.each do |line|
assert_nothing_raised { JSON.parse(line) }
end
# Check that each line has expected structure
first_line = JSON.parse(lines.first)
assert first_line.key?("type")
assert first_line.key?("data")
end
end
test "only exports data from the specified family" do
# Create data for another family that should NOT be exported
other_account = @other_family.accounts.create!(
name: "Other Family Account",
accountable: Depository.new,
balance: 5000,
currency: "USD"
)
other_category = @other_family.categories.create!(
name: "Other Family Category",
color: "#0000FF"
)
zip_data = @exporter.generate_export
Zip::File.open_buffer(zip_data) do |zip|
# Check accounts.csv doesn't contain other family's data
accounts_csv = zip.read("accounts.csv")
assert accounts_csv.include?(@account.name)
refute accounts_csv.include?(other_account.name)
# Check categories.csv doesn't contain other family's data
categories_csv = zip.read("categories.csv")
assert categories_csv.include?(@category.name)
refute categories_csv.include?(other_category.name)
# Check NDJSON doesn't contain other family's data
ndjson_content = zip.read("all.ndjson")
refute ndjson_content.include?(other_account.id)
refute ndjson_content.include?(other_category.id)
end
end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class FamilyExportTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,58 @@
require "test_helper"
class NoopApiRateLimiterTest < ActiveSupport::TestCase
setup do
@user = users(:family_admin)
# Clean up any existing API keys for this user to ensure tests start fresh
@user.api_keys.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Noop Rate Limiter Test Key",
scopes: [ "read" ],
display_key: "noop_rate_limiter_test_#{SecureRandom.hex(8)}"
)
@rate_limiter = NoopApiRateLimiter.new(@api_key)
end
test "should never be rate limited" do
assert_not @rate_limiter.rate_limit_exceeded?
end
test "should not increment request count" do
@rate_limiter.increment_request_count!
assert_equal 0, @rate_limiter.current_count
end
test "should always have zero request count" do
assert_equal 0, @rate_limiter.current_count
end
test "should have infinite rate limit" do
assert_equal Float::INFINITY, @rate_limiter.rate_limit
end
test "should have zero reset time" do
assert_equal 0, @rate_limiter.reset_time
end
test "should provide correct usage info" do
usage_info = @rate_limiter.usage_info
assert_equal 0, usage_info[:current_count]
assert_equal Float::INFINITY, usage_info[:rate_limit]
assert_equal Float::INFINITY, usage_info[:remaining]
assert_equal 0, usage_info[:reset_time]
assert_equal :noop, usage_info[:tier]
end
test "class method usage_for should work" do
usage_info = NoopApiRateLimiter.usage_for(@api_key)
assert_equal 0, usage_info[:current_count]
assert_equal Float::INFINITY, usage_info[:rate_limit]
assert_equal Float::INFINITY, usage_info[:remaining]
assert_equal 0, usage_info[:reset_time]
assert_equal :noop, usage_info[:tier]
end
end

View file

@ -0,0 +1,72 @@
module BalanceTestHelper
def create_balance(account:, date:, balance:, cash_balance: nil, **attributes)
# If cash_balance is not provided, default to entire balance being cash
cash_balance ||= balance
# Calculate non-cash balance
non_cash_balance = balance - cash_balance
# Set default component values that will generate the desired end_balance
# flows_factor should be 1 for assets, -1 for liabilities
flows_factor = account.classification == "liability" ? -1 : 1
defaults = {
date: date,
balance: balance,
cash_balance: cash_balance,
currency: account.currency,
start_cash_balance: cash_balance,
start_non_cash_balance: non_cash_balance,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: flows_factor
}
account.balances.create!(defaults.merge(attributes))
end
def create_balance_with_flows(account:, date:, start_balance:, end_balance:,
cash_portion: 1.0, cash_flow: 0, non_cash_flow: 0,
market_flow: 0, **attributes)
# Calculate cash and non-cash portions
start_cash = start_balance * cash_portion
start_non_cash = start_balance * (1 - cash_portion)
# Calculate adjustments needed to reach end_balance
expected_end_cash = start_cash + cash_flow
expected_end_non_cash = start_non_cash + non_cash_flow + market_flow
expected_total = expected_end_cash + expected_end_non_cash
# Calculate adjustments if end_balance doesn't match expected
total_adjustment = end_balance - expected_total
cash_adjustment = cash_portion * total_adjustment
non_cash_adjustment = (1 - cash_portion) * total_adjustment
# flows_factor should be 1 for assets, -1 for liabilities
flows_factor = account.classification == "liability" ? -1 : 1
defaults = {
date: date,
balance: end_balance,
cash_balance: expected_end_cash + cash_adjustment,
currency: account.currency,
start_cash_balance: start_cash,
start_non_cash_balance: start_non_cash,
cash_inflows: cash_flow > 0 ? cash_flow : 0,
cash_outflows: cash_flow < 0 ? -cash_flow : 0,
non_cash_inflows: non_cash_flow > 0 ? non_cash_flow : 0,
non_cash_outflows: non_cash_flow < 0 ? -non_cash_flow : 0,
net_market_flows: market_flow,
cash_adjustments: cash_adjustment,
non_cash_adjustments: non_cash_adjustment,
flows_factor: flows_factor
}
account.balances.create!(defaults.merge(attributes))
end
end

View file

@ -12,6 +12,8 @@ module LedgerTestingHelper
created_account = families(:empty).accounts.create!(
name: "Test Account",
accountable: account_type.new,
balance: account[:balance] || 0, # Doesn't matter, ledger derives this
cash_balance: account[:cash_balance] || 0, # Doesn't matter, ledger derives this
**account_attrs
)
@ -109,13 +111,20 @@ module LedgerTestingHelper
created_account
end
def assert_calculated_ledger_balances(calculated_data:, expected_balances:)
# Convert expected balances to a hash for easier lookup
expected_hash = expected_balances.to_h do |date, balance_data|
[ date.to_date, balance_data ]
def assert_calculated_ledger_balances(calculated_data:, expected_data:)
# Convert expected data to a hash for easier lookup
# Structure: [ { date:, legacy_balances: { balance:, cash_balance: }, balances: { start:, start_cash:, etc... }, flows: { ... }, adjustments: { ... } } ]
expected_hash = {}
expected_data.each do |data|
expected_hash[data[:date].to_date] = {
legacy_balances: data[:legacy_balances] || {},
balances: data[:balances] || {},
flows: data[:flows] || {},
adjustments: data[:adjustments] || {}
}
end
# Get all unique dates from both calculated and expected data
# Get all unique dates from all data sources
all_dates = (calculated_data.map(&:date) + expected_hash.keys).uniq.sort
# Check each date
@ -126,15 +135,163 @@ module LedgerTestingHelper
if expected
assert calculated_balance, "Expected balance for #{date} but none was calculated"
if expected[:balance]
assert_equal expected[:balance], calculated_balance.balance.to_d,
"Balance mismatch for #{date}"
end
# Always assert flows_factor is correct based on account classification
expected_flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
assert_equal expected_flows_factor, calculated_balance.flows_factor,
"Flows factor mismatch for #{date}: expected #{expected_flows_factor} for #{calculated_balance.account.classification} account"
if expected[:cash_balance]
assert_equal expected[:cash_balance], calculated_balance.cash_balance.to_d,
legacy_balances = expected[:legacy_balances]
balances = expected[:balances]
flows = expected[:flows]
adjustments = expected[:adjustments]
# Legacy balance assertions
if legacy_balances.any?
assert_equal legacy_balances[:balance], calculated_balance.balance,
"Balance mismatch for #{date}"
assert_equal legacy_balances[:cash_balance], calculated_balance.cash_balance,
"Cash balance mismatch for #{date}"
end
# Balance assertions
if balances.any?
assert_equal balances[:start_cash], calculated_balance.start_cash_balance,
"Start cash balance mismatch for #{date}" if balances.key?(:start_cash)
assert_equal balances[:start_non_cash], calculated_balance.start_non_cash_balance,
"Start non-cash balance mismatch for #{date}" if balances.key?(:start_non_cash)
# Calculate end_cash_balance using the formula from the migration
if balances.key?(:end_cash)
# Determine flows_factor based on account classification
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_cash = calculated_balance.start_cash_balance +
((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
calculated_balance.cash_adjustments
assert_equal balances[:end_cash], expected_end_cash,
"End cash balance mismatch for #{date}"
end
# Calculate end_non_cash_balance using the formula from the migration
if balances.key?(:end_non_cash)
# Determine flows_factor based on account classification
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_non_cash = calculated_balance.start_non_cash_balance +
((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
calculated_balance.net_market_flows +
calculated_balance.non_cash_adjustments
assert_equal balances[:end_non_cash], expected_end_non_cash,
"End non-cash balance mismatch for #{date}"
end
# Calculate start_balance using the formula from the migration
if balances.key?(:start)
expected_start = calculated_balance.start_cash_balance + calculated_balance.start_non_cash_balance
assert_equal balances[:start], expected_start,
"Start balance mismatch for #{date}"
end
# Calculate end_balance using the formula from the migration since we're not persisting balances,
# and generated columns are not available until the record is persisted
if balances.key?(:end)
# Determine flows_factor based on account classification
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_cash_component = calculated_balance.start_cash_balance +
((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
calculated_balance.cash_adjustments
expected_end_non_cash_component = calculated_balance.start_non_cash_balance +
((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
calculated_balance.net_market_flows +
calculated_balance.non_cash_adjustments
expected_end = expected_end_cash_component + expected_end_non_cash_component
assert_equal balances[:end], expected_end,
"End balance mismatch for #{date}"
end
end
# Flow assertions
# If flows passed is 0, we assert all columns are 0
if flows.is_a?(Integer) && flows == 0
assert_equal 0, calculated_balance.cash_inflows,
"Cash inflows mismatch for #{date}"
assert_equal 0, calculated_balance.cash_outflows,
"Cash outflows mismatch for #{date}"
assert_equal 0, calculated_balance.non_cash_inflows,
"Non-cash inflows mismatch for #{date}"
assert_equal 0, calculated_balance.non_cash_outflows,
"Non-cash outflows mismatch for #{date}"
assert_equal 0, calculated_balance.net_market_flows,
"Net market flows mismatch for #{date}"
elsif flows.is_a?(Hash) && flows.any?
# Cash flows - must be asserted together
if flows.key?(:cash_inflows) || flows.key?(:cash_outflows)
assert flows.key?(:cash_inflows) && flows.key?(:cash_outflows),
"Cash inflows and outflows must be asserted together for #{date}"
assert_equal flows[:cash_inflows], calculated_balance.cash_inflows,
"Cash inflows mismatch for #{date}"
assert_equal flows[:cash_outflows], calculated_balance.cash_outflows,
"Cash outflows mismatch for #{date}"
end
# Non-cash flows - must be asserted together
if flows.key?(:non_cash_inflows) || flows.key?(:non_cash_outflows)
assert flows.key?(:non_cash_inflows) && flows.key?(:non_cash_outflows),
"Non-cash inflows and outflows must be asserted together for #{date}"
assert_equal flows[:non_cash_inflows], calculated_balance.non_cash_inflows,
"Non-cash inflows mismatch for #{date}"
assert_equal flows[:non_cash_outflows], calculated_balance.non_cash_outflows,
"Non-cash outflows mismatch for #{date}"
end
# Market flows - can be asserted independently
if flows.key?(:net_market_flows)
assert_equal flows[:net_market_flows], calculated_balance.net_market_flows,
"Net market flows mismatch for #{date}"
end
end
# Adjustment assertions
if adjustments.is_a?(Integer) && adjustments == 0
assert_equal 0, calculated_balance.cash_adjustments,
"Cash adjustments mismatch for #{date}"
assert_equal 0, calculated_balance.non_cash_adjustments,
"Non-cash adjustments mismatch for #{date}"
elsif adjustments.is_a?(Hash) && adjustments.any?
assert_equal adjustments[:cash_adjustments], calculated_balance.cash_adjustments,
"Cash adjustments mismatch for #{date}" if adjustments.key?(:cash_adjustments)
assert_equal adjustments[:non_cash_adjustments], calculated_balance.non_cash_adjustments,
"Non-cash adjustments mismatch for #{date}" if adjustments.key?(:non_cash_adjustments)
end
# Temporary assertions during migration (remove after migration complete)
# TODO: Remove these assertions after migration is complete
# Since we're not persisting balances, we calculate the end values
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_cash = calculated_balance.start_cash_balance +
((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
calculated_balance.cash_adjustments
expected_end_balance = expected_end_cash +
calculated_balance.start_non_cash_balance +
((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
calculated_balance.net_market_flows +
calculated_balance.non_cash_adjustments
assert_equal calculated_balance.cash_balance, expected_end_cash,
"Temporary assertion failed: end_cash_balance should equal cash_balance for #{date}"
assert_equal calculated_balance.balance, expected_end_balance,
"Temporary assertion failed: end_balance should equal balance for #{date}"
else
assert_nil calculated_balance, "Unexpected balance calculated for #{date}"
end

View file

@ -34,6 +34,7 @@ class TransactionsTest < ApplicationSystemTestCase
within "form#transactions-search" do
fill_in "Search transactions ...", with: @transaction.name
find("#q_search").send_keys(:tab) # Trigger blur to submit form
end
assert_selector "#" + dom_id(@transaction), count: 1