1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +02:00

CSV Transaction Imports (#708)

Introduces a basic CSV import module for bulk-importing account transactions.

Changes include:

- User can load a CSV
- User can configure the column mappings for a CSV
- Imported CSV shows invalid cells
- User can clean up their data directly in the UI
- User can see a preview of the import rows and confirm import
- Layout refactor + Import nav stepper
- System test stability improvements
This commit is contained in:
Zach Gollwitzer 2024-05-17 09:09:32 -04:00 committed by GitHub
parent 3d9ff3ad2a
commit 45ae4a9737
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 1657 additions and 117 deletions

View file

@ -46,6 +46,7 @@ gem "octokit"
gem "pagy"
gem "rails-settings-cached"
gem "tzinfo-data", platforms: %i[ windows jruby ]
gem "csv"
group :development, :test do
gem "debug", platforms: %i[ mri windows ]

View file

@ -156,6 +156,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
csv (3.2.8)
date (3.3.4)
debug (1.9.2)
irb (~> 1.10)
@ -456,6 +457,7 @@ DEPENDENCIES
brakeman
capybara
climate_control
csv
debug
dotenv-rails
erb_lint

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View file

@ -1,4 +1,6 @@
class AccountsController < ApplicationController
layout "with_sidebar"
include Filterable
before_action :set_account, only: %i[ show update destroy sync ]
@ -48,7 +50,7 @@ class AccountsController < ApplicationController
end
end
else
render "edit", status: :unprocessable_entity
render "show", status: :unprocessable_entity
end
end

View file

@ -0,0 +1,102 @@
require "ostruct"
class ImportsController < ApplicationController
before_action :set_import, except: %i[ index new create ]
def index
@imports = Current.family.imports
render layout: "with_sidebar"
end
def new
@import = Import.new
end
def edit
end
def update
account = Current.family.accounts.find(params[:import][:account_id])
@import.update! account: account
redirect_to load_import_path(@import), notice: t(".import_updated")
end
def create
account = Current.family.accounts.find(params[:import][:account_id])
@import = Import.create!(account: account)
redirect_to load_import_path(@import), notice: t(".import_created")
end
def destroy
@import.destroy!
redirect_to imports_url, notice: t(".import_destroyed"), status: :see_other
end
def load
end
def load_csv
if @import.update(import_params)
redirect_to configure_import_path(@import), notice: t(".import_loaded")
else
flash.now[:error] = @import.errors.full_messages.to_sentence
render :load, status: :unprocessable_entity
end
end
def configure
unless @import.loaded?
redirect_to load_import_path(@import), alert: t(".invalid_csv")
end
end
def update_mappings
@import.update! import_params(@import.expected_fields.map(&:key))
redirect_to clean_import_path(@import), notice: t(".column_mappings_saved")
end
def clean
unless @import.loaded?
redirect_to load_import_path(@import), alert: t(".invalid_csv")
end
end
def update_csv
update_params = import_params[:csv_update]
@import.update_csv! \
row_idx: update_params[:row_idx],
col_idx: update_params[:col_idx],
value: update_params[:value]
render :clean
end
def confirm
unless @import.cleaned?
redirect_to clean_import_path(@import), alert: t(".invalid_data")
end
end
def publish
if @import.valid?
@import.publish_later
redirect_to imports_path, notice: t(".import_published")
else
flash.now[:error] = t(".invalid_data")
render :confirm, status: :unprocessable_entity
end
end
private
def set_import
@import = Current.family.imports.find(params[:id])
end
def import_params(permitted_mappings = nil)
params.require(:import).permit(:raw_csv_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
end
end

View file

@ -1,4 +1,6 @@
class PagesController < ApplicationController
layout "with_sidebar"
include Filterable
def dashboard

View file

@ -1,4 +1,4 @@
class Settings::BillingsController < ApplicationController
class Settings::BillingsController < SettingsController
def edit
end

View file

@ -1,4 +1,4 @@
class Settings::HostingsController < ApplicationController
class Settings::HostingsController < SettingsController
before_action :verify_hosting_mode
def show

View file

@ -1,4 +1,4 @@
class Settings::NotificationsController < ApplicationController
class Settings::NotificationsController < SettingsController
def edit
end

View file

@ -1,4 +1,4 @@
class Settings::PreferencesController < ApplicationController
class Settings::PreferencesController < SettingsController
def edit
end
@ -14,11 +14,12 @@ class Settings::PreferencesController < ApplicationController
redirect_to settings_preferences_path, notice: t(".success")
else
redirect_to settings_preferences_path, notice: t(".success")
render :edit, status: :unprocessable_entity
render :show, status: :unprocessable_entity
end
end
private
def preference_params
params.require(:user).permit(family_attributes: [ :id, :currency ])
end

View file

@ -1,4 +1,4 @@
class Settings::ProfilesController < ApplicationController
class Settings::ProfilesController < SettingsController
def show
end

View file

@ -1,4 +1,4 @@
class Settings::SecuritiesController < ApplicationController
class Settings::SecuritiesController < SettingsController
def edit
end

View file

@ -0,0 +1,3 @@
class SettingsController < ApplicationController
layout "with_sidebar"
end

View file

@ -1,4 +1,6 @@
class Transactions::Categories::DeletionsController < ApplicationController
layout "with_sidebar"
before_action :set_category
before_action :set_replacement_category, only: :create

View file

@ -1,4 +1,6 @@
class Transactions::CategoriesController < ApplicationController
layout "with_sidebar"
before_action :set_category, only: %i[ edit update ]
before_action :set_transaction, only: :create

View file

@ -1,4 +1,6 @@
class Transactions::MerchantsController < ApplicationController
layout "with_sidebar"
before_action :set_merchant, only: %i[ edit update destroy ]
def index

View file

@ -1,4 +1,6 @@
class Transactions::RulesController < ApplicationController
layout "with_sidebar"
def index
end
end

View file

@ -1,4 +1,6 @@
class TransactionsController < ApplicationController
layout "with_sidebar"
before_action :set_transaction, only: %i[ show edit update destroy ]
def index

View file

@ -51,6 +51,11 @@ module ApplicationHelper
end
end
def return_to_path(params, fallback = root_path)
uri = URI.parse(params[:return_to] || fallback)
uri.relative? ? uri.path : root_path
end
def trend_styles(trend)
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
return fallback if trend.nil? || trend.direction.flat?

View file

@ -0,0 +1,19 @@
module ImportsHelper
def table_corner_class(row_idx, col_idx, rows, cols)
return "rounded-tl-xl" if row_idx == 0 && col_idx == 0
return "rounded-tr-xl" if row_idx == 0 && col_idx == cols.size - 1
return "rounded-bl-xl" if row_idx == rows.size - 1 && col_idx == 0
return "rounded-br-xl" if row_idx == rows.size - 1 && col_idx == cols.size - 1
""
end
def nav_steps(import = Import.new)
[
{ name: "Select", complete: import.persisted?, path: import.persisted? ? edit_import_path(import) : new_import_path },
{ name: "Import", complete: import.loaded?, path: import.persisted? ? load_import_path(import) : nil },
{ name: "Setup", complete: import.configured?, path: import.persisted? ? configure_import_path(import) : nil },
{ name: "Clean", complete: import.cleaned?, path: import.persisted? ? clean_import_path(import) : nil },
{ name: "Confirm", complete: import.complete?, path: import.persisted? ? confirm_import_path(import) : nil }
]
end
end

7
app/jobs/import_job.rb Normal file
View file

@ -0,0 +1,7 @@
class ImportJob < ApplicationJob
queue_as :default
def perform(import)
import.publish
end
end

View file

@ -9,6 +9,7 @@ class Account < ApplicationRecord
has_many :balances, dependent: :destroy
has_many :valuations, dependent: :destroy
has_many :transactions, dependent: :destroy
has_many :imports, dependent: :destroy
monetize :balance

View file

@ -2,6 +2,7 @@ class Family < ApplicationRecord
has_many :users, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :transactions, through: :accounts
has_many :imports, through: :accounts
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
has_many :transaction_merchants, dependent: :destroy, class_name: "Transaction::Merchant"

161
app/models/import.rb Normal file
View file

@ -0,0 +1,161 @@
class Import < ApplicationRecord
belongs_to :account
validate :raw_csv_must_be_parsable
before_save :initialize_csv, if: :should_initialize_csv?
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
store_accessor :column_mappings, :define_column_mapping_keys
scope :ordered, -> { order(created_at: :desc) }
def publish_later
ImportJob.perform_later(self)
end
def loaded?
raw_csv_str.present?
end
def configured?
csv.present?
end
def cleaned?
loaded? && configured? && csv.valid?
end
def csv
get_normalized_csv_with_validation
end
def available_headers
get_raw_csv.table.headers
end
def get_selected_header_for_field(field)
column_mappings&.dig(field) || field.key
end
def update_csv!(row_idx:, col_idx:, value:)
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
update! normalized_csv_str: updated_csv.to_s
end
# Type-specific methods (potential STI inheritance in future when more import types added)
def publish
update!(status: "importing")
transaction do
generate_transactions.each do |txn|
txn.save!
end
end
update!(status: "complete")
rescue => e
update!(status: "failed")
Rails.logger.error("Import with id #{id} failed: #{e}")
end
def dry_run
generate_transactions
end
def expected_fields
@expected_fields ||= create_expected_fields
end
private
def get_normalized_csv_with_validation
return nil if normalized_csv_str.nil?
csv = Import::Csv.new(normalized_csv_str)
expected_fields.each do |field|
csv.define_validator(field.key, field.validator) if field.validator
end
csv
end
def get_raw_csv
return nil if raw_csv_str.nil?
Import::Csv.new(raw_csv_str)
end
def should_initialize_csv?
raw_csv_str_changed? || column_mappings_changed?
end
def initialize_csv
generated_csv = generate_normalized_csv(raw_csv_str)
self.normalized_csv_str = generated_csv.table.to_s
end
# Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import
def generate_normalized_csv(csv_str)
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings)
end
def update_csv(row_idx, col_idx, value)
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
update! normalized_csv_str: updated_csv.to_s
end
def generate_transactions
transactions = []
csv.table.each do |row|
category = account.family.transaction_categories.find_or_initialize_by(name: row["category"])
txn = account.transactions.build \
name: row["name"] || "Imported transaction",
date: Date.iso8601(row["date"]),
category: category,
amount: BigDecimal(row["amount"]) * -1 # User inputs amounts with opposite signage of our internal representation
transactions << txn
end
transactions
end
def create_expected_fields
date_field = Import::Field.new \
key: "date",
label: "Date",
validator: ->(value) { Import::Field.iso_date_validator(value) }
name_field = Import::Field.new \
key: "name",
label: "Name"
category_field = Import::Field.new \
key: "category",
label: "Category"
amount_field = Import::Field.new \
key: "amount",
label: "Amount",
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
[ date_field, name_field, category_field, amount_field ]
end
def define_column_mapping_keys
expected_fields.each do |field|
field.key.to_sym
end
end
def raw_csv_must_be_parsable
begin
CSV.parse(raw_csv_str || "")
rescue CSV::MalformedCSVError
errors.add(:raw_csv_str, "is not a valid CSV format")
end
end
end

74
app/models/import/csv.rb Normal file
View file

@ -0,0 +1,74 @@
class Import::Csv
def self.parse_csv(csv_str)
CSV.parse((csv_str || "").strip, headers: true, converters: [ ->(str) { str.strip } ])
end
def self.create_with_field_mappings(raw_csv_str, fields, field_mappings)
raw_csv = self.parse_csv(raw_csv_str)
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true do |csv|
raw_csv.each do |row|
row_values = []
fields.each do |field|
# Finds the column header name the user has designated for the expected field
mapped_field_key = field_mappings[field.key] if field_mappings
mapped_header = mapped_field_key || field.key
row_values << row.fetch(mapped_header, "")
end
csv << row_values
end
end
new(generated_csv_str)
end
attr_reader :csv_str
def initialize(csv_str, column_validators: nil)
@csv_str = csv_str
@column_validators = column_validators || {}
end
def table
@table ||= self.class.parse_csv(csv_str)
end
def update_cell(row_idx, col_idx, value)
copy = table.by_col_or_row
copy[row_idx][col_idx] = value
copy
end
def valid?
table.each_with_index.all? do |row, row_idx|
row.each_with_index.all? do |cell, col_idx|
cell_valid?(row_idx, col_idx)
end
end
end
def cell_valid?(row_idx, col_idx)
value = table.dig(row_idx, col_idx)
header = table.headers[col_idx]
validator = get_validator_by_header(header)
validator.call(value)
end
def define_validator(header_key, validator = nil, &block)
header = table.headers.find { |h| h.strip == header_key }
raise "Cannot define validator for header #{header_key}: header does not exist in CSV" if header.nil?
column_validators[header] = validator || block
end
private
attr_accessor :column_validators
def get_validator_by_header(header)
column_validators&.dig(header) || ->(_v) { true }
end
end

View file

@ -0,0 +1,32 @@
class Import::Field
def self.iso_date_validator(value)
Date.iso8601(value)
true
rescue
false
end
def self.bigdecimal_validator(value)
BigDecimal(value)
true
rescue
false
end
attr_reader :key, :label, :validator
def initialize(key:, label:, validator: nil)
@key = key.to_s
@label = label
@validator = validator
end
def define_validator(validator = nil, &block)
@validator = validator || block
end
def validate(value)
return true if validator.nil?
validator.call(value)
end
end

View file

@ -0,0 +1,9 @@
<div class="flex justify-center items-center py-20">
<div class="text-center flex flex-col items-center max-w-[300px]">
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".message") %></p>
<%= link_to new_import_path(enable_type_selector: true), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new") %></span>
<% end %>
</div>
</div>

