mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 15:35:22 +02:00
Merge branch 'main' of github.com:maybe-finance/maybe into rule-name
This commit is contained in:
commit
611f4580bf
16 changed files with 197 additions and 240 deletions
|
@ -20,9 +20,15 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
|
|||
|
||||
if valuation.present?
|
||||
@balances << build_balance(date, previous_cash_balance, holdings_value)
|
||||
else
|
||||
# If date is today, we don't distinguish cash vs. total since provider's are inconsistent with treatment
|
||||
# of the cash component. Instead, just set the balance equal to the "total value" reported by the provider
|
||||
if date == Date.current
|
||||
@balances << build_balance(date, account.cash_balance, account.balance - account.cash_balance)
|
||||
else
|
||||
@balances << build_balance(date, current_cash_balance, holdings_value)
|
||||
end
|
||||
end
|
||||
|
||||
current_cash_balance = previous_cash_balance
|
||||
end
|
||||
|
|
|
@ -5,90 +5,26 @@
|
|||
class Balance::TrendCalculator
|
||||
BalanceTrend = Struct.new(:trend, :cash, keyword_init: true)
|
||||
|
||||
class << self
|
||||
def for(entries)
|
||||
return nil if entries.blank?
|
||||
|
||||
account = entries.first.account
|
||||
|
||||
date_range = entries.minmax_by(&:date)
|
||||
min_entry_date, max_entry_date = date_range.map(&:date)
|
||||
|
||||
# In case view is filtered and there are entry gaps, refetch all entries in range
|
||||
all_entries = account.entries.where(date: min_entry_date..max_entry_date).chronological.to_a
|
||||
balances = account.balances.where(date: (min_entry_date - 1.day)..max_entry_date).chronological.to_a
|
||||
holdings = account.holdings.where(date: (min_entry_date - 1.day)..max_entry_date).to_a
|
||||
|
||||
new(all_entries, balances, holdings)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(entries, balances, holdings)
|
||||
@entries = entries
|
||||
def initialize(balances)
|
||||
@balances = balances
|
||||
@holdings = holdings
|
||||
end
|
||||
|
||||
def trend_for(entry)
|
||||
intraday_balance = nil
|
||||
intraday_cash_balance = nil
|
||||
def trend_for(date)
|
||||
balance = @balances.find { |b| b.date == date }
|
||||
prior_balance = @balances.find { |b| b.date == date - 1.day }
|
||||
|
||||
start_of_day_balance = balances.find { |b| b.date == entry.date - 1.day && b.currency == entry.currency }
|
||||
end_of_day_balance = balances.find { |b| b.date == entry.date && b.currency == entry.currency }
|
||||
|
||||
return BalanceTrend.new(trend: nil) if start_of_day_balance.blank? || end_of_day_balance.blank?
|
||||
|
||||
todays_holdings_value = holdings.select { |h| h.date == entry.date }.sum(&:amount)
|
||||
|
||||
prior_balance = start_of_day_balance.balance
|
||||
prior_cash_balance = start_of_day_balance.cash_balance
|
||||
current_balance = nil
|
||||
current_cash_balance = nil
|
||||
|
||||
todays_entries = entries.select { |e| e.date == entry.date }
|
||||
|
||||
todays_entries.each_with_index do |e, idx|
|
||||
if e.valuation?
|
||||
current_balance = e.amount
|
||||
current_cash_balance = e.amount
|
||||
else
|
||||
multiplier = e.account.liability? ? 1 : -1
|
||||
balance_change = e.trade? ? 0 : multiplier * e.amount
|
||||
cash_change = multiplier * e.amount
|
||||
|
||||
current_balance = prior_balance + balance_change
|
||||
current_cash_balance = prior_cash_balance + cash_change
|
||||
end
|
||||
|
||||
if e.id == entry.id
|
||||
# Final entry should always match the end-of-day balances
|
||||
if idx == todays_entries.size - 1
|
||||
intraday_balance = end_of_day_balance.balance
|
||||
intraday_cash_balance = end_of_day_balance.cash_balance
|
||||
else
|
||||
intraday_balance = current_balance
|
||||
intraday_cash_balance = current_cash_balance
|
||||
end
|
||||
|
||||
break
|
||||
else
|
||||
prior_balance = current_balance
|
||||
prior_cash_balance = current_cash_balance
|
||||
end
|
||||
end
|
||||
|
||||
return BalanceTrend.new(trend: nil) unless intraday_balance.present?
|
||||
return BalanceTrend.new(trend: nil) unless balance.present?
|
||||
|
||||
BalanceTrend.new(
|
||||
trend: Trend.new(
|
||||
current: Money.new(intraday_balance, entry.currency),
|
||||
previous: Money.new(prior_balance, entry.currency),
|
||||
favorable_direction: entry.account.favorable_direction
|
||||
current: Money.new(balance.balance, balance.currency),
|
||||
previous: Money.new(prior_balance.balance, balance.currency),
|
||||
favorable_direction: balance.account.favorable_direction
|
||||
),
|
||||
cash: Money.new(intraday_cash_balance, entry.currency),
|
||||
cash: Money.new(balance.cash_balance, balance.currency),
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :entries, :balances, :holdings
|
||||
attr_reader :balances
|
||||
end
|
||||
|
|
|
@ -11,6 +11,7 @@ class PlaidInvestmentSync
|
|||
@securities = securities
|
||||
|
||||
PlaidAccount.transaction do
|
||||
normalize_cash_balance!
|
||||
sync_transactions!
|
||||
sync_holdings!
|
||||
end
|
||||
|
@ -19,6 +20,23 @@ class PlaidInvestmentSync
|
|||
private
|
||||
attr_reader :transactions, :holdings, :securities
|
||||
|
||||
# Plaid considers "brokerage cash" and "cash equivalent holdings" to all be part of "cash balance"
|
||||
# Internally, we DO NOT.
|
||||
# Maybe clearly distinguishes between "brokerage cash" vs. "holdings (i.e. invested cash)"
|
||||
# For this reason, we must back out cash + cash equivalent holdings from the reported cash balance to avoid double counting
|
||||
def normalize_cash_balance!
|
||||
excludable_cash_holdings = holdings.select do |h|
|
||||
internal_security, plaid_security = get_security(h.security_id, securities)
|
||||
internal_security.present? && (plaid_security&.is_cash_equivalent || plaid_security&.type == "cash")
|
||||
end
|
||||
|
||||
excludable_cash_holdings_value = excludable_cash_holdings.sum { |h| h.quantity * h.institution_price }
|
||||
|
||||
plaid_account.account.update!(
|
||||
cash_balance: plaid_account.account.cash_balance - excludable_cash_holdings_value
|
||||
)
|
||||
end
|
||||
|
||||
def sync_transactions!
|
||||
transactions.each do |transaction|
|
||||
security, plaid_security = get_security(transaction.security_id, securities)
|
||||
|
@ -88,8 +106,8 @@ class PlaidInvestmentSync
|
|||
|
||||
# Find any matching security
|
||||
security = Security.find_or_create_by!(
|
||||
ticker: plaid_security.ticker_symbol,
|
||||
exchange_operating_mic: operating_mic
|
||||
ticker: plaid_security.ticker_symbol&.upcase,
|
||||
exchange_operating_mic: operating_mic&.upcase
|
||||
)
|
||||
|
||||
[ security, plaid_security ]
|
||||
|
|
|
@ -42,7 +42,7 @@ class PlaidItem < ApplicationRecord
|
|||
|
||||
begin
|
||||
Rails.logger.info("Fetching and loading Plaid data")
|
||||
plaid_data = fetch_and_load_plaid_data
|
||||
fetch_and_load_plaid_data(sync)
|
||||
update!(status: :good) if requires_update?
|
||||
|
||||
# Schedule account syncs
|
||||
|
@ -51,7 +51,6 @@ class PlaidItem < ApplicationRecord
|
|||
end
|
||||
|
||||
Rails.logger.info("Plaid data fetched and loaded")
|
||||
plaid_data
|
||||
rescue Plaid::ApiError => e
|
||||
handle_plaid_error(e)
|
||||
raise e
|
||||
|
@ -120,7 +119,7 @@ class PlaidItem < ApplicationRecord
|
|||
end
|
||||
|
||||
private
|
||||
def fetch_and_load_plaid_data
|
||||
def fetch_and_load_plaid_data(sync)
|
||||
data = {}
|
||||
|
||||
# Log what we're about to fetch
|
||||
|
@ -147,6 +146,7 @@ class PlaidItem < ApplicationRecord
|
|||
# Accounts
|
||||
fetched_accounts = plaid_provider.get_item_accounts(self).accounts
|
||||
data[:accounts] = fetched_accounts || []
|
||||
sync.update!(data: data)
|
||||
Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})"
|
||||
|
||||
internal_plaid_accounts = fetched_accounts.map do |account|
|
||||
|
@ -158,6 +158,7 @@ class PlaidItem < ApplicationRecord
|
|||
# Transactions
|
||||
fetched_transactions = safe_fetch_plaid_data(:get_item_transactions)
|
||||
data[:transactions] = fetched_transactions || []
|
||||
sync.update!(data: data)
|
||||
|
||||
if fetched_transactions
|
||||
Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})"
|
||||
|
@ -177,6 +178,7 @@ class PlaidItem < ApplicationRecord
|
|||
# Investments
|
||||
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
|
||||
data[:investments] = fetched_investments || []
|
||||
sync.update!(data: data)
|
||||
|
||||
if fetched_investments
|
||||
Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})"
|
||||
|
@ -194,6 +196,7 @@ class PlaidItem < ApplicationRecord
|
|||
# Liabilities
|
||||
fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities)
|
||||
data[:liabilities] = fetched_liabilities || []
|
||||
sync.update!(data: data)
|
||||
|
||||
if fetched_liabilities
|
||||
Rails.logger.info "Processing Plaid liabilities (credit: #{fetched_liabilities.credit&.size || 0}, mortgage: #{fetched_liabilities.mortgage&.size || 0}, student: #{fetched_liabilities.student&.size || 0})"
|
||||
|
@ -209,8 +212,6 @@ class PlaidItem < ApplicationRecord
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
def safe_fetch_plaid_data(method)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
class Security < ApplicationRecord
|
||||
include Provided
|
||||
|
||||
before_save :upcase_ticker
|
||||
before_validation :upcase_symbols
|
||||
|
||||
has_many :trades, dependent: :nullify, class_name: "Trade"
|
||||
has_many :prices, dependent: :destroy
|
||||
|
@ -29,8 +29,8 @@ class Security < ApplicationRecord
|
|||
end
|
||||
|
||||
private
|
||||
|
||||
def upcase_ticker
|
||||
def upcase_symbols
|
||||
self.ticker = ticker.upcase
|
||||
self.exchange_operating_mic = exchange_operating_mic.upcase if exchange_operating_mic.present?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -19,8 +19,7 @@ class Sync < ApplicationRecord
|
|||
start!
|
||||
|
||||
begin
|
||||
data = syncable.sync_data(self, start_date: start_date)
|
||||
update!(data: data) if data
|
||||
syncable.sync_data(self, start_date: start_date)
|
||||
|
||||
unless has_pending_child_syncs?
|
||||
complete!
|
||||
|
|
|
@ -90,7 +90,7 @@ class TradeImport < Import
|
|||
return internal_security if internal_security.present?
|
||||
|
||||
# If security prices provider isn't properly configured or available, create with nil exchange_operating_mic
|
||||
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) unless Security.provider.present?
|
||||
return Security.find_or_create_by!(ticker: ticker&.upcase, exchange_operating_mic: nil) unless Security.provider.present?
|
||||
|
||||
# Cache provider responses so that when we're looping through rows and importing,
|
||||
# we only hit our provider for the unique combinations of ticker / exchange_operating_mic
|
||||
|
@ -104,9 +104,9 @@ class TradeImport < Import
|
|||
).first
|
||||
end
|
||||
|
||||
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) if provider_security.nil?
|
||||
return Security.find_or_create_by!(ticker: ticker&.upcase, exchange_operating_mic: nil) if provider_security.nil?
|
||||
|
||||
Security.find_or_create_by!(ticker: provider_security[:ticker], exchange_operating_mic: provider_security[:exchange_operating_mic]) do |security|
|
||||
Security.find_or_create_by!(ticker: provider_security[:ticker]&.upcase, exchange_operating_mic: provider_security[:exchange_operating_mic]&.upcase) do |security|
|
||||
security.name = provider_security[:name]
|
||||
security.country_code = provider_security[:country_code]
|
||||
security.logo_url = provider_security[:logo_url]
|
||||
|
|
|
@ -77,10 +77,11 @@
|
|||
<div>
|
||||
<div class="rounded-tl-lg rounded-tr-lg bg-container border-alpha-black-25 shadow-xs">
|
||||
<div class="space-y-4">
|
||||
<% calculator = Balance::TrendCalculator.for(@entries) %>
|
||||
<% calculator = Balance::TrendCalculator.new(@account.balances) %>
|
||||
|
||||
<%= entries_by_date(@entries) do |entries| %>
|
||||
<% entries.each do |entry| %>
|
||||
<%= render entry, balance_trend: calculator&.trend_for(entry), view_ctx: "account" %>
|
||||
<% entries.each_with_index do |entry, index| %>
|
||||
<%= render entry, balance_trend: index == 0 ? calculator.trend_for(entry.date) : nil, view_ctx: "account" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
Period.as_options,
|
||||
{ selected: period.key },
|
||||
data: { "auto-submit-form-target": "auto" },
|
||||
class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
||||
class: "bg-container border border-secondary rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,36 @@
|
|||
<%
|
||||
nav_sections = [
|
||||
{
|
||||
header: t('.general_section_title'),
|
||||
items: [
|
||||
{ label: t('.profile_label'), path: settings_profile_path, icon: 'circle-user' },
|
||||
{ label: t('.preferences_label'), path: settings_preferences_path, icon: 'bolt' },
|
||||
{ label: t('.security_label'), path: settings_security_path, icon: 'shield-check' },
|
||||
{ label: t('.self_hosting_label'), path: settings_hosting_path, icon: 'database', if: self_hosted? },
|
||||
{ label: t('.billing_label'), path: settings_billing_path, icon: 'circle-dollar-sign', if: !self_hosted? },
|
||||
{ label: t('.accounts_label'), path: accounts_path, icon: 'layers' },
|
||||
{ label: t('.imports_label'), path: imports_path, icon: 'download' }
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('.transactions_section_title'),
|
||||
items: [
|
||||
{ label: t('.tags_label'), path: tags_path, icon: 'tags' },
|
||||
{ label: t('.categories_label'), path: categories_path, icon: 'shapes' },
|
||||
{ label: t('.rules_label'), path: rules_path, icon: 'git-branch' },
|
||||
{ label: t('.merchants_label'), path: family_merchants_path, icon: 'store' }
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('.other_section_title'),
|
||||
items: [
|
||||
{ label: t('.whats_new_label'), path: changelog_path, icon: 'box' },
|
||||
{ label: t('.feedback_label'), path: feedback_path, icon: 'megaphone' }
|
||||
]
|
||||
}
|
||||
]
|
||||
%>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="hidden lg:flex items-center gap-2 p-1.5">
|
||||
<%= render LinkComponent.new(
|
||||
|
@ -6,86 +39,27 @@
|
|||
href: previous_path,
|
||||
variant: "ghost",
|
||||
) %>
|
||||
|
||||
<%= link_to previous_path, class: "hidden md:block uppercase bg-surface-inset-hover rounded-sm px-1 py-0.5 text-xs text-secondary shadow-sm ml-1 pointer-events-none", data: { controller: "hotkey", hotkey: "Escape" } do %>
|
||||
<kbd>esc</kbd>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<nav class="space-y-4 hidden md:block">
|
||||
<% nav_sections.each do |section| %>
|
||||
<section class="space-y-2">
|
||||
<div class="flex items-center gap-2 px-3">
|
||||
<h3 class="uppercase text-secondary font-medium text-xs"><%= t(".general_section_title") %></h3>
|
||||
<h3 class="uppercase text-secondary font-medium text-xs"><%= section[:header] %></h3>
|
||||
<div class="h-px bg-alpha-black-100 w-full"></div>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<% section[:items].each do |item| %>
|
||||
<% next if item[:if] == false %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".profile_label"), path: settings_profile_path, icon: "circle-user" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".security_label"), path: settings_security_path, icon: "shield-check" %>
|
||||
</li>
|
||||
|
||||
<% if self_hosted? %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".self_hosting_label"), path: settings_hosting_path, icon: "database" %>
|
||||
<%= render "settings/settings_nav_item", name: item[:label], path: item[:path], icon: item[:icon] %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% unless self_hosted? %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign" %>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".accounts_label"), path: accounts_path, icon: "layers" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".imports_label"), path: imports_path, icon: "download" %>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<div class="flex items-center gap-2 px-3">
|
||||
<h3 class="uppercase text-secondary font-medium text-xs"><%= t(".transactions_section_title") %></h3>
|
||||
<div class="h-px bg-alpha-black-100 w-full"></div>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".tags_label"), path: tags_path, icon: "tags" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".categories_label"), path: categories_path, icon: "shapes" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: "Rules", path: rules_path, icon: "git-branch" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".merchants_label"), path: family_merchants_path, icon: "store" %>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<div class="flex items-center gap-2 px-3">
|
||||
<h3 class="uppercase text-secondary font-medium text-xs"><%= t(".other_section_title") %></h3>
|
||||
<div class="h-px bg-alpha-black-100 w-full"></div>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".whats_new_label"), path: changelog_path, icon: "box" %>
|
||||
<%= render "settings/settings_nav_item", name: t(".feedback_label"), path: feedback_path, icon: "megaphone" %>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %>
|
||||
<%= icon("log-out", color: "current") %>
|
||||
|
@ -93,7 +67,6 @@
|
|||
<% end %>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<nav class="space-y-4 overflow-y-auto md:hidden" id="mobile-settings-nav">
|
||||
<ul class="flex space-y-1">
|
||||
<li>
|
||||
|
@ -104,61 +77,18 @@
|
|||
variant: "ghost",
|
||||
) %>
|
||||
</li>
|
||||
|
||||
<% nav_sections.each do |section| %>
|
||||
<% section[:items].each do |item| %>
|
||||
<% next if item[:if] == false %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".profile_label"), path: settings_profile_path, icon: "circle-user" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".security_label"), path: settings_security_path, icon: "shield-check" %>
|
||||
</li>
|
||||
|
||||
<% if self_hosted? %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".self_hosting_label"), path: settings_hosting_path, icon: "database" %>
|
||||
<%= render "settings/settings_nav_item", name: item[:label], path: item[:path], icon: item[:icon] %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% unless self_hosted? %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign" %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".accounts_label"), path: accounts_path, icon: "layers" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".imports_label"), path: imports_path, icon: "download" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".tags_label"), path: tags_path, icon: "tags" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".categories_label"), path: categories_path, icon: "shapes" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".merchants_label"), path: family_merchants_path, icon: "store" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".whats_new_label"), path: changelog_path, icon: "box" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".feedback_label"), path: feedback_path, icon: "megaphone" %>
|
||||
</li>
|
||||
|
||||
<%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %>
|
||||
<%= icon("log-out", color: "current") %>
|
||||
<span><%= t(".logout") %></span>
|
||||
<% end %>
|
||||
|
||||
</ul>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
@ -4,12 +4,11 @@
|
|||
|
||||
<%= turbo_frame_tag dom_id(entry) do %>
|
||||
<%= turbo_frame_tag dom_id(transaction) do %>
|
||||
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4 md:p-4
|
||||
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4 lg:p-4
|
||||
<%= @focused_record == entry || @focused_record == transaction ?
|
||||
"border border-gray-900 rounded-lg" : "" %>">
|
||||
|
||||
<div class="pr-4 md:pr-10 flex items-center gap-3 md:gap-4
|
||||
<%= balance_trend ? "col-span-8 md:col-span-6" : "col-span-8" %>">
|
||||
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 lg:col-span-6">
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
disabled: transaction.transfer?,
|
||||
class: "checkbox checkbox--light",
|
||||
|
@ -58,7 +57,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="text-secondary text-xs font-normal hidden md:block">
|
||||
<div class="text-secondary text-xs font-normal hidden lg:block">
|
||||
<% if transaction.transfer? %>
|
||||
<%= render "transfers/account_links",
|
||||
transfer: transaction.transfer,
|
||||
|
@ -76,26 +75,24 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1 col-span-2">
|
||||
<div class="hidden lg:flex items-center gap-1 col-span-2">
|
||||
<%= render "transactions/transaction_category", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-4 md:col-span-2 ml-auto text-right">
|
||||
<div class="col-span-4 lg:col-span-2 ml-auto text-right">
|
||||
<%= content_tag :p,
|
||||
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
|
||||
class: ["text-green-600": entry.amount.negative?] %>
|
||||
</div>
|
||||
|
||||
<% if balance_trend %>
|
||||
<div class="col-span-2 justify-self-end hidden md:block">
|
||||
<% if balance_trend.trend %>
|
||||
<div class="col-span-2 justify-self-end hidden lg:block">
|
||||
<% if balance_trend&.trend %>
|
||||
<%= tag.p format_money(balance_trend.trend.current),
|
||||
class: "font-medium text-sm text-primary" %>
|
||||
<% else %>
|
||||
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
@ -14,9 +14,7 @@
|
|||
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<%= tag.div class: "w-6 h-6 rounded-full p-1.5 flex items-center justify-center", style: "color: #{color}" do %>
|
||||
<%= icon(icon, size: "sm", color: "current") %>
|
||||
<% end %>
|
||||
<%= render FilledIconComponent.new(icon: icon, size: "sm", hex_color: color, rounded: true) %>
|
||||
|
||||
<div class="truncate text-primary">
|
||||
<%= link_to entry.name,
|
||||
|
|
|
@ -59,6 +59,8 @@ services:
|
|||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- maybe_net
|
||||
|
||||
worker:
|
||||
image: ghcr.io/maybe-finance/maybe:latest
|
||||
|
@ -69,6 +71,8 @@ services:
|
|||
condition: service_healthy
|
||||
environment:
|
||||
<<: *rails_env
|
||||
networks:
|
||||
- maybe_net
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
|
@ -82,11 +86,11 @@ services:
|
|||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- maybe_net
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
@ -95,8 +99,14 @@ services:
|
|||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- maybe_net
|
||||
|
||||
volumes:
|
||||
app-storage:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
|
||||
networks:
|
||||
maybe_net:
|
||||
driver: bridge
|
||||
|
|
|
@ -80,6 +80,7 @@ en:
|
|||
other_section_title: More
|
||||
preferences_label: Preferences
|
||||
profile_label: Account
|
||||
rules_label: Rules
|
||||
security_label: Security
|
||||
self_hosting_label: Self hosting
|
||||
tags_label: Tags
|
||||
|
|
|
@ -120,4 +120,23 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
|
|||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "uses provider reported holdings and cash value on current day" do
|
||||
aapl = securities(:aapl)
|
||||
|
||||
# Implied holdings value of $1,000 from provider
|
||||
@account.update!(cash_balance: 19000, balance: 20000)
|
||||
|
||||
# Create a holding that differs in value from provider ($2,000 vs. the $1,000 reported by provider)
|
||||
Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD")
|
||||
Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD")
|
||||
|
||||
# Today reports the provider value. Yesterday, provider won't give us any data, so we MUST look at the generated holdings value
|
||||
# to calculate the end balance ($19,000 cash + $2,000 holdings = $21,000 total value)
|
||||
expected = [ 21000, 20000 ]
|
||||
|
||||
calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
end
|
||||
|
|
41
test/models/security_test.rb
Normal file
41
test/models/security_test.rb
Normal file
|
@ -0,0 +1,41 @@
|
|||
require "test_helper"
|
||||
|
||||
class SecurityTest < ActiveSupport::TestCase
|
||||
# Below has 3 example scenarios:
|
||||
# 1. Original ticker
|
||||
# 2. Duplicate ticker on a different exchange (different market price)
|
||||
# 3. "Offline" version of the same ticker (for users not connected to a provider)
|
||||
test "can have duplicate tickers if exchange is different" do
|
||||
original = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
|
||||
duplicate = Security.create!(ticker: "TEST", exchange_operating_mic: "CBOE")
|
||||
offline = Security.create!(ticker: "TEST", exchange_operating_mic: nil)
|
||||
|
||||
assert original.valid?
|
||||
assert duplicate.valid?
|
||||
assert offline.valid?
|
||||
end
|
||||
|
||||
test "cannot have duplicate tickers if exchange is the same" do
|
||||
original = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
|
||||
duplicate = Security.new(ticker: "TEST", exchange_operating_mic: "XNAS")
|
||||
|
||||
assert_not duplicate.valid?
|
||||
assert_equal [ "has already been taken" ], duplicate.errors[:ticker]
|
||||
end
|
||||
|
||||
test "cannot have duplicate tickers if exchange is nil" do
|
||||
original = Security.create!(ticker: "TEST", exchange_operating_mic: nil)
|
||||
duplicate = Security.new(ticker: "TEST", exchange_operating_mic: nil)
|
||||
|
||||
assert_not duplicate.valid?
|
||||
assert_equal [ "has already been taken" ], duplicate.errors[:ticker]
|
||||
end
|
||||
|
||||
test "casing is ignored when checking for duplicates" do
|
||||
original = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS")
|
||||
duplicate = Security.new(ticker: "tEst", exchange_operating_mic: "xNaS")
|
||||
|
||||
assert_not duplicate.valid?
|
||||
assert_equal [ "has already been taken" ], duplicate.errors[:ticker]
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue