diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 20e848ab4..dd4024d7b 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -62,6 +62,29 @@ class Settings::HostingsController < ApplicationController Setting.syncs_include_pending = hosting_params[:syncs_include_pending] == "1" end + sync_settings_changed = false + + if hosting_params.key?(:auto_sync_enabled) + Setting.auto_sync_enabled = hosting_params[:auto_sync_enabled] == "1" + sync_settings_changed = true + end + + if hosting_params.key?(:auto_sync_time) + time_value = hosting_params[:auto_sync_time] + unless Setting.valid_auto_sync_time?(time_value) + flash[:alert] = t(".invalid_sync_time") + return redirect_to settings_hosting_path + end + + Setting.auto_sync_time = time_value + Setting.auto_sync_timezone = current_user_timezone + sync_settings_changed = true + end + + if sync_settings_changed + sync_auto_sync_scheduler! + end + if hosting_params.key?(:openai_access_token) token_param = hosting_params[:openai_access_token].to_s.strip # Ignore blanks and redaction placeholders to prevent accidental overwrite @@ -103,10 +126,22 @@ class Settings::HostingsController < ApplicationController private def hosting_params - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time) end def ensure_admin redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin? end + + def sync_auto_sync_scheduler! + AutoSyncScheduler.sync! + rescue StandardError => error + Rails.logger.error("[AutoSyncScheduler] Failed to sync scheduler: #{error.message}") + Rails.logger.error(error.backtrace.join("\n")) + flash[:alert] = t(".scheduler_sync_failed") + end + + def current_user_timezone + Current.family&.timezone.presence || "UTC" + end end diff --git a/app/models/setting.rb b/app/models/setting.rb index e54817911..4407d7293 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -24,6 +24,21 @@ class Setting < RailsSettings::Base simplefin && plaid end field :syncs_include_pending, type: :boolean, default: SYNCS_INCLUDE_PENDING_DEFAULT + field :auto_sync_enabled, type: :boolean, default: ENV.fetch("AUTO_SYNC_ENABLED", "1") == "1" + field :auto_sync_time, type: :string, default: ENV.fetch("AUTO_SYNC_TIME", "02:22") + field :auto_sync_timezone, type: :string, default: ENV.fetch("AUTO_SYNC_TIMEZONE", "UTC") + + AUTO_SYNC_TIME_FORMAT = /\A([01]?\d|2[0-3]):([0-5]\d)\z/ + + def self.valid_auto_sync_time?(time_str) + return false if time_str.blank? + AUTO_SYNC_TIME_FORMAT.match?(time_str.to_s.strip) + end + + def self.valid_auto_sync_timezone?(timezone_str) + return false if timezone_str.blank? + ActiveSupport::TimeZone[timezone_str].present? + end # Dynamic fields are now stored as individual entries with "dynamic:" prefix # This prevents race conditions and ensures each field is independently managed diff --git a/app/services/auto_sync_scheduler.rb b/app/services/auto_sync_scheduler.rb new file mode 100644 index 000000000..44adea3b0 --- /dev/null +++ b/app/services/auto_sync_scheduler.rb @@ -0,0 +1,52 @@ +class AutoSyncScheduler + JOB_NAME = "sync_all_accounts" + + def self.sync! + Rails.logger.info("[AutoSyncScheduler] auto_sync_enabled=#{Setting.auto_sync_enabled}, time=#{Setting.auto_sync_time}") + if Setting.auto_sync_enabled? + upsert_job + else + remove_job + end + end + + def self.upsert_job + time_str = Setting.auto_sync_time || "02:22" + timezone_str = Setting.auto_sync_timezone || "UTC" + + unless Setting.valid_auto_sync_time?(time_str) + Rails.logger.error("[AutoSyncScheduler] Invalid time format: #{time_str}, using default 02:22") + time_str = "02:22" + end + + hour, minute = time_str.split(":").map(&:to_i) + timezone = ActiveSupport::TimeZone[timezone_str] || ActiveSupport::TimeZone["UTC"] + local_time = timezone.now.change(hour: hour, min: minute, sec: 0) + utc_time = local_time.utc + + cron = "#{utc_time.min} #{utc_time.hour} * * *" + + job = Sidekiq::Cron::Job.create( + name: JOB_NAME, + cron: cron, + class: "SyncAllJob", + queue: "scheduled", + description: "Syncs all accounts for all families" + ) + + if job.nil? || (job.respond_to?(:valid?) && !job.valid?) + error_msg = job.respond_to?(:errors) ? job.errors.to_a.join(", ") : "unknown error" + Rails.logger.error("[AutoSyncScheduler] Failed to create cron job: #{error_msg}") + raise StandardError, "Failed to create sync schedule: #{error_msg}" + end + + Rails.logger.info("[AutoSyncScheduler] Created cron job with schedule: #{cron} (#{time_str} #{timezone_str})") + job + end + + def self.remove_job + if (job = Sidekiq::Cron::Job.find(JOB_NAME)) + job.destroy + end + end +end diff --git a/app/views/settings/hostings/_sync_settings.html.erb b/app/views/settings/hostings/_sync_settings.html.erb index 0846e039d..82cfb86ef 100644 --- a/app/views/settings/hostings/_sync_settings.html.erb +++ b/app/views/settings/hostings/_sync_settings.html.erb @@ -17,6 +17,40 @@ <% end %> +
+
+

<%= t(".auto_sync_label") %>

+

<%= t(".auto_sync_description") %>

+
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> + <%= form.toggle :auto_sync_enabled, + checked: Setting.auto_sync_enabled, + data: { auto_submit_form_target: "auto" } %> + <% end %> +
+ +
+
+

<%= t(".auto_sync_time_label") %>

+

<%= t(".auto_sync_time_description") %>

+
+ + <%= form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> + <%= form.time_field :auto_sync_time, + value: Setting.auto_sync_time, + disabled: !Setting.auto_sync_enabled, + class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container text-primary w-full", + data: { auto_submit_form_target: "auto" } %> + <% end %> +
+ <% if env_configured %>
diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index f824501ed..4742bc809 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -75,10 +75,16 @@ en: failure: Invalid setting value success: Settings updated invalid_onboarding_state: Invalid onboarding state + invalid_sync_time: Invalid sync time format. Please use HH:MM format (e.g., 02:30). + scheduler_sync_failed: Settings saved, but failed to update the sync schedule. Please try again or check the server logs. clear_cache: cache_cleared: Data cache has been cleared. This may take a few moments to complete. not_authorized: You are not authorized to perform this action sync_settings: + auto_sync_label: Enable automatic sync + auto_sync_description: When enabled, all accounts will be automatically synced daily at the specified time. + auto_sync_time_label: Sync time (HH:MM) + auto_sync_time_description: Specify the time of day when automatic sync should occur. include_pending_label: Include pending transactions include_pending_description: When enabled, pending (uncleared) transactions will be imported and automatically reconciled when they post. Disable if your bank provides unreliable pending data. env_configured_message: This setting is disabled because a provider environment variable (SIMPLEFIN_INCLUDE_PENDING or PLAID_INCLUDE_PENDING) is set. Remove it to enable this setting. diff --git a/config/schedule.yml b/config/schedule.yml index b12d75ee7..c0d324408 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -19,12 +19,6 @@ run_security_health_checks: queue: "scheduled" description: "Runs security health checks to detect issues with security data" -sync_all_accounts: - cron: "22 2 * * *" # every 24 hours at 2:22am - class: "SyncAllJob" - queue: "scheduled" - description: "Syncs all accounts for all families" - sync_hourly: cron: "0 * * * *" # every hour at the top of the hour class: "SyncHourlyJob"