Files
sure/test/models/ynab_import_test.rb
ghost 6ea931f3fe feat(imports): add YNAB CSV import (#2361)
Adds YnabImport (mirroring ActualImport) for YNAB "Export budget" register CSVs:

- Amount — combines the split Outflow/Inflow columns into a single signed amount
  (inflow - |outflow|), stripping currency symbols and thousands separators. A
  single signed Amount column takes precedence when present.
- Category — resolves across export shapes: the combined "Category Group/Category"
  column, the split "Category Group" + "Category", or legacy YNAB 4
  "Master Category" + "Sub Category".
- Names — falls back from a blank Payee to the Memo, then the default row name.
- Validation — requires at least one amount source (Outflow/Inflow or Amount); a
  file exposing none leaves rows un-clean instead of importing zero-dollar entries.

Enables the previously-disabled YNAB option on the imports screen (using the YNAB
logo, like Mint) with its configuration partial, and removes the now-dead
imports.new.coming_soon locale key. Documents the type in the API import-type
enums (rswag request spec + swagger_helper + generated openapi.yaml).

Closes #1255.
2026-06-16 08:32:17 +02:00

206 lines
8.0 KiB
Ruby

require "test_helper"
class YnabImportTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
end
test "default column mappings are applied after create" do
import = @family.imports.create!(type: "YnabImport")
YnabImport.default_column_mappings.each do |attribute, value|
assert_equal value, import.public_send(attribute)
end
end
test "generated rows preserve stable source row numbers" do
import = ynab_import(file_fixture("imports/ynab.csv").read)
import.generate_rows_from_csv
assert_equal (1..5).to_a, import.rows.order(:source_row_number).pluck(:source_row_number)
end
test "outflow becomes a positive (expense) amount and inflow a negative (income) amount" do
import = ynab_import(file_fixture("imports/ynab.csv").read)
import.generate_rows_from_csv
rows = import.rows.order(:source_row_number)
# Row 1 is a pure outflow (rent): an expense, stored positive in Sure's convention
assert_equal BigDecimal("1500"), rows.first.signed_amount
# Row 3 is a pure inflow (paycheck): income, stored negative in Sure's convention
assert_equal BigDecimal("-2500"), rows.third.signed_amount
end
test "strips currency symbols and thousands separators from outflow/inflow" do
import = ynab_import(<<~CSV)
Account,Date,Payee,Category Group/Category,Memo,Outflow,Inflow
Checking,02/01/2024,Big Bill,Bills: Utilities,Quarterly,"$1,234.56",
Checking,02/02/2024,Refund,Income: Refund,,,"$1,000.00"
CSV
import.generate_rows_from_csv
rows = import.rows.order(:source_row_number)
assert_equal BigDecimal("1234.56"), rows.first.signed_amount
assert_equal BigDecimal("-1000"), rows.second.signed_amount
end
test "combines the category group and category from the single YNAB column" do
import = ynab_import(file_fixture("imports/ynab.csv").read)
import.generate_rows_from_csv
assert_equal "Housing: Rent", import.rows.order(:source_row_number).first.category
end
test "composes separate Category Group and Category columns" do
import = ynab_import(<<~CSV)
Account,Date,Payee,Category Group,Category,Memo,Outflow,Inflow
Checking,03/01/2024,Store,Food,Groceries,Weekly,42.00,0.00
Checking,03/02/2024,Misc,Food,,No category,5.00,0.00
CSV
import.generate_rows_from_csv
rows = import.rows.order(:source_row_number)
assert_equal "Food: Groceries", rows.first.category
assert_equal "Food", rows.second.category
end
test "composes legacy YNAB 4 Master Category and Sub Category columns" do
import = ynab_import(<<~CSV)
Account,Date,Payee,Category,Master Category,Sub Category,Memo,Outflow,Inflow
Checking,03/10/2024,Store,Everyday Expenses: Groceries,Everyday Expenses,Groceries,Weekly,42.00,0.00
Checking,03/11/2024,Misc,Everyday Expenses,Everyday Expenses,,No sub,5.00,0.00
CSV
import.generate_rows_from_csv
rows = import.rows.order(:source_row_number)
assert_equal "Everyday Expenses: Groceries", rows.first.category
assert_equal "Everyday Expenses", rows.second.category
end
test "a single signed Amount column takes precedence over outflow/inflow" do
import = ynab_import(<<~CSV)
Account,Date,Payee,Category Group/Category,Memo,Amount
Checking,04/01/2024,Employer,Income: Salary,Paycheck,2000.00
Checking,04/02/2024,Store,Food: Groceries,Weekly,-50.00
CSV
import.generate_rows_from_csv
rows = import.rows.order(:source_row_number)
assert_equal BigDecimal("-2000"), rows.first.signed_amount # inflow positive -> income negative
assert_equal BigDecimal("50"), rows.second.signed_amount # outflow negative -> expense positive
end
test "blank payee falls back to memo, then to the default row name" do
import = ynab_import(file_fixture("imports/ynab.csv").read)
import.generate_rows_from_csv
# Last row has a blank Payee but a meaningful Memo
assert_equal "Reconciliation balance adjustment",
import.rows.order(:source_row_number).last.name
blank_both = ynab_import(<<~CSV)
Account,Date,Payee,Category Group/Category,Memo,Outflow,Inflow
Checking,01/04/2024,,,,0.00,0.43
CSV
blank_both.generate_rows_from_csv
assert_equal "Imported item", blank_both.rows.order(:source_row_number).first.name
end
test "publishes entries with combined money movement and mapped category/account" do
import = ynab_import(file_fixture("imports/ynab.csv").read)
import.generate_rows_from_csv
import.mappings.create! key: "Housing: Rent", create_when_empty: true, type: "Import::CategoryMapping"
import.mappings.create! key: "Food: Coffee", create_when_empty: true, type: "Import::CategoryMapping"
import.mappings.create! key: "Income: Paycheck", create_when_empty: true, type: "Import::CategoryMapping"
import.mappings.create! key: "Checking", mappable: accounts(:depository), type: "Import::AccountMapping"
import.mappings.create! key: "Credit Card", mappable: accounts(:credit_card), type: "Import::AccountMapping"
import.reload
assert_difference -> { Entry.count } => 5, -> { Transaction.count } => 5 do
import.publish
end
assert_equal "complete", import.status
entries = import.entries.reload
assert_equal BigDecimal("1500"), entries.find { |e| e.name == "Landlord" }.amount # expense, positive
assert_equal BigDecimal("-2500"), entries.find { |e| e.name == "Employer" }.amount # income, negative
end
test "nets the amount when both outflow and inflow are present on a row" do
import = ynab_import(<<~CSV)
Account,Date,Payee,Category Group/Category,Memo,Outflow,Inflow
Checking,05/01/2024,Adjustment,Income: Adjust,Net,30.00,100.00
CSV
import.generate_rows_from_csv
# inflow 100 - outflow 30 = +70 (income), reversed to -70 in Sure's convention
assert_equal BigDecimal("-70"), import.rows.first.signed_amount
end
test "treats an already-signed (negative) outflow as a magnitude" do
import = ynab_import(<<~CSV)
Account,Date,Payee,Category Group/Category,Memo,Outflow,Inflow
Checking,05/02/2024,Store,Food: Groceries,Signed,-25.00,
CSV
import.generate_rows_from_csv
# A negative outflow is still an expense (+25 in Sure's convention), never income
assert_equal BigDecimal("25"), import.rows.first.signed_amount
end
test "non-numeric outflow/inflow yields a zero amount instead of erroring" do
import = ynab_import(<<~CSV)
Account,Date,Payee,Category Group/Category,Memo,Outflow,Inflow
Checking,05/03/2024,Weird,Food: Groceries,Garbage,n/a,
CSV
import.generate_rows_from_csv
assert_equal BigDecimal("0"), import.rows.first.signed_amount
end
test "leaves the account blank when the export omits an Account column" do
import = ynab_import(<<~CSV)
Date,Payee,Category Group/Category,Memo,Outflow,Inflow
05/04/2024,Store,Food: Groceries,No account column,5.00,
CSV
import.generate_rows_from_csv
assert_equal "", import.rows.first.account
end
test "blocks the import when no Outflow/Inflow/Amount column is present" do
import = ynab_import(<<~CSV)
Account,Date,Payee,Category Group/Category,Memo
Checking,01/01/2024,Store,Food: Groceries,No amount columns here
CSV
import.generate_rows_from_csv
row = import.rows.first
# No amount source -> blank amount -> fails the required-column validation
assert_predicate row.amount.to_s, :blank?
assert_not row.valid?
assert_includes row.errors[:amount], "is required"
assert_not import.cleaned?
end
test "still allows a genuine zero-dollar row when amount columns exist" do
import = ynab_import(<<~CSV)
Account,Date,Payee,Category Group/Category,Memo,Outflow,Inflow
Checking,01/01/2024,Placeholder,Food: Groceries,Zero,$0.00,$0.00
CSV
import.generate_rows_from_csv
row = import.rows.first
assert_equal BigDecimal("0"), row.signed_amount
assert row.valid?
end
private
def ynab_import(csv)
@family.imports.create!(type: "YnabImport", raw_file_str: csv, col_sep: ",")
end
end