View file

@ -0,0 +1,7 @@
<%= form_with model: @import do |form| %>
<div class="mb-4">
<%= form.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".account"), required: true } %>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium" %>
<% end %>

View file

@ -0,0 +1,61 @@
<div id="<%= dom_id import %>" class="flex items-center justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
<div>
<div class="flex items-center gap-1 mb-1">
<p class="text-sm text-gray-900">
<%= t(".label", account: import.account.name) %>
</p>
<% if import.pending? %>
<span class="px-1 py text-xs rounded-full bg-gray-500/5 text-gray-500 border border-alpha-black-50">
<%= t(".in_progress") %>
</span>
<% elsif import.importing? %>
<span class="px-1 py text-xs animate-pulse rounded-full bg-orange-500/5 text-orange-500 border border-alpha-black-50">
<%= t(".uploading") %>
</span>
<% elsif import.failed? %>
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
<%= t(".failed") %>
</span>
<% elsif import.complete? %>
<span class="px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50">
<%= t(".complete") %>
</span>
<% end %>
</div>
<% if import.complete? %>
<p class="text-xs text-gray-500"><%= t(".completed_on", datetime: import.updated_at.strftime("%Y-%m-%d")) %></p>
<% else %>
<p class="text-xs text-gray-500"><%= t(".started_on", datetime: import.created_at.strftime("%Y-%m-%d")) %></p>
<% end %>
</div>
<% if import.complete? %>
<div class="w-7 h-7 bg-green-500/5 flex items-center justify-center rounded-full">
<%= lucide_icon("check", class: "text-green-500 w-4 h-4") %>
</div>
<% else %>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to edit_import_path(import),
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".edit") %></span>
<% end %>
<%= button_to import_path(import),
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",
data: { turbo_confirm: true } do %>
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
<span><%= t(".delete") %></span>
<% end %>
</div>
<% end %>
<% end %>
</div>

View file

@ -0,0 +1,18 @@
<% is_current = request.path == step[:path] %>
<% text_class = if is_current
"text-gray-900"
else
step[:complete] ? "text-green-600" : "text-gray-500"
end %>
<% step_class = if is_current
"bg-gray-900 text-white"
else
step[:complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
end %>
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
<%= step[:complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : step_idx + 1 %>
</span>
<span><%= step[:name] %></span>
</div>

View file

@ -0,0 +1,22 @@
<!--TODO: Once we have more styled tables for reference, refactor and DRY this up -->
<div class="grid grid-cols-4 border border-alpha-black-200 rounded-md shadow-xs text-sm bg-white">
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tl-md">Date</div>
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">Name</div>
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">Category</div>
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tr-md">Amount</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-01-01</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Amazon</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Shopping</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-24.99</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-03-01</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Spotify</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200"></div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-16.32</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-bl-md">2023-01-06</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Acme</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Income</div>
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-br-md">151.22</div>
</div>

View file

@ -0,0 +1,90 @@
<div class="p-4 space-y-4 max-w-[420px]">
<div class="space-y-2">
<div class="flex justify-between items-center">
<h2 class="font-medium text-gray-900"><%= t(".import_transactions") %></h2>
<button data-action="modal#close">
<%= lucide_icon("x", class: "w-5 h-5 text-gray-900") %>
</button>
</div>
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
</div>
<div class="rounded-xl bg-gray-25 p-1">
<h3 class="uppercase text-gray-500 text-xs font-medium px-3 py-1.5"><%= t(".sources") %></h3>
<ul class="bg-white border border-alpha-black-25 rounded-lg shadow-xs">
<li>
<% if Current.family.imports.pending.present? %>
<%= link_to edit_import_path(Current.family.imports.pending.ordered.first), class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
</div>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".resume_latest_import") %>
</span>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
<% end %>
<div class="pl-14 pr-3">
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<% end %>
<li>
<%= link_to new_import_path, class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
<div class="bg-indigo-500/5 rounded-md w-8 h-8 flex items-center justify-center">
<%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %>
</div>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".import_from_csv") %>
</span>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
<% end %>
<div class="pl-14 pr-3">
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<li>
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
<%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".import_from_mint") %>
</span>
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
</div>
<div class="pl-14 pr-3">
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<li>
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
<%= image_tag("empower-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 border border-alpha-black-100 rounded-md") %>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".import_from_empower") %>
</span>
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
</div>
<div class="pl-14 pr-3">
<div class="h-px bg-alpha-black-50"></div>
</div>
</li>
<li>
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
<%= image_tag("apple-logo.png", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
<span class="text-sm text-gray-900 group-hover:text-gray-700">
<%= t(".import_from_apple") %>
</span>
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
</div>
</li>
</ul>
</div>
</div>

View file

@ -0,0 +1,49 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<div class="mx-auto max-w-screen-md w-full py-24">
<h1 class="sr-only"><%= t(".clean_import") %></h1>
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-8">
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".clean_and_edit") %></h2>
<p class="text-gray-500 text-sm"><%= t(".clean_description") %></p>
</div>
<div class="bg-gray-25 rounded-xl p-1 mb-6">
<div
class="grid items-center uppercase text-xs font-medium text-gray-500 py-3"
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
<% @import.expected_fields.each do |field| %>
<div class="px-5"><%= field.label %></div>
<% end %>
</div>
<div class="bg-white border border-alpha-black-200 rounded-xl shadow-xs divide-y divide-alpha-black-200">
<% @import.csv.table.each_with_index do |row, row_index| %>
<div
class="grid divide-x divide-alpha-black-200"
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
<% row.fields.each_with_index do |value, col_index| %>
<%= form_with model: @import,
builder: ActionView::Helpers::FormBuilder,
url: clean_import_url(@import),
method: :patch,
data: { turbo: false, controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
<%= form.fields_for :csv_update do |ff| %>
<%= ff.hidden_field :row_idx, value: row_index %>
<%= ff.hidden_field :col_idx, value: col_index %>
<%= ff.text_field :value, value: value,
id: "cell-#{row_index}-#{col_index}",
class: "#{@import.csv.cell_valid?(row_index, col_index) ? "focus:border-transparent border-transparent" : "border-red-500"} border px-4 py-3.5 text-sm w-full bg-transparent focus:ring-gray-900 focus:ring-2 focus-visible:outline-none #{table_corner_class(row_index, col_index, @import.csv.table, row.fields)}",
data: { "auto-submit-form-target" => "auto" } %>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
</div>
</div>
<% if @import.csv.valid? %>
<%= link_to "Next", confirm_import_path(@import), class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
<% end %>
</div>

View file

@ -0,0 +1,24 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<div class="mx-auto max-w-[400px] w-full py-24 space-y-4">
<h1 class="sr-only"><%= t(".configure_title") %></h1>
<div class="text-center space-y-2">
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".configure_subtitle") %></h2>
<p class="text-gray-500 text-sm"><%= t(".configure_description") %></p>
</div>
<%= form_with model: @import, url: configure_import_path(@import) do |form| %>
<div class="mb-4 space-y-4">
<%= form.fields_for :column_mappings do |mappings| %>
<% @import.expected_fields.each do |field| %>
<%= mappings.select field.key,
options_for_select(@import.available_headers, @import.get_selected_header_for_field(field)),
label: field.label %>
<% end %>
<% end %>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.column_mappings? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<% end %>
</div>

