Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
20 changes: 19 additions & 1 deletion app/controllers/recurring_transactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,22 @@ def index
@recurring_transactions = Current.family.recurring_transactions
.includes(:merchant)
.order(status: :asc, next_expected_date: :asc)
@family = Current.family
end

def update_settings
Current.family.update!(recurring_settings_params)

respond_to do |format|
format.html do
flash[:notice] = t("recurring_transactions.settings_updated")
redirect_to recurring_transactions_path
end
end
end

def identify
count = RecurringTransaction.identify_patterns_for(Current.family)
count = RecurringTransaction.identify_patterns_for!(Current.family)

respond_to do |format|
format.html do
Expand Down Expand Up @@ -55,4 +67,10 @@ def destroy
flash[:notice] = t("recurring_transactions.deleted")
redirect_to recurring_transactions_path
end

private

def recurring_settings_params
{ recurring_transactions_disabled: params[:recurring_transactions_disabled] == "true" }
end
end
11 changes: 11 additions & 0 deletions app/controllers/transactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,13 @@ def mark_as_recurring
end
end

def update_preferences
Current.user.update_transactions_preferences(preferences_params)
head :ok
rescue => e
render json: { error: e.message }, status: :unprocessable_entity
end

private
def per_page
params[:per_page].to_i.positive? ? params[:per_page].to_i : 20
Expand Down Expand Up @@ -236,4 +243,8 @@ def should_restore_params?
def stored_params
Current.session.prev_transaction_page_params
end

def preferences_params
params.require(:preferences).permit(collapsed_sections: {})
end
end
96 changes: 96 additions & 0 deletions app/javascript/controllers/transactions_section_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static targets = ["content", "chevron", "button"];
static values = {
sectionKey: String,
collapsed: Boolean,
};

connect() {
if (this.collapsedValue) {
this.collapse(false);
}
}

toggle(event) {
event.preventDefault();
if (this.collapsedValue) {
this.expand();
} else {
this.collapse();
}
}

handleToggleKeydown(event) {
// Handle Enter and Space keys for keyboard accessibility
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
this.toggle(event);
}
}

collapse(persist = true) {
this.contentTarget.classList.add("hidden");
this.chevronTarget.classList.add("rotate-180");
this.collapsedValue = true;
if (this.hasButtonTarget) {
this.buttonTarget.setAttribute("aria-expanded", "false");
}
if (persist) {
this.savePreference(true);
}
}

expand() {
this.contentTarget.classList.remove("hidden");
this.chevronTarget.classList.remove("rotate-180");
this.collapsedValue = false;
if (this.hasButtonTarget) {
this.buttonTarget.setAttribute("aria-expanded", "true");
}
this.savePreference(false);
}

async savePreference(collapsed) {
const preferences = {
collapsed_sections: {
[this.sectionKeyValue]: collapsed,
},
};

const csrfToken = document.querySelector('meta[name="csrf-token"]');
if (!csrfToken) {
console.error(
"[Transactions Section] CSRF token not found. Cannot save preferences.",
);
return;
}

try {
const response = await fetch("/transactions/update_preferences", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken.content,
},
body: JSON.stringify({ preferences }),
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error(
"[Transactions Section] Failed to save preferences:",
response.status,
errorData,
);
}
} catch (error) {
console.error(
"[Transactions Section] Network error saving preferences:",
error,
);
}
}
}
83 changes: 83 additions & 0 deletions app/jobs/identify_recurring_transactions_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
class IdentifyRecurringTransactionsJob < ApplicationJob
queue_as :default

# Debounce: if called multiple times within the delay window,
# only the last scheduled job will actually run
DEBOUNCE_DELAY = 30.seconds

def perform(family_id, scheduled_at)
family = Family.find_by(id: family_id)
return unless family
return if family.recurring_transactions_disabled?

# Check if this job is stale (a newer one was scheduled)
latest_scheduled = Rails.cache.read(cache_key(family_id))
return if latest_scheduled && latest_scheduled > scheduled_at

# Check if there are still incomplete syncs - if so, skip and let the last sync trigger it
return if family_has_incomplete_syncs?(family)

# Use advisory lock as final safety net against concurrent execution
with_advisory_lock(family_id) do
RecurringTransaction::Identifier.new(family).identify_recurring_patterns
end
end

def self.schedule_for(family)
return if family.recurring_transactions_disabled?

scheduled_at = Time.current.to_f
cache_key = "recurring_transaction_identify:#{family.id}"

# Store the latest scheduled time
Rails.cache.write(cache_key, scheduled_at, expires_in: DEBOUNCE_DELAY + 10.seconds)

# Schedule the job with delay
set(wait: DEBOUNCE_DELAY).perform_later(family.id, scheduled_at)
end

private

def cache_key(family_id)
"recurring_transaction_identify:#{family_id}"
end

def family_has_incomplete_syncs?(family)
# Check family's own syncs
return true if family.syncs.incomplete.exists?

# Check all provider items' syncs
return true if family.plaid_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:plaid_items)
return true if family.simplefin_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:simplefin_items)
return true if family.lunchflow_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:lunchflow_items)
return true if family.enable_banking_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:enable_banking_items)

# Check accounts' syncs
return true if family.accounts.joins(:syncs).merge(Sync.incomplete).exists?

false
end

def with_advisory_lock(family_id)
lock_key = advisory_lock_key(family_id)
acquired = ActiveRecord::Base.connection.select_value(
ActiveRecord::Base.sanitize_sql_array([ "SELECT pg_try_advisory_lock(?)", lock_key ])
)

