Files
sure/test/models/account_test.rb
ghost e59235fdc5 feat(statements): add account statement vault (#1753)
* feat(statements): add account statement vault

Add web-only statement uploads, account linking, duplicate detection, and per-account coverage/reconciliation checks without mutating transactions. Extend ActiveStorage authorization and targeted tests for family/account scoping.

* fix(statements): return deleted account statements to inbox

Preserve linked statement records when an account is deleted by moving them back to the unmatched inbox, then expand coverage for upload validation, sanitized parser metadata, unavailable reconciliation, and missing-month coverage.

* fix(statements): harden vault upload review flows

Address review and security findings in the statement vault by preserving sanitized parser metadata, failing closed on orphaned statement blobs, avoiding account_id mass assignment permits, and adding regression coverage for link/delete edge cases.

* fix(statements): harden vault upload and access controls

* fix(statements): address vault hardening review

* fix(statements): address vault review feedback

Prioritize SHA-256 duplicate detection while preserving MD5 fallback for legacy rows.

Remove free-form account notes from statement matching, document direct account-destroy unlinking, and add year-selectable historical coverage with muted out-of-range months.

* fix(statements): harden vault review follow-ups

Clarify legacy MD5 checksum use, whitelist statement balance helper dispatch, and preserve sanitized parser metadata.

Hide statement management controls from read-only viewers while keeping server-side authorization unchanged.

* fix(statements): repair settings system coverage

Allow the changelog provider lookup in the self-hosting settings system test, include Statement Vault in settings navigation coverage, and align the feature title casing. Update the devcontainer so ActiveStorage and parallel system tests can run in the documented environment.

* fix(statements): move vault beside accounts

Place Statement Vault with account settings instead of between Imports and Exports. Keep settings footer ordering and system navigation coverage aligned, including the non-admin visibility guard.

* fix(statements): address vault review cleanup

Resolve CodeRabbit review feedback for statement upload validation, duplicate race handling, account statement matching semantics, metadata detection, ActiveStorage authorization tests, and small UI/style cleanups.

* fix(statements): address vault cleanup review

* fix(statements): deduplicate vault style helpers

* fix(statements): close vault review follow-ups

* fix(statements): refresh schema after upstream rebase

* fix(statements): process vault uploads sequentially

* fix(statements): close vault review follow-ups

* fix(statements): scope vault index to accessible accounts

* fix(statements): harden statement vault readiness

Squash the statement vault migration hardening into the feature migration, tighten Active Storage authorization edge cases, bound CSV metadata detection, and add real PDF fixture coverage for stored statements.

Validation: targeted statement/auth/controller/provider tests, full Rails suite, system tests, RuboCop, Biome, Brakeman, Zeitwerk, importmap audit, npm audit, ERB lint, CodeRabbit, and Codex Security all passed locally.

* fix(statements): close vault review follow-ups

Move statement unlinking to after account destroy commit, keep Kraken account creation on the shared crypto helper, and add statement metadata length limits with DB checks.

Validation: fresh devcontainer with fresh DB via db:prepare, focused account/statement/Kraken/Binance tests, RuboCop, Brakeman, Zeitwerk, git diff --check, CodeRabbit, and Codex Security passed before commit.

* fix(statements): address vault scan follow-ups

Move statement tab data setup out of the ERB partial, harden reconciliation labels and coverage initialization, and tighten statement schema constraints.

Validation: CodeRabbit and Codex Security reviewed the current PR diff; Rails focused tests, full Rails tests, system tests, RuboCop, Brakeman, Zeitwerk, ERB lint, npm lint, importmap audit, npm audit, and git diff --check passed.

* fix(statements): defer vault tab loading

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 21:05:11 +02:00

379 lines
11 KiB
Ruby

require "test_helper"
class AccountTest < ActiveSupport::TestCase
include SyncableInterfaceTest, EntriesTestHelper, ActiveJob::TestHelper
setup do
@account = @syncable = accounts(:depository)
@family = families(:dylan_family)
@admin = users(:family_admin)
@member = users(:family_member)
end
test "can destroy" do
assert_difference "Account.count", -1 do
@account.destroy
end
end
test "create_and_sync calls sync_later by default" do
Account.any_instance.expects(:sync_later).once
account = Account.create_and_sync({
family: @family,
owner: @admin,
name: "Test Account",
balance: 100,
currency: "USD",
accountable_type: "Depository",
accountable_attributes: {}
})
assert account.persisted?
assert_equal "USD", account.currency
assert_equal 100, account.balance
end
test "create_and_sync skips sync_later when skip_initial_sync is true" do
Account.any_instance.expects(:sync_later).never
account = Account.create_and_sync(
{
family: @family,
owner: @admin,
name: "Linked Account",
balance: 500,
currency: "EUR",
accountable_type: "Depository",
accountable_attributes: {}
},
skip_initial_sync: true
)
assert account.persisted?
assert_equal "EUR", account.currency
assert_equal 500, account.balance
end
test "create_and_sync creates opening anchor with correct currency" do
Account.any_instance.stubs(:sync_later)
account = Account.create_and_sync(
{
family: @family,
owner: @admin,
name: "Test Account",
balance: 1000,
currency: "GBP",
accountable_type: "Depository",
accountable_attributes: {}
},
skip_initial_sync: true
)
opening_anchor = account.valuations.opening_anchor.first
assert_not_nil opening_anchor
assert_equal "GBP", opening_anchor.entry.currency
assert_equal 1000, opening_anchor.entry.amount
end
test "create_and_sync uses provided opening balance date" do
Account.any_instance.stubs(:sync_later)
opening_date = Time.zone.today
account = Account.create_and_sync(
{
family: @family,
owner: @admin,
name: "Test Account",
balance: 1000,
currency: "USD",
accountable_type: "Depository",
accountable_attributes: {}
},
skip_initial_sync: true,
opening_balance_date: opening_date
)
opening_anchor = account.valuations.opening_anchor.first
assert_equal opening_date, opening_anchor.entry.date
end
test "gets short/long subtype label" do
investment = Investment.new(subtype: "hsa")
account = @family.accounts.create!(
owner: @admin,
name: "Test Investment",
balance: 1000,
currency: "USD",
accountable: investment
)
assert_equal "HSA", account.short_subtype_label
assert_equal "Health Savings Account", account.long_subtype_label
# Test with nil subtype
account.accountable.update!(subtype: nil)
assert_equal "Investments", account.short_subtype_label
assert_equal "Investments", account.long_subtype_label
end
# Tax treatment tests (TaxTreatable concern)
test "tax_treatment delegates to accountable for Investment" do
investment = Investment.new(subtype: "401k")
account = @family.accounts.create!(
owner: @admin,
name: "Test 401k",
balance: 1000,
currency: "USD",
accountable: investment
)
assert_equal :tax_deferred, account.tax_treatment
assert_equal I18n.t("accounts.tax_treatments.tax_deferred"), account.tax_treatment_label
end
test "tax_treatment delegates to accountable for Crypto" do
crypto = Crypto.new(tax_treatment: :taxable)
account = @family.accounts.create!(
owner: @admin,
name: "Test Crypto",
balance: 500,
currency: "USD",
accountable: crypto
)
assert_equal :taxable, account.tax_treatment
assert_equal I18n.t("accounts.tax_treatments.taxable"), account.tax_treatment_label
end
test "tax_treatment returns nil for non-investment accounts" do
# Depository accounts don't have tax_treatment
assert_nil @account.tax_treatment
assert_nil @account.tax_treatment_label
end
test "tax_advantaged? returns true for tax-advantaged accounts" do
investment = Investment.new(subtype: "401k")
account = @family.accounts.create!(
owner: @admin,
name: "Test 401k",
balance: 1000,
currency: "USD",
accountable: investment
)
assert account.tax_advantaged?
assert_not account.taxable?
end
test "tax_advantaged? returns false for taxable accounts" do
investment = Investment.new(subtype: "brokerage")
account = @family.accounts.create!(
owner: @admin,
name: "Test Brokerage",
balance: 1000,
currency: "USD",
accountable: investment
)
assert_not account.tax_advantaged?
assert account.taxable?
end
test "taxable? returns true for accounts without tax_treatment" do
# Depository accounts
assert @account.taxable?
assert_not @account.tax_advantaged?
end
test "destroying account purges attached logo" do
@account.logo.attach(
io: StringIO.new("fake-logo-content"),
filename: "logo.png",
content_type: "image/png"
)
attachment_id = @account.logo.id
assert ActiveStorage::Attachment.exists?(attachment_id)
perform_enqueued_jobs do
@account.destroy!
end
assert_not ActiveStorage::Attachment.exists?(attachment_id)
end
test "destroying account moves linked statements to inbox after commit" do
statement = AccountStatement.create_from_upload!(
family: @family,
account: @account,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
statement.update!(match_confidence: 0.8)
@account.destroy!
statement.reload
assert_nil statement.account_id
assert_equal "unmatched", statement.review_status
assert_nil statement.match_confidence
end
test "rolled back account destroy keeps linked statements unchanged" do
statement = AccountStatement.create_from_upload!(
family: @family,
account: @account,
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
)
statement.update!(match_confidence: 0.8)
Account.transaction do
@account.destroy!
raise ActiveRecord::Rollback
end
statement.reload
assert Account.exists?(@account.id)
assert_equal @account.id, statement.account_id
assert_equal "linked", statement.review_status
assert_equal 0.8.to_d, statement.match_confidence
end
# Account sharing tests
test "owned_by? returns true for account owner" do
assert @account.owned_by?(@admin)
assert_not @account.owned_by?(@member)
end
test "shared_with? returns true for owner and shared users" do
assert @account.shared_with?(@admin) # owner
# depository already shared with member via fixture
assert @account.shared_with?(@member)
end
test "shared? returns true when account has shares" do
account = accounts(:investment)
account.account_shares.destroy_all
assert_not account.shared?
account.share_with!(@member, permission: "read_only")
assert account.shared?
end
test "permission_for returns correct permission level" do
assert_equal :owner, @account.permission_for(@admin)
# depository already shared with member via fixture
share = @account.account_shares.find_by(user: @member)
share.update!(permission: "read_write")
assert_equal :read_write, @account.permission_for(@member)
end
test "accessible_by scope returns owned and shared accounts" do
# Clear existing shares for clean test
AccountShare.delete_all
admin_accessible = @family.accounts.accessible_by(@admin)
member_accessible = @family.accounts.accessible_by(@member)
# Admin owns all fixture accounts
assert_equal @family.accounts.count, admin_accessible.count
# Member has no access (no shares, no owned accounts)
assert_equal 0, member_accessible.count
# Share one account
@account.share_with!(@member, permission: "read_only")
member_accessible = @family.accounts.accessible_by(@member)
assert_equal 1, member_accessible.count
assert_includes member_accessible, @account
end
test "included_in_finances_for scope respects include_in_finances flag" do
AccountShare.delete_all
@account.share_with!(@member, permission: "read_only", include_in_finances: true)
assert_includes @family.accounts.included_in_finances_for(@member), @account
share = @account.account_shares.find_by(user: @member)
share.update!(include_in_finances: false)
assert_not_includes @family.accounts.included_in_finances_for(@member), @account
end
test "auto_share_with_family creates shares for all non-owner members" do
@family.update!(default_account_sharing: "private")
account = Account.create_and_sync({
family: @family,
owner: @admin,
name: "New Shared Account",
balance: 100,
currency: "USD",
accountable_type: "Depository",
accountable_attributes: {}
})
assert_difference -> { AccountShare.count }, @family.users.where.not(id: @admin.id).count do
account.auto_share_with_family!
end
share = account.account_shares.find_by(user: @member)
assert_not_nil share
assert_equal "read_write", share.permission
assert share.include_in_finances?
end
test "current_holdings prefers latest provider snapshot holdings across currencies" do
account = @family.accounts.create!(
owner: @admin,
name: "Linked Brokerage",
balance: 1000,
currency: "USD",
accountable: Investment.new
)
coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Brokerage", currency: "USD")
account_provider = AccountProvider.create!(account: account, provider: coinstats_account)
eur_security = Security.create!(ticker: "ASML", name: "ASML")
chf_security = Security.create!(ticker: "NOVN", name: "Novartis")
provider_holding = account.holdings.create!(
security: eur_security,
date: Date.current,
qty: 2,
price: 500,
amount: 1000,
currency: "EUR",
account_provider: account_provider,
cost_basis: 450
)
account.holdings.create!(
security: eur_security,
date: Date.current,
qty: 2,
price: 540,
amount: 1080,
currency: "USD"
)
second_provider_holding = account.holdings.create!(
security: chf_security,
date: Date.current,
qty: 3,
price: 90,
amount: 270,
currency: "CHF",
account_provider: account_provider,
cost_basis: 80
)
assert_equal [ provider_holding.id, second_provider_holding.id ].sort, account.current_holdings.pluck(:id).sort
assert_equal %w[CHF EUR], account.current_holdings.pluck(:currency).sort
end
end