Compare commits

...

17 Commits

Author SHA1 Message Date
Zach Gollwitzer
dfc7e1c30c Bump to v0.1.0-alpha.12
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-07-26 10:48:21 -04:00
Zach Gollwitzer
76dd5e57fb Set minimum supported date for account entries (#1023)
* Set minimum supported date for account entries

* Fix validation proc

* Fix date input in system tests
2024-07-26 10:47:27 -04:00
Zach Gollwitzer
701e17829d Fix currency formatting in pie chart visualization (#1022) 2024-07-26 10:36:29 -04:00
Zach Gollwitzer
7c2091b343 Basic Portfolio Views (#1000)
* Add holdings tab to account view

* Basic portfolio UI

* Cleanup

* Handle missing holding data

* Remove synced at (implemented in separate pr)

* translations

* Tweak post sync streams

* Remove stale methods from merge conflict
2024-07-25 16:46:04 -04:00
Zach Gollwitzer
ef4be7948a Implement auto family syncs on login (#1021) 2024-07-25 12:51:50 -04:00
Julius Mieliauskas
c8590d53ba Fix curency format (#1020)
* Fixed currency formatting

* Revert "Fixed currency formatting"

This reverts commit 8c7ff442b8.

* fix currency formating
2024-07-25 10:40:03 -04:00
Tony Vincent
f62c5e43c3 Fix form labels (#1004)
* Fix form labels

* Fix typo

* Change form builder

* Simplify label_html private method of StyledFormBuilder
2024-07-22 10:04:55 -04:00
dependabot[bot]
82568b4d8c Bump rails from 8035bec to 1b89033 (#1007)
Bumps [rails](https://github.com/rails/rails) from `8035bec` to `1b89033`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](8035bece70...1b89033460)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 10:00:39 -04:00
dependabot[bot]
9d006409c2 Bump turbo-rails from 2.0.5 to 2.0.6 (#1008)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.5 to 2.0.6.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.5...v2.0.6)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 09:53:21 -04:00
dependabot[bot]
55a085f01f Bump ruby-lsp-rails from 0.3.10 to 0.3.11 (#1010)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.10 to 0.3.11.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.10...v0.3.11)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 09:53:01 -04:00
dependabot[bot]
23dcdf6e26 Bump selenium-webdriver from 4.22.0 to 4.23.0 (#1011)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.22.0 to 4.23.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.22.0...selenium-4.23.0)

---
updated-dependencies:
- dependency-name: selenium-webdriver
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 09:52:34 -04:00
dependabot[bot]
05e3e689b5 Bump good_job from 4.0.3 to 4.1.0 (#1012)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.0.3 to 4.1.0.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.0.3...v4.1.0)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 09:52:17 -04:00
dependabot[bot]
01f50dc54c Bump mocha from 2.4.0 to 2.4.2 (#1013)
Bumps [mocha](https://github.com/freerange/mocha) from 2.4.0 to 2.4.2.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.4.0...v2.4.2)

---
updated-dependencies:
- dependency-name: mocha
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 09:51:43 -04:00
dependabot[bot]
5d213f2e6a Bump tailwindcss-rails from 2.6.1 to 2.6.3 (#1014)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.1 to 2.6.3.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.1...v2.6.3)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 09:51:31 -04:00
dependabot[bot]
952d847c15 Bump faker from 3.4.1 to 3.4.2 (#1015)
Bumps [faker](https://github.com/faker-ruby/faker) from 3.4.1 to 3.4.2.
- [Release notes](https://github.com/faker-ruby/faker/releases)
- [Changelog](https://github.com/faker-ruby/faker/blob/main/CHANGELOG.md)
- [Commits](https://github.com/faker-ruby/faker/compare/v3.4.1...v3.4.2)

---
updated-dependencies:
- dependency-name: faker
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 09:51:12 -04:00
Tony Vincent
e7dc6b88ea Bump pagy with breaking changes fix (#1016) 2024-07-22 09:49:53 -04:00
Tony Vincent
75ded1c18f Set last_login_at only at login instead of every single action (#1017) 2024-07-22 09:37:03 -04:00
63 changed files with 729 additions and 166 deletions

View File

@@ -7,7 +7,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 8035bece705f60e6bddca70ee7d88e935a242bf8
revision: 1b8903346000e1848e62e09429d325499af03b3f
branch: 7-2-stable
specs:
actioncable (7.2.0.beta3)
@@ -178,7 +178,7 @@ GEM
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
faker (3.4.1)
faker (3.4.2)
i18n (>= 1.8.11, < 2)
faraday (2.10.0)
faraday-net_http (>= 2.0, < 3.2)
@@ -193,7 +193,7 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.0.3)
good_job (4.1.0)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
@@ -257,7 +257,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
minitest (5.24.1)
mocha (2.4.0)
mocha (2.4.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
net-http (0.4.1)
@@ -287,7 +287,7 @@ GEM
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (8.6.3)
pagy (9.0.2)
parallel (1.24.0)
parser (3.3.1.0)
ast (~> 2.4.1)
@@ -340,7 +340,7 @@ GEM
regexp_parser (2.9.2)
reline (0.5.9)
io-console (~> 0.5)
rexml (3.3.0)
rexml (3.3.2)
strscan
rubocop (1.63.5)
json (~> 2.3)
@@ -371,12 +371,12 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.17.7)
ruby-lsp (0.17.8)
language_server-protocol (~> 3.17.0)
prism (>= 0.29.0, < 0.31)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.10)
ruby-lsp-rails (0.3.11)
ruby-lsp (>= 0.17.2, < 0.18.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.1)
@@ -386,7 +386,7 @@ GEM
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
selenium-webdriver (4.22.0)
selenium-webdriver (4.23.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -405,29 +405,29 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11481)
sorbet-runtime (0.5.11491)
stackprof (0.2.26)
stimulus-rails (1.3.3)
railties (>= 6.0.0)
stringio (3.1.1)
strscan (3.1.0)
tailwindcss-rails (2.6.1)
tailwindcss-rails (2.6.3)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-aarch64-linux)
tailwindcss-rails (2.6.3-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-arm-linux)
tailwindcss-rails (2.6.3-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-arm64-darwin)
tailwindcss-rails (2.6.3-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-x86_64-darwin)
tailwindcss-rails (2.6.3-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.1-x86_64-linux)
tailwindcss-rails (2.6.3-x86_64-linux)
railties (>= 7.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.1)
timeout (0.4.1)
turbo-rails (2.0.5)
turbo-rails (2.0.6)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
@@ -447,7 +447,7 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1)
websocket (1.2.10)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)

View File

@@ -12,6 +12,10 @@ class Account::EntriesController < ApplicationController
@valuation_entries = @account.entries.account_valuations.reverse_chronological
end
def trades
@trades = @account.entries.account_trades.reverse_chronological
end
def new
@entry = @account.entries.build.tap do |entry|
if params[:entryable_type]

View File

@@ -0,0 +1,23 @@
class Account::HoldingsController < ApplicationController
layout "with_sidebar"
before_action :set_account
before_action :set_holding, only: :show
def index
@holdings = @account.holdings.current
end
def show
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_holding
@holding = @account.holdings.current.find(params[:id])
end
end

View File

@@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
include Authentication, Invitable, SelfHostable
include AutoSync, Authentication, Invitable, SelfHostable
include Pagy::Backend
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.

View File

@@ -3,13 +3,11 @@ module Authentication
included do
before_action :authenticate_user!
after_action :set_last_login_at, if: -> { Current.user }
end
class_methods do
def skip_authentication(**options)
skip_before_action :authenticate_user!, **options
skip_after_action :set_last_login_at, **options
end
end
@@ -27,6 +25,7 @@ module Authentication
Current.user = user
reset_session
session[:user_id] = user.id
set_last_login_at
end
def logout

View File

@@ -0,0 +1,13 @@
module AutoSync
extend ActiveSupport::Concern
included do
before_action :sync_family, if: -> { Current.family.present? && Current.family.needs_sync? }
end
private
def sync_family
Current.family.sync
end
end

View File

@@ -4,7 +4,7 @@ class TransactionsController < ApplicationController
def index
@q = search_params
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
@pagy, @transaction_entries = pagy(result, items: params[:per_page] || "50")
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
@totals = {
count: result.select { |t| t.currency == Current.family.currency }.count,

View File

@@ -33,7 +33,7 @@ module Account::EntriesHelper
private
def permitted_entryable_key(entry)
permitted_entryable_paths = %w[transaction valuation]
permitted_entryable_paths = %w[transaction valuation trade]
entry.entryable_name_short.presence_in(permitted_entryable_paths)
end
end

View File

@@ -23,18 +23,37 @@ module AccountsHelper
class_mapping(accountable_type)[:hex]
end
def account_tabs(account)
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: valuation_account_entries_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: transaction_account_entries_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: trade_account_entries_path(account) }
return [ holdings_tab, trades_tab ] if account.investment?
[ value_tab, transactions_tab ]
end
def selected_account_tab(account)
available_tabs = account_tabs(account)
tab = available_tabs.find { |tab| tab[:key] == params[:tab] }
tab || available_tabs.first
end
private
def class_mapping(accountable_type)
{
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
end
def class_mapping(accountable_type)
{
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
end
end

View File

@@ -17,6 +17,10 @@ module ApplicationHelper
turbo_stream_from [ Current.family, :notifications ] if Current.family
end
def family_stream
turbo_stream_from Current.family if Current.family
end
def render_flash_notifications
notifications = flash.flat_map do |type, message_or_messages|
Array(message_or_messages).map do |message|

View File

@@ -50,6 +50,9 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end
def label_html(method, options)
options[:label] ? label(method, options[:label], class: "form-field__label") : "".html_safe
return label(method, class: "form-field__label") if options[:label] == true
return "".html_safe unless options[:label]
label(method, options[:label], class: "form-field__label")
end
end

View File

@@ -1,17 +1,13 @@
module ValueGroupsHelper
def value_group_pie_data(value_group)
value_group.children
.map do |child|
{
label: to_accountable_title(Accountable.from_type(child.name)),
percent_of_total: child.percent_of_total.round(1).to_f,
value: child.sum.amount.to_f,
currency: child.sum.currency.iso_code,
bg_color: accountable_bg_class(child.name),
fill_color: accountable_fill_class(child.name)
}
end
.filter { |child| child[:value] > 0 }
.to_json
value_group.children.filter { |c| c.sum > 0 }.map do |child|
{
label: to_accountable_title(Accountable.from_type(child.name)),
percent_of_total: child.percent_of_total.round(1).to_f,
formatted_value: format_money(child.sum, precision: 0),
bg_color: accountable_bg_class(child.name),
fill_color: accountable_fill_class(child.name)
}
end.to_json
end
end

View File

@@ -59,6 +59,7 @@ export default class extends Controller {
deselectAll() {
this.selectedIdsValue = []
this.element.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false)
}
selectedIdsValueChanged() {

View File

@@ -5,6 +5,7 @@ import * as d3 from "d3";
export default class extends Controller {
static values = {
data: Array,
total: String,
label: String,
};
@@ -38,7 +39,7 @@ export default class extends Controller {
#draw() {
this.#d3Container.attr("class", "relative");
this.#d3Content.html(this.#contentSummaryTemplate(this.dataValue));
this.#d3Content.html(this.#contentSummaryTemplate());
const pie = d3
.pie()
@@ -75,23 +76,17 @@ export default class extends Controller {
this.#d3Svg
.selectAll(".arc path")
.attr("class", (d) => d.data.fill_color);
this.#d3ContentMemo.html(this.#contentSummaryTemplate(this.dataValue));
this.#d3ContentMemo.html(this.#contentSummaryTemplate());
});
}
#contentSummaryTemplate(data) {
const total = data.reduce((acc, cur) => acc + cur.value, 0);
const currency = data[0].currency;
return `${this.#currencyValue({
value: total,
currency,
})} <span class="text-xs">${this.labelValue}</span>`;
#contentSummaryTemplate() {
return `<span class="text-xl text-gray-900 font-medium">${this.totalValue}</span> <span class="text-xs">${this.labelValue}</span>`;
}
#contentDetailTemplate(datum) {
return `
<span>${this.#currencyValue(datum)}</span>
<span class="text-xl text-gray-900 font-medium">${datum.formatted_value}</span>
<div class="flex flex-row text-xs gap-2 items-center">
<div class="w-[10px] h-[10px] rounded-full ${datum.bg_color}"></div>
<span>${datum.label}</span>
@@ -100,21 +95,6 @@ export default class extends Controller {
`;
}
#currencyValue(datum) {
const formattedValue = Intl.NumberFormat(undefined, {
style: "currency",
currency: datum.currency,
currencyDisplay: "narrowSymbol",
}).format(datum.value);
const firstDigitIndex = formattedValue.search(/\d/);
const currencyPrefix = formattedValue.substring(0, firstDigitIndex);
const mainPart = formattedValue.substring(firstDigitIndex);
const [integerPart, fractionalPart] = mainPart.split(".");
return `<p class="text-gray-500 -space-x-0.5">${currencyPrefix}<span class="text-xl text-gray-900 font-medium">${integerPart}</span>.${fractionalPart}</p>`;
}
get #radius() {
return Math.min(this.#d3ViewboxWidth, this.#d3ViewboxHeight) / 2;
}

View File

@@ -11,6 +11,7 @@ class Account::Entry < ApplicationRecord
validates :date, :amount, :currency, presence: true
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
validate :trade_valid?, if: -> { account_trade? }
scope :chronological, -> { order(:date, :created_at) }
@@ -64,6 +65,11 @@ class Account::Entry < ApplicationRecord
end
class << self
# arbitrary cutoff date to avoid expensive sync operations
def min_supported_date
10.years.ago.to_date
end
def daily_totals(entries, currency, period: Period.last_30_days)
# Sum spending and income for each day in the period with the given currency
select(

View File

@@ -1,6 +1,46 @@
class Account::Holding < ApplicationRecord
include Monetizable
monetize :amount
belongs_to :account
belongs_to :security
validates :qty, :currency, presence: true
scope :chronological, -> { order(:date) }
scope :current, -> { where(date: Date.current).order(amount: :desc) }
scope :for, ->(security) { where(security_id: security).order(:date) }
delegate :name, to: :security
delegate :symbol, to: :security
def weight
return nil unless amount
portfolio_value = account.holdings.current.where.not(amount: nil).sum(&:amount)
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
end
# Basic approximation of cost-basis
def avg_cost
avg_cost = account.holdings.for(security).where("date <= ?", date).average(:price)
Money.new(avg_cost, currency)
end
def trend
@trend ||= calculate_trend
end
private
def calculate_trend
return nil unless amount_money
start_amount = qty * avg_cost
TimeSeries::Trend.new \
current: amount_money,
previous: start_amount
end
end

View File

@@ -38,14 +38,18 @@ class Account::Holding::Syncer
@portfolio = generate_next_portfolio(@portfolio, trades)
@portfolio.map do |isin, holding|
price = Security::Price.find_by!(date: date, isin: isin).price
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
trade_price = trade&.account_trade&.price
price = Security::Price.find_by(date: date, isin: isin)&.price || trade_price
account.holdings.build \
date: date,
security_id: holding[:security_id],
qty: holding[:qty],
price: price,
amount: price * holding[:qty]
amount: price ? (price * holding[:qty]) : nil,
currency: holding[:currency]
end
end
@@ -61,6 +65,7 @@ class Account::Holding::Syncer
qty: new_qty,
price: price,
amount: new_qty * price,
currency: entry.currency,
security_id: trade.security_id
}
end
@@ -85,6 +90,7 @@ class Account::Holding::Syncer
qty: holding.qty,
price: holding.price,
amount: holding.amount,
currency: holding.currency,
security_id: holding.security_id
}
end

View File

@@ -78,5 +78,6 @@ class Account::Sync < ApplicationRecord
partial: "shared/notification",
locals: { type: type, message: message }
)
account.family.broadcast_refresh
end
end

View File

@@ -11,6 +11,14 @@ module Account::Syncable
syncs.syncing.any?
end
def latest_sync_date
syncs.where.not(last_ran_at: nil).pluck(:last_ran_at).max&.to_date
end
def needs_sync?
latest_sync_date.nil? || latest_sync_date < Date.current
end
def sync_later(start_date: nil)
AccountSyncJob.perform_later(self, start_date: start_date)
end

View File

@@ -34,10 +34,6 @@ class Demo::Generator
create_car_and_loan!
puts "accounts created"
family.sync
puts "balances synced"
puts "Demo data loaded successfully!"
end
end
@@ -169,6 +165,9 @@ class Demo::Generator
end
def load_securities!
# Create an unknown security to simulate edge cases
Security.create! isin: "unknown", symbol: "UNKNOWN", name: "Unknown Demo Stock"
securities = [
{ isin: "US0378331005", symbol: "AAPL", name: "Apple Inc.", reference_price: 210 },
{ isin: "JP3633400001", symbol: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
@@ -204,6 +203,10 @@ class Demo::Generator
aapl = Security.find_by(symbol: "AAPL")
tm = Security.find_by(symbol: "TM")
msft = Security.find_by(symbol: "MSFT")
unknown = Security.find_by(symbol: "UNKNOWN")
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown)
trades = [
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
@@ -216,7 +219,7 @@ class Demo::Generator
date = Faker::Number.positive(to: 730).days.ago.to_date
security = trade[:security]
qty = trade[:qty]
price = Security::Price.find_by!(isin: security.isin, date: date).price
price = Security::Price.find_by(isin: security.isin, date: date)&.price || 1
name_prefix = qty < 0 ? "Sell " : "Buy "
account.entries.create! \

View File

@@ -105,6 +105,16 @@ class Family < ApplicationRecord
end
def sync(start_date: nil)
accounts.active.sync(start_date: start_date)
accounts.active.each do |account|
if account.needs_sync?
account.sync_later(start_date: start_date || account.last_sync_date)
end
end
update! last_synced_at: Time.now
end
def needs_sync?
last_synced_at.nil? || last_synced_at.to_date < Date.current
end
end

View File

@@ -44,7 +44,7 @@ class TimeSeries::Trend
end
def percent
if previous.nil?
if previous.nil? || (previous.zero? && current.zero?)
0.0
elsif previous.zero?
Float::INFINITY

View File

@@ -0,0 +1,15 @@
<div class="fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5">
<div class="flex items-center gap-2">
<%= check_box_tag "entry_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
<p data-bulk-select-target="selectionBarText"></p>
</div>
<div class="flex items-center gap-1 text-gray-500">
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
</button>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,31 @@
<%# locals: (entry:) %>
<% trade, account = entry.account_trade, entry.account %>
<%= drawer do %>
<div>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl"><%= format_money -entry.amount_money %></span>
<span class="text-lg text-gray-500"><%= entry.currency %></span>
</h3>
</div>
<span class="text-sm text-gray-500"><%= entry.date.strftime("%A %d %B") %></span>
</header>
<div class="space-y-2">
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".overview") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6 pl-4 text-gray-500">
<p>Details coming soon...</p>
</div>
</details>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,41 @@
<%# locals: (entry:, selectable: true, **opts) %>
<% trade, account = entry.account_trade, entry.account %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="pr-10 flex items-center gap-4 col-span-6">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
<% end %>
<div class="max-w-full">
<%= tag.div class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= entry.name[0].upcase %>
</div>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<% else %>
<%= link_to entry.name,
account_entry_path(account, entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
</div>
<% end %>
</div>
</div>
<div class="flex items-center justify-end gap-1 col-span-3">
<%= tag.p trade.buy? ? t(".buy") : t(".sell") %>
</div>
<div class="col-span-3 flex items-center justify-end">
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
</div>
</div>

View File

@@ -8,7 +8,7 @@
<%= lucide_icon("pencil-line", class: "w-4 h-4 text-gray-500") %>
</div>
<div class="w-full flex items-center justify-between gap-2">
<%= f.date_field :date, required: "required", max: Date.current, 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", min: Account::Entry.min_supported_date, max: Date.current, 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 :amount, 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.hidden_field :currency, value: entry.account.currency %>
<%= f.hidden_field :entryable_type, value: entry.entryable_type %>

View File

@@ -0,0 +1,42 @@
<%= turbo_frame_tag dom_id(@account, "trades") do %>
<div id="trades" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".trade") %>" class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex justify-between items-center">
<h3 class="font-medium text-lg"><%= t(".trades") %></h3>
<%= link_to new_account_entry_path(@account),
disabled: true,
class: "cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<span class="text-sm"><%= t(".new") %></span>
<% end %>
</div>
<div class="bg-gray-25 rounded-xl grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-5 py-3">
<div class="pl-0.5 col-span-6 flex items-center gap-4">
<%= check_box_tag "selection_entry",
class: "maybe-checkbox maybe-checkbox--light",
data: { action: "bulk-select#togglePageSelection" } %>
<%= tag.p t(".trade") %>
</div>
<%= tag.p t(".type"), class: "col-span-3 justify-self-end" %>
<%= tag.p t(".amount"), class: "col-span-3 justify-self-end" %>
</div>
<div>
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<%= render "account/entries/entryables/trade/selection_bar" %>
</div>
<% if @trades.empty? %>
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
<% else %>
<div class="space-y-6">
<% @trades.group_by(&:date).each do |date, entries| %>
<%= render "entry_group", date:, entries: entries %>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>

View File

@@ -12,7 +12,7 @@
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>">
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<%= render "selection_bar" %>
<%= render "account/entries/entryables/transaction/selection_bar" %>
</div>
<% if @transaction_entries.empty? %>

View File

@@ -0,0 +1,45 @@
<%# locals: (holding:) %>
<%= turbo_frame_tag dom_id(holding) do %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-4 flex items-center gap-4">
<%= render "shared/circle_logo", name: holding.name %>
<div>
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
<%= tag.p holding.symbol, class: "text-gray-500 text-xs uppercase" %>
</div>
</div>
<div class="col-span-2 flex justify-end items-center gap-2">
<% if holding.weight %>
<%= render "shared/progress_circle", progress: holding.weight, text_class: "text-blue-500" %>
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
</div>
<div class="col-span-2 text-right">
<%= tag.p format_money holding.avg_cost %>
<%= tag.p t(".per_share"), class: "font-normal text-gray-500" %>
</div>
<div class="col-span-2 text-right">
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-gray-500" %>
</div>
<div class="col-span-2 text-right">
<% if holding.trend %>
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1 @@
<div class="h-px bg-alpha-black-50 ml-16 mr-4"></div>

View File

@@ -0,0 +1,37 @@
<%= turbo_frame_tag dom_id(@account, "holdings") do %>
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
<%= link_to new_account_holding_path(@account),
disabled: true,
data: { turbo_frame: :modal },
class: "cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<%= tag.span t(".new_holding"), class: "text-sm" %>
<% end %>
</div>
<div class="rounded-xl bg-gray-25 p-1">
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
<%= tag.p t(".name"), class: "col-span-4" %>
<%= tag.p t(".weight"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".cost"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".holdings"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".return"), class: "col-span-2 justify-self-end" %>
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<% if @holdings.any? %>
<%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %>
<% elsif @account.needs_sync? || true %>
<div class="flex flex-col justify-center items-center pt-4 pb-8">
<p class="text-gray-500 p-4"><%= t(".needs_sync") %></p>
<%= button_to "Sync holding prices", sync_account_path(@account), class: "bg-gray-900 text-white text-sm rounded-lg px-3 py-2" %>
</div>
<% else %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
<% end %>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1 @@
<p>Coming soon...</p>

View File

@@ -0,0 +1,45 @@
<%= drawer do %>
<div class="space-y-4">
<header class="flex justify-between">
<div>
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-gray-900" %>
<%= tag.p @holding.symbol.upcase, class: "text-sm text-gray-500" %>
</div>
<%= render "shared/circle_logo", name: @holding.name %>
</header>
<details class="group space-y-2">
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".overview") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<p class="pl-4 text-gray-500">Coming soon...</p>
</div>
</details>
<details class="group space-y-2">
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".history") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<p class="pl-4 text-gray-500">Coming soon...</p>
</div>
</details>
<details class="group space-y-2">
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".settings") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<p class="pl-4 text-gray-500">Coming soon...</p>
</div>
</details>
</div>
<% end %>

View File

@@ -86,7 +86,7 @@
<%= label_tag :add_start_values, t(".optional_start_balance_message"), class: "pl-1 text-sm text-gray-500" %>
<div class="hidden peer-checked:flex items-center gap-2 mt-3 mb-6">
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.yesterday %></div>
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.yesterday, min: Account::Entry.min_supported_date %></div>
<div class="w-1/2"><%= f.number_field :start_balance, label: t(".start_balance") %></div>
</div>
</div>

View File

@@ -1,4 +1,6 @@
<div class="space-y-4">
<%= turbo_stream_from @account %>
<%= tag.div id: dom_id(@account), class: "space-y-4" do %>
<div class="flex justify-between items-center">
<div class="flex items-center gap-3">
<%= image_tag account_logo_url(@account), class: "w-8 h-8" %>
@@ -72,22 +74,17 @@
</div>
</div>
<% selected_tab = params[:tab] || "value" %>
<% selected_tab_key, selected_tab_content_path = selected_account_tab(@account).values_at(:key, :content_path) %>
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
<%= link_to t(".value"), account_path(tab: "value"), class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab == "value"] %>
<%= link_to t(".transactions"), account_path(tab: "transactions"), class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab == "transactions"] %>
<% account_tabs(@account).each do |tab| %>
<%= link_to tab[:label], tab[:path], class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab_key == tab[:key]] %>
<% end %>
</div>
<div class="min-h-[800px]">
<% if selected_tab == "transactions" %>
<%= turbo_frame_tag dom_id(@account, "transactions"), src: transaction_account_entries_path(@account) do %>
<%= render "account/entries/loading" %>
<% end %>
<% else %>
<%= turbo_frame_tag dom_id(@account, "valuations"), src: valuation_account_entries_path(@account) do %>
<%= render "account/entries/loading" %>
<% end %>
<%= turbo_frame_tag dom_id(@account, selected_tab_key), src: selected_tab_content_path do %>
<%= render "account/entries/loading" %>
<% end %>
</div>
</div>
<% end %>

View File

@@ -32,6 +32,7 @@
</div>
<%= family_notifications_stream %>
<%= family_stream %>
<%= content_for?(:content) ? yield(:content) : yield %>

View File

@@ -10,8 +10,8 @@
<div
data-controller="pie-chart"
class="w-full aspect-1"
data-pie-chart-label-value="Total Assets"
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:assets]) %>">
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:assets]) %>"
data-pie-chart-total-value="<%= format_money(account_groups[:assets].sum, precision: 0) %>">
</div>
</div>
</div>
@@ -20,8 +20,8 @@
<div
data-controller="pie-chart"
class="w-full aspect-1"
data-pie-chart-label-value="Total Debts"
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:liabilities]) %>">
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:liabilities]) %>"
data-pie-chart-total-value="<%= format_money(account_groups[:liabilities].sum, precision: 0) %>">
</div>
</div>
</div>

View File

@@ -1,8 +1,8 @@
<%= turbo_frame_tag "drawer" do %>
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none" data-controller="modal" data-action="click->modal#clickOutside">
<div class="flex flex-col h-full p-4">
<div class="flex justify-end items-center h-9">
<div data-action="click->modal#close" class="cursor-pointer">
<div class="flex justify-end items-center pb-4">
<div data-action="click->modal#close" class="cursor-pointer p-2">
<%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
</div>
</div>

View File

@@ -18,7 +18,7 @@
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
<% end %>
<%= f.date_field :date, label: t(".date"), required: true, max: Date.today %>
<%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.today %>
</section>
<section>

View File

@@ -46,7 +46,7 @@
data: { controller: "auto-submit-form" } do |f| %>
<%= f.label :per_page, t(".rows_per_page"), class: "text-sm text-gray-500" %>
<%= f.select :per_page,
options_for_select(["10", "20", "30", "50"], pagy.items),
options_for_select(["10", "20", "30", "50"], pagy.limit),
{},
class: "py-1.5 pr-8 text-sm text-gray-900 font-medium border border-gray-200 rounded-lg focus:border-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900",
data: { "auto-submit-form-target": "auto" } %>

View File

@@ -11,7 +11,7 @@
<% if @transaction_entries.present? %>
<div hidden id="entry-selection-bar" data-bulk-select-target="selectionBar">
<%= render "account/entries/selection_bar" %>
<%= render "account/entries/entryables/transaction/selection_bar" %>
</div>
<div class="grow overflow-y-auto">
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">

View File

@@ -10,7 +10,7 @@ module Maybe
private
def semver
"0.1.0-alpha.11"
"0.1.0-alpha.12"
end
end
end

View File

@@ -10,7 +10,18 @@ en:
description: Try adding an entry, editing filters or refining your search
title: No entries found
entryables:
trade:
show:
overview: Overview
trade:
buy: Buy
sell: Sell
transaction:
selection_bar:
mark_transfers: Mark as transfers?
mark_transfers_confirm: Mark as transfers
mark_transfers_message: By marking transactions as transfers, they will
no longer be included in income or spending calculations.
show:
account_label: Account
account_placeholder: Select an account
@@ -57,11 +68,13 @@ en:
value_update: Value update
loading:
loading: Loading entries...
selection_bar:
mark_transfers: Mark as transfers?
mark_transfers_confirm: Mark as transfers
mark_transfers_message: By marking transactions as transfers, they will no
longer be included in income or spending calculations.
trades:
amount: Amount
new: New trade
no_trades: No trades for this account yet.
trade: trade
trades: Trades
type: Type
transactions:
new: New transaction
no_transactions: No transactions for this account yet.

View File

@@ -0,0 +1,21 @@
---
en:
account:
holdings:
holding:
per_share: per share
shares: "%{qty} shares"
index:
cost: cost
holdings: Holdings
name: name
needs_sync: Your account needs to sync the latest prices to calculate this
portfolio
new_holding: New holding
no_holdings: No holdings to show.
return: total return
weight: weight
show:
history: History
overview: Overview
settings: Settings

View File

@@ -56,12 +56,14 @@ en:
information because you'll need to add it as a new account.</p>"
confirm_title: Delete account?
edit: Edit
holdings: Holdings
import: Import transactions
no_change: No change
sync_message_missing_rates: Since exchange rates haven't been synced, balance
graphs may not reflect accurate values.
sync_message_unknown_error: An error has occurred during the sync.
total_value: Total Value
trades: Trades
transactions: Transactions
value: Value
summary:

View File

@@ -78,10 +78,13 @@ Rails.application.routes.draw do
scope module: :account do
resource :logo, only: :show
resources :holdings, only: %i[ index new show ]
resources :entries, except: :index do
collection do
get "transactions", as: :transaction
get "valuations", as: :valuation
get "trades", as: :trade
end
end
end

View File

@@ -0,0 +1,5 @@
class AddLastSyncedAtToFamily < ActiveRecord::Migration[7.2]
def change
add_column :families, :last_synced_at, :datetime
end
end

5
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_07_17_113535) do
ActiveRecord::Schema[7.2].define(version: 2024_07_25_163339) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -118,7 +118,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_17_113535) do
t.boolean "is_active", default: true, null: false
t.date "last_sync_date"
t.uuid "institution_id"
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["family_id"], name: "index_accounts_on_family_id"
t.index ["institution_id"], name: "index_accounts_on_institution_id"
@@ -194,6 +194,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_17_113535) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "currency", default: "USD"
t.datetime "last_synced_at"
end
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|

