mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Add start balance to manual accounts (#735)
* Add start_balance to accounts * Add tests * Cleanup * Refactor code and add tests * Update physical cash demo account to be manual * Do not populate start_balance in migration * Cleanup * Review fixes * Revert calc change * Update app/models/exchange_rate.rb Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com> Signed-off-by: Jakub Kottnauer <jk@jakubkottnauer.com> * Add test * Fix syncable bug and update csv tests --------- Signed-off-by: Jakub Kottnauer <jk@jakubkottnauer.com> Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
This commit is contained in:
parent
daf7ff8ef4
commit
3d9ff3ad2a
13 changed files with 50 additions and 17 deletions
|
@ -53,10 +53,13 @@ class AccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@account = Current.family.accounts.build(account_params.except(:accountable_type))
|
@account = Current.family.accounts.build(account_params.except(:accountable_type, :start_date))
|
||||||
@account.accountable = Accountable.from_type(account_params[:accountable_type])&.new
|
@account.accountable = Accountable.from_type(account_params[:accountable_type])&.new
|
||||||
|
|
||||||
if @account.save
|
if @account.save
|
||||||
|
@valuation = @account.valuations.new(date: account_params[:start_date] || Date.today, value: @account.balance, currency: @account.currency)
|
||||||
|
@valuation.save!
|
||||||
|
|
||||||
redirect_to accounts_path, notice: t(".success")
|
redirect_to accounts_path, notice: t(".success")
|
||||||
else
|
else
|
||||||
render "new", status: :unprocessable_entity
|
render "new", status: :unprocessable_entity
|
||||||
|
@ -86,6 +89,6 @@ class AccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_params
|
def account_params
|
||||||
params.require(:account).permit(:name, :accountable_type, :balance, :currency, :subtype, :is_active)
|
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :currency, :subtype, :is_active)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,7 +13,7 @@ class Account::Balance::Calculator
|
||||||
def calculate
|
def calculate
|
||||||
prior_balance = implied_start_balance
|
prior_balance = implied_start_balance
|
||||||
|
|
||||||
calculated_balances = ((@calc_start_date + 1.day)...Date.current).map do |date|
|
calculated_balances = ((@calc_start_date + 1.day)..Date.current).map do |date|
|
||||||
valuation = normalized_valuations.find { |v| v["date"] == date }
|
valuation = normalized_valuations.find { |v| v["date"] == date }
|
||||||
|
|
||||||
if valuation
|
if valuation
|
||||||
|
@ -30,8 +30,7 @@ class Account::Balance::Calculator
|
||||||
|
|
||||||
@daily_balances = [
|
@daily_balances = [
|
||||||
{ date: @calc_start_date, balance: implied_start_balance, currency: @account.currency, updated_at: Time.current },
|
{ date: @calc_start_date, balance: implied_start_balance, currency: @account.currency, updated_at: Time.current },
|
||||||
*calculated_balances,
|
*calculated_balances
|
||||||
{ date: Date.current, balance: @account.balance, currency: @account.currency, updated_at: Time.current } # Last balance must always match "source of truth"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if @account.foreign_currency?
|
if @account.foreign_currency?
|
||||||
|
@ -66,10 +65,7 @@ class Account::Balance::Calculator
|
||||||
value = entry.send(value_key)
|
value = entry.send(value_key)
|
||||||
|
|
||||||
if currency != @account.currency
|
if currency != @account.currency
|
||||||
rate = ExchangeRate.find_by(base_currency: currency, converted_currency: @account.currency, date: date)
|
value = ExchangeRate.convert(value:, from: currency, to: @account.currency, date:)
|
||||||
raise "Rate for #{currency} to #{@account.currency} not found" unless rate
|
|
||||||
|
|
||||||
value *= rate.rate
|
|
||||||
currency = @account.currency
|
currency = @account.currency
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,10 @@ module Account::Syncable
|
||||||
calculator.calculate
|
calculator.calculate
|
||||||
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
||||||
self.balances.where("date < ?", effective_start_date).delete_all
|
self.balances.where("date < ?", effective_start_date).delete_all
|
||||||
|
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
|
||||||
|
self.balance = new_balance
|
||||||
|
self.save!
|
||||||
|
|
||||||
update!(status: "ok", last_sync_date: Date.today)
|
update!(status: "ok", last_sync_date: Date.today)
|
||||||
rescue => e
|
rescue => e
|
||||||
update!(status: "error")
|
update!(status: "error")
|
||||||
|
|
|
@ -18,5 +18,12 @@ class ExchangeRate < ApplicationRecord
|
||||||
def get_rate_series(from, to, date_range)
|
def get_rate_series(from, to, date_range)
|
||||||
where(base_currency: from, converted_currency: to, date: date_range).order(:date)
|
where(base_currency: from, converted_currency: to, date: date_range).order(:date)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def convert(value:, from:, to:, date:)
|
||||||
|
rate = ExchangeRate.find_by(base_currency: from, converted_currency: to, date:)
|
||||||
|
raise "Conversion from: #{from} to: #{to} on: #{date} not found" unless rate
|
||||||
|
|
||||||
|
value * rate.rate
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -67,9 +67,10 @@
|
||||||
<%= form_with model: @account, url: accounts_path, scope: :account, html: { class: "m-5 mt-1 flex flex-col justify-between grow", data: { turbo: false } } do |f| %>
|
<%= form_with model: @account, url: accounts_path, scope: :account, html: { class: "m-5 mt-1 flex flex-col justify-between grow", data: { turbo: false } } do |f| %>
|
||||||
<div class="space-y-4 grow">
|
<div class="space-y-4 grow">
|
||||||
<%= f.hidden_field :accountable_type %>
|
<%= f.hidden_field :accountable_type %>
|
||||||
<%= f.text_field :name, placeholder: t("accounts.new.name.placeholder"), required: "required", label: t("accounts.new.name.label"), autofocus: true %>
|
<%= f.text_field :name, placeholder: t(".name.placeholder"), required: "required", label: t(".name.label"), autofocus: true %>
|
||||||
<%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
|
<%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
|
||||||
<%= f.money_field :balance_money, label: "Balance", required: "required" %>
|
<%= f.money_field :balance_money, label: t(".balance.label"), required: "required" %>
|
||||||
|
<%= f.date_field :date, label: t(".start_date.label"), required: true, max: Date.today, value: Date.today %>
|
||||||
</div>
|
</div>
|
||||||
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
|
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true %>
|
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true %>
|
||||||
<%= f.money_field :amount_money, label: t(".amount"), required: true %>
|
<%= f.money_field :amount_money, label: t(".amount"), required: true %>
|
||||||
<%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") }, required: true %>
|
<%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") }, required: true %>
|
||||||
<%= f.date_field :date, label: t(".date"), required: true %>
|
<%= f.date_field :date, label: t(".date"), required: true, max: Date.today %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
<%= f.date_field :date, label: "Date", "data-auto-submit-form-target": "auto" %>
|
<%= f.date_field :date, label: "Date", max: Date.today, "data-auto-submit-form-target": "auto" %>
|
||||||
<div class="h-2"></div>
|
<div class="h-2"></div>
|
||||||
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-400" }, {class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled"} %>
|
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-400" }, {class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled"} %>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between grow">
|
<div class="flex items-center justify-between grow">
|
||||||
<%= f.date_field :date, required: "required", class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
|
<%= f.date_field :date, required: "required", max: Date.today, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
|
||||||
<%= f.number_field :value, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
|
<%= f.number_field :value, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-[296px] flex gap-2 justify-end items-center">
|
<div class="w-[296px] flex gap-2 justify-end items-center">
|
||||||
|
|
|
@ -15,6 +15,8 @@ en:
|
||||||
index:
|
index:
|
||||||
new_account: New account
|
new_account: New account
|
||||||
new:
|
new:
|
||||||
|
balance:
|
||||||
|
label: Balance
|
||||||
currency:
|
currency:
|
||||||
all_others: All Others
|
all_others: All Others
|
||||||
popular: Popular
|
popular: Popular
|
||||||
|
@ -22,6 +24,8 @@ en:
|
||||||
label: Account name
|
label: Account name
|
||||||
placeholder: Example account name
|
placeholder: Example account name
|
||||||
select_accountable_type: What would you like to add?
|
select_accountable_type: What would you like to add?
|
||||||
|
start_date:
|
||||||
|
label: Start date
|
||||||
title: Add an account
|
title: Add an account
|
||||||
show:
|
show:
|
||||||
confirm_accept: Delete "%{name}"
|
confirm_accept: Delete "%{name}"
|
||||||
|
|
|
@ -16,10 +16,20 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_response :ok
|
assert_response :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create" do
|
test "should create account" do
|
||||||
assert_difference -> { Account.count }, +1 do
|
assert_difference -> { Account.count }, +1 do
|
||||||
post accounts_path, params: { account: { accountable_type: "Account::Credit" } }
|
post accounts_path, params: { account: { accountable_type: "Account::Credit" } }
|
||||||
assert_redirected_to accounts_url
|
assert_redirected_to accounts_url
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "should create a valuation together with account" do
|
||||||
|
balance = 700
|
||||||
|
start_date = 3.days.ago.to_date
|
||||||
|
post accounts_path, params: { account: { accountable_type: "Account::Credit", balance:, start_date: } }
|
||||||
|
|
||||||
|
new_valuation = Valuation.order(:created_at).last
|
||||||
|
assert new_valuation.value == balance
|
||||||
|
assert new_valuation.date == start_date
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
2
test/fixtures/account/expected_balances.csv
vendored
2
test/fixtures/account/expected_balances.csv
vendored
|
@ -29,4 +29,4 @@ date_offset,collectable,checking,savings_with_valuation_overrides,credit_card,eu
|
||||||
-3,550,5000,20500,1000,12000,13046.4,10000
|
-3,550,5000,20500,1000,12000,13046.4,10000
|
||||||
-2,550,5000,20500,1000,12000,12982.8,10000
|
-2,550,5000,20500,1000,12000,12982.8,10000
|
||||||
-1,550,5000,20500,1000,12000,13014,10000
|
-1,550,5000,20500,1000,12000,13014,10000
|
||||||
0,550,5000,20000,1000,12000,13000.8,10000
|
0,550,5000,20500,1000,12000,13000.8,10000
|
|
2
test/fixtures/family/expected_snapshots.csv
vendored
2
test/fixtures/family/expected_snapshots.csv
vendored
|
@ -29,4 +29,4 @@ date_offset,net_worth,assets,liabilities,depositories,investments,loans,credits,
|
||||||
-3,48096.40,49096.40,1000.00,48546.40,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
-3,48096.40,49096.40,1000.00,48546.40,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
||||||
-2,48032.80,49032.80,1000.00,48482.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
-2,48032.80,49032.80,1000.00,48482.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
||||||
-1,48064.00,49064.00,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
-1,48064.00,49064.00,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
||||||
0,47550.80,48550.80,1000.00,48000.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
0,48050.80,49050.80,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
|
|
@ -78,4 +78,12 @@ class Account::SyncableTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
assert_equal 31, account.balances.count
|
assert_equal 31, account.balances.count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "account balance is updated after sync" do
|
||||||
|
account = accounts(:savings_with_valuation_overrides)
|
||||||
|
|
||||||
|
assert_changes -> { account.balance }, to: 20500 do
|
||||||
|
account.sync
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue