From 6ea931f3fe7b19fc1c76135be8a7e1677b42714e Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:32:17 -0700 Subject: [PATCH] feat(imports): add YNAB CSV import (#2361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/assets/images/ynab-logo.png | Bin 0 -> 32580 bytes app/helpers/imports_helper.rb | 2 +- app/models/import.rb | 2 +- app/models/ynab_import.rb | 162 ++++++++++++++ .../configurations/_ynab_import.html.erb | 24 ++ app/views/imports/new.html.erb | 22 +- config/locales/views/imports/ca.yml | 1 - config/locales/views/imports/en.yml | 14 +- config/locales/views/imports/fr.yml | 1 - config/locales/views/imports/hu.yml | 1 - config/locales/views/imports/pl.yml | 1 - config/locales/views/imports/vi.yml | 1 - config/locales/views/imports/zh-CN.yml | 1 - docs/api/openapi.yaml | 32 +++ spec/requests/api/v1/imports_spec.rb | 6 +- spec/swagger_helper.rb | 6 +- .../import/configurations_controller_test.rb | 12 + test/controllers/imports_controller_test.rb | 3 +- test/fixtures/files/imports/ynab.csv | 6 + test/models/ynab_import_test.rb | 205 ++++++++++++++++++ test/system/imports_test.rb | 38 ++++ 21 files changed, 513 insertions(+), 27 deletions(-) create mode 100644 app/assets/images/ynab-logo.png create mode 100644 app/models/ynab_import.rb create mode 100644 app/views/import/configurations/_ynab_import.html.erb create mode 100644 test/fixtures/files/imports/ynab.csv create mode 100644 test/models/ynab_import_test.rb diff --git a/app/assets/images/ynab-logo.png b/app/assets/images/ynab-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..21ce33ac5d653e21b9960722899f54bb826116f2 GIT binary patch literal 32580 zcmZU5byQSe)b<^Q?sjOTq*EFMq(qQz=~lWyYG_bG8YBe-K_sP9x;sT0q`UjO^ZVZS zuXo{ExYnIH_nv+C+3`HjCQ?;d9tV>g6951lg;z4_0DuJk7YRT|1%I7+P2PaNP|XzO zWq?P-Pfl}T0szng3NlifUKx7}-oBbsb7)6Pt_Mn_+O;*9mDWrqns#}TyuqkXcJymw zw4WwK*xT6}{`oU~;U|&g;Ap$QI{yCK$G(tn*zWy@Jbp#0(X8M2r#jzD=SEKfq`I;vlP==XqJ#|Cl z#+GiDbT|8Gam?T?HiBYRjXROq!;O8e1T}%(D|x%$F}(7pY%>AYK366yWnOplG`Nz5 zeNBD`VCT`KkvoXTF=U}Qbp|}L-RgN|XAv8@=hv8onKE0G107q>koewyV%&NMWH*fQ zT_JOoSPzss2%AYPy@$!+(k(!+JIa@erhfRnOLIVW`o4M3)lOg7^DkzpfHv6_MxI6h zbo4rJ=(O;#a%hY!j|v6=sX>Y77P8+hKQGi{cZ?1^2g2eym_}YvDpK#Fz&}*{jfM8T zJj{((Rm}4113xf?_&^?Osf}pA?eXg3!ri0!%_zhhFYKkArFg0-EXD+U%3i5Jy`RAe zM#l_LB3?fnvlbg^(^Y@?kik>%QUQcJic*8{eV_DeG3=v0J+Jy=$-4VZJuj*bZpv=N z^3pG#)`a2q`rx;n?KQJLGOMQ5@%WTk!@`JCQQNp(db@)<4)9id6bij@xy zVWu|bS^H#iG3a4yg4|Ax(TpOrTW7jI4`nHAVPRh*K-L2g#TS6 zal^f*f@!!8#JXV6@F61&ccJhyPoU~(gA^5EC$5T<7AG+xO_mjEbRq0obA zs&?+EMIOYS6u$W5nPeQmvR_i-YBRlTFo%VWx&9*nugH}1Izxygaz%Tje78sziO6kx zntq5zn8L!XIs;|!6ieYyo<><%IBQ}i}&%3`q$W_l;$1N+GtcT(&x z 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 d{_t)jSm~RVg + +
+ + <%= icon("check-circle", color: "current") %> + +

<%= t(".preconfigured_notice") %>

+
+ +<%= styled_form_with model: import, url: import_configuration_path(import), scope: :import, method: :patch, class: "space-y-4" do |form| %> +
+ <%= form.select :date_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".date_label") }, required: true, disabled: import.complete? %> + <%= form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format_label") }, label: true, required: true, disabled: import.complete? %> +
+ +