View file

@ -0,0 +1,16 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<div class="mx-auto max-w-screen-md w-full py-24">
<h1 class="sr-only"><%= t(".confirm_title") %></h1>
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-8">
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".confirm_subtitle") %></h2>
<p class="text-gray-500 text-sm"><%= t(".confirm_description") %></p>
</div>
<div class="mb-8 space-y-4">
<%= render partial: "imports/transactions/transaction_group", collection: @import.dry_run.group_by(&:date) %>
</div>
<%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
</div>

View file

@ -0,0 +1,10 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<div class="mx-auto max-w-[400px] w-full py-56">
<h1 class="sr-only"><%= t(".edit_title") %></h1>
<div class="space-y-2 mb-6 text-center">
<p class="text-3xl font-medium text-gray-900"><%= t(".header_text") %></p>
<p class="text-gray-500 text-sm"><%= t(".description_text") %></p>
</div>
<%= render "form", import: @import %>
</div>

View file

@ -0,0 +1,30 @@
<% content_for :sidebar do %>
<%= render "settings/nav" %>
<% end %>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-medium text-gray-900"><%= t(".title") %></h1>
<%= link_to new_import_path(enable_type_selector: true), class: "flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new") %></span>
<% end %>
</div>
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
<% if @imports.empty? %>
<%= render partial: "imports/empty" %>
<% else %>
<div class="rounded-xl bg-gray-25 p-1">
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
<%= render @imports.ordered %>
</div>
</div>
<% end %>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Rules", transaction_rules_path) %>
<%= next_setting("What's new", changelog_path) %>
</div>
</div>

View file

@ -0,0 +1,39 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<div class="mx-auto max-w-[450px] w-full py-24 space-y-4">
<h1 class="sr-only"><%= t(".load_title") %></h1>
<div class="text-center space-y-2">
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".subtitle") %></h2>
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
</div>
<%= form_with model: @import, url: load_import_path(@import) do |form| %>
<div>
<%= form.text_area :raw_csv_str,
rows: 10,
required: true,
placeholder: "Paste your CSV file contents here",
class: "rounded-md w-full border text-sm border-alpha-black-100 bg-white placeholder:text-gray-400 p-4" %>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<% end %>
<div class="bg-alpha-black-25 rounded-xl p-1">
<div class="text-gray-500 p-2 mb-2">
<div class="flex gap-2 mb-2">
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
<p class="text-sm"><%= t(".instructions") %></p>
</div>
<ul class="list-disc text-sm pl-10">
<li><%= t(".requirement1") %></li>
<li><%= t(".requirement2") %></li>
</ul>
</div>
<%= render partial: "imports/sample_table" %>
</div>
</div>

View file

@ -0,0 +1,16 @@
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
<% if params[:enable_type_selector].present? %>
<%= modal do %>
<%= render "type_selector" %>
<% end %>
<% end %>
<div class="mx-auto max-w-[400px] w-full py-56">
<h1 class="sr-only">New import</h1>
<div class="space-y-2 mb-6 text-center">
<p class="text-3xl font-medium text-gray-900"><%= t(".header_text") %></p>
<p class="text-gray-500 text-sm"><%= t(".description_text") %></p>
</div>
<%= render "form", import: @import %>
</div>

View file

@ -0,0 +1,15 @@
<div class="mx-auto md:w-2/3 w-full flex">
<div class="mx-auto">
<% if notice.present? %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<%= render @import %>
<%= link_to "Edit this import", edit_import_path(@import), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<div class="inline-block ml-2">
<%= button_to "Destroy this import", import_path(@import), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
</div>
<%= link_to "Back to imports", imports_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>
</div>

View file

