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 %> +
<%= 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 @@