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

Merge branch 'main' into ai

This commit is contained in:
Josh Pigford 2025-03-11 11:50:11 -05:00
commit e1af36dbd8
10 changed files with 122 additions and 6 deletions

View file

@ -1,6 +1,7 @@
module UpgradesHelper
def get_upgrade_for_notification(user, upgrades_mode)
return nil unless ENV["UPGRADES_ENABLED"] == "true"
return nil unless user.present?
completed_upgrade = Upgrader.completed_upgrade
return completed_upgrade if completed_upgrade && user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha

View file

@ -1,5 +1,5 @@
class Account < ApplicationRecord
include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable
include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable, Convertible
validates :name, :balance, :currency, presence: true

View file

@ -19,6 +19,8 @@ class Account::Balance::Syncer
if strategy == :forward
update_account_info
end
account.sync_required_exchange_rates
end
end

View file

@ -0,0 +1,28 @@
module Account::Convertible
extend ActiveSupport::Concern
def sync_required_exchange_rates
unless requires_exchange_rates?
Rails.logger.info("No exchange rate sync needed for account #{id}")
return
end
rates = ExchangeRate.find_rates(
from: currency,
to: target_currency,
start_date: start_date,
cache: true # caches from provider to DB
)
Rails.logger.info("Synced #{rates.count} exchange rates for account #{id}")
end
private
def target_currency
family.currency
end
def requires_exchange_rates?
currency != target_currency
end
end

View file

@ -19,7 +19,7 @@ class IncomeStatement
total_expense = result.select { |t| t.classification == "expense" }.sum(&:total)
ScopeTotals.new(
transactions_count: transactions_scope.count,
transactions_count: result.sum(&:transactions_count),
income_money: Money.new(total_income, family.currency),
expense_money: Money.new(total_expense, family.currency),
missing_exchange_rates?: result.any?(&:missing_exchange_rates?)

View file

@ -8,6 +8,7 @@ module IncomeStatement::BaseQuery
date_trunc(:interval, ae.date) as date,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
SUM(ae.amount * COALESCE(er.rate, 1)) as total,
COUNT(ae.id) as transactions_count,
BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates
FROM (#{transactions_scope.to_sql}) at
JOIN account_entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Account::Transaction'
@ -29,7 +30,7 @@ module IncomeStatement::BaseQuery
)
WHERE (
transfer_info.transfer_id IS NULL OR
(ae.amount < 0 AND transfer_info.accountable_type = 'Loan')
(ae.amount > 0 AND transfer_info.accountable_type = 'Loan')
)
GROUP BY 1, 2, 3, 4
SQL

View file

@ -13,13 +13,14 @@ class IncomeStatement::Totals
category_id: row["category_id"],
classification: row["classification"],
total: row["total"],
transactions_count: row["transactions_count"],
missing_exchange_rates?: row["missing_exchange_rates"]
)
end
end
private
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :missing_exchange_rates?)
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :missing_exchange_rates?)
def query_sql
base_sql = base_query_sql(family: @family, interval: "day", transactions_scope: @transactions_scope)
@ -33,7 +34,8 @@ class IncomeStatement::Totals
category_id,
classification,
ABS(SUM(total)) as total,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
BOOL_OR(missing_exchange_rates) as missing_exchange_rates,
SUM(transactions_count) as transactions_count
FROM base_totals
GROUP BY 1, 2, 3;
SQL

View file

@ -2,7 +2,7 @@
<div class="grid grid-cols-3 bg-white rounded-xl shadow-border-xs divide-x divide-alpha-black-100">
<div class="p-4 space-y-2">
<p class="text-sm text-secondary">Total transactions</p>
<p class="text-primary font-medium text-xl" id="total-transactions"><%= totals.transactions_count %></p>
<p class="text-primary font-medium text-xl" id="total-transactions"><%= totals.transactions_count.round(0) %></p>
</div>
<div class="p-4 space-y-2">
<p class="text-sm text-secondary">Income</p>

View file

@ -35,4 +35,27 @@ class Account::ChartableTest < ActiveSupport::TestCase
assert_equal 3000, series.values.find { |v| v.date == 20.days.ago.to_date }.trend.current.amount
assert_equal 3500, series.values.last.trend.current.amount
end
test "generates correct totals for multi currency families" do
family = families(:empty)
family.update!(currency: "USD")
usd_account = family.accounts.create!(name: "Asset", currency: "USD", balance: 5000, accountable: Depository.new)
eur_account = family.accounts.create!(name: "Asset", currency: "EUR", balance: 1000, accountable: Depository.new)
usd_account.balances.create!(date: 3.days.ago.to_date, balance: 5000, currency: "USD")
eur_account.balances.create!(date: 3.days.ago.to_date, balance: 1000, currency: "EUR")
# 1 EUR = 1.1 USD, so 1000 EUR = 1100 USD
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: 3.days.ago.to_date, rate: 1.1)
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: 2.days.ago.to_date, rate: 1.1)
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: 1.days.ago.to_date, rate: 1.1)
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.1)
series = family.accounts.balance_series(currency: "USD", period: Period.last_7_days)
assert_equal 0, series.values.first.trend.current.amount
assert_equal 6100, series.values.find { |v| v.date == 3.days.ago.to_date }.trend.current.amount
assert_equal 6100, series.values.last.trend.current.amount
end
end

View file

@ -0,0 +1,59 @@
require "test_helper"
require "ostruct"
class Account::ConvertibleTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@family = families(:empty)
@family.update!(currency: "USD")
# Foreign account (currency is not in the family's primary currency, so it will require exchange rates for net worth rollups)
@account = @family.accounts.create!(name: "Test Account", currency: "EUR", balance: 10000, accountable: Depository.new)
@provider = mock
ExchangeRate.stubs(:provider).returns(@provider)
end
test "syncs required exchange rates for an account" do
create_valuation(account: @account, date: 5.days.ago.to_date, amount: 9500, currency: "EUR")
# Since we had a valuation 5 days ago, this account starts 6 days ago and needs daily exchange rates looking forward
assert_equal 6.days.ago.to_date, @account.start_date
@provider.expects(:fetch_exchange_rates)
.with(
from: "EUR",
to: "USD",
start_date: 6.days.ago.to_date,
end_date: Date.current
).returns(
OpenStruct.new(
success?: true,
rates: [
OpenStruct.new(date: 6.days.ago.to_date, rate: 1.1),
OpenStruct.new(date: 5.days.ago.to_date, rate: 1.2),
OpenStruct.new(date: 4.days.ago.to_date, rate: 1.3),
OpenStruct.new(date: 3.days.ago.to_date, rate: 1.4),
OpenStruct.new(date: 2.days.ago.to_date, rate: 1.5),
OpenStruct.new(date: 1.day.ago.to_date, rate: 1.6),
OpenStruct.new(date: Date.current, rate: 1.7)
]
)
)
assert_difference "ExchangeRate.count", 7 do
@account.sync_required_exchange_rates
end
end
test "does not sync rates for a domestic account" do
@account.update!(currency: "USD")
@provider.expects(:fetch_exchange_rates).never
assert_no_difference "ExchangeRate.count" do
@account.sync_required_exchange_rates
end
end
end