mirror of
https://github.com/we-promise/sure.git
synced 2026-09-07 23:54:25 +00:00
* feat(bills): schema and domain core for the bills subsystem First of three chunks carved out of #3083. This one carries the schema and the domain layer: no bills pages, no calendar feed, no assistant tools. Nothing here is reachable from the UI yet, so it changes no user-visible behavior on its own. Schema, in a single migration with a full down: - recurrence_rules, recurring_occurrences, recurring_allocations, recurring_price_changes and recurring_match_rejections - bill columns on recurring_transactions (bill_type, payment_url, autopay, notes, anchor and end conditions, weekend adjustment, dedup scope) - the four data backfills, in their original order Domain layer: - Schedule, the pure date PORO every cadence resolves through, and FrequencyPreset for the labels - OccurrenceGenerator, Matcher, Allocator, PriceChangeDetector, Classifier, DeclaredBill, HistoryBackfiller and PaycheckPlanner - Pipeline, tying detection to generation, plus the nightly job and rake task Existing detection code changed in three places, each a bug this schema exposes: - Cleaner used a flat two-month staleness threshold, which silently retired every quarterly and annual series - SubscriptionAuditGenerator used a flat 45-day overdue threshold, meaningless at both ends of the frequency range - CashFlowWarningGenerator read one projected entry per series, which only equalled the monthly amount because every series was monthly; weekly bills were under-counted fourfold in its 30-day projection The JSON API travels with the model rather than the UI, because the status enum widens here. The API accepts only active and inactive on write; suggested, paused and ended are lifecycle states owned by detection, so the documented enum stays truthful. Uniqueness keys gain dedup_scope alongside amount, never instead of it: a series that is not price-forked carries a blank scope, so amount is what keeps two different prices apart. Suite 7,550 runs, 0 failures. Rubocop and brakeman clean. Eager loading verified, and the migration reverses and re-applies. Includes the first review round: orphan repair matches income and refuses coincidental twins, session imports persist occurrence mappings across chunks, semimonthly anchors canonicalize, classifier keywords match whole words, and the down refuses rather than failing when price-forked rows exist. * Address second review round Bound the cross-currency default allocation by the entry leftover and the occurrence remainder, matching the same-currency path. Let keyword stems carry a suffix again after the word-boundary fix silenced them. Skip an incoherent recurrence rule row instead of rolling back the whole import. Check rollback collisions per restored index so a refusal cannot land after the bills tables are dropped. Replay the closed_at test through a real second import. Preload the orphan repair associations and move the allocator errors to locale keys. * Match index NULL semantics in the rollback collision checks GROUP BY treats NULLs as equal but the restored unique indexes do not: account_id is nullable and indexed, so two accountless rows can never collide under any of them. Excluding NULL accounts keeps the guard from refusing a rollback PostgreSQL can perform. Verified live both ways: accountless duplicates roll back, a real collision still refuses. * Address maintainer review Scope the payable debt-destination subquery to the row and its family instead of scanning every account in the installation. Batch the cash flow generator remaining-amount sums into one grouped query, matching the two sibling sites. Enforce both window bounds in the after_count branch so a future-anchored plan cannot leak past the requested end date. Skip the explicit regeneration when the day column change will fire the model callback anyway. Add the missing locale entry for the allocation currency validation.
1542 lines
56 KiB
Ruby
1542 lines
56 KiB
Ruby
require "test_helper"
|
|
|
|
class RecurringTransactionTest < ActiveSupport::TestCase
|
|
def setup
|
|
@family = families(:dylan_family)
|
|
@merchant = merchants(:netflix)
|
|
@account = accounts(:depository)
|
|
# Clear any existing recurring transactions
|
|
@family.recurring_transactions.destroy_all
|
|
end
|
|
|
|
test "payable ignores a debt destination that belongs to another family" do
|
|
foreign_card = families(:empty).accounts.create!(
|
|
name: "Foreign Card", balance: 0, currency: "USD", accountable: CreditCard.new
|
|
)
|
|
transfer = @family.recurring_transactions.create!(
|
|
name: "Card payment", account: @account, amount: 200, currency: "USD",
|
|
expected_day_of_month: 5, last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date, status: "active", manual: true
|
|
)
|
|
transfer.update_column(:destination_account_id, foreign_card.id)
|
|
|
|
assert_not_includes @family.recurring_transactions.payable, transfer,
|
|
"a destination outside the family is not this family's obligation"
|
|
end
|
|
|
|
test "status is required" do
|
|
recurring = @family.recurring_transactions.build(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 29.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
status: nil
|
|
)
|
|
|
|
assert_not recurring.valid?
|
|
assert_includes recurring.errors[:status], "can't be blank"
|
|
end
|
|
|
|
# end_after_count is a permitted parameter and the generator materialises a
|
|
# finite plan whole, so an absurd value is not a silly setting: it is one
|
|
# request deciding how far the table grows. 100,000 wrote 100,000 rows.
|
|
test "an absurd installment plan length is refused before any row is written" do
|
|
plan = build_recurring(bill_type: "installment", end_mode: "after_count",
|
|
end_after_count: 100_000)
|
|
|
|
assert_not plan.valid?
|
|
assert_includes plan.errors.attribute_names, :end_after_count
|
|
end
|
|
|
|
test "a realistic installment plan length is accepted" do
|
|
plan = build_recurring(bill_type: "installment", end_mode: "after_count",
|
|
end_after_count: 24, anchor_date: Date.current)
|
|
|
|
assert plan.valid?, plan.errors.full_messages.to_sentence
|
|
end
|
|
|
|
# category_id is permitted too, and nothing checked whose category it was.
|
|
test "a category belonging to another family is refused" do
|
|
other = Family.create!(name: "Other Household", currency: "USD")
|
|
foreign = Category.create!(family: other, name: "Their Groceries", color: "#ff0000")
|
|
|
|
bill = build_recurring(category_id: foreign.id)
|
|
|
|
assert_not bill.valid?
|
|
assert_includes bill.errors.attribute_names, :category_id
|
|
end
|
|
|
|
test "a category belonging to this family is accepted" do
|
|
own = Category.create!(family: @family, name: "Our Groceries", color: "#00ff00")
|
|
|
|
bill = build_recurring(category_id: own.id)
|
|
|
|
assert bill.valid?, bill.errors.full_messages.to_sentence
|
|
end
|
|
|
|
test "occurrence count cannot be negative" do
|
|
recurring = @family.recurring_transactions.build(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 29.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
status: "active",
|
|
occurrence_count: -1
|
|
)
|
|
|
|
assert_not recurring.valid?
|
|
assert_includes recurring.errors[:occurrence_count], "must be greater than or equal to 0"
|
|
end
|
|
|
|
test "identify_patterns_for creates recurring transactions for patterns with 3+ occurrences" do
|
|
# Create a series of transactions with same merchant and amount on similar days
|
|
# Use dates within the last 3 months: today, 1 month ago, 2 months ago
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
@account.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 5.days,
|
|
amount: 15.99,
|
|
currency: "USD",
|
|
name: "Netflix Subscription",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
assert_difference "@family.recurring_transactions.count", 1 do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
|
|
recurring = @family.recurring_transactions.last
|
|
assert_equal @merchant, recurring.merchant
|
|
assert_equal @account, recurring.account
|
|
assert_equal 15.99, recurring.amount
|
|
assert_equal "USD", recurring.currency
|
|
assert_equal "suggested", recurring.status
|
|
assert_equal 3, recurring.occurrence_count
|
|
end
|
|
|
|
test "identify_patterns_for does not create recurring transaction for less than 3 occurrences" do
|
|
# Create only 2 transactions
|
|
2.times do |i|
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
@account.entries.create!(
|
|
date: (i + 1).months.ago.beginning_of_month + 5.days,
|
|
amount: 15.99,
|
|
currency: "USD",
|
|
name: "Netflix Subscription",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
assert_no_difference "@family.recurring_transactions.count" do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
end
|
|
|
|
test "calculate_next_expected_date handles end of month correctly" do
|
|
recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 29.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 31,
|
|
last_occurrence_date: Date.new(2025, 1, 31),
|
|
next_expected_date: Date.new(2025, 2, 28),
|
|
status: "active"
|
|
)
|
|
|
|
# February doesn't have 31 days, should return last day of February
|
|
next_date = recurring.calculate_next_expected_date(Date.new(2025, 1, 31))
|
|
assert_equal Date.new(2025, 2, 28), next_date
|
|
end
|
|
|
|
test "should_be_inactive? returns true when last occurrence is over 2 months ago" do
|
|
recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: merchants(:amazon),
|
|
amount: 19.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: 3.months.ago.to_date,
|
|
next_expected_date: 2.months.ago.to_date,
|
|
status: "active"
|
|
)
|
|
|
|
assert recurring.should_be_inactive?
|
|
end
|
|
|
|
test "should_be_inactive? returns false when last occurrence is within 2 months" do
|
|
recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: merchants(:amazon),
|
|
amount: 25.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: 1.month.ago.to_date,
|
|
next_expected_date: Date.current,
|
|
status: "active"
|
|
)
|
|
|
|
assert_not recurring.should_be_inactive?
|
|
end
|
|
|
|
test "cleanup_stale_for marks inactive when no recent occurrences" do
|
|
recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: merchants(:amazon),
|
|
amount: 35.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: 3.months.ago.to_date,
|
|
next_expected_date: 2.months.ago.to_date,
|
|
status: "active"
|
|
)
|
|
|
|
RecurringTransaction.cleanup_stale_for(@family)
|
|
|
|
assert_equal "inactive", recurring.reload.status
|
|
end
|
|
|
|
test "record_occurrence! updates recurring transaction with new occurrence" do
|
|
recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: merchants(:amazon),
|
|
amount: 45.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: 1.month.ago.to_date,
|
|
next_expected_date: Date.current,
|
|
status: "active",
|
|
occurrence_count: 3
|
|
)
|
|
|
|
new_date = Date.current
|
|
recurring.record_occurrence!(new_date)
|
|
|
|
assert_equal new_date, recurring.last_occurrence_date
|
|
assert_equal 4, recurring.occurrence_count
|
|
assert_equal "active", recurring.status
|
|
assert recurring.next_expected_date > new_date
|
|
end
|
|
|
|
test "identify_patterns_for preserves sign for income transactions" do
|
|
# Create recurring income transactions (negative amounts)
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:income)
|
|
)
|
|
@account.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 15.days,
|
|
amount: -1000.00,
|
|
currency: "USD",
|
|
name: "Monthly Salary",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
assert_difference "@family.recurring_transactions.count", 1 do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
|
|
recurring = @family.recurring_transactions.last
|
|
assert_equal @merchant, recurring.merchant
|
|
assert_equal @account, recurring.account
|
|
assert_equal(-1000.00, recurring.amount)
|
|
assert recurring.amount.negative?, "Income should have negative amount"
|
|
assert_equal "USD", recurring.currency
|
|
assert_equal "suggested", recurring.status
|
|
end
|
|
|
|
test "identify_patterns_for creates name-based recurring transactions for transactions without merchants" do
|
|
# Create transactions without merchants (e.g., from CSV imports or standard accounts)
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(
|
|
category: categories(:food_and_drink)
|
|
)
|
|
@account.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 10.days,
|
|
amount: 25.00,
|
|
currency: "USD",
|
|
name: "Local Coffee Shop",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
assert_difference "@family.recurring_transactions.count", 1 do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
|
|
recurring = @family.recurring_transactions.last
|
|
assert_nil recurring.merchant
|
|
assert_equal @account, recurring.account
|
|
assert_equal "Local Coffee Shop", recurring.name
|
|
assert_equal 25.00, recurring.amount
|
|
assert_equal "USD", recurring.currency
|
|
assert_equal "suggested", recurring.status
|
|
assert_equal 3, recurring.occurrence_count
|
|
end
|
|
|
|
test "identify_patterns_for creates separate patterns for same merchant but different names" do
|
|
# Create two different recurring transactions from the same merchant
|
|
|
|
# First pattern: Netflix Standard
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
@account.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 5.days,
|
|
amount: 15.99,
|
|
currency: "USD",
|
|
name: "Netflix Standard",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
# Second pattern: Netflix Premium
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
@account.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 10.days,
|
|
amount: 19.99,
|
|
currency: "USD",
|
|
name: "Netflix Premium",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
# Should create 2 patterns - one for each amount
|
|
assert_difference "@family.recurring_transactions.count", 2 do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
end
|
|
|
|
test "matching_transactions works with name-based recurring transactions" do
|
|
# Skip when schema enforces NOT NULL merchant_id (branch-specific behavior)
|
|
unless RecurringTransaction.columns_hash["merchant_id"].null
|
|
skip "merchant_id is NOT NULL in this schema; name-based patterns disabled"
|
|
end
|
|
|
|
# Create transactions for pattern
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(
|
|
category: categories(:food_and_drink)
|
|
)
|
|
@account.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 15.days,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
name: "Gym Membership",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
recurring = @family.recurring_transactions.last
|
|
|
|
# Verify matching transactions finds the correct entries
|
|
matches = recurring.matching_transactions
|
|
assert_equal 3, matches.size
|
|
assert matches.all? { |entry| entry.name == "Gym Membership" }
|
|
end
|
|
|
|
test "matching_transactions rejects a same-day charge in an off-cycle month" do
|
|
recurring = build_recurring(
|
|
merchant: nil,
|
|
name: "Quarterly water bill",
|
|
amount: 90.00,
|
|
anchor_date: Date.new(2026, 5, 15),
|
|
last_occurrence_date: Date.new(2026, 5, 15),
|
|
next_expected_date: Date.new(2026, 8, 15)
|
|
)
|
|
recurring.save!
|
|
recurring.recurrence_rules.create!(frequency: "monthly", interval: 3, day_of_month: 15)
|
|
|
|
entries = [ Date.new(2026, 8, 15), Date.new(2026, 7, 15) ].map do |date|
|
|
@account.entries.create!(
|
|
date: date,
|
|
amount: 90.00,
|
|
currency: "USD",
|
|
name: "Quarterly water bill",
|
|
entryable: Transaction.create!
|
|
)
|
|
end
|
|
on_cycle, off_cycle = entries
|
|
|
|
matches = recurring.matching_transactions.map(&:id)
|
|
# Positive control: the on-cycle month's charge still matches.
|
|
assert_includes matches, on_cycle.id
|
|
# Same calendar day, off-cycle month: the day window alone would accept it.
|
|
assert_not_includes matches, off_cycle.id
|
|
end
|
|
|
|
test "validation requires either merchant or name" do
|
|
recurring = @family.recurring_transactions.build(
|
|
account: @account,
|
|
amount: 25.00,
|
|
currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date
|
|
)
|
|
|
|
assert_not recurring.valid?
|
|
assert_includes recurring.errors[:base], "Either merchant or name must be present"
|
|
end
|
|
|
|
test "both merchant-based and name-based patterns can coexist" do
|
|
# Skip when schema enforces NOT NULL merchant_id (branch-specific behavior)
|
|
unless RecurringTransaction.columns_hash["merchant_id"].null
|
|
skip "merchant_id is NOT NULL in this schema; name-based patterns disabled"
|
|
end
|
|
|
|
# Create merchant-based pattern
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
@account.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 5.days,
|
|
amount: 15.99,
|
|
currency: "USD",
|
|
name: "Netflix Subscription",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
# Create name-based pattern (no merchant)
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(
|
|
category: categories(:one)
|
|
)
|
|
@account.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 1.days,
|
|
amount: 1200.00,
|
|
currency: "USD",
|
|
name: "Monthly Rent",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
assert_difference "@family.recurring_transactions.count", 2 do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
|
|
# Verify both types exist
|
|
merchant_based = @family.recurring_transactions.where.not(merchant_id: nil).first
|
|
name_based = @family.recurring_transactions.where(merchant_id: nil).first
|
|
|
|
assert merchant_based.present?
|
|
assert_equal @merchant, merchant_based.merchant
|
|
|
|
assert name_based.present?
|
|
assert_equal "Monthly Rent", name_based.name
|
|
end
|
|
|
|
# Manual recurring transaction tests
|
|
test "create_from_transaction creates a manual recurring transaction" do
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
entry = @account.entries.create!(
|
|
date: 2.months.ago,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: transaction
|
|
)
|
|
|
|
recurring = nil
|
|
assert_difference "@family.recurring_transactions.count", 1 do
|
|
recurring = RecurringTransaction.create_from_transaction(transaction)
|
|
end
|
|
|
|
assert recurring.present?
|
|
assert recurring.manual?
|
|
assert_equal @merchant, recurring.merchant
|
|
assert_equal @account, recurring.account
|
|
assert_equal 50.00, recurring.amount
|
|
assert_equal "USD", recurring.currency
|
|
assert_equal 2.months.ago.day, recurring.expected_day_of_month
|
|
assert_equal "active", recurring.status
|
|
assert_equal 1, recurring.occurrence_count
|
|
# Next expected date should be in the future (either this month or next month)
|
|
assert recurring.next_expected_date >= Date.current
|
|
end
|
|
|
|
test "create_from_transaction automatically calculates amount variance from history" do
|
|
# Create multiple historical transactions with varying amounts on the same day of month
|
|
amounts = [ 90.00, 100.00, 110.00, 120.00 ]
|
|
amounts.each_with_index do |amount, i|
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
@account.entries.create!(
|
|
date: (amounts.size - i).months.ago.beginning_of_month + 14.days, # Day 15
|
|
amount: amount,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
# Mark the most recent one as recurring (find the 120.00 entry we created last)
|
|
most_recent_entry = @account.entries.where(amount: 120.00, currency: "USD").order(date: :desc).first
|
|
recurring = RecurringTransaction.create_from_transaction(most_recent_entry.transaction)
|
|
|
|
assert recurring.manual?
|
|
assert_equal @account, recurring.account
|
|
assert_equal 90.00, recurring.expected_amount_min
|
|
assert_equal 120.00, recurring.expected_amount_max
|
|
assert_equal 105.00, recurring.expected_amount_avg # (90 + 100 + 110 + 120) / 4
|
|
assert_equal 4, recurring.occurrence_count
|
|
# Next expected date should be in the future
|
|
assert recurring.next_expected_date >= Date.current
|
|
end
|
|
|
|
test "create_from_transaction with single transaction sets fixed amount" do
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
entry = @account.entries.create!(
|
|
date: 1.month.ago,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: transaction
|
|
)
|
|
|
|
recurring = RecurringTransaction.create_from_transaction(transaction)
|
|
|
|
assert recurring.manual?
|
|
assert_equal @account, recurring.account
|
|
assert_equal 50.00, recurring.expected_amount_min
|
|
assert_equal 50.00, recurring.expected_amount_max
|
|
assert_equal 50.00, recurring.expected_amount_avg
|
|
assert_equal 1, recurring.occurrence_count
|
|
# Next expected date should be in the future
|
|
assert recurring.next_expected_date >= Date.current
|
|
end
|
|
|
|
test "create_from_transaction does not blend a distinct same-day charge type into the variance band" do
|
|
# Mirrors a real production case: two genuinely different charges from
|
|
# the same merchant, same day (a small fee alongside a larger due),
|
|
# ~6.5x apart -- not one fluctuating payment.
|
|
fee_transaction = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
|
fee_entry = @account.entries.create!(
|
|
date: 1.month.ago.beginning_of_month + 14.days,
|
|
amount: 3.00,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: fee_transaction
|
|
)
|
|
|
|
due_transaction = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
|
@account.entries.create!(
|
|
date: 1.month.ago.beginning_of_month + 14.days,
|
|
amount: 19.68,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: due_transaction
|
|
)
|
|
|
|
recurring = RecurringTransaction.create_from_transaction(fee_entry.transaction)
|
|
|
|
assert_equal 3.00, recurring.expected_amount_min
|
|
assert_equal 3.00, recurring.expected_amount_max
|
|
assert_equal 3.00, recurring.expected_amount_avg
|
|
assert_equal 1, recurring.occurrence_count
|
|
end
|
|
|
|
test "create_from_transaction collides on an identical series and still forks a different price tier" do
|
|
first_transaction = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
|
@account.entries.create!(
|
|
date: 2.months.ago,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: first_transaction
|
|
)
|
|
|
|
recurring = RecurringTransaction.create_from_transaction(first_transaction)
|
|
assert_equal "50.0", recurring.dedup_scope, "the amount is stamped before the first insert"
|
|
|
|
# An identical series (same identity, same amount) must hit the unique
|
|
# index on the first insert, not slip past it as a stamped duplicate.
|
|
duplicate_transaction = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
|
@account.entries.create!(
|
|
date: 1.month.ago,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: duplicate_transaction
|
|
)
|
|
|
|
assert_no_difference "@family.recurring_transactions.count" do
|
|
assert_raises ActiveRecord::RecordNotUnique do
|
|
RecurringTransaction.create_from_transaction(duplicate_transaction)
|
|
end
|
|
end
|
|
|
|
# A different amount is a legitimate second tier from the same biller and
|
|
# still forks a sibling series.
|
|
tier_transaction = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
|
@account.entries.create!(
|
|
date: 1.month.ago,
|
|
amount: 80.00,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: tier_transaction
|
|
)
|
|
|
|
assert_difference "@family.recurring_transactions.count", 1 do
|
|
tier = RecurringTransaction.create_from_transaction(tier_transaction)
|
|
assert_equal "80.0", tier.dedup_scope
|
|
end
|
|
end
|
|
|
|
test "amount_within_variance_band? allows up to 2x and excludes beyond" do
|
|
assert RecurringTransaction.amount_within_variance_band?(199, 100)
|
|
assert_not RecurringTransaction.amount_within_variance_band?(201, 100)
|
|
assert RecurringTransaction.amount_within_variance_band?(50, 100) # halved is still within band
|
|
assert_not RecurringTransaction.amount_within_variance_band?(49, 100)
|
|
end
|
|
|
|
test "amount_within_variance_band? handles a zero anchor without dividing by zero" do
|
|
assert RecurringTransaction.amount_within_variance_band?(0, 0)
|
|
assert_not RecurringTransaction.amount_within_variance_band?(5, 0)
|
|
end
|
|
|
|
test "amount_within_variance_band? does not match across a sign mismatch" do
|
|
assert_not RecurringTransaction.amount_within_variance_band?(50, -50)
|
|
assert_not RecurringTransaction.amount_within_variance_band?(-10, 100)
|
|
end
|
|
|
|
test "matching_transactions with amount variance matches within range" do
|
|
# Create manual recurring with variance for day 15 of the month
|
|
recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 100.00,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: 1.month.ago,
|
|
next_expected_date: Date.current.next_month.beginning_of_month + 14.days,
|
|
status: "active",
|
|
manual: true,
|
|
expected_amount_min: 80.00,
|
|
expected_amount_max: 120.00,
|
|
expected_amount_avg: 100.00
|
|
)
|
|
|
|
# Create transactions with varying amounts on day 14 (within +/-2 days of day 15)
|
|
transaction_within_range = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
|
entry_within = @account.entries.create!(
|
|
date: Date.current.next_month.beginning_of_month + 13.days, # Day 14
|
|
amount: 90.00,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: transaction_within_range
|
|
)
|
|
|
|
transaction_outside_range = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
|
entry_outside = @account.entries.create!(
|
|
date: Date.current.next_month.beginning_of_month + 14.days, # Day 15
|
|
amount: 150.00,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: transaction_outside_range
|
|
)
|
|
|
|
matches = recurring.matching_transactions
|
|
assert_includes matches, entry_within
|
|
assert_not_includes matches, entry_outside
|
|
end
|
|
|
|
test "should_be_inactive? has longer threshold for manual recurring" do
|
|
# Manual recurring - 6 months threshold
|
|
manual_recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: 5.months.ago,
|
|
next_expected_date: 15.days.from_now,
|
|
status: "active",
|
|
manual: true
|
|
)
|
|
|
|
# Auto recurring - 2 months threshold. A second series on the same
|
|
# identity carries a dedup_scope discriminator now that amount is no
|
|
# longer part of the unique indexes.
|
|
auto_recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 60.00,
|
|
dedup_scope: "60.0",
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: 3.months.ago,
|
|
next_expected_date: 15.days.from_now,
|
|
status: "active",
|
|
manual: false
|
|
)
|
|
|
|
assert_not manual_recurring.should_be_inactive?
|
|
assert auto_recurring.should_be_inactive?
|
|
end
|
|
|
|
test "update_amount_variance updates min/max/avg correctly" do
|
|
recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 100.00,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now,
|
|
status: "active",
|
|
manual: true,
|
|
occurrence_count: 1
|
|
)
|
|
|
|
# Record first occurrence with amount variance
|
|
recurring.record_occurrence!(Date.current, 100.00)
|
|
assert_equal 100.00, recurring.expected_amount_min.to_f
|
|
assert_equal 100.00, recurring.expected_amount_max.to_f
|
|
assert_equal 100.00, recurring.expected_amount_avg.to_f
|
|
|
|
# Record second occurrence with different amount
|
|
recurring.record_occurrence!(1.month.from_now, 120.00)
|
|
assert_equal 100.00, recurring.expected_amount_min.to_f
|
|
assert_equal 120.00, recurring.expected_amount_max.to_f
|
|
assert_in_delta 110.00, recurring.expected_amount_avg.to_f, 0.01
|
|
|
|
# Record third occurrence with lower amount
|
|
recurring.record_occurrence!(2.months.from_now, 90.00)
|
|
assert_equal 90.00, recurring.expected_amount_min.to_f
|
|
assert_equal 120.00, recurring.expected_amount_max.to_f
|
|
assert_in_delta 103.33, recurring.expected_amount_avg.to_f, 0.01
|
|
end
|
|
|
|
test "identify_patterns_for updates variance for manual recurring transactions" do
|
|
# Create a manual recurring transaction with initial variance
|
|
manual_recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: 3.months.ago,
|
|
next_expected_date: 1.month.from_now,
|
|
status: "active",
|
|
manual: true,
|
|
occurrence_count: 1,
|
|
expected_amount_min: 50.00,
|
|
expected_amount_max: 50.00,
|
|
expected_amount_avg: 50.00
|
|
)
|
|
|
|
# Create new transactions with varying amounts that would match the pattern
|
|
amounts = [ 45.00, 55.00, 60.00 ]
|
|
amounts.each_with_index do |amount, i|
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
@account.entries.create!(
|
|
date: (amounts.size - i).months.ago.beginning_of_month + 14.days,
|
|
amount: amount,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
# Run pattern identification
|
|
assert_no_difference "@family.recurring_transactions.count" do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
|
|
# Manual recurring should be updated with new variance
|
|
manual_recurring.reload
|
|
assert manual_recurring.manual?
|
|
assert_equal 45.00, manual_recurring.expected_amount_min
|
|
assert_equal 60.00, manual_recurring.expected_amount_max
|
|
assert_in_delta 53.33, manual_recurring.expected_amount_avg.to_f, 0.01 # (45 + 55 + 60) / 3
|
|
assert manual_recurring.occurrence_count > 1
|
|
end
|
|
|
|
test "identify_patterns_for does not re-blend a distinct charge type into an existing manual recurring transaction" do
|
|
# Mirrors the real production corruption: a manual recurring row seeded
|
|
# at 3.00 must not have its variance widened by a same-day, same-merchant
|
|
# 19.68 entry when the periodic identification job runs.
|
|
manual_recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 3.00,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: 3.months.ago,
|
|
next_expected_date: 1.month.from_now,
|
|
status: "active",
|
|
manual: true,
|
|
occurrence_count: 1,
|
|
expected_amount_min: 3.00,
|
|
expected_amount_max: 3.00,
|
|
expected_amount_avg: 3.00
|
|
)
|
|
|
|
fee_transaction = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
|
@account.entries.create!(
|
|
date: 1.month.ago.beginning_of_month + 14.days,
|
|
amount: 3.00,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: fee_transaction
|
|
)
|
|
|
|
due_transaction = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink))
|
|
@account.entries.create!(
|
|
date: 1.month.ago.beginning_of_month + 14.days,
|
|
amount: 19.68,
|
|
currency: "USD",
|
|
name: "Test Transaction",
|
|
entryable: due_transaction
|
|
)
|
|
|
|
assert_no_difference "@family.recurring_transactions.count" do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
|
|
manual_recurring.reload
|
|
assert_equal 3.00, manual_recurring.expected_amount_min
|
|
assert_equal 3.00, manual_recurring.expected_amount_max
|
|
assert_equal 3.00, manual_recurring.expected_amount_avg
|
|
end
|
|
|
|
test "cleaner does not delete manual recurring transactions" do
|
|
# Create inactive manual recurring
|
|
manual_recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: 1.year.ago,
|
|
next_expected_date: 1.year.ago + 1.month,
|
|
status: "inactive",
|
|
manual: true,
|
|
occurrence_count: 1
|
|
)
|
|
# Set updated_at to be old enough for cleanup
|
|
manual_recurring.update_column(:updated_at, 1.year.ago)
|
|
|
|
# Create inactive auto recurring with different merchant
|
|
auto_recurring = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
merchant: merchants(:amazon),
|
|
amount: 30.00,
|
|
currency: "USD",
|
|
expected_day_of_month: 10,
|
|
last_occurrence_date: 1.year.ago,
|
|
next_expected_date: 1.year.ago + 1.month,
|
|
status: "inactive",
|
|
manual: false,
|
|
occurrence_count: 1
|
|
)
|
|
# Set updated_at to be old enough for cleanup
|
|
auto_recurring.update_column(:updated_at, 1.year.ago)
|
|
|
|
cleaner = RecurringTransaction::Cleaner.new(@family)
|
|
cleaner.remove_old_inactive_transactions
|
|
|
|
assert RecurringTransaction.exists?(manual_recurring.id)
|
|
assert_not RecurringTransaction.exists?(auto_recurring.id)
|
|
end
|
|
|
|
# Account access scoping tests
|
|
test "accessible_by scope returns only recurring transactions from accessible accounts" do
|
|
admin = users(:family_admin)
|
|
member = users(:family_member)
|
|
|
|
# depository is shared with family_member (full_control)
|
|
# investment is NOT shared with family_member
|
|
shared_account = accounts(:depository)
|
|
unshared_account = accounts(:investment)
|
|
|
|
shared_recurring = @family.recurring_transactions.create!(
|
|
account: shared_account,
|
|
merchant: @merchant,
|
|
amount: 15.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: 1.month.ago.to_date,
|
|
next_expected_date: 5.days.from_now.to_date,
|
|
status: "active"
|
|
)
|
|
|
|
unshared_recurring = @family.recurring_transactions.create!(
|
|
account: unshared_account,
|
|
merchant: merchants(:amazon),
|
|
amount: 9.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: 1.month.ago.to_date,
|
|
next_expected_date: 5.days.from_now.to_date,
|
|
status: "active"
|
|
)
|
|
|
|
# Admin (owner of all accounts) sees both
|
|
admin_results = @family.recurring_transactions.accessible_by(admin)
|
|
assert_includes admin_results, shared_recurring
|
|
assert_includes admin_results, unshared_recurring
|
|
|
|
# Family member only sees the one from the shared account
|
|
member_results = @family.recurring_transactions.accessible_by(member)
|
|
assert_includes member_results, shared_recurring
|
|
assert_not_includes member_results, unshared_recurring
|
|
end
|
|
|
|
test "identifier creates per-account patterns for same merchant on different accounts" do
|
|
account_a = accounts(:depository)
|
|
account_b = accounts(:credit_card)
|
|
|
|
# Create pattern on account A
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
account_a.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 5.days,
|
|
amount: 15.99,
|
|
currency: "USD",
|
|
name: "Netflix Subscription",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
# Create same pattern on account B
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(
|
|
merchant: @merchant,
|
|
category: categories(:food_and_drink)
|
|
)
|
|
account_b.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 5.days,
|
|
amount: 15.99,
|
|
currency: "USD",
|
|
name: "Netflix Subscription",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
assert_difference "@family.recurring_transactions.count", 2 do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
|
|
recurring_a = @family.recurring_transactions.find_by(account: account_a, merchant: @merchant, amount: 15.99)
|
|
recurring_b = @family.recurring_transactions.find_by(account: account_b, merchant: @merchant, amount: 15.99)
|
|
|
|
assert recurring_a.present?
|
|
assert recurring_b.present?
|
|
assert_not_equal recurring_a, recurring_b
|
|
end
|
|
|
|
# ----- Recurring transfers (issue #895 / discussion #1224) -----
|
|
|
|
test "transfer? is false when destination_account is absent" do
|
|
rt = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
name: "Spotify",
|
|
amount: 9.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 5.days.from_now.to_date,
|
|
manual: true
|
|
)
|
|
assert_not rt.transfer?
|
|
end
|
|
|
|
test "transfer? is true when destination_account is present" do
|
|
destination = accounts(:credit_card)
|
|
rt = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
destination_account: destination,
|
|
name: "Transfer to #{destination.name}",
|
|
amount: 500,
|
|
currency: "USD",
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
manual: true
|
|
)
|
|
assert rt.transfer?
|
|
end
|
|
|
|
test "validation rejects same source and destination accounts" do
|
|
rt = @family.recurring_transactions.build(
|
|
account: @account,
|
|
destination_account: @account,
|
|
name: "Self-transfer",
|
|
amount: 100,
|
|
currency: "USD",
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
manual: true
|
|
)
|
|
assert_not rt.valid?
|
|
assert_includes rt.errors[:destination_account], "cannot be the same as the source account"
|
|
end
|
|
|
|
test "validation rejects dangling source account_id (account does not exist)" do
|
|
rt = @family.recurring_transactions.build(
|
|
account_id: SecureRandom.uuid, # references nothing
|
|
destination_account: accounts(:credit_card),
|
|
name: "Phantom source",
|
|
amount: 100,
|
|
currency: "USD",
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
manual: true
|
|
)
|
|
assert_not rt.valid?
|
|
assert_includes rt.errors[:account], "must exist"
|
|
end
|
|
|
|
test "validation rejects dangling destination_account_id (account does not exist)" do
|
|
rt = @family.recurring_transactions.build(
|
|
account: @account,
|
|
destination_account_id: SecureRandom.uuid, # references nothing
|
|
name: "Phantom transfer",
|
|
amount: 100,
|
|
currency: "USD",
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
manual: true
|
|
)
|
|
assert_not rt.valid?
|
|
assert_includes rt.errors[:destination_account], "must exist"
|
|
end
|
|
|
|
test "validation rejects destination on different family" do
|
|
other_family = Family.create!(name: "Other", locale: "en", date_format: "%Y-%m-%d", currency: "USD")
|
|
other_account = other_family.accounts.create!(name: "Other depository", balance: 0, currency: "USD", accountable: Depository.new)
|
|
|
|
rt = @family.recurring_transactions.build(
|
|
account: @account,
|
|
destination_account: other_account,
|
|
name: "Foreign transfer",
|
|
amount: 100,
|
|
currency: "USD",
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
manual: true
|
|
)
|
|
assert_not rt.valid?
|
|
assert_includes rt.errors[:destination_account], "must belong to the same family as the source account"
|
|
end
|
|
|
|
test "validation rejects a source account from a different family" do
|
|
other_family = Family.create!(name: "Other", locale: "en", date_format: "%Y-%m-%d", currency: "USD")
|
|
other_account = other_family.accounts.create!(name: "Other depository", balance: 0, currency: "USD", accountable: Depository.new)
|
|
|
|
rt = build_recurring(account: other_account)
|
|
|
|
assert_not rt.valid?
|
|
assert_includes rt.errors[:account], "must belong to this family"
|
|
end
|
|
|
|
test "validation rejects a transfer whose endpoints both sit in another family" do
|
|
# The endpoint pair is internally consistent here, so only the check
|
|
# against the bill's own family can catch it.
|
|
other_family = Family.create!(name: "Other", locale: "en", date_format: "%Y-%m-%d", currency: "USD")
|
|
other_source = other_family.accounts.create!(name: "Other depository", balance: 0, currency: "USD", accountable: Depository.new)
|
|
other_destination = other_family.accounts.create!(name: "Other card", balance: 0, currency: "USD", accountable: CreditCard.new)
|
|
|
|
rt = @family.recurring_transactions.build(
|
|
account: other_source,
|
|
destination_account: other_destination,
|
|
name: "Foreign transfer pair",
|
|
amount: 100,
|
|
currency: "USD",
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
manual: true
|
|
)
|
|
|
|
assert_not rt.valid?
|
|
assert_includes rt.errors[:account], "must belong to this family"
|
|
assert_includes rt.errors[:destination_account], "must belong to this family"
|
|
end
|
|
|
|
test "create_from_transfer builds a recurring transfer with both endpoints" do
|
|
source = @account
|
|
destination = accounts(:credit_card)
|
|
|
|
outflow_entry = source.entries.create!(
|
|
date: 5.days.ago.to_date, amount: 250, currency: "USD",
|
|
name: "Manual transfer",
|
|
entryable: Transaction.new(kind: "standard")
|
|
)
|
|
inflow_entry = destination.entries.create!(
|
|
date: 5.days.ago.to_date, amount: -250, currency: "USD",
|
|
name: "Manual transfer",
|
|
entryable: Transaction.new(kind: "standard")
|
|
)
|
|
transfer = Transfer.create!(
|
|
outflow_transaction: outflow_entry.entryable,
|
|
inflow_transaction: inflow_entry.entryable
|
|
)
|
|
|
|
rt = RecurringTransaction.create_from_transfer(transfer)
|
|
|
|
assert rt.transfer?
|
|
assert_equal source, rt.account
|
|
assert_equal destination, rt.destination_account
|
|
assert_equal 250, rt.amount
|
|
assert_equal "USD", rt.currency
|
|
assert_equal 5.days.ago.to_date.day, rt.expected_day_of_month
|
|
assert rt.manual?
|
|
assert_equal "active", rt.status
|
|
end
|
|
|
|
test "projected_entry exposes source and destination on a recurring transfer" do
|
|
destination = accounts(:credit_card)
|
|
rt = @family.recurring_transactions.create!(
|
|
account: @account,
|
|
destination_account: destination,
|
|
name: "Transfer to #{destination.name}",
|
|
amount: 500,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 15.days.from_now.to_date,
|
|
manual: true
|
|
)
|
|
|
|
projected = rt.projected_entry
|
|
assert projected.transfer
|
|
assert_equal @account, projected.source_account
|
|
assert_equal destination, projected.destination_account
|
|
assert_equal 500, projected.amount
|
|
assert_equal "USD", projected.currency
|
|
end
|
|
|
|
test "Identifier skips transfer-kind transactions" do
|
|
# Three depository transactions tagged as funds_movement (e.g. they're
|
|
# one half of a Transfer pair). Identifier shouldn't latch onto these
|
|
# as a single-account "pattern" because the underlying flow is two-
|
|
# account and is tracked on a different shape (destination_account_id).
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
transaction = Transaction.create!(merchant: @merchant, kind: "funds_movement")
|
|
@account.entries.create!(
|
|
date: months_ago.months.ago.beginning_of_month + 5.days,
|
|
amount: 50.00,
|
|
currency: "USD",
|
|
name: "Recurring transfer half",
|
|
entryable: transaction
|
|
)
|
|
end
|
|
|
|
assert_no_difference "@family.recurring_transactions.count" do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
end
|
|
|
|
test "Identifier creates a pattern from expense halves while ignoring co-resident transfer halves" do
|
|
# Same merchant, amount, day-of-month: 3 standard expenses + 3 transfer halves.
|
|
# Without the TRANSFER_KINDS filter, the identifier would either double-count
|
|
# (six occurrences) or surface a weird pattern. With the filter, only the
|
|
# expense pattern is created.
|
|
[ 0, 1, 2 ].each do |months_ago|
|
|
base_date = months_ago.months.ago.beginning_of_month + 5.days
|
|
|
|
@account.entries.create!(
|
|
date: base_date, amount: 50.00, currency: "USD", name: "Coffee",
|
|
entryable: Transaction.create!(merchant: @merchant, kind: "standard")
|
|
)
|
|
@account.entries.create!(
|
|
date: base_date, amount: 50.00, currency: "USD", name: "Half of transfer",
|
|
entryable: Transaction.create!(merchant: @merchant, kind: "funds_movement")
|
|
)
|
|
end
|
|
|
|
assert_difference "@family.recurring_transactions.count", 1 do
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
end
|
|
assert_nil @family.recurring_transactions.last.destination_account_id
|
|
end
|
|
|
|
test "create_from_transfer name reflects Transfer#name (Payment vs Transfer based on destination)" do
|
|
# Transfer#name returns "Payment to ..." for liability destinations
|
|
# and "Transfer to ..." otherwise, mirroring Transfer::Creator's
|
|
# name_prefix logic. The recurring row should pick that up rather
|
|
# than hard-coding "Transfer to ...".
|
|
source = @account
|
|
cc_destination = accounts(:credit_card) # liability
|
|
outflow = source.entries.create!(
|
|
date: 5.days.ago.to_date, amount: 100, currency: "USD",
|
|
name: "raw", entryable: Transaction.new(kind: "standard")
|
|
)
|
|
inflow = cc_destination.entries.create!(
|
|
date: 5.days.ago.to_date, amount: -100, currency: "USD",
|
|
name: "raw", entryable: Transaction.new(kind: "standard")
|
|
)
|
|
transfer = Transfer.create!(
|
|
outflow_transaction: outflow.entryable, inflow_transaction: inflow.entryable
|
|
)
|
|
|
|
rt = RecurringTransaction.create_from_transfer(transfer)
|
|
assert_equal "Payment to #{cc_destination.name}", rt.name
|
|
end
|
|
|
|
test "create_from_transfer stores source-side currency on multi-currency transfers" do
|
|
source = @account # USD depository
|
|
destination = @family.accounts.create!(
|
|
name: "EUR cash", balance: 0, currency: "EUR", accountable: Depository.new
|
|
)
|
|
outflow_entry = source.entries.create!(
|
|
date: 5.days.ago.to_date, amount: 100, currency: "USD",
|
|
name: "FX transfer", entryable: Transaction.new(kind: "standard")
|
|
)
|
|
inflow_entry = destination.entries.create!(
|
|
date: 5.days.ago.to_date, amount: -92, currency: "EUR",
|
|
name: "FX transfer", entryable: Transaction.new(kind: "standard")
|
|
)
|
|
transfer = Transfer.create!(
|
|
outflow_transaction: outflow_entry.entryable,
|
|
inflow_transaction: inflow_entry.entryable
|
|
)
|
|
|
|
rt = RecurringTransaction.create_from_transfer(transfer)
|
|
assert_equal "USD", rt.currency, "stores source-side currency"
|
|
assert_equal 100, rt.amount, "stores source-side amount"
|
|
end
|
|
|
|
test "destroying the destination account cascades to inbound recurring transfers" do
|
|
source = @account
|
|
destination = accounts(:credit_card)
|
|
rt = @family.recurring_transactions.create!(
|
|
account: source, destination_account: destination,
|
|
name: "Transfer to CC", amount: 250, currency: "USD",
|
|
expected_day_of_month: 1, last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date, manual: true
|
|
)
|
|
|
|
assert_difference -> { RecurringTransaction.count }, -1 do
|
|
destination.destroy
|
|
end
|
|
assert_not RecurringTransaction.exists?(rt.id)
|
|
end
|
|
|
|
test "Cleaner keeps a recurring transfer active when its pair still occurs (issue #1590)" do
|
|
# The seed name rarely matches future occurrences, so pair detection (not
|
|
# name matching) is what keeps a live transfer active past the threshold.
|
|
rt = @family.recurring_transactions.create!(
|
|
account: @account, destination_account: accounts(:credit_card),
|
|
name: "Transfer to CC", amount: 250, currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: 7.months.ago.to_date,
|
|
next_expected_date: 5.days.from_now.to_date,
|
|
manual: true
|
|
)
|
|
assert rt.should_be_inactive?, "guard sanity: stale last_occurrence_date"
|
|
|
|
# A fresh transfer pair this cycle, carrying a *different* free-text name.
|
|
date = 1.month.ago.beginning_of_month + 4.days # day-of-month 5
|
|
outflow = @account.entries.create!(
|
|
date: date, amount: 250, currency: "USD", name: "rent transfer",
|
|
entryable: Transaction.new(kind: "funds_movement")
|
|
)
|
|
inflow = accounts(:credit_card).entries.create!(
|
|
date: date, amount: -250, currency: "USD", name: "rent transfer",
|
|
entryable: Transaction.new(kind: "funds_movement")
|
|
)
|
|
Transfer.create!(outflow_transaction: outflow.entryable, inflow_transaction: inflow.entryable)
|
|
|
|
RecurringTransaction.cleanup_stale_for(@family)
|
|
assert_equal "active", rt.reload.status
|
|
end
|
|
|
|
test "Cleaner retires a recurring transfer whose pair has stopped" do
|
|
# No matching Transfer pair → genuinely stale → should be retired. This is
|
|
# the correctness the pair-detection (vs the old blanket skip) buys us.
|
|
rt = @family.recurring_transactions.create!(
|
|
account: @account, destination_account: accounts(:credit_card),
|
|
name: "Transfer to CC", amount: 250, currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: 7.months.ago.to_date,
|
|
next_expected_date: 5.days.from_now.to_date,
|
|
manual: true
|
|
)
|
|
|
|
RecurringTransaction.cleanup_stale_for(@family)
|
|
assert_equal "inactive", rt.reload.status
|
|
end
|
|
|
|
test "matching_transactions finds the transfer pair regardless of occurrence name" do
|
|
rt = @family.recurring_transactions.create!(
|
|
account: @account, destination_account: accounts(:credit_card),
|
|
name: "Transfer to CC", amount: 250, currency: "USD",
|
|
expected_day_of_month: 5, last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date, manual: true
|
|
)
|
|
date = Date.current.beginning_of_month + 4.days # day-of-month 5
|
|
outflow = @account.entries.create!(
|
|
date: date, amount: 250, currency: "USD", name: "an importer's wording",
|
|
entryable: Transaction.new(kind: "funds_movement")
|
|
)
|
|
inflow = accounts(:credit_card).entries.create!(
|
|
date: date, amount: -250, currency: "USD", name: "an importer's wording",
|
|
entryable: Transaction.new(kind: "funds_movement")
|
|
)
|
|
Transfer.create!(outflow_transaction: outflow.entryable, inflow_transaction: inflow.entryable)
|
|
|
|
assert_includes rt.matching_transactions.map(&:id), outflow.id
|
|
end
|
|
|
|
test "Identifier#update_manual_recurring_transactions skips recurring transfers" do
|
|
# Same reasoning as the Cleaner skip. Without the guard, the helper
|
|
# would call find_matching_transaction_entries (single-account, by
|
|
# name) on a transfer row and silently overwrite its variance /
|
|
# occurrence_count with []. The variance fields should stay nil.
|
|
rt = @family.recurring_transactions.create!(
|
|
account: @account, destination_account: accounts(:credit_card),
|
|
name: "Transfer to CC", amount: 500, currency: "USD",
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
manual: true,
|
|
occurrence_count: 7
|
|
)
|
|
|
|
RecurringTransaction.identify_patterns_for!(@family)
|
|
|
|
rt.reload
|
|
assert_nil rt.expected_amount_min
|
|
assert_nil rt.expected_amount_max
|
|
assert_nil rt.expected_amount_avg
|
|
assert_equal 7, rt.occurrence_count, "occurrence_count must not be overwritten by the manual-recurring update path"
|
|
end
|
|
|
|
test "unique partial index still de-duplicates non-transfer recurring rows after destination widening" do
|
|
base_attrs = {
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 15.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
manual: false,
|
|
occurrence_count: 3
|
|
}
|
|
@family.recurring_transactions.create!(base_attrs)
|
|
|
|
assert_raises(ActiveRecord::RecordNotUnique) do
|
|
@family.recurring_transactions.create!(base_attrs)
|
|
end
|
|
end
|
|
|
|
# The scheme allowlist is the security boundary for payment_url: the value is
|
|
# rendered as a link, so a "javascript:" or "data:" scheme surviving to the view
|
|
# would be stored XSS. These cases are the contract, not incidental coverage.
|
|
test "payment_url rejects any scheme other than http and https" do
|
|
[
|
|
"javascript:alert(1)",
|
|
"JaVaScRiPt:alert(1)",
|
|
" javascript:alert(1) ",
|
|
"data:text/html,<script>alert(1)</script>",
|
|
"mailto:billing@example.com",
|
|
"ftp://example.com/pay",
|
|
"https://"
|
|
].each do |hostile|
|
|
recurring = build_recurring(payment_url: hostile)
|
|
|
|
assert_not recurring.valid?, "expected #{hostile.inspect} to be rejected"
|
|
assert_includes recurring.errors.attribute_names, :payment_url
|
|
end
|
|
end
|
|
|
|
test "payment_url accepts http and https and promotes a bare host to https" do
|
|
{
|
|
"https://pay.example.com/bill" => "https://pay.example.com/bill",
|
|
"http://pay.example.com" => "http://pay.example.com",
|
|
"pay.example.com" => "https://pay.example.com",
|
|
" pay.example.com " => "https://pay.example.com",
|
|
# A colon followed by digits is a port, not a scheme. Self-hosters link to
|
|
# LAN services this way.
|
|
"192.168.1.5:3000/pay" => "https://192.168.1.5:3000/pay"
|
|
}.each do |input, expected|
|
|
recurring = build_recurring(payment_url: input)
|
|
|
|
assert recurring.valid?, "expected #{input.inspect} to be accepted: #{recurring.errors.full_messages}"
|
|
assert_equal expected, recurring.payment_url
|
|
end
|
|
end
|
|
|
|
test "payment_url is optional and normalizes blank to nil" do
|
|
[ nil, "", " " ].each do |blank|
|
|
recurring = build_recurring(payment_url: blank)
|
|
|
|
assert recurring.valid?
|
|
assert_nil recurring.payment_url
|
|
end
|
|
end
|
|
|
|
# `calculate_next_expected_date` jumps to `last_occurrence_date.next_month`, so a
|
|
# payment posting earlier in the month than the expected day skips the occurrence
|
|
# still ahead this month: rent due on the 29th, last paid on the 6th, is stored as
|
|
# due next month. Bills must not repeat that to the user.
|
|
test "next_due_date corrects a stored date that skipped this month's occurrence" do
|
|
travel_to Date.new(2026, 8, 13) do
|
|
recurring = build_recurring(
|
|
expected_day_of_month: 29,
|
|
last_occurrence_date: Date.new(2026, 8, 6),
|
|
next_expected_date: Date.new(2026, 9, 29)
|
|
)
|
|
|
|
assert_equal Date.new(2026, 8, 29), recurring.next_due_date
|
|
assert_not recurring.overdue?
|
|
end
|
|
end
|
|
|
|
test "next_due_date leaves a correct stored date alone" do
|
|
travel_to Date.new(2026, 8, 13) do
|
|
recurring = build_recurring(
|
|
expected_day_of_month: 21,
|
|
last_occurrence_date: Date.new(2026, 7, 21),
|
|
next_expected_date: Date.new(2026, 8, 21)
|
|
)
|
|
|
|
assert_equal Date.new(2026, 8, 21), recurring.next_due_date
|
|
end
|
|
end
|
|
|
|
test "next_due_date does not advance an overdue bill past its due date" do
|
|
travel_to Date.new(2026, 8, 13) do
|
|
recurring = build_recurring(
|
|
expected_day_of_month: 5,
|
|
last_occurrence_date: Date.new(2026, 7, 5),
|
|
next_expected_date: Date.new(2026, 8, 5)
|
|
)
|
|
|
|
assert_equal Date.new(2026, 8, 5), recurring.next_due_date
|
|
assert recurring.overdue?
|
|
end
|
|
end
|
|
|
|
test "bills scope keeps expenses and drops income, transfers and paused rows" do
|
|
expense = build_recurring(name: "Rent", merchant: nil, amount: 1200).tap(&:save!)
|
|
build_recurring(name: "Paycheck", merchant: nil, amount: -2000).tap(&:save!)
|
|
build_recurring(name: "Paused", merchant: nil, amount: 40, status: "inactive").tap(&:save!)
|
|
build_recurring(name: "Card payment", merchant: nil, amount: 500,
|
|
destination_account: accounts(:credit_card)).tap(&:save!)
|
|
|
|
assert_equal [ expense.id ], @family.recurring_transactions.bills.pluck(:id)
|
|
end
|
|
|
|
test "display_name prefers the merchant over the free-text name" do
|
|
assert_equal @merchant.name, build_recurring(merchant: @merchant, name: nil).display_name
|
|
assert_equal "Rent", build_recurring(merchant: nil, name: "Rent").display_name
|
|
end
|
|
|
|
|
|
# next_expected_date only advances when a bank entry matches during sync, so
|
|
# a bill settled through mark_paid!, a manual payment or the assistant froze
|
|
# it in the past forever. next_due_date returned it verbatim, which reported
|
|
# a fully paid bill as long overdue and answered due_within_days for a date
|
|
# 45 days gone.
|
|
test "a settled bill looks ahead to its next open cycle, not at a stale hint" do
|
|
series = stale_hint_series
|
|
|
|
series.recurring_occurrences.where("due_on <= ?", Date.current).find_each do |occurrence|
|
|
RecurringTransaction::Allocator.new(occurrence).mark_paid!
|
|
end
|
|
series.reload
|
|
|
|
assert_operator series.next_due_date, :>, Date.current,
|
|
"every owed cycle is paid, so nothing is due in the past"
|
|
assert_not series.overdue?
|
|
assert_equal 0, series.cycles_overdue
|
|
end
|
|
|
|
test "an unpaid past cycle is still overdue" do
|
|
series = stale_hint_series
|
|
|
|
assert series.overdue?, "the oldest unpaid cycle is what makes a bill late"
|
|
assert_equal series.recurring_occurrences.open_status.minimum(:due_on), series.next_due_date
|
|
end
|
|
|
|
# "Whole cycles" is meant literally. The old formula added one, so a bill a
|
|
# single day late already claimed a full cycle missed.
|
|
test "cycles_overdue counts only whole cycles elapsed" do
|
|
series = stale_hint_series
|
|
due = Date.current - 1
|
|
series.recurring_occurrences.destroy_all
|
|
series.recurring_occurrences.create!(
|
|
family: @family, original_due_on: due, due_on: due,
|
|
currency: "USD", expected_amount: 60, status: "scheduled"
|
|
)
|
|
|
|
assert series.reload.overdue?
|
|
assert_equal 0, series.cycles_overdue, "one day late is not one cycle missed"
|
|
end
|
|
|
|
private
|
|
|
|
def stale_hint_series
|
|
stale = 45.days.ago.to_date
|
|
series = @family.recurring_transactions.create!(
|
|
name: "Gym #{stale}", account: accounts(:depository), amount: 60,
|
|
currency: "USD", status: "active", bill_type: "subscription", manual: true,
|
|
dedup_scope: "gym-#{stale}", expected_day_of_month: stale.day,
|
|
last_occurrence_date: stale << 1, next_expected_date: stale
|
|
)
|
|
series.reload
|
|
end
|
|
|
|
def build_recurring(**overrides)
|
|
@family.recurring_transactions.build({
|
|
account: @account,
|
|
merchant: @merchant,
|
|
amount: 29.99,
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
status: "active"
|
|
}.merge(overrides))
|
|
end
|
|
end
|