<%= 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