diff --git a/app/controllers/depositories_controller.rb b/app/controllers/depositories_controller.rb index 34a2842da1a..8ddffb29b56 100644 --- a/app/controllers/depositories_controller.rb +++ b/app/controllers/depositories_controller.rb @@ -1,3 +1,5 @@ class DepositoriesController < ApplicationController include AccountableResource + + permitted_accountable_attributes :subtype end diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 65e02ded10d..e85d73ac120 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -96,6 +96,7 @@ def set_rule def rule_params params.require(:rule).permit( :resource_type, :effective_date, :active, :name, + :schedule_enabled, :schedule_cron, conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy, sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ] diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 067abbd253e..b7262e9b4b6 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -106,7 +106,7 @@ def user_params params.require(:user).permit( :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, - family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ], + family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :cash_subgroup_enabled, :id ], goals: [] ) end diff --git a/app/javascript/controllers/rules_controller.js b/app/javascript/controllers/rules_controller.js index 3618acc64c8..08d23dde3c0 100644 --- a/app/javascript/controllers/rules_controller.js +++ b/app/javascript/controllers/rules_controller.js @@ -9,11 +9,17 @@ export default class extends Controller { "conditionsList", "actionsList", "effectiveDateInput", + "scheduleCheckbox", + "scheduleFields", + "scheduleCronInput", ]; connect() { // Update condition prefixes on first connection (form render on edit) this.updateConditionPrefixes(); + + // Set initial schedule UI state + this.toggleSchedule(); } addConditionGroup() { @@ -40,6 +46,17 @@ export default class extends Controller { this.effectiveDateInputTarget.value = ""; } + toggleSchedule() { + if (!this.hasScheduleCheckboxTarget || !this.hasScheduleFieldsTarget) return; + + const enabled = this.scheduleCheckboxTarget.checked; + this.scheduleFieldsTarget.classList.toggle("opacity-50", !enabled); + + if (this.hasScheduleCronInputTarget) { + this.scheduleCronInputTarget.disabled = !enabled; + } + } + #appendTemplate(templateEl, listEl) { const html = templateEl.innerHTML.replaceAll( "IDX_PLACEHOLDER", diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb index e8f5f8442bd..406b32ba2fb 100644 --- a/app/models/balance_sheet.rb +++ b/app/models/balance_sheet.rb @@ -13,7 +13,8 @@ def assets @assets ||= ClassificationGroup.new( classification: "asset", currency: family.currency, - accounts: sorted(account_totals.asset_accounts) + accounts: sorted(account_totals.asset_accounts), + family: family ) end @@ -21,7 +22,8 @@ def liabilities @liabilities ||= ClassificationGroup.new( classification: "liability", currency: family.currency, - accounts: sorted(account_totals.liability_accounts) + accounts: sorted(account_totals.liability_accounts), + family: family ) end diff --git a/app/models/balance_sheet/account_group.rb b/app/models/balance_sheet/account_group.rb index a4b23d1f726..305bbf1b86f 100644 --- a/app/models/balance_sheet/account_group.rb +++ b/app/models/balance_sheet/account_group.rb @@ -37,6 +37,38 @@ def total accounts.sum(&:converted_balance) end + def subgroups + return [] unless cash_subgroup_enabled? && accountable_type == Depository + + grouped_accounts = accounts.group_by { |account| normalized_subtype(account.subtype) } + + order = Depository::SUBTYPES.keys + + grouped_accounts + .reject { |subtype, _| subtype.nil? } + .map do |subtype, rows| + BalanceSheet::SubtypeGroup.new(subtype: subtype, accounts: rows, account_group: self) + end + .sort_by do |subgroup| + idx = order.index(subgroup.subtype) + [ idx || order.length, subgroup.name ] + end + end + + def uncategorized_accounts + return [] unless cash_subgroup_enabled? && accountable_type == Depository + + accounts.select { |account| normalized_subtype(account.subtype).nil? } + end + + def uncategorized_total + uncategorized_accounts.sum(&:converted_balance) + end + + def uncategorized_total_money + Money.new((uncategorized_total * 100).to_i, currency) + end + def weight return 0 if classification_group.total.zero? @@ -56,6 +88,17 @@ def currency classification_group.currency end + def cash_subgroup_enabled? + classification_group.family.cash_subgroup_enabled != false + end + private + def normalized_subtype(subtype) + value = subtype&.to_s&.strip&.downcase + return nil if value.blank? + + Depository::SUBTYPES.key?(value) ? value : nil + end + attr_reader :classification_group end diff --git a/app/models/balance_sheet/classification_group.rb b/app/models/balance_sheet/classification_group.rb index 32e64214cce..017a0dc5445 100644 --- a/app/models/balance_sheet/classification_group.rb +++ b/app/models/balance_sheet/classification_group.rb @@ -3,13 +3,14 @@ class BalanceSheet::ClassificationGroup monetize :total, as: :total_money - attr_reader :classification, :currency + attr_reader :classification, :currency, :family - def initialize(classification:, currency:, accounts:) + def initialize(classification:, currency:, accounts:, family:) @classification = normalize_classification!(classification) @name = name @currency = currency @accounts = accounts + @family = family end def name diff --git a/app/models/balance_sheet/subtype_group.rb b/app/models/balance_sheet/subtype_group.rb new file mode 100644 index 00000000000..5db7b1371df --- /dev/null +++ b/app/models/balance_sheet/subtype_group.rb @@ -0,0 +1,29 @@ +class BalanceSheet::SubtypeGroup + include Monetizable + + monetize :total, as: :total_money + + attr_reader :subtype, :accounts, :account_group + + def initialize(subtype:, accounts:, account_group:) + @subtype = subtype + @accounts = accounts + @account_group = account_group + end + + def name + account_group.accountable_type.short_subtype_label_for(subtype) || account_group.name + end + + def key + subtype.presence || "other" + end + + def total + accounts.sum(&:converted_balance) + end + + def currency + account_group.currency + end +end diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index d7f2a331517..997bf4e4d8e 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -35,7 +35,8 @@ def subtype_label_for(subtype, format: :short) return nil if subtype.nil? label_type = format == :long ? :long : :short - self::SUBTYPES[subtype]&.fetch(label_type, nil) + subtype_key = subtype.to_s.downcase + self::SUBTYPES[subtype_key]&.fetch(label_type, nil) end # Convenience method for getting the short label diff --git a/app/models/rule.rb b/app/models/rule.rb index 4ffb8caa2ed..c6488d3e652 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -1,3 +1,6 @@ +require "fugit" +require "sidekiq/cron/job" + class Rule < ApplicationRecord UnsupportedResourceTypeError = Class.new(StandardError) @@ -14,11 +17,15 @@ class Rule < ApplicationRecord validates :resource_type, presence: true validates :name, length: { minimum: 1 }, allow_nil: true validate :no_nested_compound_conditions + validate :valid_schedule_when_enabled # Every rule must have at least 1 action validate :min_actions validate :no_duplicate_actions + after_commit :sync_cron_schedule, if: :schedule_state_changed?, on: [ :create, :update ] + after_commit :remove_cron_schedule, on: :destroy, if: :scheduled_before_change? + def action_executors registry.action_executors end @@ -130,4 +137,66 @@ def no_nested_compound_conditions def normalize_name self.name = nil if name.is_a?(String) && name.strip.empty? end + + def schedule_state_changed? + saved_change_to_schedule_cron? || saved_change_to_schedule_enabled? || saved_change_to_active? + end + + def scheduled_before_change? + schedule_enabled_was = saved_change_to_schedule_enabled? ? schedule_enabled_before_last_save : schedule_enabled? + active_was = saved_change_to_active? ? active_before_last_save : active? + cron_was_present = if saved_change_to_schedule_cron? + schedule_cron_before_last_save.present? + else + schedule_cron.present? + end + + schedule_enabled_was && active_was && cron_was_present + end + + def valid_schedule_when_enabled + return unless schedule_enabled? + + if schedule_cron.blank? + errors.add(:schedule_cron, "can't be blank when scheduling is enabled") + return + end + + parsed_cron = begin + Fugit::Cron.parse(schedule_cron) + rescue StandardError + nil + end + + errors.add(:schedule_cron, "is invalid") unless parsed_cron + end + + def cron_job_name + "rule-#{id}" + end + + def sync_cron_schedule + if schedule_enabled? && active? && schedule_cron.present? + Sidekiq::Cron::Job.create( + name: cron_job_name, + cron: schedule_cron, + class: "RuleScheduleWorker", + args: [ id ], + description: "Scheduled rule #{id}", + queue: "scheduled" + ) + elsif scheduled_before_change? + remove_cron_schedule + end + rescue StandardError => e + Rails.logger.error("Failed to sync schedule for rule #{id}: #{e.message}") + end + + def remove_cron_schedule + return unless id + + Sidekiq::Cron::Job.destroy(cron_job_name) + rescue StandardError => e + Rails.logger.error("Failed to remove schedule for rule #{id}: #{e.message}") + end end diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index 934f2bb3e21..74dcfc29b4b 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -21,32 +21,109 @@ <% end %> -
Cash
+<%= format_money(account_group.uncategorized_total_money) %>
<%= subgroup.name %>
+<%= format_money(subgroup.total_money) %>
+Example: 0 0 * * * to run every day at midnight.
+Please note, we are still working on translations for various languages.
+Please note, we are still working on translations for various languages. Please see the <%= link_to "I18n issue", "https://github.com/maybe-finance/maybe/issues/1225", target: "_blank", class: "underline" %> for more information.
+ +<%= t(".cash_subgroup_enabled_label") %>
+<%= t(".cash_subgroup_enabled_hint") %>
+