mirror of
https://github.com/we-promise/sure.git
synced 2026-04-12 16:47:22 +00:00
855 lines
27 KiB
Ruby
855 lines
27 KiB
Ruby
require "test_helper"
|
|
|
|
class QifImportTest < ActiveSupport::TestCase
|
|
# ── QifParser unit tests ────────────────────────────────────────────────────
|
|
|
|
SAMPLE_QIF = <<~QIF
|
|
!Type:Tag
|
|
NTRIP2025
|
|
^
|
|
NVACATION2023
|
|
DSummer Vacation 2023
|
|
^
|
|
!Type:Cat
|
|
NFood & Dining
|
|
DFood and dining expenses
|
|
E
|
|
^
|
|
NFood & Dining:Restaurants
|
|
DRestaurants
|
|
E
|
|
^
|
|
NSalary
|
|
DSalary Income
|
|
I
|
|
^
|
|
!Type:CCard
|
|
D6/ 4'20
|
|
U-99.00
|
|
T-99.00
|
|
C*
|
|
NTXFR
|
|
PMerchant A
|
|
LFees & Charges
|
|
^
|
|
D3/29'21
|
|
U-28,500.00
|
|
T-28,500.00
|
|
PTransfer Out
|
|
L[Savings Account]
|
|
^
|
|
D10/ 1'20
|
|
U500.00
|
|
T500.00
|
|
PPayment Received
|
|
LFood & Dining/TRIP2025
|
|
^
|
|
QIF
|
|
|
|
QIF_WITH_HIERARCHICAL_CATEGORIES = <<~QIF
|
|
!Type:Bank
|
|
D1/ 1'24
|
|
U-150.00
|
|
T-150.00
|
|
PHardware Store
|
|
LHome:Home Improvement
|
|
^
|
|
D2/ 1'24
|
|
U-50.00
|
|
T-50.00
|
|
PGrocery Store
|
|
LFood:Groceries
|
|
^
|
|
QIF
|
|
|
|
# A QIF file that includes an Opening Balance entry as the first transaction.
|
|
# This mirrors how Quicken exports bank accounts.
|
|
QIF_WITH_OPENING_BALANCE = <<~QIF
|
|
!Type:Bank
|
|
D1/ 1'20
|
|
U500.00
|
|
T500.00
|
|
POpening Balance
|
|
L[Checking Account]
|
|
^
|
|
D3/ 1'20
|
|
U100.00
|
|
T100.00
|
|
PFirst Deposit
|
|
^
|
|
D4/ 1'20
|
|
U-25.00
|
|
T-25.00
|
|
PCoffee Shop
|
|
^
|
|
QIF
|
|
|
|
# A minimal investment QIF with two securities, trades, a dividend, and a cash transfer.
|
|
SAMPLE_INVST_QIF = <<~QIF
|
|
!Type:Security
|
|
NACME
|
|
SACME
|
|
TStock
|
|
^
|
|
!Type:Security
|
|
NCORP
|
|
SCORP
|
|
TStock
|
|
^
|
|
!Type:Invst
|
|
D1/17'22
|
|
NDiv
|
|
YACME
|
|
U190.75
|
|
T190.75
|
|
^
|
|
D1/17'22
|
|
NBuy
|
|
YACME
|
|
I66.10
|
|
Q2
|
|
U132.20
|
|
T132.20
|
|
^
|
|
D1/ 7'22
|
|
NXIn
|
|
PMonthly Deposit
|
|
U8000.00
|
|
T8000.00
|
|
^
|
|
D2/ 1'22
|
|
NSell
|
|
YCORP
|
|
I45.00
|
|
Q3
|
|
U135.00
|
|
T135.00
|
|
^
|
|
QIF
|
|
|
|
# A QIF file that includes split transactions (S/$ fields) with an L field category.
|
|
QIF_WITH_SPLITS = <<~QIF
|
|
!Type:Cat
|
|
NFood & Dining
|
|
E
|
|
^
|
|
NHousehold
|
|
E
|
|
^
|
|
NUtilities
|
|
E
|
|
^
|
|
!Type:Bank
|
|
D1/ 1'24
|
|
U-150.00
|
|
T-150.00
|
|
PGrocery & Hardware Store
|
|
LFood & Dining
|
|
SFood & Dining
|
|
$-100.00
|
|
EGroceries
|
|
SHousehold
|
|
$-50.00
|
|
ESupplies
|
|
^
|
|
D1/ 2'24
|
|
U-75.00
|
|
T-75.00
|
|
PElectric Company
|
|
LUtilities
|
|
^
|
|
QIF
|
|
|
|
# A QIF file where Quicken uses --Split-- as the L field for split transactions.
|
|
QIF_WITH_SPLIT_PLACEHOLDER = <<~QIF
|
|
!Type:Bank
|
|
D1/ 1'24
|
|
U-100.00
|
|
T-100.00
|
|
PWalmart
|
|
L--Split--
|
|
SClothing
|
|
$-25.00
|
|
SFood
|
|
$-25.00
|
|
SHome Improvement
|
|
$-50.00
|
|
^
|
|
D1/ 2'24
|
|
U-30.00
|
|
T-30.00
|
|
PCoffee Shop
|
|
LFood & Dining
|
|
^
|
|
QIF
|
|
|
|
# ── QifParser: valid? ───────────────────────────────────────────────────────
|
|
|
|
test "valid? returns true for QIF content" do
|
|
assert QifParser.valid?(SAMPLE_QIF)
|
|
end
|
|
|
|
test "valid? returns false for non-QIF content" do
|
|
refute QifParser.valid?("<OFX><STMTTRN></STMTTRN></OFX>")
|
|
refute QifParser.valid?("date,amount,name\n2024-01-01,100,Coffee")
|
|
refute QifParser.valid?(nil)
|
|
refute QifParser.valid?("")
|
|
end
|
|
|
|
# ── QifParser: account_type ─────────────────────────────────────────────────
|
|
|
|
test "account_type extracts transaction section type" do
|
|
assert_equal "CCard", QifParser.account_type(SAMPLE_QIF)
|
|
end
|
|
|
|
test "account_type ignores Tag and Cat sections" do
|
|
qif = "!Type:Tag\nNMyTag\n^\n!Type:Cat\nNMyCat\n^\n!Type:Bank\nD1/1'24\nT100.00\nPTest\n^\n"
|
|
assert_equal "Bank", QifParser.account_type(qif)
|
|
end
|
|
|
|
# ── QifParser: parse (transactions) ─────────────────────────────────────────
|
|
|
|
test "parse returns correct number of transactions" do
|
|
assert_equal 3, QifParser.parse(SAMPLE_QIF).length
|
|
end
|
|
|
|
test "parse extracts dates correctly" do
|
|
transactions = QifParser.parse(SAMPLE_QIF)
|
|
assert_equal "2020-06-04", transactions[0].date
|
|
assert_equal "2021-03-29", transactions[1].date
|
|
assert_equal "2020-10-01", transactions[2].date
|
|
end
|
|
|
|
test "parse extracts negative amount with commas" do
|
|
assert_equal "-28500.00", QifParser.parse(SAMPLE_QIF)[1].amount
|
|
end
|
|
|
|
test "parse extracts simple negative amount" do
|
|
assert_equal "-99.00", QifParser.parse(SAMPLE_QIF)[0].amount
|
|
end
|
|
|
|
test "parse extracts payee" do
|
|
transactions = QifParser.parse(SAMPLE_QIF)
|
|
assert_equal "Merchant A", transactions[0].payee
|
|
assert_equal "Transfer Out", transactions[1].payee
|
|
end
|
|
|
|
test "parse extracts category and ignores transfer accounts" do
|
|
transactions = QifParser.parse(SAMPLE_QIF)
|
|
assert_equal "Fees & Charges", transactions[0].category
|
|
assert_equal "", transactions[1].category # [Savings Account] = transfer
|
|
assert_equal "Food & Dining", transactions[2].category
|
|
end
|
|
|
|
test "parse extracts tags from L field slash suffix" do
|
|
transactions = QifParser.parse(SAMPLE_QIF)
|
|
assert_equal [], transactions[0].tags
|
|
assert_equal [], transactions[1].tags
|
|
assert_equal [ "TRIP2025" ], transactions[2].tags
|
|
end
|
|
|
|
# ── QifParser: parse_categories ─────────────────────────────────────────────
|
|
|
|
test "parse_categories returns all categories" do
|
|
names = QifParser.parse_categories(SAMPLE_QIF).map(&:name)
|
|
assert_includes names, "Food & Dining"
|
|
assert_includes names, "Food & Dining:Restaurants"
|
|
assert_includes names, "Salary"
|
|
end
|
|
|
|
test "parse_categories marks income vs expense correctly" do
|
|
categories = QifParser.parse_categories(SAMPLE_QIF)
|
|
salary = categories.find { |c| c.name == "Salary" }
|
|
food = categories.find { |c| c.name == "Food & Dining" }
|
|
assert salary.income
|
|
refute food.income
|
|
end
|
|
|
|
# ── QifParser: parse_tags ───────────────────────────────────────────────────
|
|
|
|
test "parse_tags returns all tags" do
|
|
names = QifParser.parse_tags(SAMPLE_QIF).map(&:name)
|
|
assert_includes names, "TRIP2025"
|
|
assert_includes names, "VACATION2023"
|
|
end
|
|
|
|
test "parse_tags captures description" do
|
|
vacation = QifParser.parse_tags(SAMPLE_QIF).find { |t| t.name == "VACATION2023" }
|
|
assert_equal "Summer Vacation 2023", vacation.description
|
|
end
|
|
|
|
# ── QifParser: encoding ──────────────────────────────────────────────────────
|
|
|
|
test "normalize_encoding returns content unchanged when already valid UTF-8" do
|
|
result = QifParser.normalize_encoding("!Type:CCard\n")
|
|
assert_equal "!Type:CCard\n", result
|
|
end
|
|
|
|
# ── QifParser: opening balance ───────────────────────────────────────────────
|
|
|
|
test "parse skips Opening Balance transaction" do
|
|
transactions = QifParser.parse(QIF_WITH_OPENING_BALANCE)
|
|
assert_equal 2, transactions.length
|
|
refute transactions.any? { |t| t.payee == "Opening Balance" }
|
|
end
|
|
|
|
test "parse_opening_balance returns date and amount" do
|
|
ob = QifParser.parse_opening_balance(QIF_WITH_OPENING_BALANCE)
|
|
assert_not_nil ob
|
|
assert_equal Date.new(2020, 1, 1), ob[:date]
|
|
assert_equal BigDecimal("500"), ob[:amount]
|
|
end
|
|
|
|
test "parse_opening_balance returns nil when no Opening Balance entry" do
|
|
assert_nil QifParser.parse_opening_balance(SAMPLE_QIF)
|
|
end
|
|
|
|
test "parse_opening_balance returns nil for blank content" do
|
|
assert_nil QifParser.parse_opening_balance(nil)
|
|
assert_nil QifParser.parse_opening_balance("")
|
|
end
|
|
|
|
# ── QifParser: split transactions ──────────────────────────────────────────
|
|
|
|
test "parse flags split transactions" do
|
|
transactions = QifParser.parse(QIF_WITH_SPLITS)
|
|
split_txn = transactions.find { |t| t.payee == "Grocery & Hardware Store" }
|
|
normal_txn = transactions.find { |t| t.payee == "Electric Company" }
|
|
|
|
assert split_txn.split, "Expected split transaction to be flagged"
|
|
refute normal_txn.split, "Expected normal transaction not to be flagged"
|
|
end
|
|
|
|
test "parse returns correct count including split transactions" do
|
|
transactions = QifParser.parse(QIF_WITH_SPLITS)
|
|
assert_equal 2, transactions.length
|
|
end
|
|
|
|
test "parse strips --Split-- placeholder from category" do
|
|
transactions = QifParser.parse(QIF_WITH_SPLIT_PLACEHOLDER)
|
|
walmart = transactions.find { |t| t.payee == "Walmart" }
|
|
|
|
assert walmart.split, "Expected split transaction to be flagged"
|
|
assert_equal "", walmart.category, "Expected --Split-- to be stripped from category"
|
|
end
|
|
|
|
test "parse preserves normal category alongside --Split-- placeholder" do
|
|
transactions = QifParser.parse(QIF_WITH_SPLIT_PLACEHOLDER)
|
|
coffee = transactions.find { |t| t.payee == "Coffee Shop" }
|
|
|
|
refute coffee.split
|
|
assert_equal "Food & Dining", coffee.category
|
|
end
|
|
|
|
# ── QifImport model ─────────────────────────────────────────────────────────
|
|
|
|
setup do
|
|
@family = families(:dylan_family)
|
|
@account = accounts(:depository)
|
|
@import = QifImport.create!(family: @family, account: @account)
|
|
end
|
|
|
|
test "generates rows from QIF content" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
assert_equal 3, @import.rows.count
|
|
end
|
|
|
|
test "rows_count is updated after generate_rows_from_csv" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
assert_equal 3, @import.reload.rows_count
|
|
end
|
|
|
|
test "generates row with correct date and amount" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
row = @import.rows.find_by(name: "Merchant A")
|
|
assert_equal "2020-06-04", row.date
|
|
assert_equal "-99.00", row.amount
|
|
end
|
|
|
|
test "generates row with category" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
row = @import.rows.find_by(name: "Merchant A")
|
|
assert_equal "Fees & Charges", row.category
|
|
end
|
|
|
|
test "generates row with tags stored as pipe-separated string" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
row = @import.rows.find_by(name: "Payment Received")
|
|
assert_equal "TRIP2025", row.tags
|
|
end
|
|
|
|
test "transfer rows have blank category" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
row = @import.rows.find_by(name: "Transfer Out")
|
|
assert row.category.blank?
|
|
end
|
|
|
|
test "requires_csv_workflow? is false" do
|
|
refute @import.requires_csv_workflow?
|
|
end
|
|
|
|
test "qif_account_type returns CCard for credit card QIF" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
assert_equal "CCard", @import.qif_account_type
|
|
end
|
|
|
|
test "row_categories excludes blank categories" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
cats = @import.row_categories
|
|
assert_includes cats, "Fees & Charges"
|
|
assert_includes cats, "Food & Dining"
|
|
refute_includes cats, ""
|
|
end
|
|
|
|
test "row_tags excludes blank tags" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
tags = @import.row_tags
|
|
assert_includes tags, "TRIP2025"
|
|
refute_includes tags, ""
|
|
end
|
|
|
|
test "split_categories returns categories from split transactions" do
|
|
@import.update!(raw_file_str: QIF_WITH_SPLITS)
|
|
@import.generate_rows_from_csv
|
|
|
|
split_cats = @import.split_categories
|
|
assert_includes split_cats, "Food & Dining"
|
|
refute_includes split_cats, "Utilities"
|
|
end
|
|
|
|
test "split_categories returns empty when no splits" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
assert_empty @import.split_categories
|
|
end
|
|
|
|
test "has_split_transactions? returns true when splits exist" do
|
|
@import.update!(raw_file_str: QIF_WITH_SPLITS)
|
|
assert @import.has_split_transactions?
|
|
end
|
|
|
|
test "has_split_transactions? returns true for --Split-- placeholder" do
|
|
@import.update!(raw_file_str: QIF_WITH_SPLIT_PLACEHOLDER)
|
|
assert @import.has_split_transactions?
|
|
end
|
|
|
|
test "has_split_transactions? returns false when no splits" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
refute @import.has_split_transactions?
|
|
end
|
|
|
|
test "split_categories is empty when splits use --Split-- placeholder" do
|
|
@import.update!(raw_file_str: QIF_WITH_SPLIT_PLACEHOLDER)
|
|
@import.generate_rows_from_csv
|
|
|
|
assert_empty @import.split_categories
|
|
refute_includes @import.row_categories, "--Split--"
|
|
end
|
|
|
|
test "categories_selected? is false before sync_mappings" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
refute @import.categories_selected?
|
|
end
|
|
|
|
test "categories_selected? is true after sync_mappings" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
@import.sync_mappings
|
|
|
|
assert @import.categories_selected?
|
|
end
|
|
|
|
test "publishable? requires account to be present" do
|
|
import_without_account = QifImport.create!(family: @family)
|
|
import_without_account.update_columns(raw_file_str: SAMPLE_QIF, rows_count: 1)
|
|
|
|
refute import_without_account.publishable?
|
|
end
|
|
|
|
# ── Opening balance handling ─────────────────────────────────────────────────
|
|
|
|
test "Opening Balance row is not generated as a transaction row" do
|
|
@import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE)
|
|
@import.generate_rows_from_csv
|
|
|
|
assert_equal 2, @import.rows.count
|
|
refute @import.rows.exists?(name: "Opening Balance")
|
|
end
|
|
|
|
test "import! sets opening anchor from QIF Opening Balance entry" do
|
|
@import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE)
|
|
@import.generate_rows_from_csv
|
|
@import.sync_mappings
|
|
@import.import!
|
|
|
|
manager = Account::OpeningBalanceManager.new(@account)
|
|
assert manager.has_opening_anchor?
|
|
assert_equal Date.new(2020, 1, 1), manager.opening_date
|
|
assert_equal BigDecimal("500"), manager.opening_balance
|
|
end
|
|
|
|
test "import! moves opening anchor back when transactions predate it" do
|
|
# Anchor set 2 years ago; SAMPLE_QIF has transactions from 2020 which predate it
|
|
@account.entries.create!(
|
|
date: 2.years.ago.to_date,
|
|
name: "Opening balance",
|
|
amount: 0,
|
|
currency: @account.currency,
|
|
entryable: Valuation.new(kind: "opening_anchor")
|
|
)
|
|
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
@import.sync_mappings
|
|
@import.import!
|
|
|
|
manager = Account::OpeningBalanceManager.new(@account.reload)
|
|
# Day before the earliest SAMPLE_QIF transaction (2020-06-04)
|
|
assert_equal Date.new(2020, 6, 3), manager.opening_date
|
|
assert_equal 0, manager.opening_balance
|
|
end
|
|
|
|
test "import! does not move opening anchor when transactions do not predate it" do
|
|
anchor_date = Date.new(2020, 1, 1) # before the earliest SAMPLE_QIF transaction (2020-06-04)
|
|
@account.entries.create!(
|
|
date: anchor_date,
|
|
name: "Opening balance",
|
|
amount: 0,
|
|
currency: @account.currency,
|
|
entryable: Valuation.new(kind: "opening_anchor")
|
|
)
|
|
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
@import.sync_mappings
|
|
@import.import!
|
|
|
|
assert_equal anchor_date, Account::OpeningBalanceManager.new(@account.reload).opening_date
|
|
end
|
|
|
|
test "import! updates a pre-existing opening anchor from QIF Opening Balance entry" do
|
|
@account.entries.create!(
|
|
date: 2.years.ago.to_date,
|
|
name: "Opening balance",
|
|
amount: 0,
|
|
currency: @account.currency,
|
|
entryable: Valuation.new(kind: "opening_anchor")
|
|
)
|
|
|
|
@import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE)
|
|
@import.generate_rows_from_csv
|
|
@import.sync_mappings
|
|
@import.import!
|
|
|
|
manager = Account::OpeningBalanceManager.new(@account.reload)
|
|
assert_equal Date.new(2020, 1, 1), manager.opening_date
|
|
assert_equal BigDecimal("500"), manager.opening_balance
|
|
end
|
|
|
|
test "will_adjust_opening_anchor? returns true when transactions predate anchor" do
|
|
@account.entries.create!(
|
|
date: 2.years.ago.to_date,
|
|
name: "Opening balance",
|
|
amount: 0,
|
|
currency: @account.currency,
|
|
entryable: Valuation.new(kind: "opening_anchor")
|
|
)
|
|
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
assert @import.will_adjust_opening_anchor?
|
|
end
|
|
|
|
test "will_adjust_opening_anchor? returns false when QIF has Opening Balance entry" do
|
|
@account.entries.create!(
|
|
date: 2.years.ago.to_date,
|
|
name: "Opening balance",
|
|
amount: 0,
|
|
currency: @account.currency,
|
|
entryable: Valuation.new(kind: "opening_anchor")
|
|
)
|
|
|
|
@import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE)
|
|
@import.generate_rows_from_csv
|
|
|
|
refute @import.will_adjust_opening_anchor?
|
|
end
|
|
|
|
test "adjusted_opening_anchor_date is one day before earliest transaction" do
|
|
@import.update!(raw_file_str: SAMPLE_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
assert_equal Date.new(2020, 6, 3), @import.adjusted_opening_anchor_date
|
|
end
|
|
|
|
# ── Hierarchical category (Parent:Child) ─────────────────────────────────────
|
|
|
|
test "generates rows with hierarchical category stored as-is" do
|
|
@import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES)
|
|
@import.generate_rows_from_csv
|
|
|
|
row = @import.rows.find_by(name: "Hardware Store")
|
|
assert_equal "Home:Home Improvement", row.category
|
|
end
|
|
|
|
test "create_mappable! creates parent and child categories for hierarchical key" do
|
|
@import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES)
|
|
@import.generate_rows_from_csv
|
|
@import.sync_mappings
|
|
|
|
mapping = @import.mappings.categories.find_by(key: "Home:Home Improvement")
|
|
mapping.update!(create_when_empty: true)
|
|
mapping.create_mappable!
|
|
|
|
child = @family.categories.find_by(name: "Home Improvement")
|
|
assert_not_nil child
|
|
assert_not_nil child.parent
|
|
assert_equal "Home", child.parent.name
|
|
end
|
|
|
|
test "create_mappable! reuses existing parent category for hierarchical key" do
|
|
existing_parent = @family.categories.create!(
|
|
name: "Home", color: "#aabbcc", lucide_icon: "house"
|
|
)
|
|
|
|
@import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES)
|
|
@import.generate_rows_from_csv
|
|
@import.sync_mappings
|
|
|
|
mapping = @import.mappings.categories.find_by(key: "Home:Home Improvement")
|
|
mapping.update!(create_when_empty: true)
|
|
|
|
assert_no_difference "@family.categories.where(name: 'Home').count" do
|
|
mapping.create_mappable!
|
|
end
|
|
|
|
child = @family.categories.find_by(name: "Home Improvement")
|
|
assert_equal existing_parent.id, child.parent_id
|
|
end
|
|
|
|
test "mappables_by_key pre-matches hierarchical key to existing child category" do
|
|
parent = @family.categories.create!(
|
|
name: "Home", color: "#aabbcc", lucide_icon: "house"
|
|
)
|
|
child = @family.categories.create!(
|
|
name: "Home Improvement", color: "#aabbcc", lucide_icon: "house",
|
|
parent: parent
|
|
)
|
|
|
|
@import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES)
|
|
@import.generate_rows_from_csv
|
|
|
|
mappables = Import::CategoryMapping.mappables_by_key(@import)
|
|
assert_equal child, mappables["Home:Home Improvement"]
|
|
end
|
|
|
|
# ── Investment (Invst) QIF: parser ──────────────────────────────────────────
|
|
|
|
test "parse_securities returns all securities from investment QIF" do
|
|
securities = QifParser.parse_securities(SAMPLE_INVST_QIF)
|
|
assert_equal 2, securities.length
|
|
tickers = securities.map(&:ticker)
|
|
assert_includes tickers, "ACME"
|
|
assert_includes tickers, "CORP"
|
|
end
|
|
|
|
test "parse_securities maps name to ticker and type correctly" do
|
|
acme = QifParser.parse_securities(SAMPLE_INVST_QIF).find { |s| s.ticker == "ACME" }
|
|
assert_equal "ACME", acme.name
|
|
assert_equal "Stock", acme.security_type
|
|
end
|
|
|
|
test "parse_securities returns empty array for non-investment QIF" do
|
|
assert_empty QifParser.parse_securities(SAMPLE_QIF)
|
|
end
|
|
|
|
test "parse_investment_transactions returns all investment records" do
|
|
assert_equal 4, QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).length
|
|
end
|
|
|
|
test "parse_investment_transactions resolves security name to ticker" do
|
|
buy = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Buy" }
|
|
assert_equal "ACME", buy.security_ticker
|
|
assert_equal "ACME", buy.security_name
|
|
end
|
|
|
|
test "parse_investment_transactions extracts price, qty, and amount for trade actions" do
|
|
buy = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Buy" }
|
|
assert_equal "66.10", buy.price
|
|
assert_equal "2", buy.qty
|
|
assert_equal "132.20", buy.amount
|
|
end
|
|
|
|
test "parse_investment_transactions extracts amount and ticker for dividend" do
|
|
div = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Div" }
|
|
assert_equal "190.75", div.amount
|
|
assert_equal "ACME", div.security_ticker
|
|
end
|
|
|
|
test "parse_investment_transactions extracts payee for cash actions" do
|
|
xin = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "XIn" }
|
|
assert_equal "Monthly Deposit", xin.payee
|
|
assert_equal "8000.00", xin.amount
|
|
end
|
|
|
|
# ── Investment (Invst) QIF: row generation ──────────────────────────────────
|
|
|
|
test "qif_account_type returns Invst for investment QIF" do
|
|
@import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
assert_equal "Invst", @import.qif_account_type
|
|
end
|
|
|
|
test "generates correct number of rows from investment QIF" do
|
|
@import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
assert_equal 4, @import.rows.count
|
|
end
|
|
|
|
test "generates trade rows with correct entity_type, ticker, qty, and price" do
|
|
@import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
buy_row = @import.rows.find_by(entity_type: "Buy")
|
|
assert_not_nil buy_row
|
|
assert_equal "ACME", buy_row.ticker
|
|
assert_equal "2.0", buy_row.qty
|
|
assert_equal "66.10", buy_row.price
|
|
assert_equal "132.20", buy_row.amount
|
|
end
|
|
|
|
test "generates sell row with negative qty" do
|
|
@import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
sell_row = @import.rows.find_by(entity_type: "Sell")
|
|
assert_not_nil sell_row
|
|
assert_equal "CORP", sell_row.ticker
|
|
assert_equal "-3.0", sell_row.qty
|
|
end
|
|
|
|
test "generates transaction row for Div with security name in row name" do
|
|
@import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
div_row = @import.rows.find_by(entity_type: "Div")
|
|
assert_not_nil div_row
|
|
assert_equal "Dividend: ACME", div_row.name
|
|
assert_equal "190.75", div_row.amount
|
|
end
|
|
|
|
test "generates transaction row for XIn using payee as name" do
|
|
@import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
@import.generate_rows_from_csv
|
|
|
|
xin_row = @import.rows.find_by(entity_type: "XIn")
|
|
assert_not_nil xin_row
|
|
assert_equal "Monthly Deposit", xin_row.name
|
|
end
|
|
|
|
# ── Investment (Invst) QIF: import! ─────────────────────────────────────────
|
|
|
|
test "import! creates Trade records for buy and sell rows" do
|
|
import = QifImport.create!(family: @family, account: accounts(:investment))
|
|
import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
import.generate_rows_from_csv
|
|
import.sync_mappings
|
|
|
|
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
|
|
|
|
assert_difference "Trade.count", 2 do
|
|
import.import!
|
|
end
|
|
end
|
|
|
|
test "import! creates Transaction records for dividend and cash rows" do
|
|
import = QifImport.create!(family: @family, account: accounts(:investment))
|
|
import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
import.generate_rows_from_csv
|
|
import.sync_mappings
|
|
|
|
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
|
|
|
|
assert_difference "Transaction.count", 2 do
|
|
import.import!
|
|
end
|
|
end
|
|
|
|
test "import! creates inflow Entry for Div (negative amount)" do
|
|
import = QifImport.create!(family: @family, account: accounts(:investment))
|
|
import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
import.generate_rows_from_csv
|
|
import.sync_mappings
|
|
|
|
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
|
|
import.import!
|
|
|
|
div_entry = accounts(:investment).entries.find_by(name: "Dividend: ACME")
|
|
assert_not_nil div_entry
|
|
assert div_entry.amount.negative?, "Dividend should be an inflow (negative amount)"
|
|
assert_in_delta(-190.75, div_entry.amount, 0.01)
|
|
end
|
|
|
|
test "import! creates outflow Entry for Buy (positive amount)" do
|
|
import = QifImport.create!(family: @family, account: accounts(:investment))
|
|
import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
import.generate_rows_from_csv
|
|
import.sync_mappings
|
|
|
|
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
|
|
import.import!
|
|
|
|
buy_entry = accounts(:investment)
|
|
.entries
|
|
.joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'")
|
|
.find_by("trades.qty > 0")
|
|
assert_not_nil buy_entry
|
|
assert buy_entry.amount.positive?, "Buy trade should be an outflow (positive amount)"
|
|
end
|
|
|
|
test "import! creates inflow Entry for Sell (negative amount)" do
|
|
import = QifImport.create!(family: @family, account: accounts(:investment))
|
|
import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
import.generate_rows_from_csv
|
|
import.sync_mappings
|
|
|
|
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
|
|
import.import!
|
|
|
|
sell_entry = accounts(:investment)
|
|
.entries
|
|
.joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'")
|
|
.find_by("trades.qty < 0")
|
|
assert_not_nil sell_entry
|
|
assert sell_entry.amount.negative?, "Sell trade should be an inflow (negative amount)"
|
|
end
|
|
|
|
test "will_adjust_opening_anchor? returns false for investment accounts" do
|
|
import = QifImport.create!(family: @family, account: accounts(:investment))
|
|
import.update!(raw_file_str: SAMPLE_INVST_QIF)
|
|
import.generate_rows_from_csv
|
|
|
|
refute import.will_adjust_opening_anchor?
|
|
end
|
|
end
|