Files
sure/app/models/recurring_transaction.rb
soky srm 3611413829 Reccuring transactions pattern fix (#308)
* Fix pattern identification without merchants

- We already support the schema and data, but pattern identification now groups either per merchant or per transaciton name.

* Fix missed this view

* Fix update schema

* Wrong schema pushed
2025-11-10 12:02:43 +01:00

117 lines
3.2 KiB
Ruby

class RecurringTransaction < ApplicationRecord
include Monetizable
belongs_to :family
belongs_to :merchant, optional: true
monetize :amount
enum :status, { active: "active", inactive: "inactive" }
validates :amount, presence: true
validates :currency, presence: true
validates :expected_day_of_month, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 31 }
validate :merchant_or_name_present
def merchant_or_name_present
if merchant_id.blank? && name.blank?
errors.add(:base, "Either merchant or name must be present")
end
end
scope :for_family, ->(family) { where(family: family) }
scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) }
# Class methods for identification and cleanup
def self.identify_patterns_for(family)
Identifier.new(family).identify_recurring_patterns
end
def self.cleanup_stale_for(family)
Cleaner.new(family).cleanup_stale_transactions
end
# Find matching transactions for this recurring pattern
def matching_transactions
entries = family.entries
.where(entryable_type: "Transaction")
.where(currency: currency)
.where("entries.amount = ?", amount)
.where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?",
[ expected_day_of_month - 2, 1 ].max,
[ expected_day_of_month + 2, 31 ].min)
.order(date: :desc)
# Filter by merchant or name
if merchant_id.present?
# Match by merchant through the entryable (Transaction)
entries.select do |entry|
entry.entryable.is_a?(Transaction) && entry.entryable.merchant_id == merchant_id
end
else
# Match by entry name
entries.where(name: name)
end
end
# Check if this recurring transaction should be marked inactive
def should_be_inactive?
return false if last_occurrence_date.nil?
last_occurrence_date < 2.months.ago
end
# Mark as inactive
def mark_inactive!
update!(status: "inactive")
end
# Mark as active
def mark_active!
update!(status: "active")
end
# Update based on a new transaction occurrence
def record_occurrence!(transaction_date)
self.last_occurrence_date = transaction_date
self.next_expected_date = calculate_next_expected_date(transaction_date)
self.occurrence_count += 1
self.status = "active"
save!
end
# Calculate the next expected date based on the last occurrence
def calculate_next_expected_date(from_date = last_occurrence_date)
# Start with next month
next_month = from_date.next_month
# Try to use the expected day of month
begin
Date.new(next_month.year, next_month.month, expected_day_of_month)
rescue ArgumentError
# If day doesn't exist in month (e.g., 31st in February), use last day of month
next_month.end_of_month
end
end
# Get the projected transaction for display
def projected_entry
return nil unless active?
return nil unless next_expected_date.future?
OpenStruct.new(
date: next_expected_date,
amount: amount,
currency: currency,
merchant: merchant,
name: merchant.present? ? merchant.name : name,
recurring: true,
projected: true
)
end
private
def monetizable_currency
currency
end
end