Compare commits

...

18 Commits

Author SHA1 Message Date
Zach Gollwitzer
8f356656fc Bump to v0.1.0-alpha.3 (#806) 2024-05-24 14:24:03 -04:00
Jakub Kottnauer
6e59fdb369 Add tag preview when importing (#800) 2024-05-24 10:39:24 -04:00
Zach Gollwitzer
457247da8e Create tagging system (#792)
* Repro

* Fix

* Update signage

* Create tagging system

* Add tags to transaction imports

* Build tagging UI

* Cleanup

* More cleanup
2024-05-23 08:09:33 -04:00
Zach Gollwitzer
41c991384a Fix duplicate category creation on import (#791)
* Repro

* Fix

* Update signage
2024-05-22 10:02:03 -04:00
Jakub Kottnauer
77f166a5f8 Ignore empty categories while importing (#789)
* Ignore empty categories while importing

* Review fixes
2024-05-22 08:12:56 -04:00
Jakub Kottnauer
ac27a1c87f Move category dropdown menu content into a turbo frame (#782)
* Move category dropdown menu content into a turbo frame

* Fix lint

* Review fixes

* Cleanup

* Review fixes

* Final cleanup

* Revert schema change
2024-05-22 06:31:25 -04:00
Jakub Kottnauer
32748b0632 Fix import crash with empty transaction name (#783) 2024-05-20 17:21:40 -04:00
Marco Kuper
444155c103 Fix issue with start_date not being set in account creation (#781) 2024-05-20 16:59:23 -04:00
Zach Gollwitzer
8654a98e6e Update feature-requests.yml
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-05-20 12:16:30 -04:00
Zach Gollwitzer
3dd67d3ed6 Merge remote-tracking branch 'origin/main' 2024-05-20 11:57:34 -04:00
Zach Gollwitzer
4efbb58197 Add feature request discussion template 2024-05-20 11:57:14 -04:00
Jakub Kottnauer
94345ddc3a Add migration to make all current users admins (#770) 2024-05-20 11:33:19 -04:00
Zach Gollwitzer
6212d57915 Update issue templates 2024-05-20 11:15:32 -04:00
dependabot[bot]
5f75e2e14f Bump rails from fb4300c to ed50b93 (#774)
Bumps [rails](https://github.com/rails/rails) from `fb4300c` to `ed50b93`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](fb4300ce19...ed50b93ebc)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-20 11:01:09 -04:00
dependabot[bot]
55f7cb1bc2 Bump mocha from 2.2.0 to 2.3.0 (#771)
Bumps [mocha](https://github.com/freerange/mocha) from 2.2.0 to 2.3.0.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.2.0...v2.3.0)

---
updated-dependencies:
- dependency-name: mocha
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-20 10:35:31 -04:00
dependabot[bot]
5ac3a808b2 Bump good_job from 3.28.2 to 3.28.3 (#773)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.28.2 to 3.28.3.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v3.28.2...v3.28.3)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-20 10:35:18 -04:00
Jakub Kottnauer
30c19b9d2e Show an error notification if account cannot be manually synced (#761) 2024-05-20 10:34:48 -04:00
Jakub Kottnauer
34811d8fd8 Fix currency when importing to foreign accounts (#762) 2024-05-20 09:55:45 -04:00
59 changed files with 877 additions and 289 deletions

View 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.

View File

@@ -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.

View File

@@ -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
View 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**

View File

@@ -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)

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
module TagsHelper
def null_tag
Tag.new \
name: "Uncategorized",
color: Tag::UNCATEGORIZED_COLOR
end
end

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -0,0 +1,4 @@
class Tagging < ApplicationRecord
belongs_to :tag
belongs_to :taggable, polymorphic: true
end

View File

@@ -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

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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") %> &middot; <%= 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 %>

View File

@@ -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>

View 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>

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

View 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>

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

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

View 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>

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

View File

@@ -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);

View File

@@ -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>

View File

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

View File

@@ -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>

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

View File

@@ -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>

View File

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

View File

@@ -10,7 +10,7 @@ module Maybe
private
def semver
"0.1.0-alpha.2"
"0.1.0-alpha.3"
end
end
end

View File

@@ -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

View File

@@ -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:

View 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

View File

@@ -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:

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class AddAdminRoleToCurrentUsers < ActiveRecord::Migration[7.2]
def up
User.update_all(role: "admin")
end
end

View 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

View 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
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_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

View File

@@ -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

View 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

View 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

View File

@@ -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
View 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
View 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

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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 ],