mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 12:55:20 +00:00
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.
206 lines
8.0 KiB
Ruby
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
|