mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 13:34:58 +00:00
* feat(i18n): complete Catalan translations + extract residual hardcoded strings
CA coverage
- All view/model/breadcrumb/doorkeeper/mailer locale files for ca: 0 missing
keys (was ~3,400). Translations follow informal "tu" register, sentence case,
domain glossary (Compte/Saldo/Transacció/Posició/Operació/Pressupost/...).
- Catalan pluralization test: ca uses one/other; mirrors
test/lib/polish_pluralization_test.rb.
- 8 LanguageTool-flagged grammar fixes applied (Connexió òrfena, Secret de
l'API, comma-pero, apostrophe elisions, etc).
Hardcoded string extraction (also fixes EN parity)
- UI::Account::Chart#title + chart.html.erb view tabs -> UI.account.chart.*
- UI::Account::BalanceReconciliation labels + tooltips ->
UI.account.balance_reconciliation.{labels,tooltips}.*
- transactions/_transfer_match.html.erb (Auto-matched, A/M, Confirm/Reject
match, Payment/Transfer is confirmed) -> transactions.transfer_match.*
- AccountOrder labels (Name/Balance asc/desc) -> account_order.* keys with
fallback to existing hardcoded labels.
- Depository::SUBTYPES surface in account list -> depositories.subtypes.*.*
- User role badge -> users.roles.* (admin / member / super_admin).
- 110+ country names -> countries.* (config/locales/countries.ca.yml).
Breadcrumb locale fix
- Breadcrumbable was a before_action that ran before Localize's around_action
switched I18n.locale, so default crumbs rendered in EN even when locale=ca.
- Convert to helper_method that defers translation to render-time (when
I18n.locale is already correct). Add all missing breadcrumb keys to ca + en.
- Layouts switched from @breadcrumbs to breadcrumbs helper.
Locale-aware helpers / formatters
- ApplicationHelper#localized_ordinal: ordinalize that respects ca
(1r/2n/3r/4t/Nè). Wired into preferences month_start_day select.
- Family#moniker_label / moniker_label_plural: translate the default "Family"/
"Group" monikers via shared.family_moniker.* with fallback to the family's
custom override.
- Budget#name: use I18n.l for month_year/short/long instead of strftime("%B %Y")
so the budget header date follows the active locale.
Tooling
- script/lt_check_ca.rb: batched LanguageTool checker (premium endpoint when
LT_USERNAME/LT_API_KEY are set, free fallback otherwise), picky mode,
motherTongue=en for false-friend detection.
- lib/tasks/i18n_screenshot.rake: dev-only rake to set user.locale=ca and
role=super_admin on the demo user so the i18n surfaces can be walked.
Out of scope (pre-existing, not introduced here)
- Native browser file input "Choose Files / No file chosen" (browser locale).
- D3.js client-side chart x-axis dates (JS-side Intl.DateTimeFormat needed).
- Sankey/donut labels = seed category names (data, not i18n).
- 2 rails-i18n datetime/errors interpolation warnings inherited from
config/locales/defaults/ca.yml.
* fix(i18n): apply idiomatic Catalan review (3-agent + native review)
Three parallel review agents flagged 203 findings (31 high / 73 medium / 99 low)
across all 111 ca.yml files. This commit applies the high-severity bugs plus a
curated subset of medium-impact fixes.
Grammar / agreement
- provider_sync_summary.health.stale_pending: `(exclòs)` -> `(exclosa/excloses)`
to agree with feminine `transacció(s)`.
- accounts.confirm_unlink.warning_no_sync: added reflexive `es` -
`el compte ja no es sincronitzarà`.
- sophtron_setup_required.heading: `no configurats` -> `sense configurar`
(avoids broken agreement across "ID" masc. + "clau" fem.).
- admin.sso_providers.form.errors_title: split into one/other pluralization
keys (en + ca); singular `ha impedit` was wrong for count > 1.
Brand consistency
- IndexaCapital -> Indexa Capital (37 occurrences across one file).
- Lunchflow -> Lunch Flow in two remaining places.
Anglicisms / domain mistranslations
- kraken_items setup_accounts.instructions: `ompliments d'operacions`
(lit. dental/food fillings) -> `execucions d'operacions`.
- settings kraken_panel.read_only_title: `Sincronització d'intercanvi`
(swap/trade) -> `Sincronització només de lectura amb l'exchange`.
- transactions convert_to_trade.security_custom + security_not_listed_hint:
`cotització` (price quote) -> `ticker` (the EN field IS a ticker symbol).
- loans.form.rate_type: `Tipus d'interès` collided with sibling
interest_rate -> `Modalitat del tipus`.
- brex_items.provider_panel.sandbox_note_html: `L'staging` (broken
contraction) -> `el staging`.
Idiom traps
- coinbase/binance/kraken wait_for_sync: `acabi de sincronitzar` is
ambiguous in CA (`acabar de + inf` reads as "has just done X") ->
`acabi la sincronització`.
- chats.ai_greeting.there: `a tothom` -> `''` (the EN fallback "Hey there"
is singular; literal CA `tothom` is plural and wrong for 1:1 chat).
- transactions.split_parent_row.split_label: `Divideix` (imperative) is
wrong as a status badge -> `Divisió` (noun).
- transactions.keep_both (2 occurrences): infinitive `mantenir ambdues` ->
imperative `mantén-les totes dues` to match the sibling Yes/No buttons.
- rules.clear_ai_cache: `Reinicia` (restart) -> `Buida` (empty/clear),
which matches the success notice (`s'està netejant`).
Moniker gender breakage (cross-file)
%{moniker} is interpolated downcased from family.moniker_label and may
resolve to feminine `família`/`llar` or masculine `grup`. Strings that
hard-code a gendered article ('al teu %{moniker}', 'aquesta %{moniker}',
'aquest/a %{moniker}') broke on at least one branch. Restructured the
affected sentences to drop the gendered determiner:
- account_sharings.show.no_members
- merchants.family_empty / family_title / provider_empty
- registrations.new.join_family_title
- settings.preferences.show.currencies_subtitle / sharing_subtitle
- simplefin_items.select_existing_account.no_accounts_found
- invitations.new.subtitle
- invitation_mailer.invite_email.subject (mailers/) + body (views/)
- snaptrade_items.providers.snaptrade.free_tier_warning
Terminology consistency
- models/account_statement/ca.yml attributes aligned with view-side
forms: `Saldo d'obertura`/`Saldo de tancament` ->
`Saldo inicial`/`Saldo final`; `Suggeriment de...` -> `Pista de...`.
- account_statements.coverage.status.not_expected:
`No s'esperava` -> `No previst` (status label, not past action).
- account_statements.index.empty_unmatched: aligned with the section's
own label `Safata sense aparellar`.
- imports.create.document_provider_not_configured + document_upload_failed:
`arxiu vectorial` -> `magatzem vectorial` (correct TermCat term).
- coinstats_items blockchain gender: `els blockchains` / `un blockchain` ->
`les blockchains` / `una blockchain` (feminine per TermCat).
- accounts.account.remove_default: `Treu el predeterminat` ->
`Treu com a predeterminat` (pairs with sibling `Estableix com a
predeterminat`).
- accounts.tax_treatments.tax_deferred: `Diferit fiscalment` (lit. calque)
-> `Tributació diferida` (standard CA tax-accounting term).
- settings.payments.show.currently_on_plan: `Actualment al` ->
`Actualment al pla:` (was a fragment).
Out of scope (review flagged, not applied here)
- LOW-severity stylistic preferences (Veure vs Mostra, etc).
- `models/category/ca.yml` default category names — seeded at family
creation, not via I18n at runtime, so changes wouldn't affect existing
families.
- `models/period/ca.yml` short labels mixing EN (MTD/YTD) and CA (STD/MA)
— needs a one-convention decision separately.
* fix(i18n,ca): drop gendered article in period_activity + tighten cash-flow terms
- pages.dashboard.investment_summary.period_activity: 'Activitat del
%{period}' contracted 'del' = 'de el' (masc.sg.). %{period} resolves
to mixed forms ('Setmana en curs' fem, 'Últims 30 dies' pl., 'Any en
curs' apostrophe), so hard-coded 'del' was wrong on most labels.
Replaced with 'Activitat — %{period}' (em-dash) to skip the
contraction entirely.
- pages.dashboard.outflows_donut.title / total_outflows: switched from
bare 'Sortides' / 'Total de sortides' to 'Sortides de caixa' /
'Total de sortides de caixa' to match TermCat's precise term
('sortida de caixa' = cash outflow).
* fix(i18n,ca): rephrase transfer source/destination amount labels
'Import d'origen' / 'Import de destinació' were literal calques of
'Source amount' / 'Destination amount'. In a multi-currency transfer
form (sender/receiver in different currencies) the natural CA pair is
'Import enviat' / 'Import rebut'.
* fix(i18n,ca): 'Dades en brut' -> 'Dades sense processar'
The literal calque of 'Raw data' read as too technical for personal-
finance UI. 'Dades sense processar' is the more natural Catalan
equivalent for raw/unprocessed data files.
* fix(i18n): localize Import col_sep label + separator options
The CSV upload form rendered 'Col sep' (the auto-humanized attribute
name) plus hardcoded English 'Comma (,)' / 'Semicolon (;)' options
from Import::SEPARATORS.
- activerecord.attributes.import.col_sep added (en + ca: 'Column
separator' / 'Separador de columnes').
- Import.separator_options class method returns translated tuples;
view switched from Import::SEPARATORS to Import.separator_options.
- activerecord.attributes.import.col_seps.{comma,semicolon} added so
the option labels follow the active locale.
* fix(i18n,ca): drop moniker apposition in sharing/currencies section titles
- sharing_title 'Compartició de %{moniker}' rendered as 'Compartició
de Família' (a noun-noun apposition that's odd in CA) -> 'Compartició
de comptes'.
- sharing_subtitle replaced '%{moniker}' with 'entre els membres' so
the sentence reads naturally and doesn't depend on moniker gender.
- currencies_title 'Divises de %{moniker}' had the same apposition
-> 'Divises'. Subtitle no longer references moniker either.
* fix(i18n,ca): keep 'Self Hosting' untranslated
Reverted 'Autoallotjament' / 'autoallotjada' / 'autoallotjats' usages
to the original English 'Self Hosting' (sidebar label, breadcrumbs,
hostings page title, chat assistant settings hint, redis configuration
subheading, LLM usages cost-estimates description).
The brand-style term reads more naturally in EN for technical users
configuring their own deployment.
* fix(i18n,ca): lowercase 'self hosting' (sentence case in labels)
* fix(i18n): extract budget_categories stepper + allocation_progress strings
Hardcoded English strings on the budget category editor:
- 'Setup' / 'Categories' stepper labels in budgets/_budget_nav.html.erb
- 'X% set' / '> 100% set' / 'left to allocate' / 'Budget exceeded by ...'
in budget_categories/_allocation_progress.erb
- '/m avg' caption + 'Shared' placeholder + 'Leave empty to share
parent's budget' tooltip in budget_categories/_budget_category_form
and _uncategorized_budget_category_form
Extracted to:
- budgets.budget_nav.{setup,categories}
- budget_categories.allocation_progress.{percent_set,over_set,left_to_allocate,budget_exceeded_html}
- budget_categories.budget_category_form.{monthly_average,shared_placeholder,shared_title}
CA translations added; EN keys mirror the prior literals.
* chore(i18n): drop translation tooling from PR
These were dev-only helpers used during the Catalan translation pass:
- script/lt_check_ca.rb: LanguageTool API checker (premium/free
endpoint, picky mode, batching). Useful for ongoing locale QA but
shouldn't ship in this feature PR.
- lib/tasks/i18n_screenshot.rake: rake task that flips user.locale and
role on the demo user for walking the i18n surfaces locally.
Both stay available locally; pulled out of the PR scope.
* fix(i18n): apply PR review feedback (CodeRabbit + Codex)
- balance_reconciliation crypto_items: use :end_balance_crypto tooltip
(was :end_balance_investment). Added new UI.account.balance_reconciliation.tooltips.end_balance_crypto key in en + ca.
- doorkeeper.ca.yml confidentiality.no: was YAML boolean false, now string 'No'.
- views/categories: 'Poor contrast, choose darker color or' continued with hardcoded 'auto-adjust.' button text; extracted to categories.form.auto_adjust key (en + ca).
- imports.create.document_upload_failed: 'a l'magatzem' was broken
contraction -> 'al magatzem'.
- invitation_mailer body + mailer subject: 'unir-se' -> 'unir-te' (was
3rd person, should be 2nd to match the rest of the copy).
- 7 strings across mercury_items / sophtron_items / simplefin_items /
lunchflow_items / brex_items / indexa_capital_items / other_assets:
'se sincronitzaran' -> 'es sincronitzaran', 'se segueixen' ->
'es segueixen' (correct reflexive pronoun before consonants).
- settings.providers.status: key was 'false' (YAML-coerced), now 'off'
to match settings/en.yml status.off used in view lookups.
- sophtron_items.sophtron_setup_required.message: stripped trailing
blank line from the quoted scalar.
- settings/profiles/show.html.erb: switched 'family_moniker ==
"Group"' branch checks to 'Current.family&.moniker == "Group"'.
After Family#moniker_label started returning translated values,
callers using the display label for branching would render the
household copy for group families in ca. Compare the stored sentinel
instead.
- Did not apply CodeRabbit's webauthn 'eliminada' -> 'desada' suggestion:
the key is wired to the destroy action (verified at
settings/webauthn_credentials_controller.rb:55), so 'eliminada' is
correct.
346 lines
10 KiB
Ruby
346 lines
10 KiB
Ruby
class Budget < ApplicationRecord
|
|
include Monetizable
|
|
|
|
PARAM_DATE_FORMAT = "%b-%Y"
|
|
|
|
attr_accessor :current_user
|
|
|
|
belongs_to :family
|
|
|
|
has_many :budget_categories, -> { includes(:category) }, dependent: :destroy
|
|
|
|
validates :start_date, :end_date, presence: true
|
|
validates :start_date, :end_date, uniqueness: { scope: :family_id }
|
|
|
|
monetize :budgeted_spending, :expected_income, :allocated_spending,
|
|
:actual_spending, :available_to_spend, :available_to_allocate,
|
|
:estimated_spending, :estimated_income, :actual_income, :remaining_expected_income
|
|
|
|
class << self
|
|
def date_to_param(date)
|
|
date.strftime(PARAM_DATE_FORMAT).downcase
|
|
end
|
|
|
|
def param_to_date(param, family: nil)
|
|
base_date = Date.strptime(param, PARAM_DATE_FORMAT)
|
|
if family&.uses_custom_month_start?
|
|
Date.new(base_date.year, base_date.month, family.month_start_day)
|
|
else
|
|
base_date.beginning_of_month
|
|
end
|
|
end
|
|
|
|
def budget_date_valid?(date, family:)
|
|
budget_start = if family.uses_custom_month_start?
|
|
family.custom_month_start_for(date)
|
|
else
|
|
date.beginning_of_month
|
|
end
|
|
|
|
budget_start >= oldest_valid_budget_date(family) &&
|
|
budget_start <= latest_valid_budget_start_date(family)
|
|
end
|
|
|
|
def find_or_bootstrap(family, start_date:, user: nil)
|
|
return nil unless budget_date_valid?(start_date, family: family)
|
|
|
|
Budget.transaction do
|
|
if family.uses_custom_month_start?
|
|
budget_start = family.custom_month_start_for(start_date)
|
|
budget_end = family.custom_month_end_for(start_date)
|
|
else
|
|
budget_start = start_date.beginning_of_month
|
|
budget_end = start_date.end_of_month
|
|
end
|
|
|
|
budget = Budget.find_or_create_by!(
|
|
family: family,
|
|
start_date: budget_start,
|
|
end_date: budget_end
|
|
) do |b|
|
|
b.currency = family.currency
|
|
end
|
|
|
|
budget.current_user = user
|
|
budget.sync_budget_categories
|
|
|
|
budget
|
|
end
|
|
end
|
|
|
|
private
|
|
def oldest_valid_budget_date(family)
|
|
two_years_ago = 2.years.ago.beginning_of_month
|
|
oldest_entry_date = family.oldest_entry_date.beginning_of_month
|
|
[ two_years_ago, oldest_entry_date ].min
|
|
end
|
|
|
|
def latest_valid_budget_start_date(family)
|
|
if family.uses_custom_month_start?
|
|
family.current_custom_month_period.start_date + 2.years
|
|
else
|
|
Date.current.beginning_of_month + 2.years
|
|
end
|
|
end
|
|
end
|
|
|
|
def period
|
|
Period.custom(start_date: start_date, end_date: end_date)
|
|
end
|
|
|
|
def to_param
|
|
self.class.date_to_param(start_date)
|
|
end
|
|
|
|
def sync_budget_categories
|
|
current_category_ids = family.categories.pluck(:id).to_set
|
|
existing_budget_category_ids = budget_categories.pluck(:category_id).to_set
|
|
categories_to_add = current_category_ids - existing_budget_category_ids
|
|
categories_to_remove = existing_budget_category_ids - current_category_ids
|
|
|
|
# Create missing categories
|
|
categories_to_add.each do |category_id|
|
|
budget_categories.create!(
|
|
category_id: category_id,
|
|
budgeted_spending: 0,
|
|
currency: family.currency
|
|
)
|
|
end
|
|
|
|
# Remove old categories
|
|
budget_categories.where(category_id: categories_to_remove).destroy_all if categories_to_remove.any?
|
|
end
|
|
|
|
def uncategorized_budget_category
|
|
budget_categories.uncategorized.tap do |bc|
|
|
bc.budgeted_spending = [ available_to_allocate, 0 ].max
|
|
bc.currency = family.currency
|
|
end
|
|
end
|
|
|
|
def transactions
|
|
scope = family.transactions.visible.in_period(period)
|
|
if current_user
|
|
scope = scope.joins(:entry).where(entries: { account_id: family.accounts.accessible_by(current_user).select(:id) })
|
|
end
|
|
scope
|
|
end
|
|
|
|
def name
|
|
if family.uses_custom_month_start?
|
|
I18n.t(
|
|
"budgets.name.custom_range",
|
|
start: I18n.l(start_date, format: :short),
|
|
end_date: I18n.l(end_date, format: :long)
|
|
)
|
|
else
|
|
I18n.t("budgets.name.month_year", month: I18n.l(start_date, format: :month_year))
|
|
end
|
|
end
|
|
|
|
def initialized?
|
|
budgeted_spending.present?
|
|
end
|
|
|
|
def most_recent_initialized_budget
|
|
family.budgets
|
|
.includes(:budget_categories)
|
|
.where("start_date < ?", start_date)
|
|
.where.not(budgeted_spending: nil)
|
|
.order(start_date: :desc)
|
|
.first
|
|
end
|
|
|
|
def copy_from!(source_budget)
|
|
raise ArgumentError, "source budget must belong to the same family" unless source_budget.family_id == family_id
|
|
raise ArgumentError, "source budget must precede target budget" unless source_budget.start_date < start_date
|
|
|
|
Budget.transaction do
|
|
update!(
|
|
budgeted_spending: source_budget.budgeted_spending,
|
|
expected_income: source_budget.expected_income
|
|
)
|
|
|
|
target_by_category = budget_categories.index_by(&:category_id)
|
|
|
|
source_budget.budget_categories.each do |source_bc|
|
|
target_bc = target_by_category[source_bc.category_id]
|
|
next unless target_bc
|
|
|
|
target_bc.update!(budgeted_spending: source_bc.budgeted_spending)
|
|
end
|
|
end
|
|
end
|
|
|
|
def income_category_totals
|
|
net_totals.net_income_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse
|
|
end
|
|
|
|
def expense_category_totals
|
|
net_totals.net_expense_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse
|
|
end
|
|
|
|
def current?
|
|
if family.uses_custom_month_start?
|
|
current_period = family.current_custom_month_period
|
|
start_date == current_period.start_date && end_date == current_period.end_date
|
|
else
|
|
start_date == Date.current.beginning_of_month && end_date == Date.current.end_of_month
|
|
end
|
|
end
|
|
|
|
def previous_budget_param
|
|
previous_date = start_date - 1.month
|
|
return nil unless self.class.budget_date_valid?(previous_date, family: family)
|
|
|
|
self.class.date_to_param(previous_date)
|
|
end
|
|
|
|
def next_budget_param
|
|
next_date = start_date + 1.month
|
|
return nil unless self.class.budget_date_valid?(next_date, family: family)
|
|
|
|
self.class.date_to_param(next_date)
|
|
end
|
|
|
|
def to_donut_segments_json
|
|
unused_segment_id = "unused"
|
|
|
|
# Continuous gray segment for empty budgets
|
|
return [ { color: "var(--budget-unallocated-fill)", amount: 1, id: unused_segment_id } ] unless allocations_valid?
|
|
|
|
segments = budget_categories.reject(&:subcategory?).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: "var(--budget-unallocated-fill)", amount: available_to_spend, id: unused_segment_id })
|
|
end
|
|
|
|
segments
|
|
end
|
|
|
|
# =============================================================================
|
|
# Actuals: How much user has spent on each budget category
|
|
# =============================================================================
|
|
def estimated_spending
|
|
income_statement.median_expense(interval: "month")
|
|
end
|
|
|
|
def actual_spending
|
|
net_totals.total_net_expense
|
|
end
|
|
|
|
def budget_category_actual_spending(budget_category)
|
|
key = budget_category.category_id || stable_synthetic_key(budget_category.category)
|
|
expense = expense_totals_by_category[key]&.total || 0
|
|
refund = income_totals_by_category[key]&.total || 0
|
|
[ expense - refund, 0 ].max
|
|
end
|
|
|
|
def category_median_monthly_expense(category)
|
|
income_statement.median_expense(category: category)
|
|
end
|
|
|
|
def category_avg_monthly_expense(category)
|
|
income_statement.avg_expense(category: category)
|
|
end
|
|
|
|
def available_to_spend
|
|
(budgeted_spending || 0) - actual_spending
|
|
end
|
|
|
|
def percent_of_budget_spent
|
|
return 0 unless budgeted_spending > 0
|
|
|
|
(actual_spending / budgeted_spending.to_f) * 100
|
|
end
|
|
|
|
def overage_percent
|
|
return 0 unless available_to_spend.negative?
|
|
|
|
available_to_spend.abs / actual_spending.to_f * 100
|
|
end
|
|
|
|
# =============================================================================
|
|
# Budget allocations: How much user has budgeted for all parent categories combined
|
|
# =============================================================================
|
|
def allocated_spending
|
|
budget_categories.reject { |bc| bc.subcategory? }.sum(&:budgeted_spending)
|
|
end
|
|
|
|
def allocated_percent
|
|
return 0 unless budgeted_spending && budgeted_spending > 0
|
|
|
|
(allocated_spending / budgeted_spending.to_f) * 100
|
|
end
|
|
|
|
def available_to_allocate
|
|
(budgeted_spending || 0) - allocated_spending
|
|
end
|
|
|
|
def allocations_valid?
|
|
initialized? && available_to_allocate >= 0 && allocated_spending > 0
|
|
end
|
|
|
|
# =============================================================================
|
|
# Income: How much user earned relative to what they expected to earn
|
|
# =============================================================================
|
|
def estimated_income
|
|
family.income_statement.median_income(interval: "month")
|
|
end
|
|
|
|
def actual_income
|
|
family.income_statement.income_totals(period: self.period).total
|
|
end
|
|
|
|
def actual_income_percent
|
|
return 0 unless expected_income > 0
|
|
|
|
(actual_income / expected_income.to_f) * 100
|
|
end
|
|
|
|
def remaining_expected_income
|
|
expected_income - actual_income
|
|
end
|
|
|
|
def surplus_percent
|
|
return 0 unless remaining_expected_income.negative?
|
|
|
|
remaining_expected_income.abs / expected_income.to_f * 100
|
|
end
|
|
|
|
private
|
|
def income_statement
|
|
@income_statement ||= family.income_statement(user: current_user)
|
|
end
|
|
|
|
def net_totals
|
|
@net_totals ||= income_statement.net_category_totals(period: period)
|
|
end
|
|
|
|
def expense_totals
|
|
@expense_totals ||= income_statement.expense_totals(period: period)
|
|
end
|
|
|
|
def income_totals
|
|
@income_totals ||= income_statement.income_totals(period: period)
|
|
end
|
|
|
|
def expense_totals_by_category
|
|
@expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) }
|
|
end
|
|
|
|
def income_totals_by_category
|
|
@income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) }
|
|
end
|
|
|
|
def stable_synthetic_key(category)
|
|
if category.uncategorized?
|
|
:uncategorized
|
|
elsif category.other_investments?
|
|
:other_investments
|
|
end
|
|
end
|
|
end
|