@ -0,0 +1,12 @@
<%# locals: (transaction:) %>
<div class="text-gray-900 flex items-center gap-6 py-4 text-sm font-medium px-4">
<%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
<div class="w-48">
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
</div>
<div class="ml-auto">
<%= content_tag :p, format_money(-transaction.amount), class: ["whitespace-nowrap", BigDecimal(transaction.amount).negative? ? "text-green-600" : "text-red-600"] %>
</div>
</div>

View file

@ -0,0 +1,13 @@
<%# locals: (transaction_group:) %>
<% date = transaction_group[0] %>
<% transactions = transaction_group[1] %>
<div class="bg-gray-25 rounded-xl p-1 w-full">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<h4><%= date.strftime("%b %d, %Y") %> &middot; <%= transactions.size %></h4>
<span><%= format_money -transactions.sum { |t| t.amount } %></span>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= render partial: "imports/transactions/transaction", collection: transactions %>
</div>
</div>

View file

@ -1,52 +1,39 @@
<!DOCTYPE html>
<html class="h-full bg-gray-25">
<html class="h-full">
<head>
<title><%= content_for(:title) || "Maybe" %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Maybe">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#ffffff">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="theme-color" content="#ffffff">
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= yield :head %>
</head>
<body class="h-full">
<div id="notification-tray" class="fixed z-50 space-y-1 top-6 right-6"></div>
<%= safe_join(flash.map { |type, message| notification(message, type: type) }) %>
<div class="flex h-full">
<div class="p-6 pb-20 w-[368px] shrink-0 h-full overflow-y-auto">
<% if content_for?(:sidebar) %>
<%= yield :sidebar %>
<% else %>
<%= render "layouts/sidebar" %>
<% end %>
</div>
<main class="grow px-20 pt-6 pb-32 h-full overflow-y-auto">
<%= yield %>
</main>
</div>
<%= content_for?(:content) ? yield(:content) : yield %>
<%= turbo_frame_tag "modal" %>
<%= render "shared/confirm_modal" %>
<%= render "shared/upgrade_notification" %>
<% if self_hosted? %>
<div class="flex items-center py-0.5 px-0.5 gap-1 fixed bottom-2 right-2 shadow-xs border border-alpha-black-50 rounded-md bg-white">
<p class="text-xs text-gray-500 pl-2">Self-hosted Maybe: <%= Maybe.version.to_release_tag %></p>
<%= link_to settings_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("settings", class: "w-4 h-4 text-gray-500 shrink-0") %>
<% end %>
</div>
<%= render "shared/app_version" %>
<% end %>
</body>
</html>

View file

@ -1,34 +1,5 @@
<!DOCTYPE html>
<html class="h-full bg-white">
<head>
<title>Maybe</title>
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Maybe">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#ffffff">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="theme-color" content="#ffffff">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
</head>
<body class="h-full">
<%= content_for :content do %>
<div class="flex flex-col justify-center min-h-full px-6 py-12">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<%= render "shared/logo" %>
@ -45,7 +16,6 @@
<%= t(".or") %> <%= link_to t(".sign_in"), new_session_path, class: "font-medium text-gray-600 hover:text-gray-400 transition" %>
</p>
<% end %>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-lg">
@ -55,7 +25,7 @@
<div class="p-8 mt-2 text-center">
<p class="mt-6 text-sm text-black"><%= link_to t(".privacy_policy"), "/privacy", class: "font-medium text-gray-600 hover:text-gray-400 transition" %> &bull; <%= link_to t(".terms_of_service"), "/terms", class: "font-medium text-gray-600 hover:text-gray-400 transition" %></p>
</div>
</div>
</body>
</html>
<% end %>
<%= render template: "layouts/application" %>

View file

@ -0,0 +1,34 @@
<%= content_for :content do %>
<div class="flex items-center justify-between p-8">
<%= link_to root_path do %>
<%= image_tag "logo.svg", alt: "Maybe", class: "h-[22px]" %>
<% end %>
<nav>
<div>
<ul class="flex items-center gap-2">
<% nav_steps(@import).each_with_index do |step, idx| %>
<li class="group flex items-center gap-2">
<% if step[:path].present? %>
<%= link_to step[:path], class: "flex items-center gap-3" do %>
<%= render partial: "nav_step", locals: { step: step, step_idx: idx } %>
<% end %>
<% else %>
<%= render partial: "nav_step", locals: { step: step, step_idx: idx } %>
<% end %>
<% if idx < nav_steps.size %>
<div class="h-px bg-alpha-black-200 w-12 group-last:hidden"></div>
<% end %>
</li>
<% end %>
</ul>
</div>
</nav>
<%= link_to content_for(:return_to_path) do %>
<%= lucide_icon("x", class: "text-gray-500 w-5 h-5") %>
<% end %>
</div>
<%= yield %>
<% end %>
<%= render template: "layouts/application" %>

View file

@ -0,0 +1,18 @@
<%= content_for :content do %>
<div class="flex h-full bg-gray-25">
<div class="p-6 pb-20 w-[368px] shrink-0 h-full overflow-y-auto">
<% if content_for?(:sidebar) %>
<%= yield :sidebar %>
<% else %>
<%= render "layouts/sidebar" %>
<% end %>
</div>
<main class="grow px-20 pt-6 pb-32 h-full overflow-y-auto">
<%= yield %>
</main>
</div>
<%= render "shared/upgrade_notification" %>
<% end %>
<%= render template: "layouts/application" %>

View file

@ -9,7 +9,7 @@
</div>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Rules", transaction_rules_path) %>
<%= previous_setting("Imports", imports_path) %>
<%= next_setting("Feedback", feedback_path) %>
</div>
</div>

View file

@ -55,6 +55,9 @@
<li>
<%= sidebar_link_to t(".rules_label"), transaction_rules_path, icon: "list-checks" %>
</li>
<li>
<%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %>
</li>
</ul>
</section>
<section class="space-y-2">

View file

@ -0,0 +1,6 @@
<div class="flex items-center py-0.5 px-0.5 gap-1 fixed bottom-2 right-2 shadow-xs border border-alpha-black-50 rounded-md bg-white">
<p class="text-xs text-gray-500 pl-2">Version: <%= Maybe.version.to_release_tag %></p>
<%= link_to settings_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("settings", class: "w-4 h-4 text-gray-500 shrink-0") %>
<% end %>
</div>

View file

