mirror of
https://github.com/we-promise/sure.git
synced 2026-05-28 06:54:56 +00:00
fix(exports): align CSV roundtrip contracts (#1725)
* fix(exports): align CSV roundtrip contracts * fix(exports): version CSV export contract * fix(exports): stabilize CSV export values * fix(imports): preserve legacy CSV roundtrip contracts * fix(imports): escape pipe characters in CSV tags --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -7,6 +7,38 @@ class AccountImportTest < ActiveSupport::TestCase
|
||||
@subject = @import = imports(:account)
|
||||
end
|
||||
|
||||
test "csv_template uses ISO dates" do
|
||||
first_row = @import.csv_template.first
|
||||
|
||||
assert_equal "2024-01-01", first_row["Balance Date"]
|
||||
end
|
||||
|
||||
test "generates rows from legacy account export headers with template labels" do
|
||||
import_csv = <<~CSV
|
||||
id,name,type,subtype,balance,currency,created_at
|
||||
account-1,Main Checking,Depository,checking,1000.00,USD,2024-01-01T00:00:00Z
|
||||
CSV
|
||||
|
||||
@import.update!(
|
||||
raw_file_str: import_csv,
|
||||
entity_type_col_label: "Account type*",
|
||||
name_col_label: "Name*",
|
||||
amount_col_label: "Balance*",
|
||||
currency_col_label: "Currency",
|
||||
date_col_label: "Balance Date",
|
||||
date_format: "%Y-%m-%d"
|
||||
)
|
||||
|
||||
@import.generate_rows_from_csv
|
||||
row = @import.rows.reload.first
|
||||
|
||||
assert row.valid?
|
||||
assert_equal "Depository", row.entity_type
|
||||
assert_equal "Main Checking", row.name
|
||||
assert_equal "1000.00", row.amount
|
||||
assert row.date.blank?
|
||||
end
|
||||
|
||||
test "import creates accounts with valuations" do
|
||||
import_csv = <<~CSV
|
||||
type,name,amount,currency
|
||||
|
||||
@@ -5,6 +5,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
@family = families(:dylan_family)
|
||||
@other_family = families(:empty)
|
||||
@exporter = Family::DataExporter.new(@family)
|
||||
@opening_anchor_date = Date.parse("2024-05-01")
|
||||
|
||||
# Create some test data for the family
|
||||
@account = @family.accounts.create!(
|
||||
@@ -13,6 +14,13 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
@account.entries.create!(
|
||||
date: @opening_anchor_date,
|
||||
amount: 1000,
|
||||
name: "Opening balance",
|
||||
currency: "USD",
|
||||
entryable: Valuation.new(kind: "opening_anchor")
|
||||
)
|
||||
|
||||
@category = @family.categories.create!(
|
||||
name: "Test Category",
|
||||
@@ -47,7 +55,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
assert zip_data.is_a?(StringIO)
|
||||
|
||||
# Check that the zip contains all expected files
|
||||
expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "rules.csv", "attachments.json", "all.ndjson" ]
|
||||
expected_files = [ "version.txt", "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "rules.csv", "attachments.json", "all.ndjson" ]
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
actual_files = zip.entries.map(&:name)
|
||||
@@ -164,15 +172,23 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
# Check accounts.csv
|
||||
accounts_csv = zip.read("accounts.csv")
|
||||
assert accounts_csv.include?("id,name,type,subtype,balance,currency,created_at")
|
||||
assert_equal [ "id", "name", "type", "subtype", "balance", "currency", "created_at" ],
|
||||
CSV.parse(accounts_csv, headers: true).headers
|
||||
|
||||
# Check version marker
|
||||
version_txt = zip.read("version.txt")
|
||||
assert_includes version_txt, "export_version: 2"
|
||||
refute_includes version_txt, "csv_export_version"
|
||||
|
||||
# Check transactions.csv
|
||||
transactions_csv = zip.read("transactions.csv")
|
||||
assert transactions_csv.include?("date,account_name,amount,name,category,tags,notes,currency")
|
||||
assert_equal [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ],
|
||||
CSV.parse(transactions_csv, headers: true).headers
|
||||
|
||||
# Check trades.csv
|
||||
trades_csv = zip.read("trades.csv")
|
||||
assert trades_csv.include?("date,account_name,ticker,quantity,price,amount,currency")
|
||||
assert_equal [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ],
|
||||
CSV.parse(trades_csv, headers: true).headers
|
||||
|
||||
# Check categories.csv
|
||||
categories_csv = zip.read("categories.csv")
|
||||
@@ -184,6 +200,109 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "exports transaction CSV rows with restored legacy ISO date stored amount account name and comma tags" do
|
||||
tag2 = @family.tags.create!(name: "Food, Dining|Cafe", color: "#0000FF")
|
||||
entry = @account.entries.create!(
|
||||
name: "CSV Grocery",
|
||||
amount: 42.50,
|
||||
currency: "USD",
|
||||
date: Date.parse("2024-05-15"),
|
||||
notes: "Weekly grocery run",
|
||||
entryable: Transaction.new(category: @category)
|
||||
)
|
||||
entry.transaction.tags << @tag
|
||||
entry.transaction.tags << tag2
|
||||
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
rows = CSV.parse(zip.read("transactions.csv"), headers: true)
|
||||
row = rows.find { |csv_row| csv_row["name"] == "CSV Grocery" }
|
||||
|
||||
assert_not_nil row
|
||||
assert_equal "2024-05-15", row["date"]
|
||||
assert_equal "42.5", row["amount"]
|
||||
assert_equal @account.name, row["account_name"]
|
||||
assert_includes row["tags"], ","
|
||||
assert_includes row["tags"], "\\,"
|
||||
assert_includes row["tags"], "\\|"
|
||||
assert_equal [ @tag.name, tag2.name ].sort, Import::Row.new(tags: row["tags"]).tags_list.sort
|
||||
end
|
||||
end
|
||||
|
||||
test "exported CSV files can generate matching import rows" do
|
||||
create_csv_export_trade!
|
||||
@account.entries.create!(
|
||||
name: "CSV Importable Transaction",
|
||||
amount: 15.25,
|
||||
currency: "USD",
|
||||
date: Date.parse("2024-05-16"),
|
||||
entryable: Transaction.new(category: @category)
|
||||
)
|
||||
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
accounts_csv = zip.read("accounts.csv")
|
||||
account_import = @other_family.imports.create!(
|
||||
type: "AccountImport",
|
||||
raw_file_str: accounts_csv,
|
||||
entity_type_col_label: "Account type*",
|
||||
name_col_label: "Name*",
|
||||
amount_col_label: "Balance*",
|
||||
currency_col_label: "Currency",
|
||||
date_col_label: "Balance Date",
|
||||
date_format: "%Y-%m-%d"
|
||||
)
|
||||
account_import.generate_rows_from_csv
|
||||
account_row = account_import.rows.reload.find_by!(name: @account.name)
|
||||
assert account_row.valid?
|
||||
assert_equal @account.accountable_type, account_row.entity_type
|
||||
assert_equal BigDecimal(@account.balance.to_s), BigDecimal(account_row.amount)
|
||||
assert account_row.date.blank?
|
||||
|
||||
transaction_import = @other_family.imports.create!(
|
||||
type: "TransactionImport",
|
||||
raw_file_str: zip.read("transactions.csv"),
|
||||
date_col_label: "date*",
|
||||
amount_col_label: "amount*",
|
||||
name_col_label: "name",
|
||||
currency_col_label: "currency",
|
||||
category_col_label: "category",
|
||||
tags_col_label: "tags",
|
||||
account_col_label: "account",
|
||||
notes_col_label: "notes",
|
||||
date_format: "%Y-%m-%d",
|
||||
signage_convention: "inflows_negative"
|
||||
)
|
||||
transaction_import.generate_rows_from_csv
|
||||
transaction_row = transaction_import.rows.reload.find_by!(name: "CSV Importable Transaction")
|
||||
assert transaction_row.valid?
|
||||
assert_equal @account.name, transaction_row.account
|
||||
assert_equal BigDecimal("15.25"), transaction_row.signed_amount
|
||||
|
||||
trade_import = @other_family.imports.create!(
|
||||
type: "TradeImport",
|
||||
raw_file_str: zip.read("trades.csv"),
|
||||
date_col_label: "date*",
|
||||
ticker_col_label: "ticker*",
|
||||
exchange_operating_mic_col_label: "exchange_operating_mic",
|
||||
currency_col_label: "currency",
|
||||
qty_col_label: "qty*",
|
||||
price_col_label: "price*",
|
||||
account_col_label: "account",
|
||||
name_col_label: "name",
|
||||
date_format: "%Y-%m-%d",
|
||||
signage_convention: "inflows_positive"
|
||||
)
|
||||
trade_import.generate_rows_from_csv
|
||||
trade_row = trade_import.rows.reload.find_by!(ticker: @csv_export_trade_ticker)
|
||||
assert trade_row.valid?
|
||||
assert_equal @account.name, trade_row.account
|
||||
assert_equal BigDecimal("10"), BigDecimal(trade_row.qty)
|
||||
end
|
||||
end
|
||||
|
||||
test "generates valid NDJSON file" do
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
@@ -750,4 +869,26 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
entryable: Transaction.new(kind: "funds_movement")
|
||||
)
|
||||
end
|
||||
|
||||
def create_csv_export_trade!
|
||||
security = Security.create!(
|
||||
ticker: "CSV#{SecureRandom.hex(3).upcase}",
|
||||
name: "CSV Export Security",
|
||||
exchange_operating_mic: "XNAS"
|
||||
)
|
||||
@csv_export_trade_ticker = security.ticker
|
||||
|
||||
@account.entries.create!(
|
||||
date: Date.parse("2024-05-17"),
|
||||
amount: 1500,
|
||||
name: "CSV Export Buy",
|
||||
currency: "USD",
|
||||
entryable: Trade.new(
|
||||
security: security,
|
||||
qty: 10,
|
||||
price: 150,
|
||||
currency: "USD"
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,38 @@ class TradeImportTest < ActiveSupport::TestCase
|
||||
Security.stubs(:provider).returns(@provider)
|
||||
end
|
||||
|
||||
test "csv_template uses ISO dates" do
|
||||
first_row = @import.csv_template.first
|
||||
|
||||
assert_equal "2024-05-15", first_row["date*"]
|
||||
end
|
||||
|
||||
test "generates rows from legacy quantity header with template labels" do
|
||||
import_csv = <<~CSV
|
||||
date,ticker,quantity,price,account_name
|
||||
2024-05-15,AAPL,10,150.00,Trading Account
|
||||
CSV
|
||||
|
||||
@import.update!(
|
||||
raw_file_str: import_csv,
|
||||
date_col_label: "date*",
|
||||
ticker_col_label: "ticker*",
|
||||
qty_col_label: "qty*",
|
||||
price_col_label: "price*",
|
||||
account_col_label: "account",
|
||||
date_format: "%Y-%m-%d",
|
||||
signage_convention: "inflows_positive"
|
||||
)
|
||||
|
||||
@import.generate_rows_from_csv
|
||||
row = @import.rows.reload.first
|
||||
|
||||
assert row.valid?
|
||||
assert_equal "Trading Account", row.account
|
||||
assert_equal "10", row.qty
|
||||
assert_equal BigDecimal("1500"), row.signed_amount
|
||||
end
|
||||
|
||||
test "imports trades and accounts" do
|
||||
aapl_resolver = mock
|
||||
googl_resolver = mock
|
||||
|
||||
@@ -103,6 +103,90 @@ class TransactionImportTest < ActiveSupport::TestCase
|
||||
assert_equal [ -100, 200, -300 ], @import.entries.order(:date).map(&:amount)
|
||||
end
|
||||
|
||||
test "csv_template uses ISO dates" do
|
||||
first_row = @import.csv_template.first
|
||||
|
||||
assert_equal "2024-05-15", first_row["date*"]
|
||||
end
|
||||
|
||||
test "generates rows from legacy account_name header with template labels" do
|
||||
import_csv = <<~CSV
|
||||
date,account_name,amount,name
|
||||
2024-05-15,Checking Account,42.50,Legacy Export Row
|
||||
CSV
|
||||
|
||||
@import.update!(
|
||||
raw_file_str: import_csv,
|
||||
date_col_label: "date*",
|
||||
amount_col_label: "amount*",
|
||||
name_col_label: "name",
|
||||
account_col_label: "account",
|
||||
date_format: "%Y-%m-%d",
|
||||
amount_type_strategy: "signed_amount",
|
||||
signage_convention: "inflows_negative"
|
||||
)
|
||||
|
||||
@import.generate_rows_from_csv
|
||||
row = @import.rows.reload.first
|
||||
|
||||
assert row.valid?
|
||||
assert_equal "Checking Account", row.account
|
||||
assert_equal "2024-05-15", row.date
|
||||
assert_equal BigDecimal("42.50"), row.signed_amount
|
||||
end
|
||||
|
||||
test "generates rows from alias column when configured column value is blank" do
|
||||
import_csv = <<~CSV
|
||||
date*,account,account_name,amount,name
|
||||
2024-05-15,,Checking Account,42.50,Legacy Export Row
|
||||
CSV
|
||||
|
||||
@import.update!(
|
||||
raw_file_str: import_csv,
|
||||
date_col_label: "date*",
|
||||
amount_col_label: "amount*",
|
||||
name_col_label: "name",
|
||||
account_col_label: "account",
|
||||
date_format: "%Y-%m-%d",
|
||||
amount_type_strategy: "signed_amount",
|
||||
signage_convention: "inflows_negative"
|
||||
)
|
||||
|
||||
@import.generate_rows_from_csv
|
||||
row = @import.rows.reload.first
|
||||
|
||||
assert row.valid?
|
||||
assert_equal "Checking Account", row.account
|
||||
end
|
||||
|
||||
test "rejects duplicate normalized CSV headers" do
|
||||
import_csv = <<~CSV
|
||||
date,date*,amount,name
|
||||
2024-05-15,2024-05-16,42.50,Duplicate Date Row
|
||||
CSV
|
||||
|
||||
@import.update!(
|
||||
raw_file_str: import_csv,
|
||||
date_col_label: "date",
|
||||
amount_col_label: "amount",
|
||||
name_col_label: "name",
|
||||
date_format: "%Y-%m-%d",
|
||||
amount_type_strategy: "signed_amount",
|
||||
signage_convention: "inflows_negative"
|
||||
)
|
||||
|
||||
error = assert_raises(ActiveRecord::RecordInvalid) { @import.generate_rows_from_csv }
|
||||
|
||||
assert_includes error.record.errors.full_messages.to_sentence, "date, date*"
|
||||
end
|
||||
|
||||
test "parses legacy comma tags and escaped pipe tags" do
|
||||
assert_equal [ "groceries", "essentials" ], Import::Row.new(tags: "groceries,essentials").tags_list
|
||||
assert_equal [ "Food, Dining", "essentials" ], Import::Row.new(tags: "Food\\, Dining,essentials").tags_list
|
||||
assert_equal [ "Food|Dining" ], Import::Row.new(tags: "Food\\|Dining").tags_list
|
||||
assert_equal [ "Food|Dining", "essentials" ], Import::Row.new(tags: "Food\\|Dining|essentials").tags_list
|
||||
end
|
||||
|
||||
test "does not create duplicate when matching transaction exists with same name" do
|
||||
account = accounts(:depository)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user