diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index f97f63a5..4af14181 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -71,18 +71,22 @@ /* Typography */ .prose { - @apply max-w-none; + @apply max-w-none text-primary; + + a { + @apply text-link; + } h2 { - @apply text-xl font-medium; + @apply text-xl font-medium text-primary; } h3 { - @apply text-lg font-medium; + @apply text-lg font-medium text-primary; } li { - @apply m-0; + @apply m-0 text-primary; } details { diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index e03ccb1b..8bf9c6c8 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -26,6 +26,11 @@ --color-destructive: var(--color-red-600); --color-shadow: --alpha(var(--color-black) / 6%); + /* Colors used in Stimulus controllers with SVGs (easier to define light/dark mode here than toggle within the controllers) */ + /* See @layer base block below for dark mode overrides */ + --budget-unused-fill: var(--color-gray-200); + --budget-unallocated-fill: var(--color-gray-50); + /* Gray scale */ --color-gray-25: #FAFAFA; --color-gray-50: #F7F7F7; @@ -250,6 +255,10 @@ --color-destructive: var(--color-red-400); --color-shadow: --alpha(var(--color-white) / 8%); + /* Dark mode overrides for colors used in Stimulus controllers with SVGs */ + --budget-unused-fill: var(--color-gray-500); + --budget-unallocated-fill: var(--color-gray-700); + --shadow-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%); --shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%); --shadow-md: 0px 4px 8px -2px --alpha(var(--color-white) / 8%); @@ -330,7 +339,7 @@ } .form-field__input { - @apply text-primary border-none bg-transparent text-sm opacity-100 w-full p-0; + @apply text-primary border-none bg-container text-sm opacity-100 w-full p-0; @apply focus:opacity-100 focus:outline-hidden focus:ring-0; @apply placeholder-shown:opacity-50; @apply disabled:text-subdued; @@ -348,8 +357,6 @@ cursor: pointer; } } - - } .form-field__radio { diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 7be8048f..ec1d5401 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -52,8 +52,12 @@ module AccountableResource end def destroy - @account.destroy_later - redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize) + if @account.linked? + redirect_to account_path(@account), alert: "Cannot delete a linked account" + else + @account.destroy_later + redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize) + end end private diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 999fd673..a86e374e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -45,10 +45,6 @@ module ApplicationHelper content_for(:header_description) { page_description } end - def family_stream - turbo_stream_from Current.family if Current.family - end - def page_active?(path) current_page?(path) || (request.path.start_with?(path) && path != "/") end diff --git a/app/javascript/controllers/category_controller.js b/app/javascript/controllers/category_controller.js index dfbcd029..fdd47729 100644 --- a/app/javascript/controllers/category_controller.js +++ b/app/javascript/controllers/category_controller.js @@ -1,22 +1,36 @@ -import { Controller } from "@hotwired/stimulus" -import Pickr from '@simonwep/pickr' +import { Controller } from "@hotwired/stimulus"; +import Pickr from "@simonwep/pickr"; export default class extends Controller { - static targets = ["pickerBtn", "colorInput", "colorsSection", "paletteSection", "pickerSection", "colorPreview", "avatar", "details", "icon","validationMessage","selection","colorPickerRadioBtn"]; + static targets = [ + "pickerBtn", + "colorInput", + "colorsSection", + "paletteSection", + "pickerSection", + "colorPreview", + "avatar", + "details", + "icon", + "validationMessage", + "selection", + "colorPickerRadioBtn", + ]; + static values = { presetColors: Array, }; initialize() { - this.pickerBtnTarget.addEventListener('click', () => { + this.pickerBtnTarget.addEventListener("click", () => { this.showPaletteSection(); }); - this.colorInputTarget.addEventListener('input', (e) => { + this.colorInputTarget.addEventListener("input", (e) => { this.picker.setColor(e.target.value); }); - this.detailsTarget.addEventListener('toggle', (e) => { + this.detailsTarget.addEventListener("toggle", (e) => { if (!this.colorInputTarget.checkValidity()) { e.preventDefault(); this.colorInputTarget.reportValidity(); @@ -38,7 +52,7 @@ export default class extends Controller { this.picker = Pickr.create({ el: this.pickerBtnTarget, - theme: 'monolith', + theme: "monolith", container: ".pickerContainer", useAsButton: true, showAlways: true, @@ -48,7 +62,7 @@ export default class extends Controller { }, }); - this.picker.on('change', (color) => { + this.picker.on("change", (color) => { const hexColor = color.toHEXA().toString(); const rgbacolor = color.toRGBA(); @@ -74,23 +88,26 @@ export default class extends Controller { handleIconColorChange(e) { const selectedIcon = e.target; this.selectedIcon = selectedIcon; - + const currentColor = this.colorInputTarget.value; - - this.iconTargets.forEach(icon => { + + this.iconTargets.forEach((icon) => { const iconWrapper = icon.nextElementSibling; - iconWrapper.style.removeProperty("background-color") - iconWrapper.style.color = "black"; + iconWrapper.style.removeProperty("background-color"); + iconWrapper.style.removeProperty("color"); }); this.updateSelectedIconColor(currentColor); } handleIconChange(e) { - const iconSVG = e.currentTarget.closest('label').querySelector('svg').cloneNode(true); - this.avatarTarget.innerHTML = ''; - iconSVG.style.padding = "0px" - iconSVG.classList.add("w-8","h-8") + const iconSVG = e.currentTarget + .closest("label") + .querySelector("svg") + .cloneNode(true); + this.avatarTarget.innerHTML = ""; + iconSVG.style.padding = "0px"; + iconSVG.classList.add("w-8", "h-8"); this.avatarTarget.appendChild(iconSVG); } @@ -112,7 +129,9 @@ export default class extends Controller { handleContrastValidation(contrastRatio) { if (contrastRatio < 4.5) { - this.colorInputTarget.setCustomValidity("Poor contrast, choose darker color or auto-adjust."); + this.colorInputTarget.setCustomValidity( + "Poor contrast, choose darker color or auto-adjust.", + ); this.validationMessageTarget.classList.remove("hidden"); } else { @@ -121,7 +140,7 @@ export default class extends Controller { } } - autoAdjust(e){ + autoAdjust(e) { const currentRGBA = this.picker.getColor(); const adjustedRGBA = this.darkenColor(currentRGBA).toString(); this.picker.setColor(adjustedRGBA); @@ -129,22 +148,29 @@ export default class extends Controller { handleParentChange(e) { const parent = e.currentTarget.value; - const display = typeof parent === "string" && parent !== "" ? "none" : "flex"; + const display = + typeof parent === "string" && parent !== "" ? "none" : "flex"; this.selectionTarget.style.display = display; } - backgroundColor([r,g,b,a], percentage) { - const mixedR = Math.round((r * (percentage / 100)) + (255 * (1 - percentage / 100))); - const mixedG = Math.round((g * (percentage / 100)) + (255 * (1 - percentage / 100))); - const mixedB = Math.round((b * (percentage / 100)) + (255 * (1 - percentage / 100))); + backgroundColor([r, g, b, a], percentage) { + const mixedR = Math.round( + r * (percentage / 100) + 255 * (1 - percentage / 100), + ); + const mixedG = Math.round( + g * (percentage / 100) + 255 * (1 - percentage / 100), + ); + const mixedB = Math.round( + b * (percentage / 100) + 255 * (1 - percentage / 100), + ); return [mixedR, mixedG, mixedB]; } - luminance([r,g,b]) { - const toLinear = c => { + luminance([r, g, b]) { + const toLinear = (c) => { const scaled = c / 255; - return scaled <= 0.04045 - ? scaled / 12.92 + return scaled <= 0.04045 + ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4; }; return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); @@ -162,12 +188,15 @@ export default class extends Controller { const backgroundColor = this.backgroundColor(darkened, 10); let contrastRatio = this.contrast(darkened, backgroundColor); - while (contrastRatio < 4.5 && (darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0)) { + while ( + contrastRatio < 4.5 && + (darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0) + ) { darkened = [ Math.max(0, darkened[0] - 10), Math.max(0, darkened[1] - 10), Math.max(0, darkened[2] - 10), - darkened[3] + darkened[3], ]; contrastRatio = this.contrast(darkened, backgroundColor); } @@ -177,23 +206,23 @@ export default class extends Controller { showPaletteSection() { this.initPicker(); - this.colorsSectionTarget.classList.add('hidden'); - this.paletteSectionTarget.classList.remove('hidden'); - this.pickerSectionTarget.classList.remove('hidden'); + this.colorsSectionTarget.classList.add("hidden"); + this.paletteSectionTarget.classList.remove("hidden"); + this.pickerSectionTarget.classList.remove("hidden"); this.picker.show(); } showColorsSection() { - this.colorsSectionTarget.classList.remove('hidden'); - this.paletteSectionTarget.classList.add('hidden'); - this.pickerSectionTarget.classList.add('hidden'); + this.colorsSectionTarget.classList.remove("hidden"); + this.paletteSectionTarget.classList.add("hidden"); + this.pickerSectionTarget.classList.add("hidden"); if (this.picker) { this.picker.destroyAndRemove(); } } toggleSections() { - if (this.colorsSectionTarget.classList.contains('hidden')) { + if (this.colorsSectionTarget.classList.contains("hidden")) { this.showColorsSection(); } else { this.showPaletteSection(); diff --git a/app/javascript/controllers/donut_chart_controller.js b/app/javascript/controllers/donut_chart_controller.js index 55c7cbb3..0c52104b 100644 --- a/app/javascript/controllers/donut_chart_controller.js +++ b/app/javascript/controllers/donut_chart_controller.js @@ -136,13 +136,13 @@ export default class extends Controller { .attr("fill", function () { if (this.dataset.segmentId === segmentId) { if (this.dataset.segmentId === unusedSegmentId) { - return "#A3A3A3"; + return "var(--budget-unused-fill)"; } return this.dataset.originalColor; } - return "#F0F0F0"; + return "var(--budget-unallocated-fill)"; }); this.defaultContentTarget.classList.add("hidden"); diff --git a/app/javascript/controllers/sidebar_tabs_controller.js b/app/javascript/controllers/sidebar_tabs_controller.js new file mode 100644 index 00000000..f88a70f7 --- /dev/null +++ b/app/javascript/controllers/sidebar_tabs_controller.js @@ -0,0 +1,16 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="sidebar-tabs" +export default class extends Controller { + static targets = ["account"]; + + select(event) { + this.accountTargets.forEach((account) => { + if (account.contains(event.target)) { + account.classList.add("bg-container"); + } else { + account.classList.remove("bg-container"); + } + }); + } +} diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index 4a7a9f48..10aa7b9a 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -4,7 +4,6 @@ export default class extends Controller { static values = { userPreference: String }; connect() { - this.applyTheme(); this.startSystemThemeListener(); } @@ -45,7 +44,7 @@ export default class extends Controller { if (isDark) { document.documentElement.setAttribute("data-theme", "dark"); } else { - document.documentElement.removeAttribute("data-theme"); + document.documentElement.setAttribute("data-theme", "light"); } } @@ -60,20 +59,12 @@ export default class extends Controller { } }; - toDark() { - this.setTheme(true); - } - - toLight() { - this.setTheme(false); - } - toggle() { const currentTheme = document.documentElement.getAttribute("data-theme"); if (currentTheme === "dark") { - this.toLight(); + this.setTheme(false); } else { - this.toDark(); + this.setTheme(true); } } diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index dc60e8cc..3de33c57 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -138,36 +138,48 @@ export default class extends Controller { .attr("x1", this._d3XScale.range()[0]) .attr("x2", this._d3XScale.range()[1]); + // First stop - solid trend color gradient .append("stop") .attr("class", "start-color") .attr("offset", "0%") .attr("stop-color", this.dataValue.trend.color); + // Second stop - trend color right before split gradient .append("stop") - .attr("class", "middle-color") + .attr("class", "split-before") .attr("offset", "100%") .attr("stop-color", this.dataValue.trend.color); + // Third stop - gray color right after split + gradient + .append("stop") + .attr("class", "split-after") + .attr("offset", "100%") + .attr("stop-color", "var(--color-gray-400)"); + + // Fourth stop - solid gray to end gradient .append("stop") .attr("class", "end-color") .attr("offset", "100%") - .attr("class", "fg-subdued") - .attr("stop-color", "currentColor"); + .attr("stop-color", "var(--color-gray-400)"); } _setTrendlineSplitAt(percent) { + const position = percent * 100; + + // Update both stops at the split point this._d3Svg .select(`#${this.element.id}-split-gradient`) - .select(".middle-color") - .attr("offset", `${percent * 100}%`); + .select(".split-before") + .attr("offset", `${position}%`); this._d3Svg .select(`#${this.element.id}-split-gradient`) - .select(".end-color") - .attr("offset", `${percent * 100}%`); + .select(".split-after") + .attr("offset", `${position}%`); this._d3Svg .select(`#${this.element.id}-trendline-gradient-rect`) diff --git a/app/models/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb index 4c124ced..6a2ba70b 100644 --- a/app/models/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -21,7 +21,13 @@ class Balance::ReverseCalculator < Balance::BaseCalculator if valuation.present? @balances << build_balance(date, previous_cash_balance, holdings_value) else - @balances << build_balance(date, current_cash_balance, holdings_value) + # 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 diff --git a/app/models/balance/trend_calculator.rb b/app/models/balance/trend_calculator.rb index 5fb8b406..b088d022 100644 --- a/app/models/balance/trend_calculator.rb +++ b/app/models/balance/trend_calculator.rb @@ -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 diff --git a/app/models/budget.rb b/app/models/budget.rb index c2393da4..e53b6f09 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -131,14 +131,14 @@ class Budget < ApplicationRecord unused_segment_id = "unused" # Continuous gray segment for empty budgets - return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid? + return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: unused_segment_id } ] unless allocations_valid? segments = budget_categories.map do |bc| { color: bc.category.color, amount: budget_category_actual_spending(bc), id: bc.id } end if available_to_spend.positive? - segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id }) + segments.push({ color: "var(--budget-unallocated-fill)", amount: available_to_spend, id: unused_segment_id }) end segments diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb index e909a7e8..a2a84850 100644 --- a/app/models/budget_category.rb +++ b/app/models/budget_category.rb @@ -79,14 +79,14 @@ class BudgetCategory < ApplicationRecord unused_segment_id = "unused" overage_segment_id = "overage" - return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless actual_spending > 0 + return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: unused_segment_id } ] unless actual_spending > 0 segments = [ { color: category.color, amount: actual_spending, id: id } ] if available_to_spend.negative? - segments.push({ color: "#EF4444", amount: available_to_spend.abs, id: overage_segment_id }) + segments.push({ color: "var(--color-destructive)", amount: available_to_spend.abs, id: overage_segment_id }) else - segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id }) + segments.push({ color: "var(--budget-unallocated-fill)", amount: available_to_spend, id: unused_segment_id }) end segments diff --git a/app/models/family.rb b/app/models/family.rb index 12e899ae..d130a685 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -97,15 +97,15 @@ class Family < ApplicationRecord broadcast_refresh end - # If family has any syncs pending/syncing within the last hour, we show a persistent "syncing" notice. - # Ignore syncs older than 1 hour as they are considered "stale" + # If family has any syncs pending/syncing within the last 10 minutes, we show a persistent "syncing" notice. + # Ignore syncs older than 10 minutes as they are considered "stale" def syncing? Sync.where( "(syncable_type = 'Family' AND syncable_id = ?) OR (syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR (syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))", id, id, id - ).where(status: [ "pending", "syncing" ], created_at: 1.hour.ago..).exists? + ).where(status: [ "pending", "syncing" ], created_at: 10.minutes.ago..).exists? end def eu? diff --git a/app/models/period.rb b/app/models/period.rb index 2fbcd30b..a1a4dea2 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -11,55 +11,55 @@ class Period PERIODS = { "last_day" => { - date_range: [ 1.day.ago.to_date, Date.current ], + date_range: -> { [ 1.day.ago.to_date, Date.current ] }, label_short: "1D", label: "Last Day", comparison_label: "vs. yesterday" }, "current_week" => { - date_range: [ Date.current.beginning_of_week, Date.current ], + date_range: -> { [ Date.current.beginning_of_week, Date.current ] }, label_short: "WTD", label: "Current Week", comparison_label: "vs. start of week" }, "last_7_days" => { - date_range: [ 7.days.ago.to_date, Date.current ], + date_range: -> { [ 7.days.ago.to_date, Date.current ] }, label_short: "7D", label: "Last 7 Days", comparison_label: "vs. last week" }, "current_month" => { - date_range: [ Date.current.beginning_of_month, Date.current ], + date_range: -> { [ Date.current.beginning_of_month, Date.current ] }, label_short: "MTD", label: "Current Month", comparison_label: "vs. start of month" }, "last_30_days" => { - date_range: [ 30.days.ago.to_date, Date.current ], + date_range: -> { [ 30.days.ago.to_date, Date.current ] }, label_short: "30D", label: "Last 30 Days", comparison_label: "vs. last month" }, "last_90_days" => { - date_range: [ 90.days.ago.to_date, Date.current ], + date_range: -> { [ 90.days.ago.to_date, Date.current ] }, label_short: "90D", label: "Last 90 Days", comparison_label: "vs. last quarter" }, "current_year" => { - date_range: [ Date.current.beginning_of_year, Date.current ], + date_range: -> { [ Date.current.beginning_of_year, Date.current ] }, label_short: "YTD", label: "Current Year", comparison_label: "vs. start of year" }, "last_365_days" => { - date_range: [ 365.days.ago.to_date, Date.current ], + date_range: -> { [ 365.days.ago.to_date, Date.current ] }, label_short: "365D", label: "Last 365 Days", comparison_label: "vs. 1 year ago" }, "last_5_years" => { - date_range: [ 5.years.ago.to_date, Date.current ], + date_range: -> { [ 5.years.ago.to_date, Date.current ] }, label_short: "5Y", label: "Last 5 Years", comparison_label: "vs. 5 years ago" @@ -72,7 +72,7 @@ class Period raise InvalidKeyError, "Invalid period key: #{key}" end - start_date, end_date = PERIODS[key].fetch(:date_range) + start_date, end_date = PERIODS[key].fetch(:date_range).call new(key: key, start_date: start_date, end_date: end_date) end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 65acf9ae..4f60013e 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -15,13 +15,20 @@ class PlaidAccount < ApplicationRecord class << self def find_or_create_from_plaid_data!(plaid_data, family) - find_or_create_by!(plaid_id: plaid_data.account_id) do |a| - a.account = family.accounts.new( - name: plaid_data.name, - balance: plaid_data.balances.current || plaid_data.balances.available, - currency: plaid_data.balances.iso_currency_code, - accountable: TYPE_MAPPING[plaid_data.type].new - ) + PlaidAccount.transaction do + plaid_account = find_or_create_by!(plaid_id: plaid_data.account_id) + + internal_account = family.accounts.find_or_initialize_by(plaid_account_id: plaid_account.id) + + internal_account.name = plaid_data.name + internal_account.balance = plaid_data.balances.current || plaid_data.balances.available + internal_account.currency = plaid_data.balances.iso_currency_code + internal_account.accountable = TYPE_MAPPING[plaid_data.type].new + + internal_account.save! + plaid_account.save! + + plaid_account end end end diff --git a/app/models/plaid_investment_sync.rb b/app/models/plaid_investment_sync.rb index 489b0ca1..cc0d56a6 100644 --- a/app/models/plaid_investment_sync.rb +++ b/app/models/plaid_investment_sync.rb @@ -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 ] diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index baab5104..a527dc3e 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -42,16 +42,15 @@ 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 accounts.each do |account| - account.sync_later(start_date: start_date) + account.sync_later(start_date: start_date, parent_sync: sync) 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) diff --git a/app/models/rule/action_executor/auto_categorize.rb b/app/models/rule/action_executor/auto_categorize.rb index 72ff324e..e1d8a50f 100644 --- a/app/models/rule/action_executor/auto_categorize.rb +++ b/app/models/rule/action_executor/auto_categorize.rb @@ -11,7 +11,7 @@ class Rule::ActionExecutor::AutoCategorize < Rule::ActionExecutor enrichable_transactions = transaction_scope.enrichable(:category_id) if enrichable_transactions.empty? - Rails.logger.info("No transactions to auto-categorize for #{rule.title} #{rule.id}") + Rails.logger.info("No transactions to auto-categorize for #{rule.id}") return end diff --git a/app/models/rule/action_executor/auto_detect_merchants.rb b/app/models/rule/action_executor/auto_detect_merchants.rb index cc523303..87c04682 100644 --- a/app/models/rule/action_executor/auto_detect_merchants.rb +++ b/app/models/rule/action_executor/auto_detect_merchants.rb @@ -11,7 +11,7 @@ class Rule::ActionExecutor::AutoDetectMerchants < Rule::ActionExecutor enrichable_transactions = transaction_scope.enrichable(:merchant_id) if enrichable_transactions.empty? - Rails.logger.info("No transactions to auto-detect merchants for #{rule.title} #{rule.id}") + Rails.logger.info("No transactions to auto-detect merchants for #{rule.id}") return end diff --git a/app/models/security.rb b/app/models/security.rb index 0adabd8a..6115aa4c 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -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 diff --git a/app/models/sync.rb b/app/models/sync.rb index 201fed02..22b61829 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -19,33 +19,35 @@ 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) - complete! unless has_pending_child_syncs? - - Rails.logger.info("Sync completed, starting post-sync") - - syncable.post_sync(self) unless has_pending_child_syncs? - - if has_parent? - notify_parent_of_completion! + unless has_pending_child_syncs? + complete! + Rails.logger.info("Sync completed, starting post-sync") + syncable.post_sync(self) + Rails.logger.info("Post-sync completed") end - - Rails.logger.info("Post-sync completed") rescue StandardError => error fail! error raise error if Rails.env.development? + ensure + notify_parent_of_completion! if has_parent? end end end def handle_child_completion_event - unless has_pending_child_syncs? - if has_failed_child_syncs? - fail!(Error.new("One or more child syncs failed")) - else + Sync.transaction do + # We need this to ensure 2 child syncs don't update the parent at the exact same time with different results + # and cause the sync to hang in "syncing" status indefinitely + self.lock! + + unless has_pending_child_syncs? complete! + + # If this sync is both a child and a parent, we need to notify the parent of completion + notify_parent_of_completion! if has_parent? + syncable.post_sync(self) end end @@ -56,10 +58,6 @@ class Sync < ApplicationRecord children.where(status: [ :pending, :syncing ]).any? end - def has_failed_child_syncs? - children.where(status: :failed).any? - end - def has_parent? parent_id.present? end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index 04a6a4cf..e7a57f64 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -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] diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index 1ed98300..9ca2781c 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -21,16 +21,17 @@ <% end %> - <%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %> - <% tabs.with_nav do |nav| %> - <% nav.with_btn(id: "assets", label: "Assets") %> - <% nav.with_btn(id: "debts", label: "Debts") %> - <% nav.with_btn(id: "all", label: "All") %> - <% end %> +
+ <%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %> + <% tabs.with_nav do |nav| %> + <% nav.with_btn(id: "assets", label: "Assets") %> + <% nav.with_btn(id: "debts", label: "Debts") %> + <% nav.with_btn(id: "all", label: "All") %> + <% end %> - <% tabs.with_panel(tab_id: "assets") do %> -
- <%= render LinkComponent.new( + <% tabs.with_panel(tab_id: "assets") do %> +
+ <%= render LinkComponent.new( text: "New asset", variant: "ghost", href: new_account_path(step: "method_select", classification: "asset"), @@ -40,17 +41,17 @@ class: "justify-start" ) %> -
- <% family.balance_sheet.account_groups("asset").each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> +
+ <% family.balance_sheet.account_groups("asset").each do |group| %> + <%= render "accounts/accountable_group", account_group: group %> + <% end %> +
-
- <% end %> + <% end %> - <% tabs.with_panel(tab_id: "debts") do %> -
- <%= render LinkComponent.new( + <% tabs.with_panel(tab_id: "debts") do %> +
+ <%= render LinkComponent.new( text: "New debt", variant: "ghost", href: new_account_path(step: "method_select", classification: "liability"), @@ -60,17 +61,17 @@ class: "justify-start" ) %> -
- <% family.balance_sheet.account_groups("liability").each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> +
+ <% family.balance_sheet.account_groups("liability").each do |group| %> + <%= render "accounts/accountable_group", account_group: group %> + <% end %> +
-
- <% end %> + <% end %> - <% tabs.with_panel(tab_id: "all") do %> -
- <%= render LinkComponent.new( + <% tabs.with_panel(tab_id: "all") do %> +
+ <%= render LinkComponent.new( text: "New account", variant: "ghost", full_width: true, @@ -80,12 +81,13 @@ class: "justify-start" ) %> -
- <% family.balance_sheet.account_groups.each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> +
+ <% family.balance_sheet.account_groups.each do |group| %> + <%= render "accounts/accountable_group", account_group: group %> + <% end %> +
-
+ <% end %> <% end %> - <% end %> +
diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index a3939727..a1ab2e3a 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -1,6 +1,6 @@ <%# locals: (account_group:) %> -<%= render DisclosureComponent.new(title: account_group.name, align: :left) do |disclosure| %> +<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: account_group.accounts.any? { |account| page_active?(account_path(account)) }) do |disclosure| %> <% disclosure.with_summary_content do %>
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %> @@ -15,7 +15,13 @@
<% account_group.accounts.each do |account| %> - <%= link_to account_path(account), class: "block flex items-center gap-2 px-3 py-2 hover:bg-surface-hover", title: account.name do %> + <%= link_to account_path(account), + class: class_names( + "block flex items-center gap-2 px-3 py-2 rounded-lg", + page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover" + ), + data: { sidebar_tabs_target: "account", action: "click->sidebar-tabs#select" }, + title: account.name do %> <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
@@ -36,13 +42,15 @@ <% end %>
- <%= render LinkComponent.new( - href: new_polymorphic_path(account_group.key, step: "method_select"), - text: "New #{account_group.name.downcase.singularize}", - icon: "plus", - full_width: true, - variant: "ghost", - frame: :modal, - class: "justify-start" - ) %> +
+ <%= render LinkComponent.new( + href: new_polymorphic_path(account_group.key, step: "method_select"), + text: "New #{account_group.name.downcase.singularize}", + icon: "plus", + full_width: true, + variant: "ghost", + frame: :modal, + class: "justify-start" + ) %> +
<% end %> diff --git a/app/views/accounts/_form.html.erb b/app/views/accounts/_form.html.erb index 8cdb39ff..8a67d20f 100644 --- a/app/views/accounts/_form.html.erb +++ b/app/views/accounts/_form.html.erb @@ -6,7 +6,10 @@ <%= form.hidden_field :return_to, value: params[:return_to] %> <%= form.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label") %> - <%= form.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %> + + <% unless account.linked? %> + <%= form.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %> + <% end %> <%= yield form %>
diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 00bf610d..704c6789 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -77,10 +77,11 @@
- <% 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 %>
diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index 1661554c..08e67e84 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -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" %>
<% end %>
diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index 56d78f62..0691f34e 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -13,13 +13,15 @@ ) %> <% end %> - <% menu.with_item( - variant: "button", - text: "Delete account", - href: account_path(account), - method: :delete, - icon: "trash-2", - confirm: CustomConfirm.for_resource_deletion("account", high_severity: true), - data: { turbo_frame: :_top } - ) %> + <% unless account.linked? %> + <% menu.with_item( + variant: "button", + text: "Delete account", + href: account_path(account), + method: :delete, + icon: "trash-2", + confirm: CustomConfirm.for_resource_deletion("account", high_severity: true), + data: { turbo_frame: :_top } + ) %> + <% end %> <% end %> diff --git a/app/views/budgets/_budgeted_summary.html.erb b/app/views/budgets/_budgeted_summary.html.erb index 41400860..37e225e7 100644 --- a/app/views/budgets/_budgeted_summary.html.erb +++ b/app/views/budgets/_budgeted_summary.html.erb @@ -15,7 +15,7 @@
<% else %>
-
+
<% end %>
@@ -41,18 +41,18 @@
<% if budget.available_to_spend.negative? %> -
-
+
+
<% else %> -
-
+
+
<% end %>

<%= format_money(budget.actual_spending_money) %> spent

<% if budget.available_to_spend.negative? %> - <%= format_money(budget.available_to_spend_money.abs) %> over + <%= format_money(budget.available_to_spend_money.abs) %> over <% else %> <%= format_money(budget.available_to_spend_money) %> left <% end %> diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb index 5d92a2f2..ff19bab1 100644 --- a/app/views/budgets/show.html.erb +++ b/app/views/budgets/show.html.erb @@ -16,31 +16,26 @@

+ <% if @budget.initialized? && @budget.available_to_allocate.positive? %> -
- <% base_classes = "rounded-md px-2 py-1 flex-1 text-center" %> - <% selected_tab = params[:tab].presence || "budgeted" %> + <%= render TabsComponent.new(active_tab: params[:tab].presence || "budgeted") do |tabs| %> + <% tabs.with_nav do |nav| %> + <% nav.with_btn(id: "budgeted", label: "Budgeted") %> + <% nav.with_btn(id: "actuals", label: "Actual") %> + <% end %> - <%= link_to "Budgeted", - budget_path(@budget, tab: "budgeted"), - class: class_names( - base_classes, - "bg-container shadow-xs text-primary": selected_tab == "budgeted", - "text-secondary": selected_tab != "budgeted" - ) %> + <% tabs.with_panel(tab_id: "budgeted") do %> +
+ <%= render "budgets/budgeted_summary", budget: @budget %> +
+ <% end %> - <%= link_to "Actual", - budget_path(@budget, tab: "actuals"), - class: class_names( - base_classes, - "bg-container shadow-xs text-primary": selected_tab == "actuals", - "text-secondary": selected_tab != "actuals" - ) %> -
- -
- <%= render selected_tab == "budgeted" ? "budgets/budgeted_summary" : "budgets/actuals_summary", budget: @budget %> -
+ <% tabs.with_panel(tab_id: "actuals") do %> +
+ <%= render "budgets/actuals_summary", budget: @budget %> +
+ <% end %> + <% end %> <% else %>
<%= render "budgets/actuals_summary", budget: @budget %> diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb index 744ce94f..10135c25 100644 --- a/app/views/categories/_badge.html.erb +++ b/app/views/categories/_badge.html.erb @@ -2,10 +2,10 @@ <% category ||= Category.uncategorized %>
- <% if category.lucide_icon.present? %> <%= icon category.lucide_icon, size: "sm", color: "current" %> diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index bb15e650..2c62d739 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -7,7 +7,7 @@ <%= render partial: "color_avatar", locals: { category: category } %>
- + <%= icon("pen", size: "sm") %> @@ -19,7 +19,7 @@ <% Category::COLORS.each do |color| %> <% end %>
-

Icon

+

Icon

<% Category.icon_codes.each do |icon| %>