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 %> -
- <% account_group.accounts.each do |account| %> - <%= link_to account_path(account), - class: class_names( - "block flex items-center gap-2 px-3 py-2 rounded-lg", - page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover" - ), - title: account.name do %> - <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %> + <% subgroups = account_group.subgroups %> + <% uncategorized_accounts = account_group.uncategorized_accounts %> -
-
- <%= tag.p account.name, class: class_names("text-sm text-primary font-medium truncate", "animate-pulse" => account.syncing?) %> +
+ <% if subgroups.any? || uncategorized_accounts.any? %> + <% if uncategorized_accounts.any? %> +
+
+

Cash

+

<%= format_money(account_group.uncategorized_total_money) %>

- <%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %> + + <% uncategorized_accounts.each do |account| %> + <%= link_to account_path(account), + class: class_names( + "block flex items-center gap-2 px-3 py-2 rounded-lg", + page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover" + ), + title: account.name do %> + <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %> + +
+
+ <%= tag.p account.name, class: class_names("text-sm text-primary font-medium truncate", "animate-pulse" => account.syncing?) %> +
+ <%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %> +
+ +
+ <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> + <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %> +
+
+
+ <% end %> +
+ <% end %> + <% end %>
+ <% end %> -
- <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> - <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %> -
-
-
+ <% subgroups.each do |subgroup| %> +
+
+

<%= subgroup.name %>

+

<%= format_money(subgroup.total_money) %>

+
+ + <% subgroup.accounts.each do |account| %> + <%= link_to account_path(account), + class: class_names( + "block flex items-center gap-2 px-3 py-2 rounded-lg", + page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover" + ), + title: account.name do %> + <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %> + +
+
+ <%= tag.p account.name, class: class_names("text-sm text-primary font-medium truncate", "animate-pulse" => account.syncing?) %> +
+ <%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %> +
+ +
+ <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> + <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %> +
+
+
+ <% end %> +
+ <% end %> <% end %>
<% end %> + <% else %> + <% account_group.accounts.each do |account| %> + <%= link_to account_path(account), + class: class_names( + "block flex items-center gap-2 px-3 py-2 rounded-lg", + page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover" + ), + title: account.name do %> + <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %> + +
+
+ <%= tag.p account.name, class: class_names("text-sm text-primary font-medium truncate", "animate-pulse" => account.syncing?) %> +
+ <%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %> +
+ +
+ <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> + <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %> +
+
+
+ <% end %> +
+ <% end %> + <% end %> <% end %>
diff --git a/app/views/depositories/_form.html.erb b/app/views/depositories/_form.html.erb index df853e8d907..a287027aca2 100644 --- a/app/views/depositories/_form.html.erb +++ b/app/views/depositories/_form.html.erb @@ -1,7 +1,9 @@ <%# locals: (account:, url:) %> <%= render "accounts/form", account: account, url: url do |form| %> - <%= form.select :subtype, - Depository::SUBTYPES.map { |k, v| [v[:long], k] }, - { label: true, prompt: t("depositories.form.subtype_prompt"), include_blank: t("depositories.form.none") } %> + <%= form.fields_for :accountable do |accountable_fields| %> + <%= accountable_fields.select :subtype, + Depository::SUBTYPES.map { |k, v| [v[:long], k] }, + { label: true, prompt: t("depositories.form.subtype_prompt"), include_blank: t("depositories.form.none") } %> + <% end %> <% end %> diff --git a/app/views/rules/_form.html.erb b/app/views/rules/_form.html.erb index 2f4f1fe183e..1932fa38a60 100644 --- a/app/views/rules/_form.html.erb +++ b/app/views/rules/_form.html.erb @@ -101,5 +101,36 @@
+
+
+

WHEN

+
+ +
+
+ <%= render DS::Toggle.new( + id: dom_id(rule, :schedule_toggle), + name: "rule[schedule_enabled]", + checked: rule.schedule_enabled?, + disabled: false, + data: { rules_target: "scheduleCheckbox", action: "change->rules#toggleSchedule" } + ) %> + +
+ +
+
+ <%= f.label :schedule_cron, "Cron expression" %> + <%= icon "info", size: "xs", class: "text-secondary", title: "Use standard cron syntax (min hour day month weekday)" %> +
+ <%= f.text_field :schedule_cron, + placeholder: "0 0 * * *", + class: "form-field__input w-full", + data: { rules_target: "scheduleCronInput" } %> +

Example: 0 0 * * * to run every day at midnight.

+
+
+
+ <%= f.submit %> <% end %> diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 8911e81782a..01f0ecd3756 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -40,7 +40,18 @@ { label: t(".country") }, { data: { auto_submit_form_target: "auto" } } %> -

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") %>

+
+ + <%= family_form.toggle :cash_subgroup_enabled, { data: { auto_submit_form_target: "auto" } } %> +
+
<% end %> <% end %>
diff --git a/app/workers/rule_schedule_worker.rb b/app/workers/rule_schedule_worker.rb new file mode 100644 index 00000000000..eddd48df361 --- /dev/null +++ b/app/workers/rule_schedule_worker.rb @@ -0,0 +1,12 @@ +class RuleScheduleWorker + include Sidekiq::Worker + + sidekiq_options queue: "scheduled" + + def perform(rule_id) + rule = Rule.find_by(id: rule_id) + return unless rule&.schedule_enabled? && rule.active? + + RuleJob.set(queue: :scheduled).perform_later(rule_id, ignore_attribute_locks: false) + end +end diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 567310cc856..d1912110b9c 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -30,6 +30,8 @@ en: general_title: General default_period: Default Period default_account_order: Default Account Order + cash_subgroup_enabled_label: Group cash accounts by subtype + cash_subgroup_enabled_hint: Show Savings/Checking sections under Cash; turn off for a flat list. language: Language page_title: Preferences theme_dark: Dark diff --git a/db/migrate/20251205120000_add_cash_subgroup_enabled_to_families.rb b/db/migrate/20251205120000_add_cash_subgroup_enabled_to_families.rb new file mode 100644 index 00000000000..290a676107e --- /dev/null +++ b/db/migrate/20251205120000_add_cash_subgroup_enabled_to_families.rb @@ -0,0 +1,5 @@ +class AddCashSubgroupEnabledToFamilies < ActiveRecord::Migration[7.2] + def change + add_column :families, :cash_subgroup_enabled, :boolean, null: false, default: true + end +end diff --git a/db/migrate/20251205162439_add_schedule_to_rules.rb b/db/migrate/20251205162439_add_schedule_to_rules.rb new file mode 100644 index 00000000000..a5f8d2a219f --- /dev/null +++ b/db/migrate/20251205162439_add_schedule_to_rules.rb @@ -0,0 +1,6 @@ +class AddScheduleToRules < ActiveRecord::Migration[7.2] + def change + add_column :rules, :schedule_cron, :string + add_column :rules, :schedule_enabled, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index ae5445cdf01..68d308b68df 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -408,6 +408,7 @@ t.boolean "auto_sync_on_login", default: true, null: false t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } + t.boolean "cash_subgroup_enabled", default: true, null: false end create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -865,10 +866,13 @@ create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "rule_id", null: false + t.string "rule_name" t.string "execution_type", null: false t.string "status", null: false + t.integer "transactions_queued", default: 0, null: false t.integer "transactions_processed", default: 0, null: false t.integer "transactions_modified", default: 0, null: false + t.integer "pending_jobs_count", default: 0, null: false t.datetime "executed_at", null: false t.text "error_message" t.datetime "created_at", null: false @@ -886,27 +890,11 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "name" + t.string "schedule_cron" + t.boolean "schedule_enabled", default: false, null: false t.index ["family_id"], name: "index_rules_on_family_id" end - create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id", null: false - t.string "rule_name" - t.string "execution_type", null: false - t.string "status", null: false - t.integer "transactions_queued", default: 0, null: false - t.integer "transactions_processed", default: 0, null: false - t.integer "transactions_modified", default: 0, null: false - t.integer "pending_jobs_count", default: 0, null: false - t.datetime "executed_at", null: false - t.text "error_message" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["executed_at"], name: "index_rule_runs_on_executed_at" - t.index ["rule_id", "executed_at"], name: "index_rule_runs_on_rule_id_and_executed_at" - t.index ["rule_id"], name: "index_rule_runs_on_rule_id" - end - create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "ticker", null: false t.string "name" diff --git a/test/jobs/rule_job_test.rb b/test/jobs/rule_job_test.rb new file mode 100644 index 00000000000..b4a965278c3 --- /dev/null +++ b/test/jobs/rule_job_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class RuleJobTest < ActiveJob::TestCase + include EntriesTestHelper + + setup do + @family = families(:empty) + @account = @family.accounts.create!(name: "Rule test", balance: 1000, currency: "USD", accountable: Depository.new) + @category = @family.categories.create!(name: "Transport") + end + + test "applies rule when job receives id" do + entry = create_transaction(account: @account, name: "Taxi ride", date: Date.current) + + rule = Rule.create!( + family: @family, + resource_type: "transaction", + active: true, + conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "=", value: "Taxi ride") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @category.id) ] + ) + + perform_enqueued_jobs do + RuleJob.perform_later(rule.id) + end + + assert_equal @category, entry.reload.transaction.category + end +end diff --git a/test/models/balance_sheet_test.rb b/test/models/balance_sheet_test.rb index 9021baf27dc..81db0df6c47 100644 --- a/test/models/balance_sheet_test.rb +++ b/test/models/balance_sheet_test.rb @@ -75,6 +75,40 @@ class BalanceSheetTest < ActiveSupport::TestCase assert_equal 3000 + 5000, liability_groups.find { |ag| ag.name == I18n.t("accounts.types.other_liability") }.total end + test "cash subgroups render savings and uncategorized when enabled" do + create_account(balance: 100, accountable: Depository.new(subtype: "savings")) + create_account(balance: 200, accountable: Depository.new) # no subtype + + cash_group = BalanceSheet.new(@family).assets.account_groups.find { |ag| ag.name == "Cash" } + + assert_equal [ "Savings" ], cash_group.subgroups.map(&:name) + assert_equal 1, cash_group.uncategorized_accounts.size + assert_equal 20000.0, cash_group.uncategorized_total_money.to_f + end + + test "cash subgroups flatten when preference disabled" do + @family.update!(cash_subgroup_enabled: false) + + create_account(balance: 100, accountable: Depository.new(subtype: "savings")) + create_account(balance: 200, accountable: Depository.new) + + cash_group = BalanceSheet.new(@family).assets.account_groups.find { |ag| ag.name == "Cash" } + + assert_empty cash_group.subgroups + assert_empty cash_group.uncategorized_accounts + assert_equal 2, cash_group.accounts.size + end + + test "unknown cash subtypes are treated as uncategorized" do + create_account(balance: 150, accountable: Depository.new(subtype: "mystery")) + + cash_group = BalanceSheet.new(@family).assets.account_groups.find { |ag| ag.name == "Cash" } + + assert_empty cash_group.subgroups + assert_equal 1, cash_group.uncategorized_accounts.size + assert_equal 15000.0, cash_group.uncategorized_total_money.to_f + end + private def create_account(attributes = {}) account = @family.accounts.create! name: "Test", currency: "USD", **attributes diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb index 5cddc644a6f..8f7a89e641e 100644 --- a/test/models/rule_test.rb +++ b/test/models/rule_test.rb @@ -74,4 +74,55 @@ class RuleTest < ActiveSupport::TestCase assert_not rule.valid? assert_equal [ "Compound conditions cannot be nested" ], rule.errors.full_messages end + + test "requires valid cron when scheduling is enabled" do + rule = Rule.new( + family: @family, + resource_type: "transaction", + schedule_enabled: true, + schedule_cron: "invalid", + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ], + conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "=", value: "Taxi") ] + ) + + assert_not rule.valid? + assert_includes rule.errors[:schedule_cron], "is invalid" + end + + test "creates cron job when schedule is enabled and active" do + Sidekiq::Cron::Job.expects(:create).with do |options| + options[:cron] == "0 0 * * *" && + options[:class] == "RuleScheduleWorker" && + options[:queue] == "scheduled" && + options[:args].length == 1 + end + + Rule.create!( + family: @family, + resource_type: "transaction", + active: true, + schedule_enabled: true, + schedule_cron: "0 0 * * *", + conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "=", value: "Taxi") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] + ) + end + + test "destroys cron job when scheduling is disabled" do + Sidekiq::Cron::Job.stubs(:create) + + rule = Rule.create!( + family: @family, + resource_type: "transaction", + active: true, + schedule_enabled: true, + schedule_cron: "0 0 * * *", + conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "=", value: "Taxi") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ] + ) + + Sidekiq::Cron::Job.expects(:destroy).with("rule-#{rule.id}") + + rule.update!(schedule_enabled: false) + end end diff --git a/test/system/chats_test.rb b/test/system/chats_test.rb index ff4a42f9876..ef4c1b1bb74 100644 --- a/test/system/chats_test.rb +++ b/test/system/chats_test.rb @@ -42,7 +42,7 @@ class ChatsTest < ApplicationSystemTestCase # After page refresh, we're still on the last chat we were viewing within "#chat-container" do - assert_selector "h1", text: @user.chats.first.title + assert_selector "h1", text: @user.chats.first.title, visible: :all end end end diff --git a/test/workers/rule_schedule_worker_test.rb b/test/workers/rule_schedule_worker_test.rb new file mode 100644 index 00000000000..482a5735a8e --- /dev/null +++ b/test/workers/rule_schedule_worker_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class RuleScheduleWorkerTest < ActiveJob::TestCase + include EntriesTestHelper + + setup do + Sidekiq::Cron::Job.stubs(:create) + Sidekiq::Cron::Job.stubs(:destroy) + + @family = families(:empty) + @account = @family.accounts.create!(name: "Rule test", balance: 500, currency: "USD", accountable: Depository.new) + @category = @family.categories.create!(name: "Transport") + + @rule = Rule.create!( + family: @family, + resource_type: "transaction", + active: true, + schedule_enabled: true, + schedule_cron: "0 0 * * *", + conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "=", value: "Taxi") ], + actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @category.id) ] + ) + end + + test "enqueues rule job on scheduled worker" do + clear_enqueued_jobs + + assert_enqueued_with(job: RuleJob, queue: "scheduled") do + RuleScheduleWorker.new.perform(@rule.id) + end + end + + test "skips when rule is inactive or scheduling disabled" do + clear_enqueued_jobs + + @rule.update!(active: false) + assert_no_enqueued_jobs do + RuleScheduleWorker.new.perform(@rule.id) + end + + @rule.update!(active: true, schedule_enabled: false) + assert_no_enqueued_jobs do + RuleScheduleWorker.new.perform(@rule.id) + end + end +end