Compare commits
19 Commits
zachgoll/e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b5469832 | ||
|
|
a90899668f | ||
|
|
fd9ba8c1b9 | ||
|
|
a2cfa0356f | ||
|
|
224f21354a | ||
|
|
3fb379d140 | ||
|
|
d90d35d97b | ||
|
|
5baf258a32 | ||
|
|
bacab94a1b | ||
|
|
7698ec03b9 | ||
|
|
0329a5f211 | ||
|
|
b7c56e2fb7 | ||
|
|
764164cf57 | ||
|
|
ef49268278 | ||
|
|
527a6128b6 | ||
|
|
32ec57146e | ||
|
|
f7f6ebb091 | ||
|
|
3f92fe0f6f | ||
|
|
da2045dbd8 |
1
Gemfile
1
Gemfile
@@ -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"
|
||||
|
||||
@@ -672,6 +672,7 @@ DEPENDENCIES
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
ruby-openai
|
||||
rubyzip (~> 2.3)
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
|
||||
57
README.md
57
README.md
@@ -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. You’re 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
|
||||
|
||||

|
||||
|
||||
## Copyright & license
|
||||
|
||||
Maybe is distributed under
|
||||
|
||||
@@ -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">
|
||||
Δ Cash
|
||||
<%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd><%= cash_change_money.format %></dd>
|
||||
</dl>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ Holdings
|
||||
<%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd><%= holdings_change_money.format %></dd>
|
||||
</dl>
|
||||
<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">
|
||||
Δ 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">
|
||||
Δ 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>
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
22
app/components/UI/account/balance_reconciliation.html.erb
Normal file
22
app/components/UI/account/balance_reconciliation.html.erb
Normal file
@@ -0,0 +1,22 @@
|
||||
<div class="space-y-3">
|
||||
<% reconciliation_items.each_with_index do |item, index| %>
|
||||
<% if item[:style] == :subtotal %>
|
||||
<hr class="border border-primary">
|
||||
<% end %>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
<%= item[:label] %>
|
||||
<%= render DS::Tooltip.new(text: item[:tooltip], placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed <%= item[:style] == :final ? "border-primary" : "border-secondary" %>">
|
||||
<dd class="<%= item[:style] == :start || item[:style] == :final ? "font-bold" : item[:style] == :subtotal ? "font-medium" : "" %>">
|
||||
<%= item[:value].format %>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<% if item[:style] == :adjustment %>
|
||||
<hr class="border border-primary">
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
155
app/components/UI/account/balance_reconciliation.rb
Normal file
155
app/components/UI/account/balance_reconciliation.rb
Normal file
@@ -0,0 +1,155 @@
|
||||
class UI::Account::BalanceReconciliation < ApplicationComponent
|
||||
attr_reader :balance, :account
|
||||
|
||||
def initialize(balance:, account:)
|
||||
@balance = balance
|
||||
@account = account
|
||||
end
|
||||
|
||||
def reconciliation_items
|
||||
case account.accountable_type
|
||||
when "Depository", "OtherAsset", "OtherLiability"
|
||||
default_items
|
||||
when "CreditCard"
|
||||
credit_card_items
|
||||
when "Investment"
|
||||
investment_items
|
||||
when "Loan"
|
||||
loan_items
|
||||
when "Property", "Vehicle"
|
||||
asset_items
|
||||
when "Crypto"
|
||||
crypto_items
|
||||
else
|
||||
default_items
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The account balance at the beginning of this day", style: :start },
|
||||
{ label: "Net cash flow", value: net_cash_flow, tooltip: "Net change in balance from all transactions during the day", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final account balance for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def credit_card_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The balance owed at the beginning of this day", style: :start },
|
||||
{ label: "Charges", value: balance.cash_outflows_money, tooltip: "New charges made during the day", style: :flow },
|
||||
{ label: "Payments", value: balance.cash_inflows_money * -1, tooltip: "Payments made to the card during the day", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final balance owed for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def investment_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The total portfolio value at the beginning of this day", style: :start }
|
||||
]
|
||||
|
||||
# Change in brokerage cash (includes deposits, withdrawals, and cash from trades)
|
||||
items << { label: "Change in brokerage cash", value: net_cash_flow, tooltip: "Net change in cash from deposits, withdrawals, and trades", style: :flow }
|
||||
|
||||
# Change in holdings from trading activity
|
||||
items << { label: "Change in holdings (buys/sells)", value: net_non_cash_flow, tooltip: "Impact on holdings from buying and selling securities", style: :flow }
|
||||
|
||||
# Market price changes
|
||||
items << { label: "Change in holdings (market price activity)", value: balance.net_market_flows_money, tooltip: "Change in holdings value from market price movements", style: :flow }
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final portfolio value for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def loan_items
|
||||
items = [
|
||||
{ label: "Start principal", value: balance.start_balance_money, tooltip: "The principal balance at the beginning of this day", style: :start },
|
||||
{ label: "Net principal change", value: net_non_cash_flow, tooltip: "Principal payments and new borrowing during the day", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End principal", value: end_balance_before_adjustments, tooltip: "The calculated principal after all transactions", style: :subtotal }
|
||||
items << { label: "Adjustments", value: balance.non_cash_adjustments_money, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final principal", value: balance.end_balance_money, tooltip: "The final principal balance for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def asset_items # Property/Vehicle
|
||||
items = [
|
||||
{ label: "Start value", value: balance.start_balance_money, tooltip: "The asset value at the beginning of this day", style: :start },
|
||||
{ label: "Net value change", value: net_total_flow, tooltip: "All value changes including improvements and depreciation", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End value", value: end_balance_before_adjustments, tooltip: "The calculated value after all changes", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual value adjustments or appraisals", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final value", value: balance.end_balance_money, tooltip: "The final asset value for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def crypto_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The crypto holdings value at the beginning of this day", style: :start }
|
||||
]
|
||||
|
||||
items << { label: "Buys", value: balance.cash_outflows_money * -1, tooltip: "Crypto purchases during the day", style: :flow } if balance.cash_outflows != 0
|
||||
items << { label: "Sells", value: balance.cash_inflows_money, tooltip: "Crypto sales during the day", style: :flow } if balance.cash_inflows != 0
|
||||
items << { label: "Market changes", value: balance.net_market_flows_money, tooltip: "Value changes from market price movements", style: :flow } if balance.net_market_flows != 0
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final crypto holdings value for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def net_cash_flow
|
||||
balance.cash_inflows_money - balance.cash_outflows_money
|
||||
end
|
||||
|
||||
def net_non_cash_flow
|
||||
balance.non_cash_inflows_money - balance.non_cash_outflows_money
|
||||
end
|
||||
|
||||
def net_total_flow
|
||||
net_cash_flow + net_non_cash_flow + balance.net_market_flows_money
|
||||
end
|
||||
|
||||
def total_adjustments
|
||||
balance.cash_adjustments_money + balance.non_cash_adjustments_money
|
||||
end
|
||||
|
||||
def has_adjustments?
|
||||
balance.cash_adjustments != 0 || balance.non_cash_adjustments != 0
|
||||
end
|
||||
|
||||
def end_balance_before_adjustments
|
||||
balance.end_balance_money - total_adjustments
|
||||
end
|
||||
end
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
47
app/controllers/family_exports_controller.rb
Normal file
47
app/controllers/family_exports_controller.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class FamilyExportsController < ApplicationController
|
||||
include StreamExtensions
|
||||
|
||||
before_action :require_admin
|
||||
before_action :set_export, only: [ :download ]
|
||||
|
||||
def new
|
||||
# Modal view for initiating export
|
||||
end
|
||||
|
||||
def create
|
||||
@export = Current.family.family_exports.create!
|
||||
FamilyDataExportJob.perform_later(@export)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." }
|
||||
format.turbo_stream {
|
||||
stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly."
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
@exports = Current.family.family_exports.ordered.limit(10)
|
||||
render layout: false # For turbo frame
|
||||
end
|
||||
|
||||
def download
|
||||
if @export.downloadable?
|
||||
redirect_to @export.export_file, allow_other_host: true
|
||||
else
|
||||
redirect_to settings_profile_path, alert: "Export not ready for download"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_export
|
||||
@export = Current.family.family_exports.find(params[:id])
|
||||
end
|
||||
|
||||
def require_admin
|
||||
unless Current.user.admin?
|
||||
redirect_to root_path, alert: "Access denied"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -5,19 +5,19 @@ export default class extends Controller {
|
||||
// will trigger a form submission when the configured event is triggered.
|
||||
static targets = ["auto"];
|
||||
static values = {
|
||||
triggerEvent: { type: String },
|
||||
triggerEvent: { type: String, default: "input" },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.autoTargets.forEach((element) => {
|
||||
const event = this.#getEventForElement(element);
|
||||
const event = this.#getTriggerEvent(element);
|
||||
element.addEventListener(event, this.handleInput);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.autoTargets.forEach((element) => {
|
||||
const event = this.#getEventForElement(element);
|
||||
const event = this.#getTriggerEvent(element);
|
||||
element.removeEventListener(event, this.handleInput);
|
||||
});
|
||||
}
|
||||
@@ -31,43 +31,45 @@ export default class extends Controller {
|
||||
}, this.#debounceTimeout(target));
|
||||
};
|
||||
|
||||
#getEventForElement(element) {
|
||||
// Check for explicitly set event first
|
||||
#getTriggerEvent(element) {
|
||||
// Check if element has explicit trigger event set
|
||||
if (element.dataset.autosubmitTriggerEvent) {
|
||||
return element.dataset.autosubmitTriggerEvent;
|
||||
}
|
||||
|
||||
// Check form-level trigger event value
|
||||
if (this.triggerEventValue) {
|
||||
|
||||
// Check if form has explicit trigger event set
|
||||
if (this.triggerEventValue !== "input") {
|
||||
return this.triggerEventValue;
|
||||
}
|
||||
|
||||
// Determine event based on input type
|
||||
const type = element.type || element.tagName.toLowerCase();
|
||||
// Otherwise, choose trigger event based on element type
|
||||
const type = element.type || element.tagName;
|
||||
|
||||
switch (type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "password":
|
||||
case "search":
|
||||
case "tel":
|
||||
case "url":
|
||||
case "number":
|
||||
case "textarea":
|
||||
return "blur";
|
||||
case "select-one":
|
||||
case "select-multiple":
|
||||
case "checkbox":
|
||||
case "radio":
|
||||
return "change";
|
||||
case "number":
|
||||
case "date":
|
||||
case "datetime-local":
|
||||
case "month":
|
||||
case "time":
|
||||
case "week":
|
||||
case "color":
|
||||
case "range":
|
||||
return "change";
|
||||
case "checkbox":
|
||||
case "radio":
|
||||
case "select":
|
||||
case "select-one":
|
||||
case "select-multiple":
|
||||
return "change";
|
||||
case "range":
|
||||
return "input";
|
||||
default:
|
||||
return "blur";
|
||||
}
|
||||
|
||||
22
app/jobs/family_data_export_job.rb
Normal file
22
app/jobs/family_data_export_job.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class FamilyDataExportJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(family_export)
|
||||
family_export.update!(status: :processing)
|
||||
|
||||
exporter = Family::DataExporter.new(family_export.family)
|
||||
zip_file = exporter.generate_export
|
||||
|
||||
family_export.export_file.attach(
|
||||
io: zip_file,
|
||||
filename: family_export.filename,
|
||||
content_type: "application/zip"
|
||||
)
|
||||
|
||||
family_export.update!(status: :completed)
|
||||
rescue => e
|
||||
Rails.logger.error "Family export failed: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
family_export.update!(status: :failed)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,4 +14,18 @@ class Balance < ApplicationRecord
|
||||
|
||||
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
|
||||
|
||||
@@ -29,16 +29,16 @@ class Balance::BaseCalculator
|
||||
end
|
||||
end
|
||||
|
||||
def cash_adjustments_for_date(start_cash, net_cash_flows, valuation)
|
||||
return 0 unless valuation && account.balance_type != :non_cash
|
||||
def cash_adjustments_for_date(start_cash, end_cash, net_cash_flows)
|
||||
return 0 unless account.balance_type != :non_cash
|
||||
|
||||
valuation.amount - start_cash - net_cash_flows
|
||||
end_cash - start_cash - net_cash_flows
|
||||
end
|
||||
|
||||
def non_cash_adjustments_for_date(start_non_cash, non_cash_flows, valuation)
|
||||
return 0 unless valuation && account.balance_type == :non_cash
|
||||
def non_cash_adjustments_for_date(start_non_cash, end_non_cash, non_cash_flows)
|
||||
return 0 unless account.balance_type == :non_cash
|
||||
|
||||
valuation.amount - start_non_cash - non_cash_flows
|
||||
end_non_cash - start_non_cash - non_cash_flows
|
||||
end
|
||||
|
||||
# If holdings value goes from $100 -> $200 (change_holdings_value is $100)
|
||||
@@ -133,7 +133,8 @@ class Balance::BaseCalculator
|
||||
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
|
||||
net_market_flows: args[:net_market_flows] || 0,
|
||||
flows_factor: account.classification == "asset" ? 1 : -1
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,21 +8,21 @@ class Balance::ChartSeriesBuilder
|
||||
end
|
||||
|
||||
def balance_series
|
||||
build_series_for(:balance)
|
||||
build_series_for(:end_balance)
|
||||
rescue => e
|
||||
Rails.logger.error "Balance series error: #{e.message} for accounts #{@account_ids}"
|
||||
raise
|
||||
end
|
||||
|
||||
def cash_balance_series
|
||||
build_series_for(:cash_balance)
|
||||
build_series_for(:end_cash_balance)
|
||||
rescue => e
|
||||
Rails.logger.error "Cash balance series error: #{e.message} for accounts #{@account_ids}"
|
||||
raise
|
||||
end
|
||||
|
||||
def holdings_balance_series
|
||||
build_series_for(:holdings_balance)
|
||||
build_series_for(:end_holdings_balance)
|
||||
rescue => e
|
||||
Rails.logger.error "Holdings balance series error: #{e.message} for accounts #{@account_ids}"
|
||||
raise
|
||||
@@ -37,13 +37,20 @@ class Balance::ChartSeriesBuilder
|
||||
|
||||
def build_series_for(column)
|
||||
values = query_data.map do |datum|
|
||||
# Map column names to their start equivalents
|
||||
previous_column = case column
|
||||
when :end_balance then :start_balance
|
||||
when :end_cash_balance then :start_cash_balance
|
||||
when :end_holdings_balance then :start_holdings_balance
|
||||
end
|
||||
|
||||
Series::Value.new(
|
||||
date: datum.date,
|
||||
date_formatted: I18n.l(datum.date, format: :long),
|
||||
value: Money.new(datum.send(column), currency),
|
||||
trend: Trend.new(
|
||||
current: Money.new(datum.send(column), currency),
|
||||
previous: Money.new(datum.send("previous_#{column}"), currency),
|
||||
previous: Money.new(datum.send(previous_column), currency),
|
||||
favorable_direction: favorable_direction
|
||||
)
|
||||
)
|
||||
@@ -88,66 +95,57 @@ class Balance::ChartSeriesBuilder
|
||||
WITH dates AS (
|
||||
SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date
|
||||
UNION DISTINCT
|
||||
SELECT :end_date::date -- Pass in date to ensure timezone-aware "today" date
|
||||
), aggregated_balances AS (
|
||||
SELECT
|
||||
d.date,
|
||||
-- Total balance (assets positive, liabilities negative)
|
||||
SUM(
|
||||
CASE WHEN accounts.classification = 'asset'
|
||||
THEN COALESCE(last_bal.balance, 0)
|
||||
ELSE -COALESCE(last_bal.balance, 0)
|
||||
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
|
||||
) AS balance,
|
||||
-- Cash-only balance
|
||||
SUM(
|
||||
CASE WHEN accounts.classification = 'asset'
|
||||
THEN COALESCE(last_bal.cash_balance, 0)
|
||||
ELSE -COALESCE(last_bal.cash_balance, 0)
|
||||
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
|
||||
) AS cash_balance,
|
||||
-- Holdings value (balance ‑ cash)
|
||||
SUM(
|
||||
CASE WHEN accounts.classification = 'asset'
|
||||
THEN COALESCE(last_bal.balance, 0) - COALESCE(last_bal.cash_balance, 0)
|
||||
ELSE 0
|
||||
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
|
||||
) AS holdings_balance
|
||||
FROM dates d
|
||||
JOIN accounts ON accounts.id = ANY(array[:account_ids]::uuid[])
|
||||
|
||||
-- Last observation carried forward (LOCF), use the most recent balance on or before the chart date
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT b.balance, b.cash_balance
|
||||
FROM balances b
|
||||
WHERE b.account_id = accounts.id
|
||||
AND b.date <= d.date
|
||||
ORDER BY b.date DESC
|
||||
LIMIT 1
|
||||
) last_bal ON TRUE
|
||||
|
||||
-- Last observation carried forward (LOCF), use the most recent exchange rate on or before the chart date
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT er.rate
|
||||
FROM exchange_rates er
|
||||
WHERE er.from_currency = accounts.currency
|
||||
AND er.to_currency = :target_currency
|
||||
AND er.date <= d.date
|
||||
ORDER BY er.date DESC
|
||||
LIMIT 1
|
||||
) er ON TRUE
|
||||
GROUP BY d.date
|
||||
SELECT :end_date::date -- Ensure end date is included
|
||||
)
|
||||
SELECT
|
||||
date,
|
||||
balance,
|
||||
cash_balance,
|
||||
holdings_balance,
|
||||
COALESCE(LAG(balance) OVER (ORDER BY date), 0) AS previous_balance,
|
||||
COALESCE(LAG(cash_balance) OVER (ORDER BY date), 0) AS previous_cash_balance,
|
||||
COALESCE(LAG(holdings_balance) OVER (ORDER BY date), 0) AS previous_holdings_balance
|
||||
FROM aggregated_balances
|
||||
ORDER BY date
|
||||
d.date,
|
||||
-- Use flows_factor: already handles asset (+1) vs liability (-1)
|
||||
COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance,
|
||||
COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance,
|
||||
-- Holdings only for assets (flows_factor = 1)
|
||||
COALESCE(SUM(
|
||||
CASE WHEN last_bal.flows_factor = 1
|
||||
THEN last_bal.end_non_cash_balance
|
||||
ELSE 0
|
||||
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
|
||||
), 0) AS end_holdings_balance,
|
||||
-- Previous balances
|
||||
COALESCE(SUM(last_bal.start_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_balance,
|
||||
COALESCE(SUM(last_bal.start_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_cash_balance,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN last_bal.flows_factor = 1
|
||||
THEN last_bal.start_non_cash_balance
|
||||
ELSE 0
|
||||
END * COALESCE(er.rate, 1) * :sign_multiplier::integer
|
||||
), 0) AS start_holdings_balance
|
||||
FROM dates d
|
||||
CROSS JOIN accounts
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT b.end_balance,
|
||||
b.end_cash_balance,
|
||||
b.end_non_cash_balance,
|
||||
b.start_balance,
|
||||
b.start_cash_balance,
|
||||
b.start_non_cash_balance,
|
||||
b.flows_factor
|
||||
FROM balances b
|
||||
WHERE b.account_id = accounts.id
|
||||
AND b.date <= d.date
|
||||
ORDER BY b.date DESC
|
||||
LIMIT 1
|
||||
) last_bal ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT er.rate
|
||||
FROM exchange_rates er
|
||||
WHERE er.from_currency = accounts.currency
|
||||
AND er.to_currency = :target_currency
|
||||
AND er.date <= d.date
|
||||
ORDER BY er.date DESC
|
||||
LIMIT 1
|
||||
) er ON TRUE
|
||||
WHERE accounts.id = ANY(array[:account_ids]::uuid[])
|
||||
GROUP BY d.date
|
||||
ORDER BY d.date
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,10 +2,10 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
|
||||
def calculate
|
||||
Rails.logger.tagged("Balance::ForwardCalculator") do
|
||||
start_cash_balance = derive_cash_balance_on_date_from_total(
|
||||
total_balance: 0,
|
||||
total_balance: account.opening_anchor_balance,
|
||||
date: account.opening_anchor_date
|
||||
)
|
||||
start_non_cash_balance = 0
|
||||
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
|
||||
|
||||
calc_start_date.upto(calc_end_date).map do |date|
|
||||
valuation = sync_cache.get_valuation(date)
|
||||
@@ -24,6 +24,9 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
|
||||
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,
|
||||
@@ -34,8 +37,8 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
|
||||
cash_outflows: flows[:cash_outflows],
|
||||
non_cash_inflows: flows[:non_cash_inflows],
|
||||
non_cash_outflows: flows[:non_cash_outflows],
|
||||
cash_adjustments: cash_adjustments_for_date(start_cash_balance, flows[:cash_inflows] - flows[:cash_outflows], valuation),
|
||||
non_cash_adjustments: non_cash_adjustments_for_date(start_non_cash_balance, flows[:non_cash_inflows] - flows[:non_cash_outflows], valuation),
|
||||
cash_adjustments: cash_adjustments,
|
||||
non_cash_adjustments: non_cash_adjustments,
|
||||
net_market_flows: market_value_change
|
||||
)
|
||||
|
||||
@@ -75,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
238
app/models/family/data_exporter.rb
Normal file
238
app/models/family/data_exporter.rb
Normal file
@@ -0,0 +1,238 @@
|
||||
require "zip"
|
||||
require "csv"
|
||||
|
||||
class Family::DataExporter
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def generate_export
|
||||
# Create a StringIO to hold the zip data in memory
|
||||
zip_data = Zip::OutputStream.write_buffer do |zipfile|
|
||||
# Add accounts.csv
|
||||
zipfile.put_next_entry("accounts.csv")
|
||||
zipfile.write generate_accounts_csv
|
||||
|
||||
# Add transactions.csv
|
||||
zipfile.put_next_entry("transactions.csv")
|
||||
zipfile.write generate_transactions_csv
|
||||
|
||||
# Add trades.csv
|
||||
zipfile.put_next_entry("trades.csv")
|
||||
zipfile.write generate_trades_csv
|
||||
|
||||
# Add categories.csv
|
||||
zipfile.put_next_entry("categories.csv")
|
||||
zipfile.write generate_categories_csv
|
||||
|
||||
# Add all.ndjson
|
||||
zipfile.put_next_entry("all.ndjson")
|
||||
zipfile.write generate_ndjson
|
||||
end
|
||||
|
||||
# Rewind and return the StringIO
|
||||
zip_data.rewind
|
||||
zip_data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_accounts_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "id", "name", "type", "subtype", "balance", "currency", "created_at" ]
|
||||
|
||||
# Only export accounts belonging to this family
|
||||
@family.accounts.includes(:accountable).find_each do |account|
|
||||
csv << [
|
||||
account.id,
|
||||
account.name,
|
||||
account.accountable_type,
|
||||
account.subtype,
|
||||
account.balance.to_s,
|
||||
account.currency,
|
||||
account.created_at.iso8601
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_transactions_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ]
|
||||
|
||||
# Only export transactions from accounts belonging to this family
|
||||
@family.transactions
|
||||
.includes(:category, :tags, entry: :account)
|
||||
.find_each do |transaction|
|
||||
csv << [
|
||||
transaction.entry.date.iso8601,
|
||||
transaction.entry.account.name,
|
||||
transaction.entry.amount.to_s,
|
||||
transaction.entry.name,
|
||||
transaction.category&.name,
|
||||
transaction.tags.pluck(:name).join(","),
|
||||
transaction.entry.notes,
|
||||
transaction.entry.currency
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_trades_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ]
|
||||
|
||||
# Only export trades from accounts belonging to this family
|
||||
@family.trades
|
||||
.includes(:security, entry: :account)
|
||||
.find_each do |trade|
|
||||
csv << [
|
||||
trade.entry.date.iso8601,
|
||||
trade.entry.account.name,
|
||||
trade.security.ticker,
|
||||
trade.qty.to_s,
|
||||
trade.price.to_s,
|
||||
trade.entry.amount.to_s,
|
||||
trade.currency
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_categories_csv
|
||||
CSV.generate do |csv|
|
||||
csv << [ "name", "color", "parent_category", "classification" ]
|
||||
|
||||
# Only export categories belonging to this family
|
||||
@family.categories.includes(:parent).find_each do |category|
|
||||
csv << [
|
||||
category.name,
|
||||
category.color,
|
||||
category.parent&.name,
|
||||
category.classification
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_ndjson
|
||||
lines = []
|
||||
|
||||
# Export accounts with full accountable data
|
||||
@family.accounts.includes(:accountable).find_each do |account|
|
||||
lines << {
|
||||
type: "Account",
|
||||
data: account.as_json(
|
||||
include: {
|
||||
accountable: {}
|
||||
}
|
||||
)
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export categories
|
||||
@family.categories.find_each do |category|
|
||||
lines << {
|
||||
type: "Category",
|
||||
data: category.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export tags
|
||||
@family.tags.find_each do |tag|
|
||||
lines << {
|
||||
type: "Tag",
|
||||
data: tag.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export merchants (only family merchants)
|
||||
@family.merchants.find_each do |merchant|
|
||||
lines << {
|
||||
type: "Merchant",
|
||||
data: merchant.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export transactions with full data
|
||||
@family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction|
|
||||
lines << {
|
||||
type: "Transaction",
|
||||
data: {
|
||||
id: transaction.id,
|
||||
entry_id: transaction.entry.id,
|
||||
account_id: transaction.entry.account_id,
|
||||
date: transaction.entry.date,
|
||||
amount: transaction.entry.amount,
|
||||
currency: transaction.entry.currency,
|
||||
name: transaction.entry.name,
|
||||
notes: transaction.entry.notes,
|
||||
excluded: transaction.entry.excluded,
|
||||
category_id: transaction.category_id,
|
||||
merchant_id: transaction.merchant_id,
|
||||
tag_ids: transaction.tag_ids,
|
||||
kind: transaction.kind,
|
||||
created_at: transaction.created_at,
|
||||
updated_at: transaction.updated_at
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export trades with full data
|
||||
@family.trades.includes(:security, entry: :account).find_each do |trade|
|
||||
lines << {
|
||||
type: "Trade",
|
||||
data: {
|
||||
id: trade.id,
|
||||
entry_id: trade.entry.id,
|
||||
account_id: trade.entry.account_id,
|
||||
security_id: trade.security_id,
|
||||
ticker: trade.security.ticker,
|
||||
date: trade.entry.date,
|
||||
qty: trade.qty,
|
||||
price: trade.price,
|
||||
amount: trade.entry.amount,
|
||||
currency: trade.currency,
|
||||
created_at: trade.created_at,
|
||||
updated_at: trade.updated_at
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export valuations
|
||||
@family.entries.valuations.includes(:account, :entryable).find_each do |entry|
|
||||
lines << {
|
||||
type: "Valuation",
|
||||
data: {
|
||||
id: entry.entryable.id,
|
||||
entry_id: entry.id,
|
||||
account_id: entry.account_id,
|
||||
date: entry.date,
|
||||
amount: entry.amount,
|
||||
currency: entry.currency,
|
||||
name: entry.name,
|
||||
created_at: entry.created_at,
|
||||
updated_at: entry.updated_at
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export budgets
|
||||
@family.budgets.find_each do |budget|
|
||||
lines << {
|
||||
type: "Budget",
|
||||
data: budget.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Export budget categories
|
||||
@family.budget_categories.includes(:budget, :category).find_each do |budget_category|
|
||||
lines << {
|
||||
type: "BudgetCategory",
|
||||
data: budget_category.as_json
|
||||
}.to_json
|
||||
end
|
||||
|
||||
lines.join("\n")
|
||||
end
|
||||
end
|
||||
22
app/models/family_export.rb
Normal file
22
app/models/family_export.rb
Normal 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
|
||||
@@ -88,7 +88,7 @@ class Import < ApplicationRecord
|
||||
entries.destroy_all
|
||||
end
|
||||
|
||||
family.sync
|
||||
family.sync_later
|
||||
|
||||
update! status: :pending
|
||||
rescue => error
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
39
app/services/noop_api_rate_limiter.rb
Normal file
39
app/services/noop_api_rate_limiter.rb
Normal 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
|
||||
@@ -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),
|
||||
|
||||
39
app/views/family_exports/_list.html.erb
Normal file
39
app/views/family_exports/_list.html.erb
Normal 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 %>
|
||||
1
app/views/family_exports/index.html.erb
Normal file
1
app/views/family_exports/index.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= render "list", exports: @exports %>
|
||||
42
app/views/family_exports/new.html.erb
Normal file
42
app/views/family_exports/new.html.erb
Normal 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 %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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| %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
url: valuation_path(entry),
|
||||
method: :patch,
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur" } do |f| %>
|
||||
<%= f.text_area :notes,
|
||||
label: t(".note_label"),
|
||||
placeholder: t(".note_placeholder"),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ module Maybe
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.5.0"
|
||||
"0.6.0"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
10
db/migrate/20250724115507_create_family_exports.rb
Normal file
10
db/migrate/20250724115507_create_family_exports.rb
Normal 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
|
||||
11
db/schema.rb
generated
11
db/schema.rb
generated
@@ -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_19_121103) 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"
|
||||
@@ -270,6 +270,14 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) 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
|
||||
@@ -830,6 +838,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) 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"
|
||||
|
||||
73
test/controllers/family_exports_controller_test.rb
Normal file
73
test/controllers/family_exports_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
3
test/fixtures/family_exports.yml
vendored
Normal file
3
test/fixtures/family_exports.yml
vendored
Normal 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
|
||||
32
test/jobs/family_data_export_job_test.rb
Normal file
32
test/jobs/family_data_export_job_test.rb
Normal 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
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ],
|
||||
|
||||
@@ -117,9 +117,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{
|
||||
date: 3.days.ago.to_date,
|
||||
legacy_balances: { balance: 17000, cash_balance: 17000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
|
||||
balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
@@ -151,9 +151,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{
|
||||
date: 3.days.ago.to_date,
|
||||
legacy_balances: { balance: 17000, cash_balance: 0.0 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 17000, end: 17000 },
|
||||
balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 17000, end: 17000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 0, non_cash_adjustments: 17000 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
@@ -185,9 +185,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{
|
||||
date: 3.days.ago.to_date,
|
||||
legacy_balances: { balance: 17000, cash_balance: 17000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 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: { cash_adjustments: 17000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
@@ -222,9 +222,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{
|
||||
date: 5.days.ago.to_date,
|
||||
legacy_balances: { balance: 20000, cash_balance: 20000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 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: 20000, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 4.days.ago.to_date,
|
||||
@@ -270,9 +270,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{
|
||||
date: 5.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 },
|
||||
balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 4.days.ago.to_date,
|
||||
@@ -318,9 +318,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{
|
||||
date: 4.days.ago.to_date,
|
||||
legacy_balances: { balance: 20000, cash_balance: 20000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 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: 20000, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 3.days.ago.to_date,
|
||||
@@ -370,9 +370,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{
|
||||
date: 4.days.ago.to_date,
|
||||
legacy_balances: { balance: 100, cash_balance: 100 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 },
|
||||
balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 100, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 3.days.ago.to_date,
|
||||
@@ -420,9 +420,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
legacy_balances: { balance: 20000, cash_balance: 0 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 20000, end: 20000 },
|
||||
balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 20000, end: 20000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 0, non_cash_adjustments: 20000 } # Valuations adjust non-cash balance for non-cash accounts like Loans
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 1.day.ago.to_date,
|
||||
@@ -455,9 +455,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{
|
||||
date: 3.days.ago.to_date,
|
||||
legacy_balances: { balance: 500000, cash_balance: 0 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 500000, end: 500000 },
|
||||
balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 0, non_cash_adjustments: 500000 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
@@ -505,9 +505,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{
|
||||
date: 3.days.ago.to_date,
|
||||
legacy_balances: { balance: 5000, cash_balance: 5000 },
|
||||
balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
|
||||
balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
|
||||
flows: 0,
|
||||
adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 }
|
||||
adjustments: 0
|
||||
},
|
||||
{
|
||||
date: 2.days.ago.to_date,
|
||||
@@ -534,6 +534,53 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
88
test/models/budget_test.rb
Normal file
88
test/models/budget_test.rb
Normal 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
|
||||
115
test/models/family/data_exporter_test.rb
Normal file
115
test/models/family/data_exporter_test.rb
Normal 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
|
||||
7
test/models/family_export_test.rb
Normal file
7
test/models/family_export_test.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class FamilyExportTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
58
test/services/noop_api_rate_limiter_test.rb
Normal file
58
test/services/noop_api_rate_limiter_test.rb
Normal 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
|
||||
72
test/support/balance_test_helper.rb
Normal file
72
test/support/balance_test_helper.rb
Normal 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
|
||||
@@ -135,6 +135,11 @@ module LedgerTestingHelper
|
||||
if expected
|
||||
assert calculated_balance, "Expected balance for #{date} but none was calculated"
|
||||
|
||||
# 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"
|
||||
|
||||
legacy_balances = expected[:legacy_balances]
|
||||
balances = expected[:balances]
|
||||
flows = expected[:flows]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user