feat(exports): preserve transfer decisions (#1639)

* feat(exports): preserve transfer decisions

* fix(api): apply transfer date filters to both sides

* fix(api): refine transfer decision handling

* fix(api): align transfer decision schemas

* fix(api): use current context for transfer filters

* fix(api): include either side in transfer date filters

* fix(api): deduplicate transfer decision filters

* fix(api): guard transfer decision exports
This commit is contained in:
ghost
2026-05-08 15:03:57 -06:00
committed by GitHub
parent 81cdccb768
commit 8abecf8a8d
23 changed files with 1817 additions and 4 deletions

View File

@@ -376,6 +376,93 @@ class Family::DataExporterTest < ActiveSupport::TestCase
end
end
test "exports transfer decisions and rejected transfers in NDJSON" do
destination_account = @family.accounts.create!(
name: "Savings Account",
accountable: Depository.new,
balance: 0,
currency: "USD"
)
transfer_outflow = create_transaction_entry(@account, amount: 100, date: Date.parse("2024-01-15"), name: "Transfer to savings")
transfer_inflow = create_transaction_entry(destination_account, amount: -100, date: Date.parse("2024-01-15"), name: "Transfer from checking")
transfer = Transfer.create!(
outflow_transaction: transfer_outflow.entryable,
inflow_transaction: transfer_inflow.entryable,
status: "confirmed",
notes: "Confirmed by user"
)
rejected_outflow = create_transaction_entry(@account, amount: 25, date: Date.parse("2024-01-20"), name: "Candidate outflow")
rejected_inflow = create_transaction_entry(destination_account, amount: -25, date: Date.parse("2024-01-20"), name: "Candidate inflow")
rejected_transfer = RejectedTransfer.create!(
outflow_transaction: rejected_outflow.entryable,
inflow_transaction: rejected_inflow.entryable
)
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) }
transfer_data = ndjson_records.find { |record| record["type"] == "Transfer" && record.dig("data", "id") == transfer.id }
assert transfer_data
assert_equal transfer_inflow.entryable.id, transfer_data["data"]["inflow_transaction_id"]
assert_equal transfer_outflow.entryable.id, transfer_data["data"]["outflow_transaction_id"]
assert_equal "confirmed", transfer_data["data"]["status"]
assert_equal "Confirmed by user", transfer_data["data"]["notes"]
rejected_transfer_data = ndjson_records.find { |record| record["type"] == "RejectedTransfer" && record.dig("data", "id") == rejected_transfer.id }
assert rejected_transfer_data
assert_equal rejected_inflow.entryable.id, rejected_transfer_data["data"]["inflow_transaction_id"]
assert_equal rejected_outflow.entryable.id, rejected_transfer_data["data"]["outflow_transaction_id"]
# Transfer decisions must follow Transaction records so import can remap both sides.
transaction_indices = ndjson_records.each_index.select { |index| ndjson_records[index]["type"] == "Transaction" }
transfer_index = ndjson_records.index(transfer_data)
rejected_transfer_index = ndjson_records.index(rejected_transfer_data)
assert_operator transaction_indices.max, :<, transfer_index
assert_operator transaction_indices.max, :<, rejected_transfer_index
end
end
test "does not export transfer decisions for split parent transactions" do
destination_account = @family.accounts.create!(
name: "Split Transfer Savings",
accountable: Depository.new,
balance: 0,
currency: "USD"
)
split_parent_outflow = create_transaction_entry(@account, amount: 60, date: Date.parse("2024-01-25"), name: "Split transfer parent")
split_parent_outflow.split!([
{ name: "Split transfer child", amount: 60, category_id: @category.id }
])
transfer_inflow = create_transaction_entry(destination_account, amount: -60, date: Date.parse("2024-01-25"), name: "Split transfer inflow")
transfer = Transfer.create!(
outflow_transaction: split_parent_outflow.entryable,
inflow_transaction: transfer_inflow.entryable,
status: "confirmed"
)
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) }
transaction_ids = ndjson_records
.select { |record| record["type"] == "Transaction" }
.map { |record| record.dig("data", "id") }
transfer_ids = ndjson_records
.select { |record| record["type"] == "Transfer" }
.map { |record| record.dig("data", "id") }
assert_not_includes transaction_ids, split_parent_outflow.entryable.id
assert_not_includes transfer_ids, transfer.id
end
end
test "exports balance history in NDJSON for backup verification" do
balance = @account.balances.create!(
date: Date.parse("2024-01-15"),
@@ -500,4 +587,16 @@ class Family::DataExporterTest < ActiveSupport::TestCase
refute ndjson_content.include?(other_rule.name)
end
end
private
def create_transaction_entry(account, amount:, date:, name:)
account.entries.create!(
date: date,
amount: amount,
name: name,
currency: account.currency,
entryable: Transaction.new(kind: "funds_movement")
)
end
end

View File

@@ -888,6 +888,199 @@ class Family::DataImporterTest < ActiveSupport::TestCase
assert_equal "reconciliation", valuation.kind
end
test "imports transfer decisions and rejected transfers with remapped transactions" do
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "checking",
name: "Checking",
balance: "1000",
currency: "USD",
accountable_type: "Depository"
}
},
{
type: "Account",
data: {
id: "savings",
name: "Savings",
balance: "2500",
currency: "USD",
accountable_type: "Depository"
}
},
{
type: "Transaction",
data: {
id: "transfer-outflow",
account_id: "checking",
date: "2024-01-15",
amount: "100.00",
name: "Transfer to savings",
currency: "USD",
kind: "funds_movement"
}
},
{
type: "Transaction",
data: {
id: "transfer-inflow",
account_id: "savings",
date: "2024-01-15",
amount: "-100.00",
name: "Transfer from checking",
currency: "USD",
kind: "funds_movement"
}
},
{
type: "Transfer",
data: {
id: "transfer-1",
inflow_transaction_id: "transfer-inflow",
outflow_transaction_id: "transfer-outflow",
status: "confirmed",
notes: "Confirmed by user"
}
},
{
type: "Transaction",
data: {
id: "rejected-outflow",
account_id: "checking",
date: "2024-01-20",
amount: "25.00",
name: "Candidate outflow",
currency: "USD",
kind: "standard"
}
},
{
type: "Transaction",
data: {
id: "rejected-inflow",
account_id: "savings",
date: "2024-01-20",
amount: "-25.00",
name: "Candidate inflow",
currency: "USD",
kind: "standard"
}
},
{
type: "RejectedTransfer",
data: {
id: "rejected-transfer-1",
inflow_transaction_id: "rejected-inflow",
outflow_transaction_id: "rejected-outflow"
}
}
])
Family::DataImporter.new(@family, ndjson).import!
transfer = Transfer.find_by!(notes: "Confirmed by user")
assert_not_nil transfer
assert_equal "confirmed", transfer.status
assert_equal "Confirmed by user", transfer.notes
assert_equal "Transfer from checking", transfer.inflow_transaction.entry.name
assert_equal "Transfer to savings", transfer.outflow_transaction.entry.name
rejected_transfer = RejectedTransfer
.joins(inflow_transaction: :entry)
.find_by!(entries: { name: "Candidate inflow" })
assert_not_nil rejected_transfer
assert_equal "Candidate inflow", rejected_transfer.inflow_transaction.entry.name
assert_equal "Candidate outflow", rejected_transfer.outflow_transaction.entry.name
end
test "imports duplicate transfer decisions idempotently with unknown status fallback" do
ndjson = build_ndjson([
{
type: "Account",
data: {
id: "checking",
name: "Checking",
balance: "1000",
currency: "USD",
accountable_type: "Depository"
}
},
{
type: "Account",
data: {
id: "savings",
name: "Savings",
balance: "2500",
currency: "USD",
accountable_type: "Depository"
}
},
{
type: "Transaction",
data: {
id: "transfer-outflow",
account_id: "checking",
date: "2024-01-15",
amount: "100.00",
name: "Transfer to savings",
currency: "USD",
kind: "funds_movement"
}
},
{
type: "Transaction",
data: {
id: "transfer-inflow",
account_id: "savings",
date: "2024-01-15",
amount: "-100.00",
name: "Transfer from checking",
currency: "USD",
kind: "funds_movement"
}
},
{
type: "Transfer",
data: {
id: "transfer-1",
inflow_transaction_id: "transfer-inflow",
outflow_transaction_id: "transfer-outflow",
status: "settled"
}
},
{
type: "Transfer",
data: {
id: "transfer-1-duplicate",
inflow_transaction_id: "transfer-inflow",
outflow_transaction_id: "transfer-outflow",
status: "settled"
}
}
])
fallback_logs = []
Rails.logger.stubs(:debug).with do |*args|
message = args.first
fallback_logs << message if message.to_s.include?("Unknown transfer status")
true
end
assert_difference("Transfer.count", 1) do
Family::DataImporter.new(@family, ndjson).import!
end
assert_equal [ 'Unknown transfer status "settled"; defaulting to pending' ], fallback_logs
imported_transfer = Transfer
.joins(inflow_transaction: :entry)
.find_by!(entries: { name: "Transfer from checking" })
assert_equal "pending", imported_transfer.status
end
test "imports budgets" do
ndjson = build_ndjson([
{