@ -10,12 +10,24 @@
<%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".edit_categories") %></span>
<% end %>
<%= link_to imports_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
<%= lucide_icon "hard-drive-upload", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".edit_imports") %></span>
<% end %>
</div>
<% end %>
<%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-50 border border-gray-200 flex items-center gap-1 justify-center px-3 py-2", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("download", class: "text-gray-500 w-4 h-4") %>
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
<% end %>
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p>New transaction</p>
<p class="text-sm font-medium">New transaction</p>
<% end %>
</div>
</div>

View file

@ -10,6 +10,6 @@
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Merchants", transaction_merchants_path) %>
<%= next_setting("What's New", changelog_path) %>
<%= next_setting("Imports", imports_path) %>
</div>
</div>

View file

@ -62,4 +62,6 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true
config.autoload_paths += %w[ test/support ]
end

View file

@ -0,0 +1,95 @@
---
en:
imports:
clean:
clean_and_edit: Clean and edit your data
clean_description: Edit your transactions in the table below. Click on any cell
to change the date, name, category, or amount.
clean_import: Clean import
invalid_csv: Please load a CSV first
configure:
configure_description: Select the columns that match the necessary data fields,
so that the columns in your CSV can be correctly mapped with our format.
configure_subtitle: Setup your CSV file
configure_title: Configure import
confirm_accept: Change mappings
confirm_body: Changing your mappings may erase any edits you have made to the
CSV so far.
confirm_title: Are you sure?
invalid_csv: Please load a CSV first
next: Next
confirm:
confirm_description: Preview your transactions below and check to see if there
are any changes that are required.
confirm_subtitle: Confirm your transactions
confirm_title: Confirm import
invalid_data: You have invalid data, please fix before continuing
create:
import_created: Import created
destroy:
import_destroyed: Import destroyed
edit:
description_text: Importing transactions can only be done for one account at
a time. You will need to go through this process again for other accounts.
edit_title: Edit import
header_text: Select the account your transactions will belong to
empty:
message: No imports to show
new: New Import
form:
account: Account
next: Next
select_account: Select account
import:
complete: Complete
completed_on: Completed on %{datetime}
delete: Delete
edit: Edit
failed: Failed
in_progress: In progress
label: 'Import for: %{account}'
started_on: Started on %{datetime}
uploading: Processing rows
index:
imports: Imports
new: New import
title: Imports
load:
confirm_accept: Yep, start over!
confirm_body: This will reset your import. Any changes you have made to the
CSV will be erased.
confirm_title: Are you sure?
description: Create a spreadsheet or upload an exported CSV from your financial
institution.
instructions: Your CSV should have the following columns and formats for the
best import experience.
load_title: Load import
next: Next
requirement1: Dates must be in ISO 8601 format (YYYY-MM-DD)
requirement2: Negative transaction is an "outflow" (expense), positive is an
"inflow" (income)
subtitle: Import your transactions
load_csv:
import_loaded: Import CSV loaded
new:
description_text: Importing transactions can only be done for one account at
a time. You will need to go through this process again for other accounts.
header_text: Select the account your transactions will belong to
publish:
import_published: Import has started in the background
invalid_data: Your import is invalid
type_selector:
description: You can manually import transactions from CSVs or from other financial
apps like Mint, Empower (formerly Personal Capital) or Apple Card.
import_from_apple: Import from Apple Card
import_from_csv: New import from CSV
import_from_empower: Import from Empower
import_from_mint: Import from Mint
import_transactions: Import transactions
resume_latest_import: Resume latest import
soon: Soon
sources: Sources
update:
import_updated: Import updated
update_mappings:
column_mappings_saved: Column mappings saved

View file

@ -55,6 +55,7 @@ en:
categories_label: Categories
feedback_label: Feedback
general_section_title: General
imports_label: Imports
invite_label: Invite friends
merchants_label: Merchants
notifications_label: Notifications

View file

@ -57,6 +57,8 @@ en:
transfer: Transfer
index:
edit_categories: Edit categories
edit_imports: Edit imports
import: Import
merchants:
create:
success: New merchant created successfully

View file

@ -21,8 +21,24 @@ Rails.application.routes.draw do
resource :security, only: %i[show update]
end
resources :imports, except: :show do
member do
get "load"
patch "load" => "imports#load_csv"
get "configure"
patch "configure" => "imports#update_mappings"
get "clean"
patch "clean" => "imports#update_csv"
get "confirm"
patch "confirm" => "imports#publish"
end
end
resources :transactions do
match "search" => "transactions#search", on: :collection, via: [ :get, :post ], as: :search
match "search" => "transactions#search", on: :collection, via: %i[ get post ], as: :search
collection do
scope module: :transactions do

View file

@ -0,0 +1,15 @@
class CreateImports < ActiveRecord::Migration[7.2]
def change
create_enum :import_status, %w[pending importing complete failed]
create_table :imports, id: :uuid do |t|
t.references :account, null: false, foreign_key: true, type: :uuid
t.jsonb :column_mappings
t.enum :status, enum_type: :import_status, default: "pending"
t.string :raw_csv_str
t.string :normalized_csv_str
t.timestamps
end
end
end

15
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
ActiveRecord::Schema[7.2].define(version: 2024_05_02_205006) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -18,6 +18,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
# Custom types defined in this 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 "import_status", ["pending", "importing", "complete", "failed"]
create_enum "user_role", ["admin", "member"]
create_table "account_balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -222,6 +223,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
end
create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.jsonb "column_mappings"
t.enum "status", default: "pending", enum_type: "import_status"
t.string "raw_csv_str"
t.string "normalized_csv_str"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_imports_on_account_id"
end
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "token", null: false
t.datetime "created_at", null: false
@ -305,6 +317,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
add_foreign_key "accounts", "families"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "imports", "accounts"
add_foreign_key "transaction_categories", "families"
add_foreign_key "transaction_merchants", "families"
add_foreign_key "transactions", "accounts", on_delete: :cascade

View file

@ -1,7 +1,7 @@
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ]
private
@ -10,12 +10,18 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
within "form" do
fill_in "Email", with: user.email
fill_in "Password", with: "password"
click_button "Log in"
click_on "Log in"
end
# Trigger Capybara's wait mechanism to avoid timing issues with logins
find("h1", text: "Dashboard")
end
def sign_out
find("#user-menu").click
click_button "Logout"
# Trigger Capybara's wait mechanism to avoid timing issues with logout
find("h2", text: "Sign in to your account")
end
end