View File

@@ -91,7 +91,8 @@ class Money
unit: currency.symbol,
precision: currency.default_precision,
delimiter: currency.delimiter,
separator: currency.separator
separator: currency.separator,
format: currency.default_format
}
end

View File

@@ -0,0 +1,20 @@
require "test_helper"
class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@account = accounts(:investment)
@holding = @account.holdings.current.first
end
test "gets holdings" do
get account_holdings_url(@account)
assert_response :success
end
test "gets holding" do
get account_holding_path(@account, @holding)
assert_response :success
end
end

View File

@@ -24,6 +24,14 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest
end
end
test "sets last_login_at on successful registration" do
post registration_url, params: { user: {
email: "john@example.com",
password: "password",
password_confirmation: "password" } }
assert_not_nil User.find_by(email: "john@example.com").last_login_at
end
test "create when hosted requires an invite code" do
with_env_overrides REQUIRE_INVITE_CODE: "true" do
assert_no_difference "User.count" do

View File

@@ -0,0 +1,18 @@
require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
end
test "can sign in" do
post session_url, params: { email: @user.email, password: "password" }
assert_redirected_to root_url
end
test "sets last_login_at on successful login" do
assert_changes -> { @user.reload.last_login_at }, from: nil do
post session_url, params: { email: @user.email, password: "password" }
end
end
end

