Files
sure/test/models/holding/cost_basis_reconciler_test.rb
LPW bbaf7a06cc Add cost basis source tracking with manual override and lock protection (#623)
* Add cost basis tracking and management to holdings

- Added migration to introduce `cost_basis_source` and `cost_basis_locked` fields to `holdings`.
- Implemented backfill for existing holdings to set `cost_basis_source` based on heuristics.
- Introduced `Holding::CostBasisReconciler` to manage cost basis resolution logic.
- Added user interface components for editing and locking cost basis in holdings.
- Updated `materializer` to integrate reconciliation logic and respect locked holdings.
- Extended tests for cost basis-related workflows to ensure accuracy and reliability.

* Fix cost basis calculation in holdings controller

- Ensure `cost_basis` is converted to decimal for accurate arithmetic.
- Fix conditional check to properly validate positive `cost_basis`.

* Improve cost basis validation and error handling in holdings controller

- Allow zero as a valid cost basis for gifted/inherited shares.
- Add error handling with user feedback for invalid cost basis values.

---------

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
2026-01-12 14:05:46 +01:00

172 lines
4.7 KiB
Ruby

require "test_helper"
class Holding::CostBasisReconcilerTest < ActiveSupport::TestCase
setup do
@family = families(:empty)
@account = @family.accounts.create!(
name: "Test Investment",
balance: 20000,
currency: "USD",
accountable: Investment.new
)
@security = securities(:aapl)
end
test "new holding uses incoming cost_basis" do
result = Holding::CostBasisReconciler.reconcile(
existing_holding: nil,
incoming_cost_basis: BigDecimal("150"),
incoming_source: "provider"
)
assert result[:should_update]
assert_equal BigDecimal("150"), result[:cost_basis]
assert_equal "provider", result[:cost_basis_source]
end
test "new holding with nil cost_basis gets nil source" do
result = Holding::CostBasisReconciler.reconcile(
existing_holding: nil,
incoming_cost_basis: nil,
incoming_source: "provider"
)
assert result[:should_update]
assert_nil result[:cost_basis]
assert_nil result[:cost_basis_source]
end
test "locked holding is never overwritten" do
holding = @account.holdings.create!(
security: @security,
date: Date.current,
qty: 10,
price: 200,
amount: 2000,
currency: "USD",
cost_basis: BigDecimal("175"),
cost_basis_source: "manual",
cost_basis_locked: true
)
result = Holding::CostBasisReconciler.reconcile(
existing_holding: holding,
incoming_cost_basis: BigDecimal("200"),
incoming_source: "calculated"
)
assert_not result[:should_update]
assert_equal BigDecimal("175"), result[:cost_basis]
assert_equal "manual", result[:cost_basis_source]
end
test "calculated overwrites provider" do
holding = @account.holdings.create!(
security: @security,
date: Date.current,
qty: 10,
price: 200,
amount: 2000,
currency: "USD",
cost_basis: BigDecimal("150"),
cost_basis_source: "provider",
cost_basis_locked: false
)
result = Holding::CostBasisReconciler.reconcile(
existing_holding: holding,
incoming_cost_basis: BigDecimal("175"),
incoming_source: "calculated"
)
assert result[:should_update]
assert_equal BigDecimal("175"), result[:cost_basis]
assert_equal "calculated", result[:cost_basis_source]
end
test "provider does not overwrite calculated" do
holding = @account.holdings.create!(
security: @security,
date: Date.current,
qty: 10,
price: 200,
amount: 2000,
currency: "USD",
cost_basis: BigDecimal("175"),
cost_basis_source: "calculated",
cost_basis_locked: false
)
result = Holding::CostBasisReconciler.reconcile(
existing_holding: holding,
incoming_cost_basis: BigDecimal("150"),
incoming_source: "provider"
)
assert_not result[:should_update]
assert_equal BigDecimal("175"), result[:cost_basis]
assert_equal "calculated", result[:cost_basis_source]
end
test "provider does not overwrite manual" do
holding = @account.holdings.create!(
security: @security,
date: Date.current,
qty: 10,
price: 200,
amount: 2000,
currency: "USD",
cost_basis: BigDecimal("175"),
cost_basis_source: "manual",
cost_basis_locked: false
)
result = Holding::CostBasisReconciler.reconcile(
existing_holding: holding,
incoming_cost_basis: BigDecimal("150"),
incoming_source: "provider"
)
assert_not result[:should_update]
assert_equal BigDecimal("175"), result[:cost_basis]
assert_equal "manual", result[:cost_basis_source]
end
test "zero provider cost_basis treated as unknown" do
result = Holding::CostBasisReconciler.reconcile(
existing_holding: nil,
incoming_cost_basis: BigDecimal("0"),
incoming_source: "provider"
)
assert result[:should_update]
assert_nil result[:cost_basis]
assert_nil result[:cost_basis_source]
end
test "nil incoming cost_basis does not overwrite existing" do
holding = @account.holdings.create!(
security: @security,
date: Date.current,
qty: 10,
price: 200,
amount: 2000,
currency: "USD",
cost_basis: BigDecimal("175"),
cost_basis_source: "provider",
cost_basis_locked: false
)
result = Holding::CostBasisReconciler.reconcile(
existing_holding: holding,
incoming_cost_basis: nil,
incoming_source: "calculated"
)
# Even though calculated > provider, nil incoming shouldn't overwrite existing value
assert_not result[:should_update]
assert_equal BigDecimal("175"), result[:cost_basis]
assert_equal "provider", result[:cost_basis_source]
end
end