mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Feature: Add the ability to "revert" a CSV import (#1814)
* Allow reverting imports * Fix tests * Add currency column to all imports * Don't auto-enrich demo account
This commit is contained in:
parent
60925bd16c
commit
536c82f2aa
17 changed files with 125 additions and 6 deletions
|
@ -1,5 +1,5 @@
|
||||||
class ImportsController < ApplicationController
|
class ImportsController < ApplicationController
|
||||||
before_action :set_import, only: %i[show publish destroy]
|
before_action :set_import, only: %i[show publish destroy revert]
|
||||||
|
|
||||||
def publish
|
def publish
|
||||||
@import.publish_later
|
@import.publish_later
|
||||||
|
@ -31,6 +31,11 @@ class ImportsController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def revert
|
||||||
|
@import.revert_later
|
||||||
|
redirect_to imports_path, notice: "Import is reverting in the background."
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@import.destroy
|
@import.destroy
|
||||||
|
|
||||||
|
|
7
app/jobs/revert_import_job.rb
Normal file
7
app/jobs/revert_import_job.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
class RevertImportJob < ApplicationJob
|
||||||
|
queue_as :latency_low
|
||||||
|
|
||||||
|
def perform(import)
|
||||||
|
import.revert
|
||||||
|
end
|
||||||
|
end
|
|
@ -24,7 +24,7 @@ class Demo::Generator
|
||||||
puts "Data cleared"
|
puts "Data cleared"
|
||||||
|
|
||||||
family_names.each_with_index do |family_name, index|
|
family_names.each_with_index do |family_name, index|
|
||||||
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", data_enrichment_enabled: index == 0)
|
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local")
|
||||||
end
|
end
|
||||||
|
|
||||||
puts "Users reset"
|
puts "Users reset"
|
||||||
|
|
|
@ -6,7 +6,14 @@ class Import < ApplicationRecord
|
||||||
|
|
||||||
scope :ordered, -> { order(created_at: :desc) }
|
scope :ordered, -> { order(created_at: :desc) }
|
||||||
|
|
||||||
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
|
enum :status, {
|
||||||
|
pending: "pending",
|
||||||
|
complete: "complete",
|
||||||
|
importing: "importing",
|
||||||
|
reverting: "reverting",
|
||||||
|
revert_failed: "revert_failed",
|
||||||
|
failed: "failed"
|
||||||
|
}, validate: true, default: "pending"
|
||||||
|
|
||||||
validates :type, inclusion: { in: TYPES }
|
validates :type, inclusion: { in: TYPES }
|
||||||
validates :col_sep, inclusion: { in: [ ",", ";" ] }
|
validates :col_sep, inclusion: { in: [ ",", ";" ] }
|
||||||
|
@ -35,6 +42,27 @@ class Import < ApplicationRecord
|
||||||
update! status: :failed, error: error.message
|
update! status: :failed, error: error.message
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def revert_later
|
||||||
|
raise "Import is not revertable" unless revertable?
|
||||||
|
|
||||||
|
update! status: :reverting
|
||||||
|
|
||||||
|
RevertImportJob.perform_later(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
def revert
|
||||||
|
Import.transaction do
|
||||||
|
accounts.destroy_all
|
||||||
|
entries.destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
family.sync
|
||||||
|
|
||||||
|
update! status: :pending
|
||||||
|
rescue => error
|
||||||
|
update! status: :revert_failed, error: error.message
|
||||||
|
end
|
||||||
|
|
||||||
def csv_rows
|
def csv_rows
|
||||||
@csv_rows ||= parsed_csv
|
@csv_rows ||= parsed_csv
|
||||||
end
|
end
|
||||||
|
@ -113,6 +141,10 @@ class Import < ApplicationRecord
|
||||||
cleaned? && mappings.all?(&:valid?)
|
cleaned? && mappings.all?(&:valid?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def revertable?
|
||||||
|
complete? || revert_failed?
|
||||||
|
end
|
||||||
|
|
||||||
def has_unassigned_account?
|
def has_unassigned_account?
|
||||||
mappings.accounts.where(key: "").any?
|
mappings.accounts.where(key: "").any?
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
<%= form.select :entity_type_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Entity Type" } %>
|
<%= form.select :entity_type_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Entity Type" } %>
|
||||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||||
<%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" } %>
|
<%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" } %>
|
||||||
|
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %>
|
||||||
|
|
||||||
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -16,6 +16,8 @@
|
||||||
<%= form.select :signage_convention, [["Incomes are negative", "inflows_negative"], ["Incomes are positive", "inflows_positive"]], { label: true }, disabled: import.complete? %>
|
<%= form.select :signage_convention, [["Incomes are negative", "inflows_negative"], ["Incomes are positive", "inflows_positive"]], { label: true }, disabled: import.complete? %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" }, disabled: import.complete? %>
|
||||||
|
|
||||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" }, disabled: import.complete? %>
|
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" }, disabled: import.complete? %>
|
||||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" }, disabled: import.complete? %>
|
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" }, disabled: import.complete? %>
|
||||||
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" }, disabled: import.complete? %>
|
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" }, disabled: import.complete? %>
|
||||||
|
|
|
@ -11,6 +11,8 @@
|
||||||
<%= form.select :signage_convention, [["Buys are positive qty", "inflows_positive"], ["Buys are negative qty", "inflows_negative"]], label: true %>
|
<%= form.select :signage_convention, [["Buys are positive qty", "inflows_positive"], ["Buys are negative qty", "inflows_negative"]], label: true %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %>
|
||||||
|
|
||||||
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %>
|
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %>
|
||||||
<%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %>
|
<%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %>
|
||||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
||||||
|
|
|
@ -11,6 +11,8 @@
|
||||||
<%= form.select :signage_convention, [["Incomes are positive", "inflows_positive"], ["Incomes are negative", "inflows_negative"]], label: true %>
|
<%= form.select :signage_convention, [["Incomes are positive", "inflows_positive"], ["Incomes are negative", "inflows_negative"]], label: true %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %>
|
||||||
|
|
||||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
||||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||||
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" } %>
|
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" } %>
|
||||||
|
|
|
@ -17,6 +17,14 @@
|
||||||
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
|
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
|
||||||
<%= t(".failed") %>
|
<%= t(".failed") %>
|
||||||
</span>
|
</span>
|
||||||
|
<% elsif import.reverting? %>
|
||||||
|
<span class="px-1 py text-xs rounded-full bg-orange-500/5 text-orange-500 border border-alpha-black-50">
|
||||||
|
<%= t(".reverting") %>
|
||||||
|
</span>
|
||||||
|
<% elsif import.revert_failed? %>
|
||||||
|
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
|
||||||
|
<%= t(".revert_failed") %>
|
||||||
|
</span>
|
||||||
<% elsif import.complete? %>
|
<% elsif import.complete? %>
|
||||||
<span class="px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50">
|
<span class="px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50">
|
||||||
<%= t(".complete") %>
|
<%= t(".complete") %>
|
||||||
|
@ -33,7 +41,16 @@
|
||||||
<span><%= t(".view") %></span>
|
<span><%= t(".view") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% unless import.complete? %>
|
<% if import.complete? || import.revert_failed? %>
|
||||||
|
<%= button_to revert_import_path(import),
|
||||||
|
method: :put,
|
||||||
|
class: "block w-full py-2 px-3 space-x-2 text-orange-600 hover:bg-orange-50 flex items-center rounded-lg",
|
||||||
|
data: { turbo_confirm: true } do %>
|
||||||
|
<%= lucide_icon "rotate-ccw", class: "w-5 h-5" %>
|
||||||
|
|
||||||
|
<span>Revert</span>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
<%= button_to import_path(import),
|
<%= button_to import_path(import),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
|
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
|
||||||
|
|
18
app/views/imports/_revert_failure.html.erb
Normal file
18
app/views/imports/_revert_failure.html.erb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<%# locals: (import:) %>
|
||||||
|
|
||||||
|
<div class="h-full flex flex-col justify-center items-center">
|
||||||
|
<div class="space-y-6 max-w-sm">
|
||||||
|
<div class="mx-auto bg-red-500/5 h-8 w-8 rounded-full flex items-center justify-center">
|
||||||
|
<%= lucide_icon "alert-octagon", class: "w-5 h-5 text-red-500" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<h1 class="font-medium text-gray-900 text-center text-3xl">Reverting import failed</h1>
|
||||||
|
<p class="text-sm text-gray-500">Please try again or contact support.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= button_to "Try again", revert_import_path(import), class: "btn btn--primary text-center w-full" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -10,6 +10,8 @@
|
||||||
<%= render "imports/success", import: @import %>
|
<%= render "imports/success", import: @import %>
|
||||||
<% elsif @import.failed? %>
|
<% elsif @import.failed? %>
|
||||||
<%= render "imports/failure", import: @import %>
|
<%= render "imports/failure", import: @import %>
|
||||||
|
<% elsif @import.revert_failed? %>
|
||||||
|
<%= render "imports/revert_failure", import: @import %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= render "imports/ready", import: @import %>
|
<%= render "imports/ready", import: @import %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -63,6 +63,8 @@ en:
|
||||||
failed: Failed
|
failed: Failed
|
||||||
in_progress: In progress
|
in_progress: In progress
|
||||||
label: "%{type}: %{datetime}"
|
label: "%{type}: %{datetime}"
|
||||||
|
reverting: Reverting
|
||||||
|
revert_failed: Revert failed
|
||||||
uploading: Processing rows
|
uploading: Processing rows
|
||||||
view: View
|
view: View
|
||||||
index:
|
index:
|
||||||
|
|
|
@ -65,6 +65,7 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
resources :imports, only: %i[index new show create destroy] do
|
resources :imports, only: %i[index new show create destroy] do
|
||||||
post :publish, on: :member
|
post :publish, on: :member
|
||||||
|
put :revert, on: :member
|
||||||
|
|
||||||
resource :upload, only: %i[show update], module: :import
|
resource :upload, only: %i[show update], module: :import
|
||||||
resource :configuration, only: %i[show update], module: :import
|
resource :configuration, only: %i[show update], module: :import
|
||||||
|
|
21
db/migrate/20250206003115_remove_import_status_enum.rb
Normal file
21
db/migrate/20250206003115_remove_import_status_enum.rb
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
class RemoveImportStatusEnum < ActiveRecord::Migration[7.2]
|
||||||
|
def up
|
||||||
|
change_column_default :imports, :status, nil
|
||||||
|
change_column :imports, :status, :string
|
||||||
|
execute "DROP TYPE IF EXISTS import_status"
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
execute <<-SQL
|
||||||
|
CREATE TYPE import_status AS ENUM (
|
||||||
|
'pending',
|
||||||
|
'importing',
|
||||||
|
'complete',
|
||||||
|
'failed'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
|
||||||
|
change_column :imports, :status, :import_status, using: 'status::import_status'
|
||||||
|
change_column_default :imports, :status, 'pending'
|
||||||
|
end
|
||||||
|
end
|
3
db/schema.rb
generated
3
db/schema.rb
generated
|
@ -18,7 +18,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_06_204404) do
|
||||||
# Custom types defined in this database.
|
# Custom types defined in this database.
|
||||||
# Note that some types may not work with other database engines. Be careful if changing database.
|
# Note that some types may not work with other database engines. Be careful if changing database.
|
||||||
create_enum "account_status", ["ok", "syncing", "error"]
|
create_enum "account_status", ["ok", "syncing", "error"]
|
||||||
create_enum "import_status", ["pending", "importing", "complete", "failed"]
|
|
||||||
|
|
||||||
create_table "account_balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "account_balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "account_id", null: false
|
t.uuid "account_id", null: false
|
||||||
|
@ -391,7 +390,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_06_204404) do
|
||||||
|
|
||||||
create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.jsonb "column_mappings"
|
t.jsonb "column_mappings"
|
||||||
t.enum "status", default: "pending", enum_type: "import_status"
|
t.string "status"
|
||||||
t.string "raw_file_str"
|
t.string "raw_file_str"
|
||||||
t.string "normalized_csv_str"
|
t.string "normalized_csv_str"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
|
1
test/fixtures/imports.yml
vendored
1
test/fixtures/imports.yml
vendored
|
@ -1,3 +1,4 @@
|
||||||
transaction:
|
transaction:
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
type: TransactionImport
|
type: TransactionImport
|
||||||
|
status: pending
|
||||||
|
|
7
test/jobs/revert_import_job_test.rb
Normal file
7
test/jobs/revert_import_job_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class RevertImportJobTest < ActiveJob::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
Loading…
Add table
Add a link
Reference in a new issue