View file

@ -0,0 +1,141 @@
require "test_helper"
class ImportsControllerTest < ActionDispatch::IntegrationTest
include ImportTestHelper
setup do
sign_in @user = users(:family_admin)
@empty_import = imports(:empty_import)
@loaded_import = imports(:loaded_import)
@completed_import = imports(:completed_import)
end
test "should get index" do
get imports_url
assert_response :success
@user.family.imports.ordered.each do |import|
assert_select "#" + dom_id(import), count: 1
end
end
test "should get new" do
get new_import_url
assert_response :success
end
test "should create import" do
assert_difference("Import.count") do
post imports_url, params: { import: { account_id: @user.family.accounts.first.id } }
end
assert_redirected_to load_import_path(Import.ordered.first)
end
test "should get edit" do
get edit_import_url(@empty_import)
assert_response :success
end
test "should update import" do
patch import_url(@empty_import), params: { import: { account_id: @empty_import.account_id } }
assert_redirected_to load_import_path(@empty_import)
end
test "should destroy import" do
assert_difference("Import.count", -1) do
delete import_url(@empty_import)
end
assert_redirected_to imports_url
end
test "should get load" do
get load_import_url(@empty_import)
assert_response :success
end
test "should save raw CSV if valid" do
patch load_import_url(@empty_import), params: { import: { raw_csv_str: valid_csv_str } }
assert_redirected_to configure_import_path(@empty_import)
assert_equal "Import CSV loaded", flash[:notice]
end
test "should flash error message if invalid CSV input" do
patch load_import_url(@empty_import), params: { import: { raw_csv_str: malformed_csv_str } }
assert_response :unprocessable_entity
assert_equal "Raw csv str is not a valid CSV format", flash[:error]
end
test "should get configure" do
get configure_import_url(@loaded_import)
assert_response :success
end
test "should redirect back to load step with an alert message if not loaded" do
get configure_import_url(@empty_import)
assert_equal "Please load a CSV first", flash[:alert]
assert_redirected_to load_import_path(@empty_import)
end
test "should update mappings" do
patch configure_import_url(@loaded_import), params: {
import: {
column_mappings: {
date: "date",
name: "name",
category: "category",
amount: "amount"
}
}
}
assert_redirected_to clean_import_path(@loaded_import)
assert_equal "Column mappings saved", flash[:notice]
end
test "can update a cell" do
assert_equal @loaded_import.csv.table[0][1], "Starbucks drink"
patch clean_import_url(@loaded_import), params: {
import: {
csv_update: {
row_idx: 0,
col_idx: 1,
value: "new_merchant"
}
}
}
assert_response :success
@loaded_import.reload
assert_equal "new_merchant", @loaded_import.csv.table[0][1]
end
test "should get clean" do
get clean_import_url(@loaded_import)
assert_response :success
end
test "should get confirm if all values are valid" do
get confirm_import_url(@loaded_import)
assert_response :success
end
test "should redirect back to clean if data is invalid" do
@empty_import.update! raw_csv_str: valid_csv_with_invalid_values
get confirm_import_url(@empty_import)
assert_equal "You have invalid data, please fix before continuing", flash[:alert]
assert_redirected_to clean_import_path(@empty_import)
end
test "should confirm import" do
patch confirm_import_url(@loaded_import)
assert_redirected_to imports_path
assert_equal "Import has started in the background", flash[:notice]
end
end

32
test/fixtures/imports.yml vendored Normal file
View file

@ -0,0 +1,32 @@
empty_import:
account: checking
created_at: <%= 1.minute.ago %>
loaded_import:
account: checking
raw_csv_str: |
date,name,category,amount
2024-01-01,Starbucks drink,Food,20
2024-01-02,Amazon stuff,Shopping,200
normalized_csv_str: |
date,name,category,amount
2024-01-01,Starbucks drink,Food,20
2024-01-02,Amazon stuff,Shopping,200
created_at: <%= 2.days.ago %>
completed_import:
account: checking
column_mappings:
date: date
name: name
category: category
amount: amount
raw_csv_str: |
date,name,category,amount
2024-01-01,Starbucks drink,Food,20
normalized_csv_str: |
date,name,category,amount
2024-01-01,Starbucks drink,Food,20
created_at: <%= 2.days.ago %>

View file

View file

@ -0,0 +1,18 @@
require "test_helper"
class ImportJobTest < ActiveJob::TestCase
include ImportTestHelper
test "import is published" do
import = imports(:empty_import)
import.update! raw_csv_str: valid_csv_str
assert import.pending?
perform_enqueued_jobs do
ImportJob.perform_later(import)
end
assert import.reload.complete?
end
end

View file

@ -0,0 +1,87 @@
require "test_helper"
class Import::CsvTest < ActiveSupport::TestCase
include ImportTestHelper
setup do
@csv = Import::Csv.new(valid_csv_str)
end
test "cannot define validator for non-existent header" do
assert_raises do
@csv.define_validator "invalid", method(:validate_iso_date)
end
end
test "csv with no validators is valid" do
assert @csv.cell_valid?(0, 0)
assert @csv.valid?
end
test "valid csv values" do
@csv.define_validator "date", method(:validate_iso_date)
assert_equal "2024-01-01", @csv.table[0][0]
assert @csv.cell_valid?(0, 0)
assert @csv.valid?
end
test "invalid csv values" do
invalid_csv = Import::Csv.new valid_csv_with_invalid_values
invalid_csv.define_validator "date", method(:validate_iso_date)
assert_equal "invalid_date", invalid_csv.table[0][0]
assert_not invalid_csv.cell_valid?(0, 0)
assert_not invalid_csv.valid?
end
test "updating a cell returns a copy of the original csv" do
original_date = "2024-01-01"
new_date = "2024-01-01"
assert_equal original_date, @csv.table[0][0]
updated = @csv.update_cell(0, 0, new_date)
assert_equal original_date, @csv.table[0][0]
assert_equal new_date, updated[0][0]
end
test "can create CSV with expected columns and field mappings with validators" do
date_field = Import::Field.new \
key: "date",
label: "Date",
validator: method(:validate_iso_date)
name_field = Import::Field.new \
key: "name",
label: "Name"
fields = [ date_field, name_field ]
raw_csv_str = <<-ROWS
date,Custom Field Header,extra_field
invalid_date_value,Starbucks drink,Food
2024-01-02,Amazon stuff,Shopping
ROWS
mappings = {
"name" => "Custom Field Header"
}
csv = Import::Csv.create_with_field_mappings(raw_csv_str, fields, mappings)
assert_equal %w[ date name ], csv.table.headers
assert_equal 2, csv.table.size
assert_equal "Amazon stuff", csv.table[1][1]
end
private
def validate_iso_date(value)
Date.iso8601(value)
true
rescue
false
end
end