return unless acquired

begin
yield
ensure
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array([ "SELECT pg_advisory_unlock(?)", lock_key ])
)
end
end

def advisory_lock_key(family_id)
# Generate a stable integer key from the family ID for PostgreSQL advisory lock
# Advisory locks require a bigint key
Digest::MD5.hexdigest("recurring_transaction_identify:#{family_id}").to_i(16) % (2**31)
end
end
2 changes: 1 addition & 1 deletion app/models/family/sync_complete_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def broadcast
Rails.logger.error("Family::SyncCompleteEvent net_worth_chart broadcast failed: #{e.message}\n#{e.backtrace&.join("\n")}")
end

# Identify recurring transaction patterns after sync
# Schedule recurring transaction pattern identification (debounced to run after all syncs complete)
begin
RecurringTransaction.identify_patterns_for(family)
rescue => e
Expand Down
7 changes: 7 additions & 0 deletions app/models/recurring_transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ def amount_variance_consistency
scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) }

# Class methods for identification and cleanup
# Schedules pattern identification with debounce to run after all syncs complete
def self.identify_patterns_for(family)
IdentifyRecurringTransactionsJob.schedule_for(family)
0 # Return immediately, actual count will be determined by the job
end

# Synchronous pattern identification (for manual triggers from UI)
def self.identify_patterns_for!(family)
Identifier.new(family).identify_recurring_patterns
end

Expand Down
68 changes: 44 additions & 24 deletions app/models/recurring_transaction/identifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,35 +81,55 @@ def identify_recurring_patterns
find_conditions[:merchant_id] = nil
end

recurring_transaction = family.recurring_transactions.find_or_initialize_by(find_conditions)
begin
recurring_transaction = family.recurring_transactions.find_or_initialize_by(find_conditions)

# Handle manual recurring transactions specially
if recurring_transaction.persisted? && recurring_transaction.manual?
# Update variance for manual recurring transactions
update_manual_recurring_variance(recurring_transaction, pattern)
next
end
# Handle manual recurring transactions specially
if recurring_transaction.persisted? && recurring_transaction.manual?
# Update variance for manual recurring transactions
update_manual_recurring_variance(recurring_transaction, pattern)
next
end

# Set the name or merchant_id on new records
if recurring_transaction.new_record?
if pattern[:merchant_id].present?
recurring_transaction.merchant_id = pattern[:merchant_id]
else
recurring_transaction.name = pattern[:name]
# Set the name or merchant_id on new records
if recurring_transaction.new_record?
if pattern[:merchant_id].present?
recurring_transaction.merchant_id = pattern[:merchant_id]
else
recurring_transaction.name = pattern[:name]
end
# New auto-detected recurring transactions are not manual
recurring_transaction.manual = false
end
# New auto-detected recurring transactions are not manual
recurring_transaction.manual = false
end

recurring_transaction.assign_attributes(
expected_day_of_month: pattern[:expected_day_of_month],
last_occurrence_date: pattern[:last_occurrence_date],
next_expected_date: calculate_next_expected_date(pattern[:last_occurrence_date], pattern[:expected_day_of_month]),
occurrence_count: pattern[:occurrence_count],
status: recurring_transaction.new_record? ? "active" : recurring_transaction.status
)
recurring_transaction.assign_attributes(
expected_day_of_month: pattern[:expected_day_of_month],
last_occurrence_date: pattern[:last_occurrence_date],
next_expected_date: calculate_next_expected_date(pattern[:last_occurrence_date], pattern[:expected_day_of_month]),
occurrence_count: pattern[:occurrence_count],
status: recurring_transaction.new_record? ? "active" : recurring_transaction.status
)

recurring_transaction.save!
rescue ActiveRecord::RecordNotUnique
# Race condition: another process created the same record between find and save.
# Retry with find to get the existing record and update it.
recurring_transaction = family.recurring_transactions.find_by(find_conditions)
next unless recurring_transaction

# Skip manual recurring transactions
if recurring_transaction.manual?
update_manual_recurring_variance(recurring_transaction, pattern)
next
end

recurring_transaction.save!
recurring_transaction.update!(
expected_day_of_month: pattern[:expected_day_of_month],
last_occurrence_date: pattern[:last_occurrence_date],
next_expected_date: calculate_next_expected_date(pattern[:last_occurrence_date], pattern[:expected_day_of_month]),
occurrence_count: pattern[:occurrence_count]
)
end
end

# Also check for manual recurring transactions that might need variance updates
Expand Down
23 changes: 23 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,29 @@ def update_reports_preferences(prefs)
end
end

# Transactions preferences management
def transactions_section_collapsed?(section_key)
preferences&.dig("transactions_collapsed_sections", section_key) == true
end

def update_transactions_preferences(prefs)
transaction do
lock!

updated_prefs = (preferences || {}).deep_dup
prefs.each do |key, value|
if value.is_a?(Hash)
updated_prefs["transactions_#{key}"] ||= {}
updated_prefs["transactions_#{key}"] = updated_prefs["transactions_#{key}"].merge(value)
else
updated_prefs["transactions_#{key}"] = value
end
end

update!(preferences: updated_prefs)
end
end

private
def default_dashboard_section_order
%w[cashflow_sankey outflows_donut net_worth_chart balance_sheet]
Expand Down
Loading