feat(exports): include holding snapshots (#1643)

* feat(exports): include holding snapshots

* fix(exports): resolve holding securities without mic

* fix(exports): harden holding snapshot imports

* fix(exports): harden holding snapshot upserts

* fix(exports): keep holding upserts database-driven
This commit is contained in:
ghost
2026-05-04 16:44:29 -06:00
committed by GitHub
parent b30f3f5bce
commit a108e6501e
4 changed files with 518 additions and 12 deletions

View File

@@ -376,6 +376,55 @@ class Family::DataExporterTest < ActiveSupport::TestCase
end
end
test "exports holding snapshots in NDJSON" do
investment_account = @family.accounts.create!(
name: "Investment Account",
accountable: Investment.new,
balance: 25_000,
currency: "USD"
)
security = Security.create!(
ticker: "VTI#{SecureRandom.hex(4).upcase}",
name: "Vanguard Total Stock Market ETF",
country_code: "US",
exchange_operating_mic: "ARCX"
)
holding = investment_account.holdings.create!(
security: security,
date: Date.parse("2024-01-15"),
qty: 100,
price: 250.25,
amount: 25_025,
currency: "USD",
cost_basis: 200,
cost_basis_source: "manual",
cost_basis_locked: true,
security_locked: true
)
zip_data = @exporter.generate_export
Zip::File.open_buffer(zip_data) do |zip|
ndjson_records = zip.read("all.ndjson").split("\n").map { |line| JSON.parse(line) }
holding_data = ndjson_records.find { |record| record["type"] == "Holding" && record.dig("data", "id") == holding.id }
assert holding_data
assert_equal investment_account.id, holding_data["data"]["account_id"]
assert_equal security.id, holding_data["data"]["security_id"]
assert_equal security.ticker, holding_data["data"]["ticker"]
assert_equal "ARCX", holding_data["data"]["exchange_operating_mic"]
assert_equal "2024-01-15", holding_data["data"]["date"]
assert_equal "100.0", BigDecimal(holding_data["data"]["qty"].to_s).to_s("F")
assert_equal "250.25", BigDecimal(holding_data["data"]["price"].to_s).to_s("F")
assert_equal "25025.0", BigDecimal(holding_data["data"]["amount"].to_s).to_s("F")
assert_equal "200.0", BigDecimal(holding_data["data"]["cost_basis"].to_s).to_s("F")
assert_equal "manual", holding_data["data"]["cost_basis_source"]
assert_equal true, holding_data["data"]["cost_basis_locked"]
assert_not holding_data["data"].key?("created_at")
assert_not holding_data["data"].key?("updated_at")
end
end
test "only exports rules from the specified family" do
# Create a rule for another family that should NOT be exported
other_rule = @other_family.rules.build(

View File

@@ -523,6 +523,302 @@ class Family::DataImporterTest < ActiveSupport::TestCase
assert_equal 150.0, trade.price.to_f
end
test "imports holding snapshots with security identity" do
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "inv-acct-1",
name: "Investment Account",
balance: "10000",
currency: "USD",
accountable_type: "Investment"
}
},
{
type: "Holding",
data: {
id: "holding-1",
account_id: "inv-acct-1",
security_id: "security-1",
ticker: "VTI",
security_name: "Vanguard Total Stock Market ETF",
exchange_operating_mic: "ARCX",
country_code: "US",
date: "2024-01-15",
qty: "100",
price: "250.25",
amount: "25025.00",
currency: "USD",
cost_basis: "200.00",
cost_basis_source: "manual",
cost_basis_locked: true,
security_locked: true
}
}
])
Family::DataImporter.new(@family, ndjson).import!
account = @family.accounts.find_by!(name: "Investment Account")
holding = account.holdings.first
assert_not_nil holding
assert_equal Date.parse("2024-01-15"), holding.date
assert_equal "VTI", holding.security.ticker
assert_equal "Vanguard Total Stock Market ETF", holding.security.name
assert_equal "ARCX", holding.security.exchange_operating_mic
assert_equal 100.0, holding.qty.to_f
assert_equal 250.25, holding.price.to_f
assert_equal 25_025.0, holding.amount.to_f
assert_equal 200.0, holding.cost_basis.to_f
assert_equal "manual", holding.cost_basis_source
assert holding.cost_basis_locked
assert holding.security_locked
opening_anchor = account.valuations.opening_anchor.first
assert_equal Date.parse("2024-01-14"), opening_anchor.entry.date
end
test "imports duplicate holding snapshots idempotently by account security date and currency" do
holding_record = {
type: "Holding",
data: {
id: "holding-1",
account_id: "inv-acct-1",
security_id: "security-1",
ticker: "VTI",
security_name: "Vanguard Total Stock Market ETF",
exchange_operating_mic: "ARCX",
kind: "unsupported",
date: "2024-01-15",
qty: "100",
price: "250.25",
amount: "25025.00",
currency: "USD"
}
}
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "inv-acct-1",
name: "Investment Account",
balance: "10000",
currency: "USD",
accountable_type: "Investment"
}
},
holding_record,
holding_record.deep_merge(data: { id: "holding-1-duplicate", qty: "101", amount: "25275.25" })
])
Family::DataImporter.new(@family, ndjson).import!
account = @family.accounts.find_by!(name: "Investment Account")
assert_equal 1, account.holdings.count
holding = account.holdings.first
assert_equal 101.0, holding.qty.to_f
assert_equal 25_275.25, holding.amount.to_f
assert_equal "standard", holding.security.kind
end
test "imports same holding date in different currencies separately" do
holding_record = {
type: "Holding",
data: {
id: "holding-1",
account_id: "inv-acct-1",
security_id: "security-1",
ticker: "VTI",
security_name: "Vanguard Total Stock Market ETF",
exchange_operating_mic: "ARCX",
date: "2024-01-15",
qty: "100",
price: "250.25",
amount: "25025.00",
currency: "USD"
}
}
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "inv-acct-1",
name: "Investment Account",
balance: "10000",
currency: "USD",
accountable_type: "Investment"
}
},
holding_record,
holding_record.deep_merge(data: { id: "holding-2", currency: "CAD", amount: "34034.00" })
])
Family::DataImporter.new(@family, ndjson).import!
account = @family.accounts.find_by!(name: "Investment Account")
assert_equal 2, account.holdings.count
assert_equal %w[CAD USD], account.holdings.order(:currency).pluck(:currency)
end
test "round trips holding snapshots through full export" do
source_family = Family.create!(
name: "Source Family",
currency: "USD",
locale: "en",
date_format: "%Y-%m-%d"
)
source_account = source_family.accounts.create!(
name: "Round Trip Investment",
accountable: Investment.new,
balance: 25_000,
currency: "USD"
)
source_security = Security.create!(
ticker: "VTI#{SecureRandom.hex(4).upcase}",
name: "Vanguard Total Stock Market ETF",
country_code: "US",
exchange_operating_mic: "ARCX"
)
source_account.holdings.create!(
security: source_security,
date: Date.parse("2024-01-15"),
qty: 100,
price: 250.25,
amount: 25_025,
currency: "USD",
cost_basis: 200,
cost_basis_source: "manual",
cost_basis_locked: true,
security_locked: true
)
zip_data = Family::DataExporter.new(source_family).generate_export
ndjson = nil
Zip::File.open_buffer(zip_data) do |zip|
ndjson = zip.read("all.ndjson")
end
Family::DataImporter.new(@family, ndjson).import!
imported_account = @family.accounts.find_by!(name: "Round Trip Investment")
imported_holding = imported_account.holdings.find_by!(date: Date.parse("2024-01-15"))
assert_equal source_security.ticker, imported_holding.security.ticker
assert_equal "ARCX", imported_holding.security.exchange_operating_mic
assert_equal 100.0, imported_holding.qty.to_f
assert_equal 250.25, imported_holding.price.to_f
assert_equal 25_025.0, imported_holding.amount.to_f
assert_equal 200.0, imported_holding.cost_basis.to_f
assert_equal "manual", imported_holding.cost_basis_source
assert imported_holding.cost_basis_locked
assert imported_holding.security_locked
end
test "imports holding snapshots with ticker fallback when exchange mic is missing" do
existing_security = Security.create!(
ticker: "VTI",
name: "Existing VTI",
exchange_operating_mic: "ARCX"
)
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "inv-acct-1",
name: "Investment Account",
balance: "10000",
currency: "USD",
accountable_type: "Investment"
}
},
{
type: "Holding",
data: {
id: "holding-1",
account_id: "inv-acct-1",
ticker: "VTI",
security_name: "Imported VTI",
date: "2024-01-15",
qty: "100",
price: "250.25",
amount: "25025.00",
currency: "USD",
cost_basis_locked: false,
security_locked: false
}
}
])
Family::DataImporter.new(@family, ndjson).import!
holding = @family.accounts.find_by!(name: "Investment Account").holdings.first
assert_equal existing_security, holding.security
assert_equal 1, Security.where(ticker: "VTI").count
end
test "updates cached security with safe holding metadata" do
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "inv-acct-1",
name: "Investment Account",
balance: "10000",
currency: "USD",
accountable_type: "Investment"
}
},
{
type: "Trade",
data: {
id: "trade-1",
account_id: "inv-acct-1",
security_id: "security-1",
ticker: "VTI",
date: "2024-01-10",
qty: "10",
price: "250.00",
amount: "-2500.00",
currency: "USD"
}
},
{
type: "Holding",
data: {
id: "holding-1",
account_id: "inv-acct-1",
security_id: "security-1",
ticker: "VTI",
security_name: "Vanguard Total Stock Market ETF",
exchange_operating_mic: "ARCX",
country_code: "US",
website_url: "https://investor.vanguard.com",
date: "2024-01-15",
qty: "100",
price: "250.25",
amount: "25025.00",
currency: "USD",
cost_basis_locked: false,
security_locked: false
}
}
])
Family::DataImporter.new(@family, ndjson).import!
security = @family.holdings.first.security
assert_equal "Vanguard Total Stock Market ETF", security.name
assert_equal "ARCX", security.exchange_operating_mic
assert_equal "US", security.country_code
assert_equal "https://investor.vanguard.com", security.website_url
end
test "imports valuations" do
ndjson = build_ndjson([
{