mirror of
https://github.com/we-promise/sure.git
synced 2026-09-03 22:01:16 +00:00
* fix: disable "Mark as Recurring" button when a manual recurring transaction already exists Previously the button was always clickable and only failed after a POST, showing "A manual recurring transaction already exists for this pattern". Extract the lookup into Transaction#existing_manual_recurring_transaction (reused by the controller guard) so the view can disable the button ahead of time and show the reason inline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback on PR #3103 Move the existing_manual_recurring lookup out of the show view and into the controller so rendering no longer runs an Active Record query in-template, and strengthen the "no match" model test with near-match recurring transactions that individually differ by account, merchant, amount, currency, and manual flag. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: move mark-recurring presentation state fully into controller, fix stale state on failed update Address CodeRabbit follow-up on PR #3103: - Compute the mark-recurring button's subtitle text/class, href, disabled state, title, and class entirely in TransactionsController (via a shared assign_mark_recurring_state helper) instead of deriving them with ternaries in the view. - Populate that state before TransactionsController#update re-renders :show on a failed entry update, so the button doesn't incorrectly appear enabled when a matching manual recurring transaction exists. - Add a controller test covering the failed-update render path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: use blank name instead of blank date to trigger validation failure in mark-recurring test The CI test_unit run flagged a real bug in the test itself: TransactionsController#entry_params strips blank :date/:amount before update, so date: "" never reached model validation and the update succeeded (302) instead of failing (422) as the test expected. Use a blank :name instead, which isn't stripped, and add DOM assertions (disabled button, no mark_as_recurring form action) per CodeRabbit's follow-up review. Verified against a live NAS Rails console reproduction (bypassing the test stack's broken fixtures) that the failed-update render now correctly shows the button as disabled with the "already exists" message and no action link. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: use transaction id instead of entry id in mark-recurring route assertion mark_as_recurring is a member route on the transactions resource keyed by the Transaction's id, not the Entry's id (Entry uses delegated_type, so Entry and its Transaction entryable have distinct ids). The prior assertion built the path from `entry`, which could produce a different URL than the one actually rendered, so the "no href" check could pass even if the button leaked a link. Use entry.entryable so the assertion matches the real route. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: refresh mark-recurring state on turbo_stream update, avoid unconditional query, keep DS::Button href Addresses jjmata's review on PR #3103: - Extract the "Mark as Recurring" block into a dom_id-wrapped partial and replace it in the successful update turbo_stream response, so inline edits that change whether the transaction matches an existing manual recurring transaction are reflected immediately instead of only on the next full page render. - Skip the existing_manual_recurring_transaction lookup entirely when the block won't be rendered (no edit permission, or split-child entry), avoiding an unconditional extra query on every transaction show/failed update. - Keep href present on the DS::Button and only toggle disabled, matching the established pattern elsewhere in the app, instead of nulling href (which flips the component to a bare <button> and leaks a stray method="post" attribute). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
311 lines
11 KiB
Ruby
311 lines
11 KiB
Ruby
require "test_helper"
|
|
|
|
class TransactionTest < ActiveSupport::TestCase
|
|
include EntriesTestHelper
|
|
|
|
test "existing_manual_recurring_transaction finds a match by merchant" do
|
|
family = families(:empty)
|
|
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
|
merchant = family.merchants.create! name: "Test Merchant"
|
|
entry = create_transaction(account: account, amount: 100, merchant: merchant)
|
|
transaction = entry.entryable
|
|
|
|
recurring = family.recurring_transactions.create!(
|
|
account: account,
|
|
merchant: merchant,
|
|
amount: entry.amount,
|
|
currency: entry.currency,
|
|
expected_day_of_month: entry.date.day,
|
|
last_occurrence_date: entry.date,
|
|
next_expected_date: 1.month.from_now,
|
|
status: "active",
|
|
manual: true,
|
|
occurrence_count: 1
|
|
)
|
|
|
|
assert_equal recurring, transaction.existing_manual_recurring_transaction
|
|
end
|
|
|
|
test "existing_manual_recurring_transaction finds a match by name when no merchant" do
|
|
family = families(:empty)
|
|
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
|
entry = create_transaction(account: account, amount: 50, name: "Netflix")
|
|
transaction = entry.entryable
|
|
|
|
recurring = family.recurring_transactions.create!(
|
|
account: account,
|
|
name: "Netflix",
|
|
amount: entry.amount,
|
|
currency: entry.currency,
|
|
expected_day_of_month: entry.date.day,
|
|
last_occurrence_date: entry.date,
|
|
next_expected_date: 1.month.from_now,
|
|
status: "active",
|
|
manual: true,
|
|
occurrence_count: 1
|
|
)
|
|
|
|
assert_equal recurring, transaction.existing_manual_recurring_transaction
|
|
end
|
|
|
|
test "existing_manual_recurring_transaction returns nil when no manual recurring transaction matches" do
|
|
family = families(:empty)
|
|
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
|
other_account = family.accounts.create! name: "Other", balance: 0, currency: "USD", accountable: Depository.new
|
|
merchant = family.merchants.create! name: "Test Merchant"
|
|
other_merchant = family.merchants.create! name: "Other Merchant"
|
|
entry = create_transaction(account: account, amount: 100, merchant: merchant)
|
|
transaction = entry.entryable
|
|
|
|
base_attrs = {
|
|
expected_day_of_month: entry.date.day,
|
|
last_occurrence_date: entry.date,
|
|
next_expected_date: 1.month.from_now,
|
|
status: "active",
|
|
occurrence_count: 1
|
|
}
|
|
|
|
# Differs by account
|
|
family.recurring_transactions.create!(base_attrs.merge(
|
|
account: other_account, merchant: merchant, amount: entry.amount, currency: entry.currency, manual: true
|
|
))
|
|
# Differs by merchant
|
|
family.recurring_transactions.create!(base_attrs.merge(
|
|
account: account, merchant: other_merchant, amount: entry.amount, currency: entry.currency, manual: true
|
|
))
|
|
# Differs by amount
|
|
family.recurring_transactions.create!(base_attrs.merge(
|
|
account: account, merchant: merchant, amount: entry.amount + 1, currency: entry.currency, manual: true
|
|
))
|
|
# Differs by currency
|
|
family.recurring_transactions.create!(base_attrs.merge(
|
|
account: account, merchant: merchant, amount: entry.amount, currency: "EUR", manual: true
|
|
))
|
|
# Differs by manual flag
|
|
family.recurring_transactions.create!(base_attrs.merge(
|
|
account: account, merchant: merchant, amount: entry.amount, currency: entry.currency, manual: false
|
|
))
|
|
|
|
assert_nil transaction.existing_manual_recurring_transaction
|
|
end
|
|
|
|
test "pending? is true when extra.simplefin.pending is truthy" do
|
|
transaction = Transaction.new(extra: { "simplefin" => { "pending" => true } })
|
|
|
|
assert transaction.pending?
|
|
end
|
|
|
|
test "pending? is true when extra.plaid.pending is truthy" do
|
|
transaction = Transaction.new(extra: { "plaid" => { "pending" => "true" } })
|
|
|
|
assert transaction.pending?
|
|
end
|
|
|
|
test "pending? is true when extra.lunchflow.pending is truthy" do
|
|
transaction = Transaction.new(extra: { "lunchflow" => { "pending" => true } })
|
|
|
|
assert transaction.pending?
|
|
end
|
|
|
|
test "pending? is false when no provider pending metadata is present" do
|
|
transaction = Transaction.new(extra: { "plaid" => { "pending" => false } })
|
|
|
|
assert_not transaction.pending?
|
|
end
|
|
|
|
test "pending? returns true for enable_banking pending transactions" do
|
|
transaction = Transaction.new(extra: { "enable_banking" => { "pending" => true } })
|
|
|
|
assert transaction.pending?
|
|
end
|
|
|
|
test "pending? returns false for enable_banking non-pending transactions" do
|
|
transaction = Transaction.new(extra: { "enable_banking" => { "pending" => false } })
|
|
|
|
assert_not transaction.pending?
|
|
end
|
|
|
|
test "investment_contribution is a valid kind" do
|
|
transaction = Transaction.new(kind: "investment_contribution")
|
|
|
|
assert_equal "investment_contribution", transaction.kind
|
|
assert transaction.investment_contribution?
|
|
end
|
|
|
|
test "TRANSFER_KINDS constant matches transfer? method" do
|
|
Transaction::TRANSFER_KINDS.each do |kind|
|
|
assert Transaction.new(kind: kind).transfer?, "#{kind} should be a transfer kind"
|
|
end
|
|
|
|
non_transfer_kinds = Transaction.kinds.keys - Transaction::TRANSFER_KINDS
|
|
non_transfer_kinds.each do |kind|
|
|
assert_not Transaction.new(kind: kind).transfer?, "#{kind} should NOT be a transfer kind"
|
|
end
|
|
end
|
|
|
|
test "all transaction kinds are valid" do
|
|
valid_kinds = %w[standard funds_movement cc_payment loan_payment one_time investment_contribution]
|
|
|
|
valid_kinds.each do |kind|
|
|
transaction = Transaction.new(kind: kind)
|
|
assert_equal kind, transaction.kind, "#{kind} should be a valid transaction kind"
|
|
end
|
|
end
|
|
|
|
test "ACTIVITY_LABELS contains all valid labels" do
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Buy"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Sell"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Sweep In"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Sweep Out"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Dividend"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Reinvestment"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Interest"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Fee"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Transfer"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Contribution"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Withdrawal"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Exchange"
|
|
assert_includes Transaction::ACTIVITY_LABELS, "Other"
|
|
end
|
|
|
|
test "exchange_rate getter returns nil when extra is nil" do
|
|
transaction = Transaction.new
|
|
assert_nil transaction.exchange_rate
|
|
end
|
|
|
|
test "exchange_rate setter stores normalized numeric value" do
|
|
transaction = Transaction.new
|
|
transaction.exchange_rate = "1.5"
|
|
|
|
assert_equal 1.5, transaction.exchange_rate
|
|
end
|
|
|
|
test "exchange_rate setter marks invalid input" do
|
|
transaction = Transaction.new
|
|
transaction.exchange_rate = "not a number"
|
|
|
|
assert_equal "not a number", transaction.extra["exchange_rate"]
|
|
assert transaction.extra["exchange_rate_invalid"]
|
|
end
|
|
|
|
test "exchange_rate setter rejects non-finite input" do
|
|
transaction = Transaction.new
|
|
transaction.exchange_rate = "Infinity"
|
|
|
|
assert_equal "Infinity", transaction.extra["exchange_rate"]
|
|
assert transaction.extra["exchange_rate_invalid"]
|
|
end
|
|
|
|
test "exchange_rate setter clears invalid flag for valid input" do
|
|
transaction = Transaction.new
|
|
transaction.exchange_rate = "not a number"
|
|
transaction.exchange_rate = "1.5"
|
|
|
|
assert_equal 1.5, transaction.exchange_rate
|
|
assert_equal false, transaction.extra["exchange_rate_invalid"]
|
|
end
|
|
|
|
test "exchange_rate validation rejects non-numeric input" do
|
|
transaction = Transaction.new(
|
|
category: categories(:income),
|
|
extra: { "exchange_rate" => "invalid" }
|
|
)
|
|
transaction.exchange_rate = "not a number"
|
|
|
|
assert_not transaction.valid?
|
|
assert_includes transaction.errors[:exchange_rate], "must be a number"
|
|
end
|
|
|
|
test "exchange_rate validation rejects zero values" do
|
|
transaction = Transaction.new(
|
|
category: categories(:income)
|
|
)
|
|
transaction.exchange_rate = 0
|
|
|
|
assert_not transaction.valid?
|
|
assert_includes transaction.errors[:exchange_rate], "must be greater than 0"
|
|
end
|
|
|
|
test "exchange_rate validation rejects negative values" do
|
|
transaction = Transaction.new(
|
|
category: categories(:income)
|
|
)
|
|
transaction.exchange_rate = -1.5
|
|
|
|
assert_not transaction.valid?
|
|
assert_includes transaction.errors[:exchange_rate], "must be greater than 0"
|
|
end
|
|
|
|
test "exchange_rate validation allows positive values" do
|
|
transaction = Transaction.new(
|
|
category: categories(:income)
|
|
)
|
|
transaction.exchange_rate = 1.5
|
|
|
|
assert transaction.valid?
|
|
end
|
|
|
|
test "activity_security returns the referenced security from extra metadata" do
|
|
security = securities(:aapl)
|
|
transaction = Transaction.new(extra: { "security_id" => security.id })
|
|
|
|
assert_equal security, transaction.activity_security
|
|
end
|
|
|
|
test "activity_security returns nil when no security metadata is present" do
|
|
transaction = Transaction.new(extra: {})
|
|
|
|
assert_nil transaction.activity_security
|
|
end
|
|
|
|
test "activity_security refreshes when security metadata changes on the same instance" do
|
|
transaction = Transaction.new(extra: { "security_id" => securities(:aapl).id })
|
|
|
|
assert_equal securities(:aapl), transaction.activity_security
|
|
|
|
transaction.extra["security_id"] = securities(:msft).id
|
|
|
|
assert_equal securities(:msft), transaction.activity_security
|
|
end
|
|
|
|
test "record_category_usage! touches the new category's last_used_at" do
|
|
transaction = transactions(:one)
|
|
category = categories(:income)
|
|
assert_nil category.last_used_at
|
|
|
|
transaction.update!(category: category)
|
|
transaction.record_category_usage!
|
|
|
|
assert_not_nil category.reload.last_used_at
|
|
end
|
|
|
|
test "record_category_usage! does nothing when category_id did not change" do
|
|
transaction = transactions(:one)
|
|
category = transaction.category
|
|
assert_nil category.last_used_at
|
|
|
|
transaction.reload
|
|
transaction.record_category_usage!
|
|
|
|
assert_nil category.reload.last_used_at
|
|
end
|
|
|
|
test "record_category_usage! does nothing when category is cleared" do
|
|
transaction = transactions(:one)
|
|
|
|
transaction.update!(category: nil)
|
|
|
|
assert_nothing_raised { transaction.record_category_usage! }
|
|
end
|
|
|
|
test "record_category_usage! is not invoked by rule-driven category enrichment" do
|
|
transaction = transactions(:one)
|
|
category = categories(:income)
|
|
assert_nil category.last_used_at
|
|
|
|
transaction.enrich_attribute(:category_id, category.id, source: "rule")
|
|
|
|
assert_nil category.reload.last_used_at
|
|
end
|
|
end
|