Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/controllers/depositories_controller.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
class DepositoriesController < ApplicationController
include AccountableResource

permitted_accountable_attributes :subtype
end
1 change: 1 addition & 0 deletions app/controllers/rules_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions app/javascript/controllers/rules_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions app/models/balance_sheet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ 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

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

Expand Down
43 changes: 43 additions & 0 deletions app/models/balance_sheet/account_group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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
5 changes: 3 additions & 2 deletions app/models/balance_sheet/classification_group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions app/models/balance_sheet/subtype_group.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion app/models/concerns/accountable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions app/models/rule.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
require "fugit"
require "sidekiq/cron/job"

class Rule < ApplicationRecord
UnsupportedResourceTypeError = Class.new(StandardError)

Expand All @@ -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
Expand Down Expand Up @@ -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
Loading
Loading