<%= t(".amount_notice") %>

+ + <%= form.select :account_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".account_label") }, disabled: import.complete? %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".name_label") }, disabled: import.complete? %> + <%= form.select :category_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".category_label") }, disabled: import.complete? %> + <%= form.select :notes_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".notes_label") }, disabled: import.complete? %> + + <%= form.submit t(".apply_configuration"), disabled: import.complete? %> +<% end %> diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index 722ec9106..e6765c6c2 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -30,8 +30,9 @@ <% end %> <% + financial_tool_types = %w[MintImport ActualImport YnabImport QifImport SureImport DocumentImport PdfImport] import_type = params[:type].presence || @pending_import&.type - active_tab = import_type.present? && !import_type.in?(%w[MintImport ActualImport QifImport SureImport DocumentImport PdfImport]) ? "raw_data" : "financial_tools" + active_tab = import_type.present? && !import_type.in?(financial_tool_types) ? "raw_data" : "financial_tools" %> <%= render DS::Tabs.new(active_tab: active_tab) do |tabs| %> <% tabs.with_nav do |nav| %> @@ -42,7 +43,7 @@ <% tabs.with_panel(tab_id: "financial_tools") do %>
    - <% if @pending_import.present? && params[:type].present? && params[:type].in?(%w[MintImport ActualImport QifImport SureImport DocumentImport PdfImport]) %> + <% if @pending_import.present? && params[:type].present? && params[:type].in?(financial_tool_types) %>
  • <%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
    @@ -119,14 +120,13 @@ enabled: true %> <% end %> - <%= render "imports/import_option", - type: "TransactionImport", - icon_name: "bar-chart-2", - icon_bg_class: "bg-surface-inset", - icon_text_class: "text-subdued", - label: t(".import_ynab"), - enabled: false, - disabled_message: t(".coming_soon") %> + <% if params[:type].nil? || params[:type] == "YnabImport" %> + <%= render "imports/import_option", + type: "YnabImport", + image: "ynab-logo.png", + label: t(".import_ynab"), + enabled: true %> + <% end %> <% if (params[:type].nil? || params[:type].in?(%w[DocumentImport PdfImport])) && @document_upload_extensions.any? %>
  • @@ -163,7 +163,7 @@ <% tabs.with_panel(tab_id: "raw_data") do %>
      - <% if @pending_import.present? && params[:type].present? && !params[:type].in?(%w[MintImport ActualImport QifImport SureImport DocumentImport PdfImport]) %> + <% if @pending_import.present? && params[:type].present? && !params[:type].in?(financial_tool_types) %>
    • <%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
      diff --git a/config/locales/views/imports/ca.yml b/config/locales/views/imports/ca.yml index 723d6471e..5be501adc 100644 --- a/config/locales/views/imports/ca.yml +++ b/config/locales/views/imports/ca.yml @@ -321,7 +321,6 @@ ca: category: Categoria tag: Etiqueta new: - coming_soon: Properament description: Importa des d'una eina financera o puja fitxers de dades sense processar. import_accounts: Importa comptes diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index 1e32b7e28..017a731c5 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -130,6 +130,17 @@ en: category_label: Category (optional) notes_label: Notes (optional) apply_configuration: Apply configuration + ynab_import: + preconfigured_notice: We have pre-configured your YNAB import for you. Please proceed to the next step. + amount_notice: Amounts are detected automatically from the Outflow and Inflow columns. + leave_empty: Leave empty + date_label: Date + date_format_label: Date format + account_label: Account (optional) + name_label: Payee (optional) + category_label: Category (optional) + notes_label: Memo (optional) + apply_configuration: Apply configuration rule_import: description: Configure your rule import. Rules will be created or updated based on the CSV data. @@ -310,6 +321,7 @@ en: account_import: "Account import" mint_import: "Mint import" actual_import: "Actual import" + ynab_import: "YNAB import" qif_import: "QIF import" category_import: "Category import" rule_import: "Rule import" @@ -344,6 +356,7 @@ en: account_import: "Account" mint_import: "Mint" actual_import: "Actual" + ynab_import: "YNAB" qif_import: "QIF" category_import: "Category" rule_import: "Rule" @@ -368,7 +381,6 @@ en: description: Import from a financial tool or upload raw data files. tab_financial_tools: Financial Tools & Files tab_raw_data: Raw Data - coming_soon: Coming soon import_ynab: Import from YNAB import_accounts: Import accounts import_categories: Import categories diff --git a/config/locales/views/imports/fr.yml b/config/locales/views/imports/fr.yml index 0ca426ad2..51ec9b48d 100644 --- a/config/locales/views/imports/fr.yml +++ b/config/locales/views/imports/fr.yml @@ -168,7 +168,6 @@ fr: description: Importez depuis un outil financier ou téléversez des fichiers de données bruts. tab_financial_tools: Outils financiers et fichiers tab_raw_data: Données brutes - coming_soon: Bientôt disponible import_ynab: Importer depuis YNAB import_accounts: Importer les comptes import_categories: Importer les catégories diff --git a/config/locales/views/imports/hu.yml b/config/locales/views/imports/hu.yml index 543194625..2acab92bd 100644 --- a/config/locales/views/imports/hu.yml +++ b/config/locales/views/imports/hu.yml @@ -302,7 +302,6 @@ hu: description: Importálás pénzügyi eszközből vagy nyers adatfájlok feltöltése. tab_financial_tools: Pénzügyi eszközök és fájlok tab_raw_data: Nyers adatok - coming_soon: Hamarosan import_ynab: Importálás YNAB-ból import_accounts: Számlák importálása import_categories: Kategóriák importálása diff --git a/config/locales/views/imports/pl.yml b/config/locales/views/imports/pl.yml index d5d1cd967..6a45d25b5 100644 --- a/config/locales/views/imports/pl.yml +++ b/config/locales/views/imports/pl.yml @@ -168,7 +168,6 @@ pl: description: Importuj dane z narzędzia finansowego lub prześlij surowe pliki danych. tab_financial_tools: Narzędzia i pliki finansowe tab_raw_data: Surowe dane - coming_soon: Wkrótce import_ynab: Importuj z YNAB import_accounts: Importuj konta import_categories: Importuj kategorie diff --git a/config/locales/views/imports/vi.yml b/config/locales/views/imports/vi.yml index 1943a9334..0ac69cc9d 100644 --- a/config/locales/views/imports/vi.yml +++ b/config/locales/views/imports/vi.yml @@ -344,7 +344,6 @@ vi: description: Nhập từ công cụ tài chính hoặc tải lên tệp dữ liệu thô. tab_financial_tools: Công cụ tài chính & Tệp tab_raw_data: Dữ liệu thô - coming_soon: Sắp ra mắt import_ynab: Nhập từ YNAB import_accounts: Nhập tài khoản import_categories: Nhập danh mục diff --git a/config/locales/views/imports/zh-CN.yml b/config/locales/views/imports/zh-CN.yml index f87646518..9c93e5630 100644 --- a/config/locales/views/imports/zh-CN.yml +++ b/config/locales/views/imports/zh-CN.yml @@ -302,7 +302,6 @@ zh-CN: title: 新建 CSV 导入 tab_financial_tools: 财务工具和文件 tab_raw_data: 原始数据 - coming_soon: 即将推出 import_ynab: 从 YNAB 导入 import_merchants: 导入商户 import_actual: 从 Actual Budget 导入 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3b748f49d..3d5edb333 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1909,8 +1909,12 @@ components: - AccountImport - MintImport - ActualImport + - YnabImport - CategoryImport - RuleImport + - MerchantImport + - PdfImport + - QifImport - SureImport valid: type: boolean @@ -1997,8 +2001,12 @@ components: - AccountImport - MintImport - ActualImport + - YnabImport - CategoryImport - RuleImport + - MerchantImport + - PdfImport + - QifImport - SureImport status: type: string @@ -2050,8 +2058,12 @@ components: - AccountImport - MintImport - ActualImport + - YnabImport - CategoryImport - RuleImport + - MerchantImport + - PdfImport + - QifImport - SureImport status: type: string @@ -5070,8 +5082,12 @@ paths: - AccountImport - MintImport - ActualImport + - YnabImport - CategoryImport - RuleImport + - MerchantImport + - PdfImport + - QifImport - SureImport responses: '200': @@ -5129,8 +5145,12 @@ paths: - AccountImport - MintImport - ActualImport + - YnabImport - CategoryImport - RuleImport + - MerchantImport + - PdfImport + - QifImport - SureImport description: Import type (defaults to TransactionImport) account_id: @@ -5236,8 +5256,12 @@ paths: - AccountImport - MintImport - ActualImport + - YnabImport - CategoryImport - RuleImport + - MerchantImport + - PdfImport + - QifImport - SureImport description: Import type (defaults to TransactionImport) account_id: @@ -5474,8 +5498,12 @@ paths: - AccountImport - MintImport - ActualImport + - YnabImport - CategoryImport - RuleImport + - MerchantImport + - PdfImport + - QifImport - SureImport description: Import type to validate (defaults to TransactionImport) account_id: @@ -5585,8 +5613,12 @@ paths: - AccountImport - MintImport - ActualImport + - YnabImport - CategoryImport - RuleImport + - MerchantImport + - PdfImport + - QifImport - SureImport description: Import type to validate (defaults to TransactionImport) account_id: diff --git a/spec/requests/api/v1/imports_spec.rb b/spec/requests/api/v1/imports_spec.rb index dc016df87..490af3da1 100644 --- a/spec/requests/api/v1/imports_spec.rb +++ b/spec/requests/api/v1/imports_spec.rb @@ -97,7 +97,7 @@ RSpec.describe 'API V1 Imports', type: :request do schema: { type: :string, enum: %w[pending complete importing reverting revert_failed failed] } parameter name: :type, in: :query, required: false, description: 'Filter by import type', - schema: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport] } + schema: { type: :string, enum: Import::TYPES } response '200', 'imports listed' do schema '$ref' => '#/components/schemas/ImportCollection' @@ -138,7 +138,7 @@ RSpec.describe 'API V1 Imports', type: :request do }, type: { type: :string, - enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport], + enum: Import::TYPES, description: 'Import type (defaults to TransactionImport)' }, account_id: { @@ -401,7 +401,7 @@ RSpec.describe 'API V1 Imports', type: :request do }, type: { type: :string, - enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport], + enum: Import::TYPES, description: 'Import type to validate (defaults to TransactionImport)' }, account_id: { diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 615d56473..179a55d4c 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -1082,7 +1082,7 @@ RSpec.configure do |config| type: :object, required: %w[type valid content stats errors warnings], properties: { - type: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport] }, + type: { type: :string, enum: Import::TYPES }, valid: { type: :boolean }, content: { '$ref' => '#/components/schemas/ImportPreflightContent' }, stats: { '$ref' => '#/components/schemas/ImportPreflightStats' }, @@ -1146,7 +1146,7 @@ RSpec.configure do |config| required: %w[id type status created_at updated_at status_detail], properties: { id: { type: :string, format: :uuid }, - type: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport] }, + type: { type: :string, enum: Import::TYPES }, status: { type: :string, enum: %w[pending complete importing reverting revert_failed failed] }, created_at: { type: :string, format: :'date-time' }, updated_at: { type: :string, format: :'date-time' }, @@ -1161,7 +1161,7 @@ RSpec.configure do |config| required: %w[id type status created_at updated_at status_detail configuration stats], properties: { id: { type: :string, format: :uuid }, - type: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport] }, + type: { type: :string, enum: Import::TYPES }, status: { type: :string, enum: %w[pending complete importing reverting revert_failed failed] }, created_at: { type: :string, format: :'date-time' }, updated_at: { type: :string, format: :'date-time' }, diff --git a/test/controllers/import/configurations_controller_test.rb b/test/controllers/import/configurations_controller_test.rb index 078c17fe9..d6541249e 100644 --- a/test/controllers/import/configurations_controller_test.rb +++ b/test/controllers/import/configurations_controller_test.rb @@ -11,6 +11,18 @@ class Import::ConfigurationsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "show renders the YNAB configuration partial" do + ynab = @user.family.imports.create!( + type: "YnabImport", + raw_file_str: file_fixture("imports/ynab.csv").read, + col_sep: "," + ) + + get import_configuration_url(ynab) + + assert_response :success + end + test "updating a valid configuration regenerates rows" do TransactionImport.any_instance.expects(:generate_rows_from_csv).once diff --git a/test/controllers/imports_controller_test.rb b/test/controllers/imports_controller_test.rb index a92db9b1d..34da55633 100644 --- a/test/controllers/imports_controller_test.rb +++ b/test/controllers/imports_controller_test.rb @@ -38,8 +38,9 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest assert_select "button", text: "Import from Mint", count: 1 assert_select "button", text: "Import from Actual Budget", count: 1 assert_select "button", text: "Import from Quicken (QIF)", count: 1 + assert_select "button", text: "Import from YNAB", count: 1 assert_select "span", text: "Import accounts first to unlock this option.", count: 2 - assert_select "div[aria-disabled=true]", count: 3 + assert_select "div[aria-disabled=true]", count: 2 end test "creates import" do diff --git a/test/fixtures/files/imports/ynab.csv b/test/fixtures/files/imports/ynab.csv new file mode 100644 index 000000000..dd991ab26 --- /dev/null +++ b/test/fixtures/files/imports/ynab.csv @@ -0,0 +1,6 @@ +"Account","Flag","Date","Payee","Category Group/Category","Category Group","Category","Memo","Outflow","Inflow","Cleared" +"Checking","","01/01/2024","Landlord","Housing: Rent","Housing","Rent","January rent","$1,500.00","$0.00","Cleared" +"Credit Card","Red","01/02/2024","Coffee Shop","Food: Coffee","Food","Coffee","Morning coffee","$4.25","$0.00","Uncleared" +"Checking","","01/05/2024","Employer","Income: Paycheck","Income","Paycheck","Monthly salary","$0.00","$2,500.00","Cleared" +"Checking","","01/06/2024","Transfer : Savings","","","","Move to savings","$250.00","$0.00","Uncleared" +"Checking","","01/04/2024","","","","","Reconciliation balance adjustment","$0.00","$0.43","Reconciled" diff --git a/test/models/ynab_import_test.rb b/test/models/ynab_import_test.rb new file mode 100644 index 000000000..ba1d91a49 --- /dev/null +++ b/test/models/ynab_import_test.rb @@ -0,0 +1,205 @@ +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 diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb index e5667817e..dd398490d 100644 --- a/test/system/imports_test.rb +++ b/test/system/imports_test.rb @@ -225,4 +225,42 @@ class ImportsTest < ApplicationSystemTestCase click_on "Back to dashboard" end + + test "ynab import" do + visit new_import_path + + # Pending CSV-style imports default the dialog to the Raw Data tab; YNAB lives under Financial Tools. + click_on "Financial Tools" + click_on "Import from YNAB" + + within_testid("import-tabs") do + click_on "Copy & Paste" + end + + fill_in "import[raw_file_str]", with: file_fixture("imports/ynab.csv").read + + find_field("import[raw_file_str]").find(:xpath, "./ancestor::form").click_button("Upload CSV") + + click_on "Apply configuration" + + click_on "Next step" + + assert_selector "h1", text: "Assign your categories" + click_on "Next" + + assert_selector "h1", text: "Assign your accounts" + click_on "Next" + + click_on "Publish import" + + assert_text "Import in progress" + + perform_enqueued_jobs + + click_on "Check status" + + assert_text "Import successful" + + click_on "Back to dashboard" + end end