feat(exports): preserve recurring transactions (#1638)

* feat(exports): preserve recurring transactions

* fix(exports): harden recurring import records
This commit is contained in:
ghost
2026-05-03 17:04:06 -06:00
committed by GitHub
parent 0fe1e06645
commit 98df770547
4 changed files with 325 additions and 1 deletions

View File

@@ -338,6 +338,44 @@ class Family::DataExporterTest < ActiveSupport::TestCase
end
end
test "exports recurring transactions in NDJSON" do
merchant = @family.merchants.create!(name: "Internet Provider")
recurring_transaction = @family.recurring_transactions.create!(
account: @account,
merchant: merchant,
amount: -89.99,
currency: "USD",
expected_day_of_month: 14,
last_occurrence_date: Date.parse("2024-01-14"),
next_expected_date: Date.parse("2024-02-14"),
status: "active",
occurrence_count: 6,
manual: true,
expected_amount_min: -95,
expected_amount_max: -85,
expected_amount_avg: -89.99
)
zip_data = @exporter.generate_export
Zip::File.open_buffer(zip_data) do |zip|
ndjson_content = zip.read("all.ndjson")
recurring_data = ndjson_content
.split("\n")
.map { |line| JSON.parse(line) }
.find { |line| line["type"] == "RecurringTransaction" && line.dig("data", "id") == recurring_transaction.id }
assert recurring_data
assert_equal recurring_transaction.id, recurring_data["data"]["id"]
assert_equal @account.id, recurring_data["data"]["account_id"]
assert_equal merchant.id, recurring_data["data"]["merchant_id"]
assert_equal "-89.99", BigDecimal(recurring_data["data"]["amount"].to_s).to_s("F")
assert_equal "active", recurring_data["data"]["status"]
assert_equal true, recurring_data["data"]["manual"]
assert_not recurring_data["data"].key?("family_id")
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

@@ -254,6 +254,176 @@ class Family::DataImporterTest < ActiveSupport::TestCase
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([
{
@@ -675,6 +845,23 @@ class Family::DataImporterTest < ActiveSupport::TestCase
}
},
# 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: {
@@ -738,6 +925,7 @@ class Family::DataImporterTest < ActiveSupport::TestCase
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
@@ -748,6 +936,10 @@ class Family::DataImporterTest < ActiveSupport::TestCase
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