Files
sure/app/models/family.rb
Guillem Arias Fauste 1ddd8bd040 feat(i18n): complete Catalan translations + extract residual hardcoded strings (#1836)
* 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.
2026-05-19 13:37:10 +02:00

371 lines
13 KiB
Ruby

class Family < ApplicationRecord
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable
include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable
include IndexaCapitalConnectable, IbkrConnectable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
[ "DD.MM.YYYY", "%d.%m.%Y" ],
[ "DD-MM-YYYY", "%d-%m-%Y" ],
[ "YYYY-MM-DD", "%Y-%m-%d" ],
[ "DD/MM/YYYY", "%d/%m/%Y" ],
[ "YYYY/MM/DD", "%Y/%m/%d" ],
[ "MM/DD/YYYY", "%m/%d/%Y" ],
[ "D/MM/YYYY", "%e/%m/%Y" ],
[ "YYYY.MM.DD", "%Y.%m.%d" ],
[ "YYYYMMDD", "%Y%m%d" ]
].freeze
MONIKERS = [ "Family", "Group" ].freeze
ASSISTANT_TYPES = %w[builtin external].freeze
SHARING_DEFAULTS = %w[shared private].freeze
has_many :users, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :invitations, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :family_exports, dependent: :destroy
has_many :account_statements, dependent: :destroy
has_many :entries, through: :accounts
has_many :transactions, through: :accounts
has_many :rules, dependent: :destroy
has_many :trades, through: :accounts
has_many :holdings, through: :accounts
has_many :tags, dependent: :destroy
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy, class_name: "FamilyMerchant"
has_many :budgets, dependent: :destroy
has_many :budget_categories, through: :budgets
has_many :llm_usages, dependent: :destroy
has_many :recurring_transactions, dependent: :destroy
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
validates :month_start_day, inclusion: { in: 1..28 }
validates :moniker, inclusion: { in: MONIKERS }
validates :assistant_type, inclusion: { in: ASSISTANT_TYPES }
validates :default_account_sharing, inclusion: { in: SHARING_DEFAULTS }
before_validation :normalize_enabled_currencies!
def primary_currency_code
normalize_currency_code(currency) || "USD"
end
def custom_enabled_currencies?
enabled_currencies.present?
end
def enabled_currency_codes(extra: [])
selected_codes = if custom_enabled_currencies?
[ primary_currency_code, *Array(enabled_currencies) ]
else
Money::Currency.as_options.map(&:iso_code)
end
normalize_currency_codes([ *selected_codes, *Array(extra) ])
end
def enabled_currency_objects(extra: [])
enabled_currency_codes(extra:).map { |code| Money::Currency.new(code) }
end
def secondary_enabled_currency_objects(extra: [])
enabled_currency_objects(extra:).reject { |currency| currency.iso_code == primary_currency_code }
end
def moniker_label
case moniker.presence
when nil, "Family"
I18n.t("shared.family_moniker.singular", default: "Family")
when "Group"
I18n.t("shared.family_moniker.group_singular", default: "Group")
else
moniker
end
end
def moniker_label_plural
case moniker.presence
when nil, "Family"
I18n.t("shared.family_moniker.plural", default: "Families")
when "Group"
I18n.t("shared.family_moniker.group_plural", default: "Groups")
else
"#{moniker}s"
end
end
def share_all_by_default?
default_account_sharing == "shared"
end
def uses_custom_month_start?
month_start_day != 1
end
def custom_month_start_for(date)
if date.day >= month_start_day
Date.new(date.year, date.month, month_start_day)
else
previous_month = date - 1.month
Date.new(previous_month.year, previous_month.month, month_start_day)
end
end
def custom_month_end_for(date)
start_date = custom_month_start_for(date)
next_month_start = start_date + 1.month
next_month_start - 1.day
end
def current_custom_month_period
start_date = custom_month_start_for(Date.current)
end_date = custom_month_end_for(Date.current)
Period.custom(start_date: start_date, end_date: end_date)
end
def assigned_merchants
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
Merchant.where(id: merchant_ids)
end
def available_merchants
assigned_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
recently_unlinked_ids = FamilyMerchantAssociation
.where(family: self)
.recently_unlinked
.pluck(:merchant_id)
family_merchant_ids = merchants.pluck(:id)
Merchant.where(id: (assigned_ids + recently_unlinked_ids + family_merchant_ids).uniq)
end
def assigned_merchants_for(user)
merchant_ids = Transaction.joins(:entry)
.where(entries: { account_id: accounts.accessible_by(user).select(:id) })
.where.not(merchant_id: nil)
.distinct
.pluck(:merchant_id)
Merchant.where(id: merchant_ids)
end
def available_merchants_for(user)
assigned_ids = Transaction.joins(:entry)
.where(entries: { account_id: accounts.accessible_by(user).select(:id) })
.where.not(merchant_id: nil)
.distinct
.pluck(:merchant_id)
recently_unlinked_ids = FamilyMerchantAssociation
.where(family: self)
.recently_unlinked
.pluck(:merchant_id)
family_merchant_ids = merchants.pluck(:id)
Merchant.where(id: (assigned_ids + recently_unlinked_ids + family_merchant_ids).uniq)
end
def auto_categorize_transactions_later(transactions, rule_run_id: nil)
AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
end
def auto_categorize_transactions(transaction_ids)
AutoCategorizer.new(self, transaction_ids: transaction_ids).auto_categorize
end
def auto_detect_transaction_merchants_later(transactions, rule_run_id: nil)
AutoDetectMerchantsJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
end
def auto_detect_transaction_merchants(transaction_ids)
AutoMerchantDetector.new(self, transaction_ids: transaction_ids).auto_detect
end
def balance_sheet(user: Current.user)
BalanceSheet.new(self, user: user)
end
def income_statement(user: Current.user)
IncomeStatement.new(self, user: user)
end
# Returns the Investment Contributions category for this family, creating it if it doesn't exist.
# This is used for auto-categorizing transfers to investment accounts.
# Always uses the family's locale to ensure consistent category naming across all users.
def investment_contributions_category
# Find ALL legacy categories (created under old request-locale behavior)
legacy = categories.where(name: Category.all_investment_contributions_names).order(:created_at).to_a
if legacy.any?
keeper = legacy.first
duplicates = legacy[1..]
# Reassign transactions and subcategories from duplicates to keeper
if duplicates.any?
duplicate_ids = duplicates.map(&:id)
categories.where(parent_id: duplicate_ids).update_all(parent_id: keeper.id)
Transaction.where(category_id: duplicate_ids).update_all(category_id: keeper.id)
BudgetCategory.where(category_id: duplicate_ids).update_all(category_id: keeper.id)
categories.where(id: duplicate_ids).delete_all
end
# Rename keeper to family's locale name if needed
I18n.with_locale(locale) do
correct_name = Category.investment_contributions_name
keeper.update!(name: correct_name) unless keeper.name == correct_name
end
return keeper
end
# Create new category using family's locale
I18n.with_locale(locale) do
categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat|
cat.color = "#0d9488"
cat.lucide_icon = "trending-up"
end
end
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
# Handle race condition: another process created the category
I18n.with_locale(locale) do
categories.find_by!(name: Category.investment_contributions_name)
end
end
# Returns account IDs for tax-advantaged accounts (401k, IRA, HSA, etc.)
# Used to exclude these accounts from budget/cashflow calculations.
# Tax-advantaged accounts are retirement savings, not daily expenses.
def tax_advantaged_account_ids
@tax_advantaged_account_ids ||= begin
# Investment accounts derive tax_treatment from subtype
tax_advantaged_subtypes = Investment::SUBTYPES.select do |_, meta|
meta[:tax_treatment].in?(%i[tax_deferred tax_exempt tax_advantaged])
end.keys
investment_ids = accounts
.joins("INNER JOIN investments ON investments.id = accounts.accountable_id AND accounts.accountable_type = 'Investment'")
.where(investments: { subtype: tax_advantaged_subtypes })
.pluck(:id)
# Crypto accounts have an explicit tax_treatment column
crypto_ids = accounts
.joins("INNER JOIN cryptos ON cryptos.id = accounts.accountable_id AND accounts.accountable_type = 'Crypto'")
.where(cryptos: { tax_treatment: %w[tax_deferred tax_exempt] })
.pluck(:id)
investment_ids + crypto_ids
end
end
def investment_statement(user: Current.user)
InvestmentStatement.new(self, user: user)
end
def eu?
country != "US" && country != "CA"
end
def requires_securities_data_provider?
# If family has any trades, they need a provider for historical prices
trades.any?
end
def requires_exchange_rates_data_provider?
# If family has any accounts not denominated in the family's currency, they need a provider for historical exchange rates
return true if accounts.where.not(currency: self.currency).any?
# If family has any entries in different currencies, they need a provider for historical exchange rates
uniq_currencies = entries.pluck(:currency).uniq
return true if uniq_currencies.count > 1
return true if uniq_currencies.count > 0 && uniq_currencies.first != self.currency
false
end
def missing_data_provider?
(requires_securities_data_provider? && Security.provider.nil?) ||
(requires_exchange_rates_data_provider? && ExchangeRate.provider.nil?)
end
# Returns securities with plan restrictions for a specific provider
# @param provider [String] The provider name (e.g., "TwelveData")
# @return [Array<Hash>] Array of hashes with ticker, name, required_plan, provider
def securities_with_plan_restrictions(provider:)
security_ids = trades.joins(:security).pluck("securities.id").uniq
return [] if security_ids.empty?
restrictions = Security.plan_restrictions_for(security_ids, provider: provider)
return [] if restrictions.empty?
Security.where(id: restrictions.keys).map do |security|
restriction = restrictions[security.id]
{
ticker: security.ticker,
name: security.name,
required_plan: restriction[:required_plan],
provider: restriction[:provider]
}
end
end
def oldest_entry_date
entries.order(:date).first&.date || Date.current
end
# Used for invalidating family / balance sheet related aggregation queries
def build_cache_key(key, invalidate_on_data_updates: false)
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
# By including it in the cache key, we can expire caches every time family account data changes.
data_invalidation_key = invalidate_on_data_updates ? latest_sync_completed_at : nil
[
id,
key,
data_invalidation_key,
accounts.maximum(:updated_at)
].compact.join("_")
end
# Used for invalidating entry related aggregation queries
def entries_cache_version
@entries_cache_version ||= begin
ts = entries.maximum(:updated_at)
ts.present? ? ts.to_i : 0
end
end
def self_hoster?
Rails.application.config.app_mode.self_hosted?
end
private
def normalize_enabled_currencies!
if enabled_currencies.blank?
self.enabled_currencies = nil
return
end
normalized_codes = normalize_currency_codes([ primary_currency_code, *Array(enabled_currencies) ])
all_codes = Money::Currency.as_options.map(&:iso_code)
all_selected = normalized_codes.size == all_codes.size && (normalized_codes - all_codes).empty?
self.enabled_currencies = all_selected ? nil : normalized_codes
end
def normalize_currency_codes(values)
Array(values).filter_map { |value| normalize_currency_code(value) }.uniq
end
def normalize_currency_code(value)
return if value.blank?
Money::Currency.new(value).iso_code
rescue Money::Currency::UnknownCurrencyError, ArgumentError
nil
end
end