mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 07:05:00 +00:00
* 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
1247 lines
33 KiB
Ruby
1247 lines
33 KiB
Ruby
require "test_helper"
|
|
|
|
class Family::DataImporterTest < ActiveSupport::TestCase
|
|
setup do
|
|
@family = families(:empty)
|
|
end
|
|
|
|
test "imports accounts with accountable data" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "old-account-1",
|
|
name: "Test Checking",
|
|
balance: "1500.00",
|
|
currency: "USD",
|
|
accountable_type: "Depository",
|
|
accountable: { subtype: "checking" }
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
result = importer.import!
|
|
|
|
assert_equal 1, result[:accounts].count
|
|
account = result[:accounts].first
|
|
assert_equal "Test Checking", account.name
|
|
assert_equal 1500.0, account.balance.to_f
|
|
assert_equal "USD", account.currency
|
|
assert_equal "Depository", account.accountable_type
|
|
end
|
|
|
|
test "imports non-destructive account status from ndjson" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "disabled-account",
|
|
name: "Closed Checking",
|
|
balance: "0.00",
|
|
currency: "USD",
|
|
accountable_type: "Depository",
|
|
status: "disabled"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
result = importer.import!
|
|
|
|
account = result[:accounts].first
|
|
assert_equal "Closed Checking", account.name
|
|
assert_equal "disabled", account.status
|
|
end
|
|
|
|
test "does not import pending deletion account status" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "pending-delete-account",
|
|
name: "Pending Delete Checking",
|
|
balance: "0.00",
|
|
currency: "USD",
|
|
accountable_type: "Depository",
|
|
status: "pending_deletion"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
result = importer.import!
|
|
|
|
account = result[:accounts].first
|
|
assert_equal "Pending Delete Checking", account.name
|
|
assert_equal "active", account.status
|
|
end
|
|
|
|
test "dates synthesized account opening balance before oldest imported activity" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "acct-1",
|
|
name: "Main Account",
|
|
balance: "5000",
|
|
currency: "USD",
|
|
accountable_type: "Depository"
|
|
}
|
|
},
|
|
{
|
|
type: "Transaction",
|
|
data: {
|
|
id: "txn-1",
|
|
account_id: "acct-1",
|
|
date: "2020-04-02",
|
|
amount: "-50.00",
|
|
name: "Grocery Store",
|
|
currency: "USD"
|
|
}
|
|
}
|
|
])
|
|
|
|
Family::DataImporter.new(@family, ndjson).import!
|
|
|
|
account = @family.accounts.find_by!(name: "Main Account")
|
|
opening_anchor = account.valuations.opening_anchor.first
|
|
|
|
assert_not_nil opening_anchor
|
|
assert_equal Date.parse("2020-04-01"), opening_anchor.entry.date
|
|
end
|
|
|
|
test "clamps explicit account opening balance dates before imported activity" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "acct-1",
|
|
name: "Main Account",
|
|
balance: "5000",
|
|
currency: "USD",
|
|
accountable_type: "Depository",
|
|
opening_balance_date: "2020-04-02"
|
|
}
|
|
},
|
|
{
|
|
type: "Transaction",
|
|
data: {
|
|
id: "txn-1",
|
|
account_id: "acct-1",
|
|
date: "2020-04-02",
|
|
amount: "-50.00",
|
|
name: "Grocery Store",
|
|
currency: "USD"
|
|
}
|
|
}
|
|
])
|
|
|
|
Family::DataImporter.new(@family, ndjson).import!
|
|
|
|
account = @family.accounts.find_by!(name: "Main Account")
|
|
opening_anchor = account.valuations.opening_anchor.first
|
|
|
|
assert_not_nil opening_anchor
|
|
assert_equal Date.parse("2020-04-01"), opening_anchor.entry.date
|
|
end
|
|
|
|
test "imports explicit opening anchor valuations without synthesizing duplicates" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "acct-1",
|
|
name: "Main Account",
|
|
balance: "5000",
|
|
currency: "USD",
|
|
accountable_type: "Depository"
|
|
}
|
|
},
|
|
{
|
|
type: "Valuation",
|
|
data: {
|
|
id: "val-opening",
|
|
account_id: "acct-1",
|
|
date: "2020-04-01",
|
|
amount: "5000",
|
|
name: "Opening balance",
|
|
currency: "USD",
|
|
kind: "opening_anchor"
|
|
}
|
|
}
|
|
])
|
|
|
|
Family::DataImporter.new(@family, ndjson).import!
|
|
|
|
account = @family.accounts.find_by!(name: "Main Account")
|
|
opening_anchors = account.valuations.opening_anchor.to_a
|
|
|
|
assert_equal 1, opening_anchors.count
|
|
assert_equal Date.parse("2020-04-01"), opening_anchors.first.entry.date
|
|
assert_equal 5000.0, opening_anchors.first.entry.amount.to_f
|
|
end
|
|
|
|
test "imports categories with parent relationships" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Category",
|
|
data: {
|
|
id: "cat-parent",
|
|
name: "Shopping",
|
|
color: "#FF5733",
|
|
classification: "expense"
|
|
}
|
|
},
|
|
{
|
|
type: "Category",
|
|
data: {
|
|
id: "cat-child",
|
|
name: "Groceries",
|
|
color: "#33FF57",
|
|
classification: "expense",
|
|
parent_id: "cat-parent"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
importer.import!
|
|
|
|
parent = @family.categories.find_by(name: "Shopping")
|
|
child = @family.categories.find_by(name: "Groceries")
|
|
|
|
assert_not_nil parent
|
|
assert_not_nil child
|
|
assert_equal parent.id, child.parent_id
|
|
end
|
|
|
|
test "imports tags" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Tag",
|
|
data: {
|
|
id: "tag-1",
|
|
name: "Important",
|
|
color: "#FF0000"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
importer.import!
|
|
|
|
tag = @family.tags.find_by(name: "Important")
|
|
assert_not_nil tag
|
|
assert_equal "#FF0000", tag.color
|
|
end
|
|
|
|
test "imports merchants" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Merchant",
|
|
data: {
|
|
id: "merchant-1",
|
|
name: "Amazon"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
importer.import!
|
|
|
|
merchant = @family.merchants.find_by(name: "Amazon")
|
|
assert_not_nil merchant
|
|
end
|
|
|
|
test "imports recurring transactions with remapped account and merchant references" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "acct-1",
|
|
name: "Main Checking",
|
|
balance: "5000",
|
|
currency: "USD",
|
|
accountable_type: "Depository"
|
|
}
|
|
},
|
|
{
|
|
type: "Merchant",
|
|
data: {
|
|
id: "merchant-1",
|
|
name: "Internet Provider"
|
|
}
|
|
},
|
|
{
|
|
type: "RecurringTransaction",
|
|
data: {
|
|
id: "recurring-1",
|
|
account_id: "acct-1",
|
|
merchant_id: "merchant-1",
|
|
amount: "-89.99",
|
|
currency: "USD",
|
|
expected_day_of_month: 14,
|
|
last_occurrence_date: "2024-01-14",
|
|
next_expected_date: "2024-02-14",
|
|
status: "active",
|
|
occurrence_count: 6,
|
|
manual: true,
|
|
expected_amount_min: "-95.00",
|
|
expected_amount_max: "-85.00",
|
|
expected_amount_avg: "-89.99"
|
|
}
|
|
}
|
|
])
|
|
|
|
Family::DataImporter.new(@family, ndjson).import!
|
|
|
|
recurring_transaction = @family.recurring_transactions.first
|
|
assert_not_nil recurring_transaction
|
|
assert_equal "Main Checking", recurring_transaction.account.name
|
|
assert_equal "Internet Provider", recurring_transaction.merchant.name
|
|
assert_equal(-89.99, recurring_transaction.amount.to_f)
|
|
assert_equal "USD", recurring_transaction.currency
|
|
assert_equal 14, recurring_transaction.expected_day_of_month
|
|
assert_equal Date.parse("2024-01-14"), recurring_transaction.last_occurrence_date
|
|
assert_equal Date.parse("2024-02-14"), recurring_transaction.next_expected_date
|
|
assert_equal "active", recurring_transaction.status
|
|
assert_equal 6, recurring_transaction.occurrence_count
|
|
assert_equal true, recurring_transaction.manual
|
|
assert_equal(-95.0, recurring_transaction.expected_amount_min.to_f)
|
|
assert_equal(-85.0, recurring_transaction.expected_amount_max.to_f)
|
|
assert_equal(-89.99, recurring_transaction.expected_amount_avg.to_f)
|
|
end
|
|
|
|
test "imports recurring transactions with unknown status fallback" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "acct-1",
|
|
name: "Main Checking",
|
|
balance: "5000",
|
|
currency: "USD",
|
|
accountable_type: "Depository"
|
|
}
|
|
},
|
|
{
|
|
type: "Merchant",
|
|
data: {
|
|
id: "merchant-1",
|
|
name: "Streaming Service"
|
|
}
|
|
},
|
|
{
|
|
type: "RecurringTransaction",
|
|
data: {
|
|
id: "recurring-1",
|
|
account_id: "acct-1",
|
|
merchant_id: "merchant-1",
|
|
amount: "-15.99",
|
|
currency: "USD",
|
|
expected_day_of_month: "8",
|
|
last_occurrence_date: "2024-01-08",
|
|
next_expected_date: "2024-02-08",
|
|
status: "paused",
|
|
occurrence_count: 2
|
|
}
|
|
}
|
|
])
|
|
|
|
Family::DataImporter.new(@family, ndjson).import!
|
|
|
|
recurring_transaction = @family.recurring_transactions.first
|
|
assert_not_nil recurring_transaction
|
|
assert_equal 8, recurring_transaction.expected_day_of_month
|
|
assert_equal Date.parse("2024-01-08"), recurring_transaction.last_occurrence_date
|
|
assert_equal Date.parse("2024-02-08"), recurring_transaction.next_expected_date
|
|
assert_equal "active", recurring_transaction.status
|
|
end
|
|
|
|
test "skips recurring transactions with missing recurrence dates" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "RecurringTransaction",
|
|
data: {
|
|
id: "recurring-1",
|
|
amount: "-15.99",
|
|
currency: "USD",
|
|
expected_day_of_month: "8",
|
|
last_occurrence_date: nil,
|
|
status: "active",
|
|
occurrence_count: 2,
|
|
name: "Streaming Service"
|
|
}
|
|
}
|
|
])
|
|
|
|
Family::DataImporter.new(@family, ndjson).import!
|
|
|
|
assert_equal 0, @family.recurring_transactions.count
|
|
end
|
|
|
|
test "skips recurring transactions when referenced account is missing" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "RecurringTransaction",
|
|
data: {
|
|
id: "recurring-1",
|
|
account_id: "missing-account",
|
|
amount: "-89.99",
|
|
currency: "USD",
|
|
expected_day_of_month: 14,
|
|
last_occurrence_date: "2024-01-14",
|
|
next_expected_date: "2024-02-14",
|
|
status: "active",
|
|
name: "Internet Provider"
|
|
}
|
|
}
|
|
])
|
|
|
|
Family::DataImporter.new(@family, ndjson).import!
|
|
|
|
assert_equal 0, @family.recurring_transactions.count
|
|
end
|
|
|
|
test "skips recurring transactions with blank expected day" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "RecurringTransaction",
|
|
data: {
|
|
id: "recurring-1",
|
|
amount: "-89.99",
|
|
currency: "USD",
|
|
expected_day_of_month: "",
|
|
status: "active",
|
|
name: "Internet Provider"
|
|
}
|
|
}
|
|
])
|
|
|
|
Family::DataImporter.new(@family, ndjson).import!
|
|
|
|
assert_equal 0, @family.recurring_transactions.count
|
|
end
|
|
|
|
test "imports transactions with references" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "acct-1",
|
|
name: "Main Account",
|
|
balance: "5000",
|
|
currency: "USD",
|
|
accountable_type: "Depository"
|
|
}
|
|
},
|
|
{
|
|
type: "Category",
|
|
data: {
|
|
id: "cat-1",
|
|
name: "Food",
|
|
color: "#FF0000",
|
|
classification: "expense"
|
|
}
|
|
},
|
|
{
|
|
type: "Tag",
|
|
data: {
|
|
id: "tag-1",
|
|
name: "Essential"
|
|
}
|
|
},
|
|
{
|
|
type: "Transaction",
|
|
data: {
|
|
id: "txn-1",
|
|
account_id: "acct-1",
|
|
date: "2024-01-15",
|
|
amount: "-50.00",
|
|
name: "Grocery Store",
|
|
currency: "USD",
|
|
category_id: "cat-1",
|
|
tag_ids: [ "tag-1" ],
|
|
notes: "Weekly groceries"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
result = importer.import!
|
|
|
|
assert_equal 1, result[:entries].count
|
|
|
|
transaction = @family.transactions.first
|
|
assert_not_nil transaction
|
|
assert_equal "Grocery Store", transaction.entry.name
|
|
assert_equal -50.0, transaction.entry.amount.to_f
|
|
assert_equal "Food", transaction.category.name
|
|
assert_equal 1, transaction.tags.count
|
|
assert_equal "Essential", transaction.tags.first.name
|
|
assert_equal "Weekly groceries", transaction.entry.notes
|
|
end
|
|
|
|
test "imports trades with securities" 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",
|
|
date: "2024-01-15",
|
|
ticker: "AAPL",
|
|
qty: "10",
|
|
price: "150.00",
|
|
amount: "-1500.00",
|
|
currency: "USD"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
result = importer.import!
|
|
|
|
# Account + Opening balance + Trade entry
|
|
assert_equal 1, result[:entries].count
|
|
|
|
trade = @family.trades.first
|
|
assert_not_nil trade
|
|
assert_equal "AAPL", trade.security.ticker
|
|
assert_equal 10.0, trade.qty.to_f
|
|
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([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "prop-acct-1",
|
|
name: "Property",
|
|
balance: "500000",
|
|
currency: "USD",
|
|
accountable_type: "Property"
|
|
}
|
|
},
|
|
{
|
|
type: "Valuation",
|
|
data: {
|
|
id: "val-1",
|
|
account_id: "prop-acct-1",
|
|
date: "2024-06-15",
|
|
amount: "520000",
|
|
name: "Updated valuation",
|
|
currency: "USD"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
result = importer.import!
|
|
|
|
assert_equal 1, result[:entries].count
|
|
|
|
account = @family.accounts.find_by(name: "Property")
|
|
valuation = account.valuations.joins(:entry).find_by(entries: { name: "Updated valuation" })
|
|
assert_not_nil valuation
|
|
assert_equal 520000.0, valuation.entry.amount.to_f
|
|
end
|
|
|
|
test "imports unknown valuation kinds as reconciliations" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "prop-acct-1",
|
|
name: "Property",
|
|
balance: "500000",
|
|
currency: "USD",
|
|
accountable_type: "Property"
|
|
}
|
|
},
|
|
{
|
|
type: "Valuation",
|
|
data: {
|
|
id: "val-1",
|
|
account_id: "prop-acct-1",
|
|
date: "2024-06-15",
|
|
amount: "520000",
|
|
name: "Updated valuation",
|
|
currency: "USD",
|
|
kind: "legacy_unknown_kind"
|
|
}
|
|
}
|
|
])
|
|
|
|
Family::DataImporter.new(@family, ndjson).import!
|
|
|
|
account = @family.accounts.find_by!(name: "Property")
|
|
valuation = account.valuations.joins(:entry).find_by!(entries: { name: "Updated valuation" })
|
|
assert_equal "reconciliation", valuation.kind
|
|
end
|
|
|
|
test "imports budgets" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Budget",
|
|
data: {
|
|
id: "budget-1",
|
|
start_date: "2024-01-01",
|
|
end_date: "2024-01-31",
|
|
budgeted_spending: "3000.00",
|
|
expected_income: "5000.00",
|
|
currency: "USD"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
importer.import!
|
|
|
|
budget = @family.budgets.first
|
|
assert_not_nil budget
|
|
assert_equal Date.parse("2024-01-01"), budget.start_date
|
|
assert_equal Date.parse("2024-01-31"), budget.end_date
|
|
assert_equal 3000.0, budget.budgeted_spending.to_f
|
|
assert_equal 5000.0, budget.expected_income.to_f
|
|
end
|
|
|
|
test "imports budget_categories" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Category",
|
|
data: {
|
|
id: "cat-groceries",
|
|
name: "Groceries",
|
|
color: "#00FF00",
|
|
classification: "expense"
|
|
}
|
|
},
|
|
{
|
|
type: "Budget",
|
|
data: {
|
|
id: "budget-1",
|
|
start_date: "2024-01-01",
|
|
end_date: "2024-01-31",
|
|
budgeted_spending: "3000.00",
|
|
expected_income: "5000.00",
|
|
currency: "USD"
|
|
}
|
|
},
|
|
{
|
|
type: "BudgetCategory",
|
|
data: {
|
|
id: "bc-1",
|
|
budget_id: "budget-1",
|
|
category_id: "cat-groceries",
|
|
budgeted_spending: "500.00",
|
|
currency: "USD"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
importer.import!
|
|
|
|
budget = @family.budgets.first
|
|
budget_category = budget.budget_categories.first
|
|
assert_not_nil budget_category
|
|
assert_equal "Groceries", budget_category.category.name
|
|
assert_equal 500.0, budget_category.budgeted_spending.to_f
|
|
end
|
|
|
|
test "imports rules with conditions and actions" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Rule",
|
|
version: 1,
|
|
data: {
|
|
name: "Categorize Coffee",
|
|
resource_type: "transaction",
|
|
active: true,
|
|
conditions: [
|
|
{
|
|
condition_type: "transaction_name",
|
|
operator: "like",
|
|
value: "starbucks"
|
|
}
|
|
],
|
|
actions: [
|
|
{
|
|
action_type: "set_transaction_category",
|
|
value: "Coffee"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
importer.import!
|
|
|
|
rule = @family.rules.find_by(name: "Categorize Coffee")
|
|
assert_not_nil rule
|
|
assert rule.active
|
|
assert_equal "transaction", rule.resource_type
|
|
|
|
assert_equal 1, rule.conditions.count
|
|
condition = rule.conditions.first
|
|
assert_equal "transaction_name", condition.condition_type
|
|
assert_equal "like", condition.operator
|
|
assert_equal "starbucks", condition.value
|
|
|
|
assert_equal 1, rule.actions.count
|
|
action = rule.actions.first
|
|
assert_equal "set_transaction_category", action.action_type
|
|
|
|
# Category should be created
|
|
category = @family.categories.find_by(name: "Coffee")
|
|
assert_not_nil category
|
|
assert_equal category.id, action.value
|
|
end
|
|
|
|
test "imports rules with compound conditions" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "Rule",
|
|
version: 1,
|
|
data: {
|
|
name: "Compound Rule",
|
|
resource_type: "transaction",
|
|
active: true,
|
|
conditions: [
|
|
{
|
|
condition_type: "compound",
|
|
operator: "or",
|
|
sub_conditions: [
|
|
{
|
|
condition_type: "transaction_name",
|
|
operator: "like",
|
|
value: "walmart"
|
|
},
|
|
{
|
|
condition_type: "transaction_name",
|
|
operator: "like",
|
|
value: "target"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
actions: [
|
|
{
|
|
action_type: "auto_categorize"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
importer.import!
|
|
|
|
rule = @family.rules.find_by(name: "Compound Rule")
|
|
assert_not_nil rule
|
|
|
|
parent_condition = rule.conditions.first
|
|
assert_equal "compound", parent_condition.condition_type
|
|
assert_equal "or", parent_condition.operator
|
|
assert_equal 2, parent_condition.sub_conditions.count
|
|
end
|
|
|
|
test "skips invalid records gracefully" do
|
|
ndjson = "not valid json\n" + build_ndjson([
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "valid-acct",
|
|
name: "Valid Account",
|
|
balance: "1000",
|
|
currency: "USD",
|
|
accountable_type: "Depository"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
result = importer.import!
|
|
|
|
assert_equal 1, result[:accounts].count
|
|
assert_equal "Valid Account", result[:accounts].first.name
|
|
end
|
|
|
|
test "skips unsupported record types" do
|
|
ndjson = build_ndjson([
|
|
{
|
|
type: "UnsupportedType",
|
|
data: { id: "unknown" }
|
|
},
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "valid-acct",
|
|
name: "Known Account",
|
|
balance: "1000",
|
|
currency: "USD",
|
|
accountable_type: "Depository"
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
result = importer.import!
|
|
|
|
assert_equal 1, result[:accounts].count
|
|
end
|
|
|
|
test "full import scenario with all entity types" do
|
|
ndjson = build_ndjson([
|
|
# Account
|
|
{
|
|
type: "Account",
|
|
data: {
|
|
id: "acct-main",
|
|
name: "Main Checking",
|
|
balance: "5000",
|
|
currency: "USD",
|
|
accountable_type: "Depository"
|
|
}
|
|
},
|
|
# Category
|
|
{
|
|
type: "Category",
|
|
data: {
|
|
id: "cat-food",
|
|
name: "Food",
|
|
color: "#FF5733",
|
|
classification: "expense"
|
|
}
|
|
},
|
|
# Tag
|
|
{
|
|
type: "Tag",
|
|
data: {
|
|
id: "tag-weekly",
|
|
name: "Weekly"
|
|
}
|
|
},
|
|
# Merchant
|
|
{
|
|
type: "Merchant",
|
|
data: {
|
|
id: "merchant-1",
|
|
name: "Local Grocery"
|
|
}
|
|
},
|
|
# Transaction
|
|
{
|
|
type: "RecurringTransaction",
|
|
data: {
|
|
id: "recurring-grocery",
|
|
account_id: "acct-main",
|
|
merchant_id: "merchant-1",
|
|
amount: "-75.50",
|
|
currency: "USD",
|
|
expected_day_of_month: 15,
|
|
last_occurrence_date: "2024-01-15",
|
|
next_expected_date: "2024-02-15",
|
|
status: "active",
|
|
occurrence_count: 3,
|
|
manual: false
|
|
}
|
|
},
|
|
# Transaction
|
|
{
|
|
type: "Transaction",
|
|
data: {
|
|
id: "txn-1",
|
|
account_id: "acct-main",
|
|
date: "2024-01-15",
|
|
amount: "-75.50",
|
|
name: "Weekly groceries",
|
|
currency: "USD",
|
|
category_id: "cat-food",
|
|
merchant_id: "merchant-1",
|
|
tag_ids: [ "tag-weekly" ]
|
|
}
|
|
},
|
|
# Budget
|
|
{
|
|
type: "Budget",
|
|
data: {
|
|
id: "budget-jan",
|
|
start_date: "2024-01-01",
|
|
end_date: "2024-01-31",
|
|
budgeted_spending: "2000",
|
|
expected_income: "4000",
|
|
currency: "USD"
|
|
}
|
|
},
|
|
# BudgetCategory
|
|
{
|
|
type: "BudgetCategory",
|
|
data: {
|
|
id: "bc-food",
|
|
budget_id: "budget-jan",
|
|
category_id: "cat-food",
|
|
budgeted_spending: "500",
|
|
currency: "USD"
|
|
}
|
|
},
|
|
# Rule
|
|
{
|
|
type: "Rule",
|
|
version: 1,
|
|
data: {
|
|
name: "Auto-tag groceries",
|
|
resource_type: "transaction",
|
|
active: true,
|
|
conditions: [
|
|
{ condition_type: "transaction_name", operator: "like", value: "grocery" }
|
|
],
|
|
actions: [
|
|
{ action_type: "set_transaction_tags", value: "Weekly" }
|
|
]
|
|
}
|
|
}
|
|
])
|
|
|
|
importer = Family::DataImporter.new(@family, ndjson)
|
|
result = importer.import!
|
|
|
|
# Verify all entities were created
|
|
assert_equal 1, result[:accounts].count
|
|
assert_equal 1, @family.categories.count
|
|
assert_equal 1, @family.tags.count
|
|
assert_equal 1, @family.merchants.count
|
|
assert_equal 1, @family.recurring_transactions.count
|
|
assert_equal 1, @family.transactions.count
|
|
assert_equal 1, @family.budgets.count
|
|
assert_equal 1, @family.budget_categories.count
|
|
assert_equal 1, @family.rules.count
|
|
|
|
# Verify relationships
|
|
transaction = @family.transactions.first
|
|
assert_equal "Food", transaction.category.name
|
|
assert_equal "Local Grocery", transaction.merchant.name
|
|
assert_equal "Weekly", transaction.tags.first.name
|
|
|
|
recurring_transaction = @family.recurring_transactions.first
|
|
assert_equal "Main Checking", recurring_transaction.account.name
|
|
assert_equal "Local Grocery", recurring_transaction.merchant.name
|
|
end
|
|
|
|
private
|
|
|
|
def build_ndjson(records)
|
|
records.map(&:to_json).join("\n")
|
|
end
|
|
end
|