Recurring scoping implementation (#1300)

* Recurring scoping implementation

* FIX tests and reviews
This commit is contained in:
soky srm
2026-03-26 19:01:35 +01:00
committed by GitHub
parent 9410e5b38d
commit f1991eaefe
11 changed files with 232 additions and 51 deletions

View File

@@ -3,6 +3,7 @@ class RecurringTransactionsController < ApplicationController
def index
@recurring_transactions = Current.family.recurring_transactions
.accessible_by(Current.user)
.includes(:merchant)
.order(status: :asc, next_expected_date: :asc)
@family = Current.family
@@ -42,7 +43,7 @@ class RecurringTransactionsController < ApplicationController
end
def toggle_status
@recurring_transaction = Current.family.recurring_transactions.find(params[:id])
@recurring_transaction = Current.family.recurring_transactions.accessible_by(Current.user).find(params[:id])
if @recurring_transaction.active?
@recurring_transaction.mark_inactive!
@@ -61,7 +62,7 @@ class RecurringTransactionsController < ApplicationController
end
def destroy
@recurring_transaction = Current.family.recurring_transactions.find(params[:id])
@recurring_transaction = Current.family.recurring_transactions.accessible_by(Current.user).find(params[:id])
@recurring_transaction.destroy!
flash[:notice] = t("recurring_transactions.deleted")

View File

@@ -54,6 +54,7 @@ class TransactionsController < ApplicationController
# Load projected recurring transactions for next 10 days
@projected_recurring = Current.family.recurring_transactions
.accessible_by(Current.user)
.active
.where("next_expected_date <= ? AND next_expected_date >= ?",
10.days.from_now.to_date,
@@ -304,6 +305,7 @@ class TransactionsController < ApplicationController
# Check if a recurring transaction already exists for this pattern
existing = Current.family.recurring_transactions.find_by(
account_id: transaction.entry.account_id,
merchant_id: transaction.merchant_id,
name: transaction.merchant_id.present? ? nil : transaction.entry.name,
currency: transaction.entry.currency,

View File

@@ -19,6 +19,7 @@ class Account < ApplicationRecord
has_many :trades, through: :entries, source: :entryable, source_type: "Trade"
has_many :holdings, dependent: :destroy
has_many :balances, dependent: :destroy
has_many :recurring_transactions, dependent: :destroy
monetize :balance, :cash_balance

View File

@@ -2,6 +2,7 @@ class RecurringTransaction < ApplicationRecord
include Monetizable
belongs_to :family
belongs_to :account, optional: true
belongs_to :merchant, optional: true
monetize :amount
@@ -35,6 +36,9 @@ class RecurringTransaction < ApplicationRecord
scope :for_family, ->(family) { where(family: family) }
scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) }
scope :accessible_by, ->(user) {
where(account_id: Account.accessible_by(user).select(:id)).or(where(account_id: nil))
}
# Class methods for identification and cleanup
# Schedules pattern identification with debounce to run after all syncs complete
@@ -66,7 +70,8 @@ class RecurringTransaction < ApplicationRecord
name: transaction.merchant_id.present? ? nil : entry.name,
currency: entry.currency,
expected_day: expected_day,
lookback_months: 6
lookback_months: 6,
account: entry.account
)
# Calculate amount variance from historical data
@@ -89,6 +94,7 @@ class RecurringTransaction < ApplicationRecord
create!(
family: family,
account: entry.account,
merchant_id: transaction.merchant_id,
name: transaction.merchant_id.present? ? nil : entry.name,
amount: entry.amount,
@@ -106,10 +112,10 @@ class RecurringTransaction < ApplicationRecord
end
# Find matching transaction entries for variance calculation
def self.find_matching_transaction_entries(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6)
def self.find_matching_transaction_entries(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6, account: nil)
lookback_date = lookback_months.months.ago.to_date
entries = family.entries
entries = (account.present? ? account.entries : family.entries)
.where(entryable_type: "Transaction")
.where(currency: currency)
.where("entries.date >= ?", lookback_date)
@@ -131,14 +137,15 @@ class RecurringTransaction < ApplicationRecord
end
# Find matching transaction amounts for variance calculation
def self.find_matching_transaction_amounts(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6)
def self.find_matching_transaction_amounts(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6, account: nil)
matching_entries = find_matching_transaction_entries(
family: family,
merchant_id: merchant_id,
name: name,
currency: currency,
expected_day: expected_day,
lookback_months: lookback_months
lookback_months: lookback_months,
account: account
)
matching_entries.map(&:amount)
@@ -173,8 +180,10 @@ class RecurringTransaction < ApplicationRecord
def matching_transactions
# For manual recurring with amount variance, match within range
# For automatic recurring, match exact amount
base = account.present? ? account.entries : family.entries
entries = if manual? && has_amount_variance?
family.entries
base
.where(entryable_type: "Transaction")
.where(currency: currency)
.where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max)
@@ -183,7 +192,7 @@ class RecurringTransaction < ApplicationRecord
[ expected_day_of_month + 2, 31 ].min)
.order(date: :desc)
else
family.entries
base
.where(entryable_type: "Transaction")
.where(currency: currency)
.where("entries.amount = ?", amount)

View File

@@ -24,12 +24,12 @@ class RecurringTransaction
transaction = entry.entryable
# Use merchant_id if present, otherwise use entry name
identifier = transaction.merchant_id.present? ? [ :merchant, transaction.merchant_id ] : [ :name, entry.name ]
[ identifier, entry.amount.round(2), entry.currency ]
[ identifier, entry.amount.round(2), entry.currency, entry.account_id ]
end
recurring_patterns = []
grouped_transactions.each do |(identifier, amount, currency), entries|
grouped_transactions.each do |(identifier, amount, currency, account_id), entries|
next if entries.size < 3 # Must have at least 3 occurrences
# Check if the last occurrence was within the last 45 days
@@ -49,6 +49,7 @@ class RecurringTransaction
pattern = {
amount: amount,
currency: currency,
account_id: account_id,
expected_day_of_month: expected_day,
last_occurrence_date: last_occurrence.date,
occurrence_count: entries.size,
@@ -70,7 +71,8 @@ class RecurringTransaction
# Build find conditions based on whether it's merchant-based or name-based
find_conditions = {
amount: pattern[:amount],
currency: pattern[:currency]
currency: pattern[:currency],
account_id: pattern[:account_id]
}
if pattern[:merchant_id].present?
@@ -148,7 +150,8 @@ class RecurringTransaction
name: recurring.name,
currency: recurring.currency,
expected_day: recurring.expected_day_of_month,
lookback_months: 6
lookback_months: 6,
account: recurring.account
)
next if matching_entries.empty?
@@ -180,7 +183,8 @@ class RecurringTransaction
name: recurring_transaction.name,
currency: recurring_transaction.currency,
expected_day: recurring_transaction.expected_day_of_month,
lookback_months: 6
lookback_months: 6,
account: recurring_transaction.account
)
# Update if we have any matching transactions