<%= 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/en.yml b/config/locales/views/imports/en.yml
index a45792793..f6d5c1523 100644
--- a/config/locales/views/imports/en.yml
+++ b/config/locales/views/imports/en.yml
@@ -112,6 +112,20 @@ en:
instructions: Select continue to parse your CSV and move on to the clean step.
mint_import:
date_format_label: Date format
+ actual_import:
+ preconfigured_notice: We have pre-configured your Actual Budget import for you. Please proceed to the next step.
+ leave_empty: Leave empty
+ date_label: Date
+ date_format_label: Date format
+ amount_label: Amount
+ signage_convention_label: Signage convention
+ incomes_are_negative: Incomes are negative
+ incomes_are_positive: Incomes are positive
+ account_label: Account (optional)
+ name_label: Payee (optional)
+ category_label: Category (optional)
+ notes_label: Notes (optional)
+ apply_configuration: Apply configuration
rule_import:
description: Configure your rule import. Rules will be created or updated based
on the CSV data.
@@ -261,6 +275,7 @@ en:
trade_import: "Trade import"
account_import: "Account import"
mint_import: "Mint import"
+ actual_import: "Actual import"
qif_import: "QIF import"
category_import: "Category import"
rule_import: "Rule import"
@@ -293,6 +308,7 @@ en:
trade_import: "Trade"
account_import: "Account"
mint_import: "Mint"
+ actual_import: "Actual"
qif_import: "QIF"
category_import: "Category"
rule_import: "Rule"
@@ -321,6 +337,7 @@ en:
import_accounts: Import accounts
import_categories: Import categories
import_mint: Import from Mint
+ import_actual: Import from Actual Budget
import_portfolio: Import investments
import_rules: Import rules
import_transactions: Import transactions
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index ebc4c4425..a26146ef5 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1802,6 +1802,7 @@ components:
- TradeImport
- AccountImport
- MintImport
+ - ActualImport
- CategoryImport
- RuleImport
- SureImport
@@ -1889,6 +1890,7 @@ components:
- TradeImport
- AccountImport
- MintImport
+ - ActualImport
- CategoryImport
- RuleImport
- SureImport
@@ -1941,6 +1943,7 @@ components:
- TradeImport
- AccountImport
- MintImport
+ - ActualImport
- CategoryImport
- RuleImport
- SureImport
@@ -4482,6 +4485,7 @@ paths:
- TradeImport
- AccountImport
- MintImport
+ - ActualImport
- CategoryImport
- RuleImport
- SureImport
@@ -4540,6 +4544,7 @@ paths:
- TradeImport
- AccountImport
- MintImport
+ - ActualImport
- CategoryImport
- RuleImport
- SureImport
@@ -4646,6 +4651,7 @@ paths:
- TradeImport
- AccountImport
- MintImport
+ - ActualImport
- CategoryImport
- RuleImport
- SureImport
@@ -4883,6 +4889,7 @@ paths:
- TradeImport
- AccountImport
- MintImport
+ - ActualImport
- CategoryImport
- RuleImport
- SureImport
@@ -4993,6 +5000,7 @@ paths:
- TradeImport
- AccountImport
- MintImport
+ - ActualImport
- CategoryImport
- RuleImport
- SureImport
diff --git a/spec/requests/api/v1/imports_spec.rb b/spec/requests/api/v1/imports_spec.rb
index a12cb7a8c..cd48a35ae 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 CategoryImport RuleImport SureImport] }
+ schema: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport] }
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 CategoryImport RuleImport SureImport],
+ enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport],
description: 'Import type (defaults to TransactionImport)'
},
account_id: {
@@ -388,7 +388,7 @@ RSpec.describe 'API V1 Imports', type: :request do
},
type: {
type: :string,
- enum: %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport SureImport],
+ enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport],
description: 'Import type to validate (defaults to TransactionImport)'
},
account_id: {
diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb
index d27ad818f..ab8ee9783 100644
--- a/spec/swagger_helper.rb
+++ b/spec/swagger_helper.rb
@@ -999,7 +999,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 CategoryImport RuleImport SureImport] },
+ type: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport] },
valid: { type: :boolean },
content: { '$ref' => '#/components/schemas/ImportPreflightContent' },
stats: { '$ref' => '#/components/schemas/ImportPreflightStats' },
@@ -1063,7 +1063,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 CategoryImport RuleImport SureImport] },
+ type: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport] },
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' },
@@ -1078,7 +1078,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 CategoryImport RuleImport SureImport] },
+ type: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport ActualImport CategoryImport RuleImport SureImport] },
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/application_system_test_case.rb b/test/application_system_test_case.rb
index 770b6256e..57f01828c 100644
--- a/test/application_system_test_case.rb
+++ b/test/application_system_test_case.rb
@@ -36,7 +36,40 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium_remote_chrome, screen_size: [ 1400, 1400 ]
else
- driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : ENV.fetch("E2E_BROWSER", :chrome).to_sym, screen_size: [ 1400, 1400 ]
+ requested_browser = ENV["E2E_BROWSER"].presence&.to_sym
+ local_browser = case requested_browser
+ when :headless_chrome then :chrome
+ when :headless_firefox then :firefox
+ else requested_browser || :chrome
+ end
+
+ headless = ENV["CI"].present? || requested_browser.in?([ :headless_chrome, :headless_firefox ]) || ENV["DISPLAY"].blank?
+
+ Capybara.register_driver :selenium_local_chrome do |app|
+ options = case local_browser
+ when :firefox
+ Selenium::WebDriver::Firefox::Options.new.tap do |firefox_options|
+ firefox_options.add_argument("--width=1400")
+ firefox_options.add_argument("--height=1400")
+ firefox_options.add_argument("-headless") if headless
+ end
+ else
+ Selenium::WebDriver::Chrome::Options.new.tap do |chrome_options|
+ chrome_options.add_argument("--window-size=1400,1400")
+ chrome_options.add_argument("--headless=new") if headless
+ chrome_options.add_argument("--no-sandbox")
+ chrome_options.add_argument("--disable-dev-shm-usage")
+ end
+ end
+
+ Capybara::Selenium::Driver.new(
+ app,
+ browser: local_browser,
+ options: options
+ )
+ end
+
+ driven_by :selenium_local_chrome, screen_size: [ 1400, 1400 ]
end
def teardown
diff --git a/test/controllers/api/v1/imports_controller_test.rb b/test/controllers/api/v1/imports_controller_test.rb
index d414e7e1b..dcf83ab44 100644
--- a/test/controllers/api/v1/imports_controller_test.rb
+++ b/test/controllers/api/v1/imports_controller_test.rb
@@ -988,6 +988,56 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest
assert_includes data["required_headers"], "Amount"
end
+ test "should apply Actual defaults before preflight header validation" do
+ actual_content = [
+ "Account,Date,Payee,Notes,Category_Group,Category,Amount,Split_Amount,Cleared",
+ "Checking Account,2024-01-01,Coffee Shop,Morning coffee,Food,Coffee,-4.25,0,Cleared"
+ ].join("\n")
+
+ assert_no_difference("Import.count") do
+ post preflight_api_v1_imports_url,
+ params: {
+ type: "ActualImport",
+ raw_file_content: actual_content
+ },
+ headers: api_headers(@read_only_api_key)
+ end
+
+ assert_response :success
+ data = JSON.parse(response.body)["data"]
+
+ assert_equal "ActualImport", data["type"]
+ assert_equal true, data["valid"]
+ assert_empty data["missing_required_headers"]
+ assert_includes data["required_headers"], "Date"
+ assert_includes data["required_headers"], "Amount"
+ end
+
+ test "should not overwrite explicit Actual preflight column mappings with defaults" do
+ actual_content = [
+ "Booked On,Value,Payee",
+ "2024-01-01,-4.25,Coffee Shop"
+ ].join("\n")
+
+ assert_no_difference("Import.count") do
+ post preflight_api_v1_imports_url,
+ params: {
+ type: "ActualImport",
+ raw_file_content: actual_content,
+ date_col_label: "Booked On",
+ amount_col_label: "Value"
+ },
+ headers: api_headers(@read_only_api_key)
+ end
+
+ assert_response :success
+ data = JSON.parse(response.body)["data"]
+
+ assert_equal true, data["valid"]
+ assert_equal [ "Booked On", "Value" ], data["required_headers"]
+ assert_empty data["missing_required_headers"]
+ end
+
test "should not overwrite explicit Mint preflight column mappings with defaults" do
mint_content = [
"Posted On,Value,Description",
diff --git a/test/controllers/imports_controller_test.rb b/test/controllers/imports_controller_test.rb
index dbea78d2d..eea32693a 100644
--- a/test/controllers/imports_controller_test.rb
+++ b/test/controllers/imports_controller_test.rb
@@ -3,6 +3,7 @@ require "test_helper"
class ImportsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
+ ensure_tailwind_build
end
test "gets index" do
@@ -33,6 +34,7 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
assert_select "button", text: "Import transactions", count: 0
assert_select "button", text: "Import investments", count: 0
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 "span", text: "Import accounts first to unlock this option.", count: 2
assert_select "div[aria-disabled=true]", count: 3
diff --git a/test/fixtures/files/imports/actual.csv b/test/fixtures/files/imports/actual.csv
new file mode 100644
index 000000000..03c504d5d
--- /dev/null
+++ b/test/fixtures/files/imports/actual.csv
@@ -0,0 +1,5 @@
+Account,Date,Payee,Notes,Category_Group,Category,Amount,Split_Amount,Cleared
+Checking Account,2024-01-01,Landlord,January rent,Housing,Rent,-1500.00,0,Reconciled
+Credit Card,2024-01-02,Coffee Shop,Morning coffee,Food,Coffee,-4.25,0,Cleared
+Checking Account,2024-01-05,Employer,Monthly salary,Income,Paycheck,2500.00,0,Reconciled
+Checking Account,2024-01-06,Internal Transfer,Move to savings,,Transfer,-250.00,0,Not cleared
diff --git a/test/models/actual_import_test.rb b/test/models/actual_import_test.rb
new file mode 100644
index 000000000..7cc0b3470
--- /dev/null
+++ b/test/models/actual_import_test.rb
@@ -0,0 +1,53 @@
+require "test_helper"
+
+class ActualImportTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ end
+
+ test "default column mappings are applied after create" do
+ import = @family.imports.create!(type: "ActualImport")
+
+ ActualImport.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 = @family.imports.create!(
+ type: "ActualImport",
+ raw_file_str: file_fixture("imports/actual.csv").read,
+ col_sep: ","
+ )
+
+ import.generate_rows_from_csv
+
+ assert_equal (1..4).to_a, import.rows.order(:source_row_number).pluck(:source_row_number)
+ end
+
+ test "generated rows combine category group and category" do
+ import = @family.imports.create!(
+ type: "ActualImport",
+ raw_file_str: file_fixture("imports/actual.csv").read,
+ col_sep: ","
+ )
+
+ import.generate_rows_from_csv
+
+ assert_equal "Food: Coffee", import.rows.order(:source_row_number).second.category
+ assert_equal "Income: Paycheck", import.rows.order(:source_row_number).third.category
+ assert_equal "Transfer", import.rows.order(:source_row_number).fourth.category
+ end
+
+ test "generated rows fall back to category group when category is blank" do
+ import = @family.imports.create!(
+ type: "ActualImport",
+ raw_file_str: file_fixture("imports/actual.csv").read.sub("Housing,Rent", "Housing,"),
+ col_sep: ","
+ )
+
+ import.generate_rows_from_csv
+
+ assert_equal "Housing", import.rows.order(:source_row_number).first.category
+ end
+end
diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb
index 15b146e24..e5667817e 100644
--- a/test/system/imports_test.rb
+++ b/test/system/imports_test.rb
@@ -22,9 +22,7 @@ class ImportsTest < ApplicationSystemTestCase
fill_in "import[raw_file_str]", with: file_fixture("imports/transactions.csv").read
- within "form" do
- click_on "Upload CSV"
- end
+ find_field("import[raw_file_str]").find(:xpath, "./ancestor::form").click_button("Upload CSV")
select "Date", from: "import[date_col_label]"
select "YYYY-MM-DD", from: "import[date_format]"
@@ -73,9 +71,7 @@ class ImportsTest < ApplicationSystemTestCase
fill_in "import[raw_file_str]", with: file_fixture("imports/trades.csv").read
- within "form" do
- click_on "Upload CSV"
- end
+ find_field("import[raw_file_str]").find(:xpath, "./ancestor::form").click_button("Upload CSV")
select "date", from: "import[date_col_label]"
select "YYYY-MM-DD", from: "import[date_format]"
@@ -116,9 +112,7 @@ class ImportsTest < ApplicationSystemTestCase
fill_in "import[raw_file_str]", with: file_fixture("imports/accounts.csv").read
- within "form" do
- click_on "Upload CSV"
- end
+ find_field("import[raw_file_str]").find(:xpath, "./ancestor::form").click_button("Upload CSV")
select "type", from: "import[entity_type_col_label]"
select "name", from: "import[name_col_label]"
@@ -166,9 +160,7 @@ class ImportsTest < ApplicationSystemTestCase
fill_in "import[raw_file_str]", with: file_fixture("imports/mint.csv").read
- within "form" do
- click_on "Upload CSV"
- end
+ find_field("import[raw_file_str]").find(:xpath, "./ancestor::form").click_button("Upload CSV")
click_on "Apply configuration"
@@ -195,4 +187,42 @@ class ImportsTest < ApplicationSystemTestCase
click_on "Back to dashboard"
end
+
+ test "actual import" do
+ visit new_import_path
+
+ # Pending CSV-style imports default the dialog to the Raw Data tab; Actual lives under Financial Tools.
+ click_on "Financial Tools"
+ click_on "Import from Actual Budget"
+
+ within_testid("import-tabs") do
+ click_on "Copy & Paste"
+ end
+
+ fill_in "import[raw_file_str]", with: file_fixture("imports/actual.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