View File

@@ -3,6 +3,7 @@ one:
security: aapl
date: <%= Date.current %>
qty: 10
price: 215
amount: 2150 # 10 * $215
currency: USD
@@ -11,5 +12,6 @@ two:
security: aapl
date: <%= 1.day.ago.to_date %>
qty: 10
price: 214
amount: 2140 # 10 * $214
currency: USD

View File

@@ -37,11 +37,11 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
test "syncs account with trades only" do
aapl = securities(:aapl)
create_trade(account: @investment_account, date: 1.day.ago.to_date, security: aapl, qty: 10, price: 200)
create_trade(aapl, account: @investment_account, date: 1.day.ago.to_date, qty: 10)
run_sync_for @investment_account
assert_equal [ 52000, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance)
assert_equal [ 52140, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance)
end
test "syncs account with valuations and transactions" do

View File

@@ -7,6 +7,12 @@ class Account::EntryTest < ActiveSupport::TestCase
@entry = account_entries :transaction
end
test "entry cannot be older than 10 years ago" do
assert_raises ActiveRecord::RecordInvalid do
@entry.update! date: 50.years.ago.to_date
end
end
test "valuations cannot have more than one entry per day" do
existing_valuation = account_entries :valuation

View File

@@ -1,7 +1,7 @@
require "test_helper"
class Account::Holding::SyncerTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
include Account::EntriesTestHelper, SecuritiesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new)
@@ -25,12 +25,12 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
{ date: Date.current, price: 124 }
])
create_trade(security1, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN
create_trade(security1, account: @account, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN
create_trade(security1, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN
create_trade(security2, qty: 20, date: 1.day.ago.to_date) # buy 20 shares of NVDA
create_trade(security1, account: @account, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN
create_trade(security2, account: @account, qty: 20, date: 1.day.ago.to_date) # buy 20 shares of NVDA
create_trade(security1, qty: -10, date: Date.current) # sell 10 shares of AMZN
create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN
expected = [
{ symbol: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date },
@@ -45,6 +45,27 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
assert_holdings(expected)
end
test "generates all holdings even when missing security prices" do
aapl = create_security("AMZN", prices: [
{ date: 1.day.ago.to_date, price: 215 }
])
create_trade(aapl, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210)
# 2 days ago — no daily price found, but since this is day of entry, we fall back to entry price
# 1 day ago — finds daily price, uses it
# Today — no daily price, no entry, so price and amount are `nil`
expected = [
{ symbol: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date },
{ symbol: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date },
{ symbol: "AMZN", qty: 10, price: nil, amount: nil, date: Date.current }
]
run_sync_for(@account)
assert_holdings(expected)
end
private
def assert_holdings(expected_holdings)
@@ -64,37 +85,6 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
end
end
def create_security(symbol, prices:)
isin_codes = {
"AMZN" => "US0231351067",
"NVDA" => "US67066G1040"
}
isin = isin_codes[symbol]
prices.each do |price|
Security::Price.create! isin: isin, date: price[:date], price: price[:price]
end
Security.create! isin: isin, symbol: symbol
end
def create_trade(security, qty:, date:)
price = Security::Price.find_by!(isin: security.isin, date: date).price
trade = Account::Trade.new \
qty: qty,
security: security,
price: price
@account.entries.create! \
name: "Trade",
date: date,
amount: qty * price,
currency: "USD",
entryable: trade
end
def run_sync_for(account)
Account::Holding::Syncer.new(account).run
end

View File

@@ -1,7 +1,71 @@
require "test_helper"
require "ostruct"
class Account::HoldingTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
include Account::EntriesTestHelper, SecuritiesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new)
# Current day holding instances
@amzn, @nvda = load_holdings
end
test "calculates portfolio weight" do
expected_portfolio_value = 6960.0
expected_amzn_weight = 3240.0 / expected_portfolio_value * 100
expected_nvda_weight = 3720.0 / expected_portfolio_value * 100
assert_in_delta expected_amzn_weight, @amzn.weight, 0.001
assert_in_delta expected_nvda_weight, @nvda.weight, 0.001
end
test "calculates simple average cost basis" do
assert_equal Money.new((212.0 + 216.0) / 2), @amzn.avg_cost
assert_equal Money.new((128.0 + 124.0) / 2), @nvda.avg_cost
end
test "calculates total return trend" do
# Gained $30, or 0.93%
assert_equal Money.new(30), @amzn.trend.value
assert_in_delta 0.9, @amzn.trend.percent, 0.001
# Lost $60, or -1.59%
assert_equal Money.new(-60), @nvda.trend.value
assert_in_delta -1.6, @nvda.trend.percent, 0.001
end
private
def load_holdings
security1 = create_security("AMZN", prices: [
{ date: 1.day.ago.to_date, price: 212.00 },
{ date: Date.current, price: 216.00 }
])
security2 = create_security("NVDA", prices: [
{ date: 1.day.ago.to_date, price: 128.00 },
{ date: Date.current, price: 124.00 }
])
create_holding(security1, 1.day.ago.to_date, 10)
amzn = create_holding(security1, Date.current, 15)
create_holding(security2, 1.day.ago.to_date, 5)
nvda = create_holding(security2, Date.current, 30)
[ amzn, nvda ]
end
def create_holding(security, date, qty)
price = Security::Price.find_by(date: date, isin: security.isin).price
@account.holdings.create! \
date: date,
security: security,
qty: qty,
price: price,
amount: qty * price,
currency: "USD"
end
end

View File

@@ -25,6 +25,10 @@ class AccountTest < ActiveSupport::TestCase
@account.sync start_date: start_date
end
test "needs sync if account has not synced today" do
assert @account.needs_sync?
end
test "groups accounts by type" do
result = @family.accounts.by_group(period: Period.all)
assets = result[:assets]

View File

@@ -48,6 +48,14 @@ class FamilyTest < ActiveSupport::TestCase
assert_equal Money.new(50000, @family.currency), @family.net_worth
end
test "needs sync if last family sync was before today" do
assert @family.needs_sync?
@family.update! last_synced_at: Time.now
assert_not @family.needs_sync?
end
test "syncs active accounts" do
account = create_account(balance: 1000, accountable: CreditCard.new, is_active: false)
@@ -57,7 +65,9 @@ class FamilyTest < ActiveSupport::TestCase
account.update! is_active: true
Account.any_instance.expects(:sync_later).with(start_date: nil).once
Account.any_instance.expects(:needs_sync?).once.returns(true)
Account.any_instance.expects(:last_sync_date).once.returns(2.days.ago.to_date)
Account.any_instance.expects(:sync_later).with(start_date: 2.days.ago.to_date).once
@family.sync
end

View File

@@ -28,12 +28,19 @@ module Account::EntriesTestHelper
Account::Entry.create! entry_defaults.merge(attributes)
end
def create_trade(account:, security:, qty:, price:, date:)
def create_trade(security, account:, qty:, date:, price: nil)
trade_price = price || Security::Price.find_by!(isin: security.isin, date: date).price
trade = Account::Trade.new \
qty: qty,
security: security,
price: trade_price
account.entries.create! \
date: date,
amount: qty * price,
currency: "USD",
name: "Trade",
entryable: Account::Trade.new(qty: qty, price: price, security: security)
date: date,
amount: qty * trade_price,
currency: "USD",
entryable: trade
end
end

View File

@@ -0,0 +1,16 @@
module SecuritiesTestHelper
def create_security(symbol, prices:)
isin_codes = {
"AMZN" => "US0231351067",
"NVDA" => "US67066G1040"
}
isin = isin_codes[symbol]
prices.each do |price|
Security::Price.create! isin: isin, date: price[:date], price: price[:price]
end
Security.create! isin: isin, symbol: symbol
end
end

View File

@@ -60,7 +60,7 @@ class AccountsTest < ApplicationSystemTestCase
select "Chase", from: "Financial institution"
fill_in "account[balance]", with: 100.99
check "Add a start balance for this account"
fill_in "Start date (optional)", with: 10.days.ago.to_date.to_s
fill_in "Start date (optional)", with: 10.days.ago.to_date
fill_in "Start balance (optional)", with: 95
click_button "Add #{humanized_accountable(accountable_type).downcase}"