Compare commits
18 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f356656fc | ||
|
|
6e59fdb369 | ||
|
|
457247da8e | ||
|
|
41c991384a | ||
|
|
77f166a5f8 | ||
|
|
ac27a1c87f | ||
|
|
32748b0632 | ||
|
|
444155c103 | ||
|
|
8654a98e6e | ||
|
|
3dd67d3ed6 | ||
|
|
4efbb58197 | ||
|
|
94345ddc3a | ||
|
|
6212d57915 | ||
|
|
5f75e2e14f | ||
|
|
55f7cb1bc2 | ||
|
|
5ac3a808b2 | ||
|
|
30c19b9d2e | ||
|
|
34811d8fd8 |
22
.github/DISCUSSION_TEMPLATE/feature-requests.yml
vendored
Normal file
22
.github/DISCUSSION_TEMPLATE/feature-requests.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
title: Feature Request
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in Maybe! Please follow the template below to submit your feature request. You can visit our [roadmap](https://github.com/orgs/maybe-finance/projects/13) to see what's currently planned.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature
|
||||
description: Provide a clear and concise description of the feature you would like.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Why is this feature important?
|
||||
description: Tell us what specific problem(s) this feature solves for you or other users.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context, screenshots, and relevant links
|
||||
description: Provide additional info to help us evaluate whether this feature is a good fit for the product.
|
||||
33
.github/ISSUE_TEMPLATE/feature-specification.md
vendored
33
.github/ISSUE_TEMPLATE/feature-specification.md
vendored
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: Feature Specification
|
||||
about: A fully scoped feature with designs, requirements, and implementation plan
|
||||
title: 'Feature: '
|
||||
labels: ":rocket: Feature"
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
_If your feature requires designs, UI changes, or input from the Maybe team, you should open a feature request instead. This template is for a **fully scoped and ready** feature. _
|
||||
|
||||
## Feature Overview
|
||||
|
||||
## Requirements
|
||||
|
||||
_If there is a missing / incorrect requirement, please leave a comment before starting work on this._
|
||||
|
||||
- [ ] Requirement 1
|
||||
|
||||
## Implementation Suggestions
|
||||
|
||||
_Below are some ideas for implementation to get you started. Use your best judgment here—if there's a better way to do things, go for it!_
|
||||
|
||||
## Designs
|
||||
|
||||
Below are the designs you should follow while implementing this:
|
||||
|
||||
## Reminders
|
||||
|
||||
- Make sure to review our [contributing guidelines](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md) before starting on an issue
|
||||
- We do our best to define a clear spec for new features and fixes, but think of them as "suggestions", not "hard requirements". We welcome ideas and suggestions!
|
||||
- If you see missing requirements to this issue, please leave a comment below explaining what is missing and why it is important.
|
||||
- If you see a requirement that you think is _incorrect_ or _not optimal_, please leave a comment explaining what you think needs to change below.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: 'Feature Request: '
|
||||
labels: ":bulb: Feature Request"
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
21
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Other
|
||||
about: All other issues
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**PLEASE READ before opening an issue:**
|
||||
|
||||
- Is this a feature request? Please [open a feature request discussion](https://github.com/maybe-finance/maybe/discussions/new?category=feature-requests).
|
||||
- Do you need help or have a question? Please [open a discussion](https://github.com/maybe-finance/maybe/discussions/new/choose) or [join our Discord](https://link.maybe.co/discord) and post to the "help" channel.
|
||||
|
||||
----------------------
|
||||
|
||||
**Is this issue related to a problem? Please describe.**
|
||||
|
||||
**Describe the work that needs to be done to address this issue**
|
||||
|
||||
**Additional context**
|
||||
12
Gemfile.lock
12
Gemfile.lock
@@ -17,7 +17,7 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: fb4300ce193c338e00c8fe3a8372dc594f6c5de8
|
||||
revision: ed50b93ebcf3c1a92ac3481297c07c33d9f7c161
|
||||
branch: 7-2-stable
|
||||
specs:
|
||||
actioncable (7.2.0.alpha)
|
||||
@@ -86,7 +86,7 @@ GIT
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1, < 5.22.0)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
rails (7.2.0.alpha)
|
||||
actioncable (= 7.2.0.alpha)
|
||||
@@ -199,7 +199,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (3.28.2)
|
||||
good_job (3.28.3)
|
||||
activejob (>= 6.0.0)
|
||||
activerecord (>= 6.0.0)
|
||||
concurrent-ruby (>= 1.0.2)
|
||||
@@ -261,8 +261,8 @@ GEM
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.21.2)
|
||||
mocha (2.2.0)
|
||||
minitest (5.23.0)
|
||||
mocha (2.3.0)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
net-http (0.4.1)
|
||||
@@ -311,7 +311,7 @@ GEM
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.7.3)
|
||||
racc (1.8.0)
|
||||
rack (3.0.11)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
|
||||
@@ -74,12 +74,20 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def sync
|
||||
@account.sync_later if @account.can_sync?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } })
|
||||
if @account.can_sync?
|
||||
@account.sync_later
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } })
|
||||
end
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: t(".cannot_sync") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "error", content: { body: t(".cannot_sync") } })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
24
app/controllers/tags/deletions_controller.rb
Normal file
24
app/controllers/tags/deletions_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class Tags::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_tag
|
||||
before_action :set_replacement_tag, only: :create
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
@tag.replace_and_destroy! @replacement_tag
|
||||
redirect_back_or_to tags_path, notice: t(".deleted")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tag
|
||||
@tag = Current.family.tags.find_by(id: params[:tag_id])
|
||||
end
|
||||
|
||||
def set_replacement_tag
|
||||
@replacement_tag = Current.family.tags.find_by(id: params[:replacement_tag_id])
|
||||
end
|
||||
end
|
||||
36
app/controllers/tags_controller.rb
Normal file
36
app/controllers/tags_controller.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
class TagsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_tag, only: %i[ edit update ]
|
||||
|
||||
def index
|
||||
@tags = Current.family.tags.alphabetically
|
||||
end
|
||||
|
||||
def new
|
||||
@tag = Current.family.tags.new color: Tag::COLORS.sample
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.tags.create!(tag_params)
|
||||
redirect_to tags_path, notice: t(".created")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@tag.update!(tag_params)
|
||||
redirect_to tags_path, notice: t(".updated")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tag
|
||||
@tag = Current.family.tags.find(params[:id])
|
||||
end
|
||||
|
||||
def tag_params
|
||||
params.require(:tag).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,7 @@ class Transactions::Categories::DeletionsController < ApplicationController
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.transaction_categories.find(params[:transaction_category_id])
|
||||
@category = Current.family.transaction_categories.find(params[:category_id])
|
||||
end
|
||||
|
||||
def set_replacement_category
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class Transactions::Categories::DropdownsController < ApplicationController
|
||||
before_action :set_from_params
|
||||
|
||||
def show
|
||||
@categories = categories_scope.to_a.excluding(@selected_category).prepend(@selected_category).compact
|
||||
end
|
||||
|
||||
private
|
||||
def set_from_params
|
||||
if params[:category_id]
|
||||
@selected_category = categories_scope.find(params[:category_id])
|
||||
end
|
||||
|
||||
if params[:transaction_id]
|
||||
@transaction = Current.family.transactions.find(params[:transaction_id])
|
||||
end
|
||||
end
|
||||
|
||||
def categories_scope
|
||||
Current.family.transaction_categories.alphabetically
|
||||
end
|
||||
end
|
||||
@@ -72,8 +72,8 @@ class TransactionsController < ApplicationController
|
||||
|
||||
def create
|
||||
@transaction = Current.family.accounts
|
||||
.find(params[:transaction][:account_id])
|
||||
.transactions.build(transaction_params.merge(amount: amount))
|
||||
.find(params[:transaction][:account_id])
|
||||
.transactions.build(transaction_params.merge(amount: amount))
|
||||
|
||||
respond_to do |format|
|
||||
if @transaction.save
|
||||
@@ -88,11 +88,20 @@ class TransactionsController < ApplicationController
|
||||
def update
|
||||
respond_to do |format|
|
||||
sync_start_date = if transaction_params[:date]
|
||||
[ @transaction.date, Date.parse(transaction_params[:date]) ].compact.min
|
||||
[ @transaction.date, Date.parse(transaction_params[:date]) ].compact.min
|
||||
else
|
||||
@transaction.date
|
||||
end
|
||||
|
||||
if params[:transaction][:tag_id].present?
|
||||
tag = Current.family.tags.find(params[:transaction][:tag_id])
|
||||
@transaction.tags << tag unless @transaction.tags.include?(tag)
|
||||
end
|
||||
|
||||
if params[:transaction][:remove_tag_id].present?
|
||||
@transaction.tags.delete(params[:transaction][:remove_tag_id])
|
||||
end
|
||||
|
||||
if @transaction.update(transaction_params)
|
||||
@transaction.account.sync_later(sync_start_date)
|
||||
|
||||
@@ -121,6 +130,7 @@ class TransactionsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delete_search_param(params, key, value: nil)
|
||||
if value
|
||||
params[key]&.delete(value)
|
||||
@@ -153,8 +163,7 @@ class TransactionsController < ApplicationController
|
||||
params[:transaction][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
# Only allow a list of trusted parameters through.
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id)
|
||||
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, :tag_id, :remove_tag_id).except(:tag_id, :remove_tag_id)
|
||||
end
|
||||
end
|
||||
|
||||
7
app/helpers/tags_helper.rb
Normal file
7
app/helpers/tags_helper.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module TagsHelper
|
||||
def null_tag
|
||||
Tag.new \
|
||||
name: "Uncategorized",
|
||||
color: Tag::UNCATEGORIZED_COLOR
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "replacementCategoryField", "submitButton" ]
|
||||
static targets = ["replacementField", "submitButton"]
|
||||
static classes = [ "dangerousAction", "safeAction" ]
|
||||
static values = {
|
||||
submitTextWhenReplacing: String,
|
||||
@@ -9,7 +9,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
updateSubmitButton() {
|
||||
if (this.replacementCategoryFieldTarget.value) {
|
||||
if (this.replacementFieldTarget.value) {
|
||||
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
|
||||
this.#markSafe()
|
||||
} else {
|
||||
@@ -1,88 +1,86 @@
|
||||
module Account::Syncable
|
||||
extend ActiveSupport::Concern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sync_later(start_date = nil)
|
||||
AccountSyncJob.perform_later(self, start_date)
|
||||
def sync_later(start_date = nil)
|
||||
AccountSyncJob.perform_later(self, start_date)
|
||||
end
|
||||
|
||||
def sync(start_date = nil)
|
||||
update!(status: "syncing")
|
||||
|
||||
sync_exchange_rates
|
||||
|
||||
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
|
||||
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
|
||||
calculator.calculate
|
||||
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
||||
self.balances.where("date < ?", effective_start_date).delete_all
|
||||
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
|
||||
|
||||
update!(status: "ok", last_sync_date: Date.today, balance: new_balance)
|
||||
rescue => e
|
||||
update!(status: "error")
|
||||
Rails.logger.error("Failed to sync account #{id}: #{e.message}")
|
||||
end
|
||||
|
||||
def can_sync?
|
||||
# Skip account sync if account is not active or the sync process is already running
|
||||
return false unless is_active
|
||||
return false if syncing?
|
||||
# If last_sync_date is blank (i.e. the account has never been synced before) allow syncing
|
||||
return true if last_sync_date.blank?
|
||||
|
||||
# If last_sync_date is not today, allow syncing
|
||||
last_sync_date != Date.today
|
||||
end
|
||||
|
||||
# The earliest date we can calculate a balance for
|
||||
def effective_start_date
|
||||
first_valuation_date = self.valuations.order(:date).pluck(:date).first
|
||||
first_transaction_date = self.transactions.order(:date).pluck(:date).first
|
||||
|
||||
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
|
||||
end
|
||||
|
||||
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
|
||||
def sync_exchange_rates
|
||||
rate_candidates = []
|
||||
|
||||
if multi_currency?
|
||||
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency.each do |currency, date|
|
||||
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
|
||||
end
|
||||
end
|
||||
|
||||
def sync(start_date = nil)
|
||||
update!(status: "syncing")
|
||||
|
||||
sync_exchange_rates
|
||||
|
||||
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
|
||||
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
|
||||
calculator.calculate
|
||||
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
||||
self.balances.where("date < ?", effective_start_date).delete_all
|
||||
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
|
||||
self.balance = new_balance
|
||||
self.save!
|
||||
|
||||
update!(status: "ok", last_sync_date: Date.today)
|
||||
rescue => e
|
||||
update!(status: "error")
|
||||
Rails.logger.error("Failed to sync account #{id}: #{e.message}")
|
||||
if foreign_currency?
|
||||
(effective_start_date..Date.current).each do |date|
|
||||
rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency }
|
||||
end
|
||||
end
|
||||
|
||||
def can_sync?
|
||||
# Skip account sync if account is not active or the sync process is already running
|
||||
return false unless is_active
|
||||
return false if syncing?
|
||||
# If last_sync_date is blank (i.e. the account has never been synced before) allow syncing
|
||||
return true if last_sync_date.blank?
|
||||
existing_rates = ExchangeRate.where(
|
||||
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
date: rate_candidates.map { |rc| rc[:date] }
|
||||
).pluck(:base_currency, :converted_currency, :date)
|
||||
|
||||
# If last_sync_date is not today, allow syncing
|
||||
last_sync_date != Date.today
|
||||
# Convert to a set for faster lookup
|
||||
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set
|
||||
|
||||
rate_candidates.each do |rate_candidate|
|
||||
rc_from = rate_candidate[:from_currency]
|
||||
rc_to = rate_candidate[:to_currency]
|
||||
rc_date = rate_candidate[:date]
|
||||
|
||||
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
|
||||
|
||||
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
|
||||
rate = ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
|
||||
ExchangeRate.create! base_currency: rc_from, converted_currency: rc_to, date: rc_date, rate: rate if rate
|
||||
end
|
||||
|
||||
# The earliest date we can calculate a balance for
|
||||
def effective_start_date
|
||||
first_valuation_date = self.valuations.order(:date).pluck(:date).first
|
||||
first_transaction_date = self.transactions.order(:date).pluck(:date).first
|
||||
|
||||
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
|
||||
end
|
||||
|
||||
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
|
||||
def sync_exchange_rates
|
||||
rate_candidates = []
|
||||
|
||||
if multi_currency?
|
||||
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency.each do |currency, date|
|
||||
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
|
||||
end
|
||||
end
|
||||
|
||||
if foreign_currency?
|
||||
(effective_start_date..Date.current).each do |date|
|
||||
rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency }
|
||||
end
|
||||
end
|
||||
|
||||
existing_rates = ExchangeRate.where(
|
||||
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
date: rate_candidates.map { |rc| rc[:date] }
|
||||
).pluck(:base_currency, :converted_currency, :date)
|
||||
|
||||
# Convert to a set for faster lookup
|
||||
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set
|
||||
|
||||
rate_candidates.each do |rate_candidate|
|
||||
rc_from = rate_candidate[:from_currency]
|
||||
rc_to = rate_candidate[:to_currency]
|
||||
rc_date = rate_candidate[:date]
|
||||
|
||||
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
|
||||
|
||||
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
|
||||
rate = ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
|
||||
ExchangeRate.create! base_currency: rc_from, converted_currency: rc_to, date: rc_date, rate: rate if rate
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Family < ApplicationRecord
|
||||
has_many :users, dependent: :destroy
|
||||
has_many :tags, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :imports, through: :accounts
|
||||
|
||||
@@ -11,6 +11,8 @@ class Import < ApplicationRecord
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
FALLBACK_TRANSACTION_NAME = "Imported transaction"
|
||||
|
||||
def publish_later
|
||||
ImportJob.perform_later(self)
|
||||
end
|
||||
@@ -108,14 +110,27 @@ class Import < ApplicationRecord
|
||||
|
||||
def generate_transactions
|
||||
transactions = []
|
||||
category_cache = {}
|
||||
tag_cache = {}
|
||||
|
||||
csv.table.each do |row|
|
||||
category = account.family.transaction_categories.find_or_initialize_by(name: row["category"])
|
||||
category_name = row["category"].presence
|
||||
tag_strings = row["tags"].presence&.split("|") || []
|
||||
tags = []
|
||||
|
||||
tag_strings.each do |tag_string|
|
||||
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
|
||||
end
|
||||
|
||||
category = category_cache[category_name] ||= account.family.transaction_categories.find_or_initialize_by(name: category_name) if category_name.present?
|
||||
|
||||
txn = account.transactions.build \
|
||||
name: row["name"] || "Imported transaction",
|
||||
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
||||
date: Date.iso8601(row["date"]),
|
||||
category: category,
|
||||
amount: BigDecimal(row["amount"]) * -1 # User inputs amounts with opposite signage of our internal representation
|
||||
tags: tags,
|
||||
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
|
||||
currency: account.currency
|
||||
|
||||
transactions << txn
|
||||
end
|
||||
@@ -137,12 +152,16 @@ class Import < ApplicationRecord
|
||||
key: "category",
|
||||
label: "Category"
|
||||
|
||||
tags_field = Import::Field.new \
|
||||
key: "tags",
|
||||
label: "Tags"
|
||||
|
||||
amount_field = Import::Field.new \
|
||||
key: "amount",
|
||||
label: "Amount",
|
||||
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
|
||||
|
||||
[ date_field, name_field, category_field, amount_field ]
|
||||
[ date_field, name_field, category_field, tags_field, amount_field ]
|
||||
end
|
||||
|
||||
def define_column_mapping_keys
|
||||
|
||||
25
app/models/tag.rb
Normal file
25
app/models/tag.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class Tag < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :taggings, dependent: :destroy
|
||||
has_many :transactions, through: :taggings, source: :taggable, source_type: "Transaction"
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :family }
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
UNCATEGORIZED_COLOR = "#737373"
|
||||
|
||||
def replace_and_destroy!(replacement)
|
||||
transaction do
|
||||
raise ActiveRecord::RecordInvalid, "Replacement tag cannot be the same as the tag being destroyed" if replacement == self
|
||||
|
||||
if replacement
|
||||
taggings.update_all tag_id: replacement.id
|
||||
end
|
||||
|
||||
destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
4
app/models/tagging.rb
Normal file
4
app/models/tagging.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class Tagging < ApplicationRecord
|
||||
belongs_to :tag
|
||||
belongs_to :taggable, polymorphic: true
|
||||
end
|
||||
@@ -5,6 +5,9 @@ class Transaction < ApplicationRecord
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
|
||||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
|
||||
validates :name, :date, :amount, :account, presence: true
|
||||
|
||||
monetize :amount
|
||||
|
||||
@@ -63,6 +63,6 @@
|
||||
<% else %>
|
||||
<%= previous_setting("Billing", settings_billing_path) %>
|
||||
<% end %>
|
||||
<%= next_setting("Categories", transaction_categories_path) %>
|
||||
<%= next_setting("Tags", tags_path) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<%= f.text_field :name, placeholder: t(".name.placeholder"), required: "required", label: t(".name.label"), autofocus: true %>
|
||||
<%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
|
||||
<%= f.money_field :balance_money, label: t(".balance.label"), required: "required" %>
|
||||
<%= f.date_field :date, label: t(".start_date.label"), required: true, max: Date.today, value: Date.today %>
|
||||
<%= f.date_field :start_date, label: t(".start_date.label"), required: true, max: Date.today, value: Date.today %>
|
||||
</div>
|
||||
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
|
||||
<% end %>
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
|
||||
</div>
|
||||
|
||||
<div class="w-48 flex gap-1">
|
||||
<% transaction.tags.each do |tag| %>
|
||||
<%= render partial: "tags/badge", locals: { tag: tag } %>
|
||||
<% end %>
|
||||
</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"] %>
|
||||
<%= content_tag :p, format_money(Money.new(-transaction.amount, @import.account.currency)), class: ["whitespace-nowrap", BigDecimal(transaction.amount).negative? ? "text-green-600" : "text-red-600"] %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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") %> · <%= transactions.size %></h4>
|
||||
<span><%= format_money -transactions.sum { |t| t.amount } %></span>
|
||||
<span><%= format_money Money.new(-transactions.sum { |t| t.amount }, @import.account.currency) %></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 %>
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
<div class="h-px bg-alpha-black-100 w-full"></div>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<%= sidebar_link_to t(".tags_label"), tags_path, icon: "tags" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= sidebar_link_to t(".categories_label"), transaction_categories_path, icon: "tags" %>
|
||||
</li>
|
||||
|
||||
10
app/views/tags/_badge.html.erb
Normal file
10
app/views/tags/_badge.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<%# locals: (tag:) %>
|
||||
<% tag ||= null_category %>
|
||||
|
||||
<span class="border text-sm font-medium px-2.5 py-1 rounded-full content-center"
|
||||
style="
|
||||
background-color: color-mix(in srgb, <%= tag.color %> 5%, white);
|
||||
border-color: color-mix(in srgb, <%= tag.color %> 10%, white);
|
||||
color: <%= tag.color %>;">
|
||||
<%= tag.name %>
|
||||
</span>
|
||||
38
app/views/tags/_form.html.erb
Normal file
38
app/views/tags/_form.html.erb
Normal file
@@ -0,0 +1,38 @@
|
||||
<%= form_with model: tag, data: { turbo: false } do |form| %>
|
||||
<div class="flex flex-col space-y-4 w-96" data-controller="color-select" data-color-select-selection-value="<%= tag.color %>">
|
||||
<fieldset class="relative">
|
||||
<span data-color-select-target="decoration" class="pointer-events-none absolute inset-y-3.5 left-3 flex items-center pl-1 block w-1 rounded-lg"></span>
|
||||
<%= form.text_field :name,
|
||||
value: tag.name,
|
||||
autofocus: "",
|
||||
required: true,
|
||||
placeholder: "Enter tag name",
|
||||
class: "rounded-lg w-full focus:ring-black focus:border-transparent placeholder:text-gray-500 pl-6" %>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<%= form.hidden_field :color, data: { color_select_target: "input" } %>
|
||||
|
||||
<ul role="radiogroup" class="flex justify-between items-center py-2">
|
||||
<% Tag::COLORS.each do |color| %>
|
||||
<li tabindex="0"
|
||||
role="radio"
|
||||
data-action="click->color-select#select keydown.enter->color-select#select keydown.space->color-select#select"
|
||||
data-value="<%= color %>"
|
||||
class="flex shrink-0 justify-center items-center w-5 h-5 cursor-pointer hover:bg-gray-200 rounded-full">
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</fieldset>
|
||||
|
||||
<section>
|
||||
<%= hidden_field_tag :tag_id, params[:tag_id] %>
|
||||
|
||||
<% if tag.persisted? %>
|
||||
<%= form.submit t(".update") %>
|
||||
<% else %>
|
||||
<%= form.submit t(".create") %>
|
||||
<% end %>
|
||||
</section>
|
||||
</div>
|
||||
<% end %>
|
||||
23
app/views/tags/_tag.html.erb
Normal file
23
app/views/tags/_tag.html.erb
Normal file
@@ -0,0 +1,23 @@
|
||||
<div id="<%= dom_id(tag) %>" class="flex justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
|
||||
<%= render "badge", tag: tag %>
|
||||
|
||||
<%= 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_tag_path(tag),
|
||||
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
|
||||
|
||||
<span><%= t(".edit") %></span>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_tag_deletion_path(tag),
|
||||
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_frame: :modal } do %>
|
||||
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
|
||||
|
||||
<span><%= t(".delete") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
33
app/views/tags/deletions/new.html.erb
Normal file
33
app/views/tags/deletions/new.html.erb
Normal file
@@ -0,0 +1,33 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto p-4 w-screen max-w-md">
|
||||
<div class="space-y-2">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl"><%= t(".delete_tag") %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<p class="text-gray-500 font-light">
|
||||
<%= t(".explanation", tag_name: @tag.name) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= form_with url: tag_deletions_path(@tag),
|
||||
data: {
|
||||
turbo: false,
|
||||
controller: "deletion",
|
||||
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", tag_name: @tag.name),
|
||||
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", tag_name: @tag.name) } do |f| %>
|
||||
<%= f.collection_select :replacement_tag_id,
|
||||
Current.family.tags.alphabetically.without(@tag),
|
||||
:id, :name,
|
||||
{ prompt: t(".replacement_tag_prompt"), label: t(".tag") },
|
||||
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
|
||||
|
||||
<%= f.submit t(".delete_and_leave_uncategorized", tag_name: @tag.name),
|
||||
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
data: { deletion_target: "submitButton" } %>
|
||||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
10
app/views/tags/edit.html.erb
Normal file
10
app/views/tags/edit.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto w-full p-4 space-y-4">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl"><%= t(".edit") %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<%= render "form", tag: @tag %>
|
||||
</article>
|
||||
<% end %>
|
||||
49
app/views/tags/index.html.erb
Normal file
49
app/views/tags/index.html.erb
Normal file
@@ -0,0 +1,49 @@
|
||||
<% content_for :sidebar do %>
|
||||
<%= render "settings/nav" %>
|
||||
<% end %>
|
||||
|
||||
<section class="space-y-4">
|
||||
<header class="flex items-center justify-between">
|
||||
<h1 class="text-gray-900 text-xl font-medium"><%= t(".tags") %></h1>
|
||||
|
||||
<%= link_to new_tag_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><%= t(".new") %></p>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
||||
|
||||
<% if @tags.any? %>
|
||||
|
||||
<div class="rounded-xl bg-gray-25 p-1">
|
||||
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".tags") %> · <%= @tags.size %></h2>
|
||||
|
||||
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
|
||||
|
||||
<%= render @tags %>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% else %>
|
||||
|
||||
<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(".empty") %></p>
|
||||
<%= link_to new_tag_path, 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>
|
||||
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-between gap-4">
|
||||
<%= previous_setting("Accounts", accounts_path) %>
|
||||
<%= next_setting("Categories", transaction_categories_path) %>
|
||||
</footer>
|
||||
</section>
|
||||
10
app/views/tags/new.html.erb
Normal file
10
app/views/tags/new.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto w-full p-4 space-y-4">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl"><%= t(".new") %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<%= render "form", tag: @tag %>
|
||||
</article>
|
||||
<% end %>
|
||||
@@ -1,7 +1,7 @@
|
||||
<%# locals: (category:) %>
|
||||
<% category ||= null_category %>
|
||||
|
||||
<span class="border text-sm font-medium px-2.5 py-1 rounded-full cursor-pointer content-center"
|
||||
<span class="border text-sm font-medium px-2.5 py-1 rounded-full content-center"
|
||||
style="
|
||||
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
|
||||
border-color: color-mix(in srgb, <%= category.color %> 10%, white);
|
||||
|
||||
@@ -1,48 +1,15 @@
|
||||
<%# locals: (transaction:) %>
|
||||
<div class="relative" data-controller="menu">
|
||||
<button data-menu-target="button cursor-pointer" class="flex">
|
||||
<button data-menu-target="button" class="flex cursor-pointer">
|
||||
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
|
||||
</button>
|
||||
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
|
||||
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<div class="flex flex-col relative" data-controller="list-filter">
|
||||
<div class="grow p-1.5">
|
||||
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
|
||||
<input placeholder="Search" autocomplete="nope" type="search" class="placeholder:text-sm placeholder:text-gray-500 font-normal h-10 relative pl-10 w-full border-none rounded-lg" data-list-filter-target="input" data-action="list-filter#filter">
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
|
||||
</div>
|
||||
<%= turbo_frame_tag "category_dropdown", src: transaction_category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
|
||||
<div class="p-6 flex items-center justify-center">
|
||||
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
|
||||
</div>
|
||||
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
|
||||
<div class="pb-2 pl-4 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
|
||||
No categories found
|
||||
</div>
|
||||
<% sorted_categories = Current.family.transaction_categories.sort_by { |category| category.id == transaction.category_id ? 0 : 1 } %>
|
||||
<% sorted_categories.each do |category| %>
|
||||
<%= render partial: "transactions/categories/dropdown/row", locals: { category:, transaction: } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="relative p-1.5 w-full">
|
||||
<%= link_to new_transaction_category_path(transaction_id: transaction),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
|
||||
<%= t(".add_new") %>
|
||||
<% end %>
|
||||
|
||||
<% if transaction.category %>
|
||||
<%= button_to transaction_path(transaction),
|
||||
method: :patch,
|
||||
params: { transaction: { category_id: nil } },
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100" do %>
|
||||
<%= lucide_icon "minus", class: "w-5 h-5" %>
|
||||
|
||||
<%= t(".clear") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,20 +14,20 @@
|
||||
<%= form_with url: transaction_category_deletions_path(@category),
|
||||
data: {
|
||||
turbo: false,
|
||||
controller: "category-deletion",
|
||||
category_deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
category_deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
category_deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
category_deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
|
||||
controller: "deletion",
|
||||
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
|
||||
<%= f.collection_select :replacement_category_id,
|
||||
Current.family.transaction_categories.alphabetically.without(@category),
|
||||
:id, :name,
|
||||
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
|
||||
{ data: { category_deletion_target: "replacementCategoryField", action: "category-deletion#updateSubmitButton" } } %>
|
||||
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
|
||||
|
||||
<%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
data: { category_deletion_target: "submitButton" } %>
|
||||
data: { deletion_target: "submitButton" } %>
|
||||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<%# locals: (category:, transaction:) %>
|
||||
<% is_selected = transaction.category_id == category.id %>
|
||||
<%# locals: (category:) %>
|
||||
<% is_selected = category.id === @selected_category&.id %>
|
||||
|
||||
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
|
||||
<%= button_to transaction_path(transaction, transaction: { category_id: category.id }), method: :patch, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
||||
<%= button_to transaction_path(@transaction, transaction: { category_id: category.id }), method: :patch, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
||||
<span class="w-5 h-5">
|
||||
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
||||
</span>
|
||||
39
app/views/transactions/categories/dropdowns/show.html.erb
Normal file
39
app/views/transactions/categories/dropdowns/show.html.erb
Normal file
@@ -0,0 +1,39 @@
|
||||
<%= turbo_frame_tag "category_dropdown" do %>
|
||||
<div class="flex flex-col relative" data-controller="list-filter">
|
||||
<div class="grow p-1.5">
|
||||
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
|
||||
<input placeholder="<%= t(".search_placeholder") %>" autocomplete="nope" type="search" class="placeholder:text-sm placeholder:text-gray-500 font-normal h-10 relative pl-10 w-full border-none rounded-lg" data-list-filter-target="input" data-action="list-filter#filter">
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
|
||||
</div>
|
||||
</div>
|
||||
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
|
||||
<div class="pb-2 pl-4 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
|
||||
<%= t(".no_categories") %>
|
||||
</div>
|
||||
<% @categories.each do |category| %>
|
||||
<%= render partial: "transactions/categories/dropdowns/row", locals: { category: } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="relative p-1.5 w-full">
|
||||
<%= link_to new_transaction_category_path(transaction_id: @transaction),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
|
||||
<%= t(".add_new") %>
|
||||
<% end %>
|
||||
|
||||
<% if @transaction.category %>
|
||||
<%= button_to transaction_path(@transaction),
|
||||
method: :patch,
|
||||
params: { transaction: { category_id: nil } },
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100" do %>
|
||||
<%= lucide_icon "minus", class: "w-5 h-5" %>
|
||||
|
||||
<%= t(".clear") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-between gap-4">
|
||||
<%= previous_setting("Accounts", accounts_path) %>
|
||||
<%= previous_setting("Tags", tags_path) %>
|
||||
<%= next_setting("Merchants", transaction_merchants_path) %>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -4,37 +4,44 @@
|
||||
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
|
||||
</h3>
|
||||
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
|
||||
<%= form_with model: @transaction, html: {data: {controller: "auto-submit-form"}} do |f| %>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-4 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
Overview
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<%= f.date_field :date, label: "Date", max: Date.today, "data-auto-submit-form-target": "auto" %>
|
||||
<div class="h-2"></div>
|
||||
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-400" }, {class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled"} %>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
Description
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-4 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
Overview
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<div class="space-y-2">
|
||||
<%= f.date_field :date, label: "Date", max: Date.today, "data-auto-submit-form-target": "auto" %>
|
||||
<%= f.collection_select :category_id, Current.family.transaction_categories, :id, :name, { prompt: "Select a category", label: "Category", class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
Description
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<%= f.text_field :name, label: "Name", "data-auto-submit-form-target": "auto" %>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<span>Settings</span>
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<% end %>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<span>Settings</span>
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<label class="flex items-center cursor-pointer justify-between mx-3">
|
||||
<%= f.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
|
||||
<div class="flex flex-col justify-center text-sm w-[340px] py-3">
|
||||
@@ -43,16 +50,39 @@
|
||||
</div>
|
||||
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-100 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<span>Additional</span>
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
<% end %>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<span>Additional</span>
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="mb-2">
|
||||
|
||||
<% if @transaction.tags.any? %>
|
||||
<div class="pt-3 pb-2 flex flex-wrap items-center gap-1">
|
||||
<% @transaction.tags.each do |tag| %>
|
||||
<div class="relative">
|
||||
<%= render partial: "tags/badge", locals: { tag: tag } %>
|
||||
<%= button_to transaction_path(@transaction, transaction: { remove_tag_id: tag.id }), method: :patch, "data-turbo": false, class: "absolute -top-2 -right-1 px-0.5 py rounded-full hover:bg-alpha-black-200 border border-alpha-black-100" do %>
|
||||
<%= lucide_icon("x", class: "w-3 h-3") %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</summary>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form", turbo: false } } do |f| %>
|
||||
<%= f.collection_select :tag_id, Current.family.tags.alphabetically.excluding(@transaction.tags), :id, :name, { prompt: "Select a tag", label: "Select a tag", class: "placeholder:text-gray-500" }, "data-auto-submit-form-target": "auto", "data-turbo": false %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<%= f.text_area :notes, label: "Notes", placeholder: "Enter a note", "data-auto-submit-form-target": "auto" %>
|
||||
</details>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
@@ -10,7 +10,7 @@ module Maybe
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.1.0-alpha.2"
|
||||
"0.1.0-alpha.3"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,6 +38,7 @@ en:
|
||||
summary:
|
||||
new: New account
|
||||
sync:
|
||||
cannot_sync: Account cannot be synced at the moment
|
||||
success: Account sync started
|
||||
update:
|
||||
success: Account updated successfully
|
||||
|
||||
@@ -65,6 +65,7 @@ en:
|
||||
rules_label: Rules
|
||||
security_label: Security
|
||||
self_hosting_label: Self-Hosting
|
||||
tags_label: Tags
|
||||
transactions_section_title: Transactions
|
||||
whats_new_label: What's New
|
||||
nav_link_large:
|
||||
|
||||
33
config/locales/views/tags/en.yml
Normal file
33
config/locales/views/tags/en.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
en:
|
||||
tags:
|
||||
create:
|
||||
created: Tag created
|
||||
deletions:
|
||||
create:
|
||||
deleted: Tag deleted
|
||||
new:
|
||||
delete_and_leave_uncategorized: Delete "%{tag_name}"
|
||||
delete_and_recategorize: Delete "%{tag_name}" and assign new tag
|
||||
delete_tag: Delete tag?
|
||||
explanation: "%{tag_name} will be removed from transactions and other taggable
|
||||
entities. Instead of leaving them untagged, you can also assign a new tag
|
||||
below."
|
||||
replacement_tag_prompt: Select tag
|
||||
tag: Tag
|
||||
edit:
|
||||
edit: Edit tag
|
||||
form:
|
||||
create: Create tag
|
||||
update: Update
|
||||
index:
|
||||
empty: No tags yet
|
||||
new: New tag
|
||||
tags: Tags
|
||||
new:
|
||||
new: New tag
|
||||
tag:
|
||||
delete: Delete
|
||||
edit: Edit
|
||||
update:
|
||||
updated: Tag updated
|
||||
@@ -16,10 +16,15 @@ en:
|
||||
category will be uncategorized. Instead of leaving them uncategorized,
|
||||
you can also assign a new category below.
|
||||
replacement_category_prompt: Select category
|
||||
dropdown:
|
||||
dropdowns:
|
||||
row:
|
||||
delete: Delete category
|
||||
edit: Edit category
|
||||
show:
|
||||
add_new: Add new
|
||||
clear: Clear
|
||||
no_categories: No categories found
|
||||
search_placeholder: Search
|
||||
edit:
|
||||
edit: Edit category
|
||||
form:
|
||||
@@ -29,8 +34,7 @@ en:
|
||||
categories: Categories
|
||||
new: New
|
||||
menu:
|
||||
add_new: Add new
|
||||
clear: Clear
|
||||
loading: Loading...
|
||||
new:
|
||||
new_category: New category
|
||||
row:
|
||||
|
||||
@@ -37,17 +37,24 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :transactions do
|
||||
match "search" => "transactions#search", on: :collection, via: %i[ get post ], as: :search
|
||||
resources :tags, except: %i[ show destroy ] do
|
||||
resources :deletions, only: %i[ new create ], module: :tags
|
||||
end
|
||||
|
||||
resources :transactions do
|
||||
collection do
|
||||
scope module: :transactions do
|
||||
resources :categories, as: :transaction_categories do
|
||||
match "search" => "transactions#search", via: %i[ get post ]
|
||||
|
||||
scope module: :transactions, as: :transaction do
|
||||
resources :categories do
|
||||
resources :deletions, only: %i[ new create ], module: :categories
|
||||
collection do
|
||||
resource :dropdown, only: :show, module: :categories, as: :category_dropdown
|
||||
end
|
||||
end
|
||||
|
||||
resources :rules, only: %i[ index ], as: :transaction_rules
|
||||
resources :merchants, only: %i[ index new create edit update destroy ], as: :transaction_merchants
|
||||
resources :rules, only: %i[ index ]
|
||||
resources :merchants, only: %i[ index new create edit update destroy ]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddAdminRoleToCurrentUsers < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
User.update_all(role: "admin")
|
||||
end
|
||||
end
|
||||
10
db/migrate/20240522133147_create_tags.rb
Normal file
10
db/migrate/20240522133147_create_tags.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class CreateTags < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :tags, id: :uuid do |t|
|
||||
t.string :name
|
||||
t.string "color", default: "#e99537", null: false
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
9
db/migrate/20240522151453_create_taggings.rb
Normal file
9
db/migrate/20240522151453_create_taggings.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class CreateTaggings < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :taggings, id: :uuid do |t|
|
||||
t.references :tag, null: false, foreign_key: true, type: :uuid
|
||||
t.references :taggable, polymorphic: true, type: :uuid
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
25
db/schema.rb
generated
25
db/schema.rb
generated
@@ -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_05_02_205006) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_05_22_151453) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -87,7 +87,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_05_02_205006) do
|
||||
t.uuid "accountable_id"
|
||||
t.decimal "balance", precision: 19, scale: 4, default: "0.0"
|
||||
t.string "currency", default: "USD"
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Account::Loan'::character varying, 'Account::Credit'::character varying, 'Account::OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Account::Loan'::character varying)::text, ('Account::Credit'::character varying)::text, ('Account::OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.boolean "is_active", default: true, null: false
|
||||
t.enum "status", default: "ok", null: false, enum_type: "account_status"
|
||||
t.jsonb "sync_warnings", default: "[]", null: false
|
||||
@@ -249,6 +249,25 @@ ActiveRecord::Schema[7.2].define(version: 2024_05_02_205006) do
|
||||
t.index ["var"], name: "index_settings_on_var", unique: true
|
||||
end
|
||||
|
||||
create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "tag_id", null: false
|
||||
t.string "taggable_type"
|
||||
t.uuid "taggable_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["tag_id"], name: "index_taggings_on_tag_id"
|
||||
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable"
|
||||
end
|
||||
|
||||
create_table "tags", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.string "color", default: "#e99537", null: false
|
||||
t.uuid "family_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id"], name: "index_tags_on_family_id"
|
||||
end
|
||||
|
||||
create_table "transaction_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "color", default: "#6172F3", null: false
|
||||
@@ -318,6 +337,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_05_02_205006) do
|
||||
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 "taggings", "tags"
|
||||
add_foreign_key "tags", "families"
|
||||
add_foreign_key "transaction_categories", "families"
|
||||
add_foreign_key "transaction_merchants", "families"
|
||||
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
||||
|
||||
@@ -6,7 +6,10 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
@empty_import = imports(:empty_import)
|
||||
@loaded_import = imports(:loaded_import)
|
||||
|
||||
@loaded_import = @empty_import.dup
|
||||
@loaded_import.update! raw_csv_str: valid_csv_str
|
||||
|
||||
@completed_import = imports(:completed_import)
|
||||
end
|
||||
|
||||
|
||||
36
test/controllers/tags/deletions_controller_test.rb
Normal file
36
test/controllers/tags/deletions_controller_test.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
require "test_helper"
|
||||
|
||||
class Tags::DeletionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
@user_tags = @user.family.tags
|
||||
@tag = tags(:hawaii_trip)
|
||||
end
|
||||
|
||||
test "should get new" do
|
||||
get new_tag_deletion_url(@tag)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "create with replacement" do
|
||||
replacement_tag = tags(:trips)
|
||||
|
||||
affected_transaction_count = @tag.transactions.count
|
||||
|
||||
assert affected_transaction_count > 0
|
||||
|
||||
assert_difference -> { Tag.count } => -1, -> { replacement_tag.transactions.count } => affected_transaction_count do
|
||||
post tag_deletions_url(@tag), params: { replacement_tag_id: replacement_tag.id }
|
||||
end
|
||||
end
|
||||
|
||||
test "create without replacement" do
|
||||
affected_transactions = @tag.transactions
|
||||
|
||||
assert affected_transactions.count > 0
|
||||
|
||||
assert_difference -> { Tag.count } => -1, -> { Tagging.count } => affected_transactions.count * -1 do
|
||||
post tag_deletions_url(@tag)
|
||||
end
|
||||
end
|
||||
end
|
||||
42
test/controllers/tags_controller_test.rb
Normal file
42
test/controllers/tags_controller_test.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
require "test_helper"
|
||||
|
||||
class TagsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
end
|
||||
|
||||
test "should get index" do
|
||||
get tags_url
|
||||
assert_response :success
|
||||
|
||||
@user.family.tags.each do |tag|
|
||||
assert_select "#" + dom_id(tag), count: 1
|
||||
end
|
||||
end
|
||||
|
||||
test "should get new" do
|
||||
get new_tag_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should create tag" do
|
||||
assert_difference("Tag.count") do
|
||||
post tags_url, params: { tag: { name: "Test Tag" } }
|
||||
end
|
||||
|
||||
assert_redirected_to tags_url
|
||||
assert_equal "Tag created", flash[:notice]
|
||||
end
|
||||
|
||||
test "should get edit" do
|
||||
get edit_tag_url(tags.first)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should update tag" do
|
||||
patch tag_url(tags.first), params: { tag: { name: "Test Tag" } }
|
||||
|
||||
assert_redirected_to tags_url
|
||||
assert_equal "Tag updated", flash[:notice]
|
||||
end
|
||||
end
|
||||
20
test/fixtures/imports.yml
vendored
20
test/fixtures/imports.yml
vendored
@@ -2,18 +2,6 @@ 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:
|
||||
@@ -22,11 +10,11 @@ completed_import:
|
||||
category: category
|
||||
amount: amount
|
||||
raw_csv_str: |
|
||||
date,name,category,amount
|
||||
2024-01-01,Starbucks drink,Food,20
|
||||
date,name,category,tags,amount
|
||||
2024-01-01,Starbucks drink,Food & Drink,Test Tag,-20
|
||||
normalized_csv_str: |
|
||||
date,name,category,amount
|
||||
2024-01-01,Starbucks drink,Food,20
|
||||
date,name,category,tags,amount
|
||||
2024-01-01,Starbucks drink,Food & Drink,Test Tag,-20
|
||||
created_at: <%= 2.days.ago %>
|
||||
|
||||
|
||||
|
||||
10
test/fixtures/taggings.yml
vendored
Normal file
10
test/fixtures/taggings.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
one:
|
||||
tag: hawaii_trip
|
||||
taggable: checking_one
|
||||
taggable_type: Transaction
|
||||
|
||||
two:
|
||||
tag: emergency_fund
|
||||
taggable: checking_two
|
||||
taggable_type: Transaction
|
||||
|
||||
11
test/fixtures/tags.yml
vendored
Normal file
11
test/fixtures/tags.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
trips:
|
||||
name: Trips
|
||||
family: dylan_family
|
||||
|
||||
hawaii_trip:
|
||||
name: Hawaii Trip
|
||||
family: dylan_family
|
||||
|
||||
emergency_fund:
|
||||
name: Emergency Fund
|
||||
family: dylan_family
|
||||
@@ -37,7 +37,7 @@ class Import::CsvTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "csv with additional columns and empty values" do
|
||||
csv = Import::Csv.new valid_csv_with_extra_column
|
||||
csv = Import::Csv.new valid_csv_with_missing_data
|
||||
assert csv.valid?
|
||||
end
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ class ImportTest < ActiveSupport::TestCase
|
||||
|
||||
setup do
|
||||
@empty_import = imports(:empty_import)
|
||||
@loaded_import = imports(:loaded_import)
|
||||
|
||||
@loaded_import = @empty_import.dup
|
||||
@loaded_import.update! raw_csv_str: valid_csv_str
|
||||
end
|
||||
|
||||
test "raw csv input must conform to csv spec" do
|
||||
@@ -39,7 +41,14 @@ class ImportTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "publishes a valid import" do
|
||||
assert_difference "Transaction.count", 2 do
|
||||
# Import has 3 unique categories: "Food & Drink", "Income", and "Shopping" (x2)
|
||||
# Fixtures already define "Food & Drink" and "Income", so these should not be created
|
||||
# "Shopping" is a new category, but should only be created 1x during import
|
||||
assert_difference \
|
||||
-> { Transaction.count } => 4,
|
||||
-> { Transaction::Category.count } => 1,
|
||||
-> { Tagging.count } => 4,
|
||||
-> { Tag.count } => 2 do
|
||||
@loaded_import.publish
|
||||
end
|
||||
|
||||
@@ -48,6 +57,19 @@ class ImportTest < ActiveSupport::TestCase
|
||||
assert @loaded_import.complete?
|
||||
end
|
||||
|
||||
test "publishes a valid import with missing data" do
|
||||
@empty_import.update! raw_csv_str: valid_csv_with_missing_data
|
||||
assert_difference -> { Transaction::Category.count } => 1, -> { Transaction.count } => 2 do
|
||||
@empty_import.publish
|
||||
end
|
||||
|
||||
assert_not_nil Transaction.find_sole_by(name: Import::FALLBACK_TRANSACTION_NAME)
|
||||
|
||||
@empty_import.reload
|
||||
|
||||
assert @empty_import.complete?
|
||||
end
|
||||
|
||||
test "failed publish results in error status" do
|
||||
@empty_import.update! raw_csv_str: valid_csv_with_invalid_values
|
||||
|
||||
|
||||
18
test/models/tag_test.rb
Normal file
18
test/models/tag_test.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
require "test_helper"
|
||||
|
||||
class TagTest < ActiveSupport::TestCase
|
||||
test "replace and destroy" do
|
||||
old_tag = tags(:hawaii_trip)
|
||||
new_tag = tags(:trips)
|
||||
|
||||
assert_difference "Tag.count", -1 do
|
||||
old_tag.replace_and_destroy!(new_tag)
|
||||
end
|
||||
|
||||
old_tag.transactions.each do |txn|
|
||||
txn.reload
|
||||
assert_includes txn.tags, new_tag
|
||||
assert_not_includes txn.tags, old_tag
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,24 +1,26 @@
|
||||
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
|
||||
date,name,category,tags,amount
|
||||
2024-01-01,Starbucks drink,Food & Drink,Tag1|Tag2,-8.55
|
||||
2024-01-01,Etsy,Shopping,Tag1,-80.98
|
||||
2024-01-02,Amazon stuff,Shopping,Tag2,-200
|
||||
2024-01-03,Paycheck,Income,,1000
|
||||
ROWS
|
||||
end
|
||||
|
||||
def valid_csv_with_invalid_values
|
||||
<<-ROWS
|
||||
date,name,category,amount
|
||||
invalid_date,Starbucks drink,Food,invalid_amount
|
||||
date,name,category,tags,amount
|
||||
invalid_date,Starbucks drink,Food,,invalid_amount
|
||||
ROWS
|
||||
end
|
||||
|
||||
def valid_csv_with_extra_column
|
||||
def valid_csv_with_missing_data
|
||||
<<-ROWS
|
||||
date,name,category,"optional id",amount
|
||||
2024-01-01,Starbucks drink,Food,1234,20
|
||||
2024-01-02,Amazon stuff,Shopping,,200
|
||||
2024-01-01,Drink,Food,1234,-200
|
||||
2024-01-02,,,,-100
|
||||
ROWS
|
||||
end
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class SettingsTest < ApplicationSystemTestCase
|
||||
[ "Security", "Security", settings_security_path ],
|
||||
[ "Billing", "Billing", settings_billing_path ],
|
||||
[ "Accounts", "Accounts", accounts_path ],
|
||||
[ "Tags", "Tags", tags_path ],
|
||||
[ "Categories", "Categories", transaction_categories_path ],
|
||||
[ "Merchants", "Merchants", transaction_merchants_path ],
|
||||
[ "Rules", "Rules", transaction_rules_path ],
|
||||
|
||||
Reference in New Issue
Block a user