View file

@ -0,0 +1,28 @@
require "test_helper"
class Import::FieldTest < ActiveSupport::TestCase
test "key is always a string" do
field1 = Import::Field.new label: "Test", key: "test"
field2 = Import::Field.new label: "Test2", key: :test2
assert_equal "test", field1.key
assert_equal "test2", field2.key
end
test "can set and override a validator for a field" do
field = Import::Field.new \
label: "Test",
key: "Test",
validator: ->(val) { val == 42 }
assert field.validate(42)
assert_not field.validate(41)
field.define_validator do |value|
value == 100
end
assert field.validate(100)
assert_not field.validate(42)
end
end

View file

@ -0,0 +1,61 @@
require "test_helper"
class ImportTest < ActiveSupport::TestCase
include ImportTestHelper, ActiveJob::TestHelper
setup do
@empty_import = imports(:empty_import)
@loaded_import = imports(:loaded_import)
end
test "raw csv input must conform to csv spec" do
@empty_import.raw_csv_str = malformed_csv_str
assert_not @empty_import.valid?
@empty_import.raw_csv_str = valid_csv_str
assert @empty_import.valid?
end
test "can update csv value without affecting raw input" do
assert_equal "Starbucks drink", @loaded_import.csv.table[0][1]
prior_raw_csv_str_value = @loaded_import.raw_csv_str
prior_normalized_csv_str_value = @loaded_import.normalized_csv_str
@loaded_import.update_csv! \
row_idx: 0,
col_idx: 1,
value: "new_category"
assert_equal "new_category", @loaded_import.csv.table[0][1]
assert_equal prior_raw_csv_str_value, @loaded_import.raw_csv_str
assert_not_equal prior_normalized_csv_str_value, @loaded_import.normalized_csv_str
end
test "publishes later" do
assert_enqueued_with(job: ImportJob) do
@loaded_import.publish_later
end
end
test "publishes a valid import" do
assert_difference "Transaction.count", 2 do
@loaded_import.publish
end
@loaded_import.reload
assert @loaded_import.complete?
end
test "failed publish results in error status" do
@empty_import.update! raw_csv_str: valid_csv_with_invalid_values
assert_difference "Transaction.count", 0 do
@empty_import.publish
end
@empty_import.reload
assert @empty_import.failed?
end
end

View file

@ -0,0 +1,24 @@
module ImportTestHelper
def valid_csv_str
<<-ROWS
date,name,category,amount
2024-01-01,Starbucks drink,Food,20
2024-01-02,Amazon stuff,Shopping,200
ROWS
end
def valid_csv_with_invalid_values
<<-ROWS
date,name,category,amount
invalid_date,Starbucks drink,Food,invalid_amount
ROWS
end
def malformed_csv_str
<<-ROWS
name,age
"John Doe,23
"Jane Doe",25
ROWS
end
end

113
test/system/imports_test.rb Normal file
View file

@ -0,0 +1,113 @@
require "application_system_test_case"
class ImportsTest < ApplicationSystemTestCase
include ImportTestHelper
setup do
sign_in @user = users(:family_admin)
@imports = @user.family.imports.ordered.to_a
end
test "can trigger new import from settings" do
trigger_import_from_settings
verify_import_modal
end
test "can resume existing import from settings" do
visit imports_url
within "#" + dom_id(@imports.first) do
click_button
click_link "Edit"
end
assert_current_path edit_import_path(@imports.first)
end
test "can resume latest import" do
trigger_import_from_transactions
verify_import_modal
click_link "Resume latest import"
assert_current_path edit_import_path(@imports.first)
end
test "can perform basic CSV import" do
trigger_import_from_settings
verify_import_modal
within "#modal" do
click_link "New import from CSV"
end
# 1) Create import step
assert_selector "h1", text: "New import"
within "form" do
select "Checking Account", from: "import_account_id"
end
click_button "Next"
# 2) Load Step
assert_selector "h1", text: "Load import"
within "form" do
fill_in "import_raw_csv_str", with: <<-ROWS
date,Custom Name Column,category,amount
invalid_date,Starbucks drink,Food,-20.50
2024-01-01,Amazon purchase,Shopping,-89.50
ROWS
end
click_button "Next"
# 3) Configure step
assert_selector "h1", text: "Configure import"
within "form" do
select "Custom Name Column", from: "import_column_mappings_name"
end
click_button "Next"
# 4) Clean step
assert_selector "h1", text: "Clean import"
# We have an invalid value, so user cannot click next yet
assert_no_text "Next"
# Replace invalid date with valid date
fill_in "cell-0-0", with: "2024-01-02"
# Trigger blur event so value saves
find("body").click
click_link "Next"
# 5) Confirm step
assert_selector "h1", text: "Confirm import"
click_button "Import 2 transactions"
assert_selector "h1", text: "Imports"
end
private
def trigger_import_from_settings
visit imports_url
click_link "New import"
end
def trigger_import_from_transactions
visit transactions_url
click_link "Import"
end
def verify_import_modal
within "#modal" do
assert_text "Import transactions"
end
end
end

View file

@ -14,6 +14,7 @@ class SettingsTest < ApplicationSystemTestCase
[ "Categories", "Categories", transaction_categories_path ],
[ "Merchants", "Merchants", transaction_merchants_path ],
[ "Rules", "Rules", transaction_rules_path ],
[ "Imports", "Imports", imports_path ],
[ "What's New", "What's New", changelog_path ],
[ "Feedback", "Feedback", feedback_path ],
[ "Invite friends", "Invite friends", invites_path ]
@ -27,6 +28,7 @@ class SettingsTest < ApplicationSystemTestCase
end
private
def open_settings_from_sidebar
find("#user-menu").click
click_link "Settings"