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

Create tagging system (#792)

* Repro

* Fix

* Update signage

* Create tagging system

* Add tags to transaction imports

* Build tagging UI

* Cleanup

* More cleanup
This commit is contained in:
Zach Gollwitzer 2024-05-23 08:09:33 -04:00 committed by GitHub
parent 41c991384a
commit 457247da8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 607 additions and 90 deletions

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

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

@ -111,16 +111,24 @@ class Import < ApplicationRecord
def generate_transactions
transactions = []
category_cache = {}
tag_cache = {}
csv.table.each do |row|
category_name = row["category"]
category_name = row["category"].presence
tag_strings = row["tags"].presence&.split("|") || []
tags = []
category = category_cache[category_name] ||= account.family.transaction_categories.find_or_initialize_by(name: category_name) if row["category"].present?
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)
txn = account.transactions.build \
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
date: Date.iso8601(row["date"]),
category: category,
tags: tags,
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
currency: account.currency
@ -144,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

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

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

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

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

@ -37,6 +37,10 @@ Rails.application.routes.draw do
end
end
resources :tags, except: %i[ show destroy ] do
resources :deletions, only: %i[ new create ], module: :tags
end
resources :transactions do
collection do
match "search" => "transactions#search", via: %i[ get post ]

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_20_074309) 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_20_074309) 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_20_074309) 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_20_074309) 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,22 +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 & Drink,-8.55
2024-01-01,Etsy,Shopping,-80.98
2024-01-02,Amazon stuff,Shopping,-200
2024-01-03,Paycheck,Income,1000
normalized_csv_str: |
date,name,category,amount
2024-01-01,Starbucks drink,Food & Drink,-8.55
2024-01-01,Etsy,Shopping,-80.98
2024-01-02,Amazon stuff,Shopping,-200
2024-01-03,Paycheck,Income,1000
created_at: <%= 2.days.ago %>
completed_import:
account: checking
column_mappings:
@ -26,11 +10,11 @@ completed_import:
category: category
amount: amount
raw_csv_str: |
date,name,category,amount
2024-01-01,Starbucks drink,Food & Drink,-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 & Drink,-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

@ -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
@ -42,7 +44,11 @@ class ImportTest < ActiveSupport::TestCase
# 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 do
assert_difference \
-> { Transaction.count } => 4,
-> { Transaction::Category.count } => 1,
-> { Tagging.count } => 4,
-> { Tag.count } => 2 do
@loaded_import.publish
end

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,17 +1,18 @@
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
2024-01-03,Paycheck,Income,1000
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

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