From 8a7657fe711941a462f3d019d05f70c122a98e65 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 14:20:16 +0200 Subject: [PATCH 01/18] feat(pockets): implement pockets feature --- app/components/UI/account_page.rb | 4 + app/controllers/accounts_controller.rb | 6 + app/controllers/pockets_controller.rb | 84 ++++++ app/models/account.rb | 9 + app/models/family/data_exporter.rb | 35 +++ app/models/pocket.rb | 108 +++++++ app/models/tagging.rb | 22 ++ app/views/accounts/pockets/_index.html.erb | 57 ++++ app/views/pockets/_form.html.erb | 46 +++ app/views/pockets/_pocket.html.erb | 104 +++++++ app/views/pockets/edit.html.erb | 10 + app/views/pockets/new.html.erb | 10 + app/views/transactions/_transaction.html.erb | 14 + config/locales/models/pocket/en.yml | 19 ++ config/locales/views/pockets/en.yml | 45 +++ config/locales/views/transactions/en.yml | 1 + config/routes.rb | 1 + .../20260603152000_align_pockets_schema.rb | 24 ++ .../20260603153000_add_tag_to_pockets.rb | 7 + ...603160000_add_fill_direction_to_pockets.rb | 8 + db/schema.rb | 20 +- test/controllers/pockets_controller_test.rb | 84 ++++++ test/fixtures/pockets.yml | 13 + test/models/pocket_test.rb | 264 ++++++++++++++++++ 24 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 app/controllers/pockets_controller.rb create mode 100644 app/models/pocket.rb create mode 100644 app/views/accounts/pockets/_index.html.erb create mode 100644 app/views/pockets/_form.html.erb create mode 100644 app/views/pockets/_pocket.html.erb create mode 100644 app/views/pockets/edit.html.erb create mode 100644 app/views/pockets/new.html.erb create mode 100644 config/locales/models/pocket/en.yml create mode 100644 config/locales/views/pockets/en.yml create mode 100644 db/migrate/20260603152000_align_pockets_schema.rb create mode 100644 db/migrate/20260603153000_add_tag_to_pockets.rb create mode 100644 db/migrate/20260603160000_add_fill_direction_to_pockets.rb create mode 100644 test/controllers/pockets_controller_test.rb create mode 100644 test/fixtures/pockets.yml create mode 100644 test/models/pocket_test.rb diff --git a/app/components/UI/account_page.rb b/app/components/UI/account_page.rb index 36be3c2b24..6e2e054f80 100644 --- a/app/components/UI/account_page.rb +++ b/app/components/UI/account_page.rb @@ -52,6 +52,8 @@ def tabs [ :activity ] end + base_tabs += [ :pockets ] if account.asset? + base_tabs + [ :statements ] end @@ -79,6 +81,8 @@ def tab_content_for(tab) when :holdings, :overview # Accountable is responsible for implementing the partial in the correct folder render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account + when :pockets + render "accounts/pockets/index", account: account when :statements render_statement_tab end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 6ce5e02c6c..60af1662c8 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -64,6 +64,12 @@ def show ) Transaction::ActivitySecurityPreloader.new(@entries).preload + # Preload taggings for transaction entries (needed for pocket indicators) + transaction_entryables = @entries.select(&:transaction?).map(&:entryable) + ActiveRecord::Associations::Preloader.new(records: transaction_entryables, associations: :taggings).call if transaction_entryables.any? + + @pocket_by_tag_id = @account.pockets.where.not(tag_id: nil).index_by(&:tag_id) + @activity_feed_data = Account::ActivityFeedData.new(@account, @entries) end diff --git a/app/controllers/pockets_controller.rb b/app/controllers/pockets_controller.rb new file mode 100644 index 0000000000..124cade787 --- /dev/null +++ b/app/controllers/pockets_controller.rb @@ -0,0 +1,84 @@ +class PocketsController < ApplicationController + before_action :set_account + before_action :set_pocket, only: %i[edit update destroy] + before_action :set_available_tags, only: %i[new create edit update] + + def index + redirect_to account_path(@account, tab: :pockets) + end + + def new + @pocket = @account.pockets.new(currency: @account.currency) + end + + def create + @pocket = @account.pockets.new(pocket_params) + @pocket.currency = @account.currency + + if @pocket.save + respond_to do |format| + format.turbo_stream { render_pocket_streams(t("pockets.create.success")) } + format.html { redirect_to account_path(@account, tab: :pockets), notice: t("pockets.create.success") } + end + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @pocket.update(pocket_params) + respond_to do |format| + format.turbo_stream { render_pocket_streams(t("pockets.update.success")) } + format.html { redirect_to account_path(@account, tab: :pockets), notice: t("pockets.update.success") } + end + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @pocket.destroy + + respond_to do |format| + format.turbo_stream { render_pocket_streams(t("pockets.destroy.success")) } + format.html { redirect_to account_path(@account, tab: :pockets), notice: t("pockets.destroy.success") } + end + end + + private + + def render_pocket_streams(notice) + render turbo_stream: [ + turbo_stream.replace("modal", ""), + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(@account, :pockets_content), + partial: "accounts/pockets/index", + locals: { account: @account } + ) + ] + end + + def set_account + @account = Current.user.accessible_accounts.find(params[:account_id]) + end + + def set_pocket + @pocket = @account.pockets.find(params[:id]) + end + + def set_available_tags + already_linked_tag_ids = @account.pockets.where.not(tag_id: nil).pluck(:tag_id) + already_linked_tag_ids -= [ @pocket&.tag_id ].compact + + @available_tags = Current.family.tags + .alphabetically + .where.not(id: already_linked_tag_ids) + end + + def pocket_params + params.require(:pocket).permit(:name, :allocated_amount, :tag_id, :fill_direction) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 48a64b7966..46df682c8d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -25,6 +25,7 @@ class Account < ApplicationRecord has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy has_many :recurring_transactions, dependent: :destroy + has_many :pockets, dependent: :destroy has_many :goal_accounts, dependent: :destroy has_many :goals, through: :goal_accounts has_many :goal_pledges, dependent: :destroy @@ -493,6 +494,14 @@ def balance_type end end + def free_balance + balance - pockets.sum(:allocated_amount) + end + + def pockets_overflow? + pockets.sum(:allocated_amount) > balance + end + def owned_by?(user) user.present? && owner_id == user.id end diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 20e08a70d9..59a2f427aa 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -35,6 +35,10 @@ def generate_export zipfile.put_next_entry("rules.csv") zipfile.write generate_rules_csv + # Add pockets.csv + zipfile.put_next_entry("pockets.csv") + zipfile.write generate_pockets_csv + # Add attachment manifest metadata. Binary file payloads are not included. zipfile.put_next_entry("attachments.json") zipfile.write generate_attachments_manifest @@ -157,6 +161,27 @@ def generate_rules_csv end end + def generate_pockets_csv + CSV.generate do |csv| + csv << [ "id", "account_name", "name", "allocated_amount", "currency", "fill_direction", "tag", "created_at" ] + + @family.accounts.find_each do |account| + account.pockets.includes(:tag).find_each do |pocket| + csv << [ + pocket.id, + account.name, + pocket.name, + pocket.allocated_amount.to_s, + pocket.currency, + pocket.fill_direction, + pocket.tag&.name, + pocket.created_at.iso8601 + ] + end + end + end + end + def generate_attachments_manifest { version: 1, @@ -292,6 +317,16 @@ def generate_ndjson }.to_json end + # Export pockets + @family.accounts.find_each do |account| + account.pockets.find_each do |pocket| + lines << { + type: "Pocket", + data: pocket.as_json + }.to_json + end + end + # Export recurring transactions after accounts and merchants so import can remap dependencies. @family.recurring_transactions.includes(:account, :merchant).find_each do |recurring_transaction| lines << { diff --git a/app/models/pocket.rb b/app/models/pocket.rb new file mode 100644 index 0000000000..47c965fdef --- /dev/null +++ b/app/models/pocket.rb @@ -0,0 +1,108 @@ +class Pocket < ApplicationRecord + include Monetizable + + belongs_to :account + belongs_to :tag, optional: true + + enum :fill_direction, { inflows: "inflows", outflows: "outflows", both: "both" }, default: :inflows + + validates :name, :currency, presence: true + validates :allocated_amount, numericality: { greater_than_or_equal_to: 0 } + validates :tag_id, uniqueness: { scope: :account_id, allow_nil: true } + validate :total_pockets_within_account_balance + validate :tag_belongs_to_same_family + + after_save :sync_from_tag, if: -> { saved_change_to_tag_id? || saved_change_to_fill_direction? } + + PALETTE = %w[#875BF7 #6471EB #4DA568 #E99537 #DB5A54 #DF4E92 #61C9EA #805DEE].freeze + + monetize :allocated_amount + + def display_color + tag&.color.presence || PALETTE[id.bytes.sum % PALETTE.size] + end + + def allocation_percent(balance) + return 0 if balance.nil? || balance <= 0 + + [ (allocated_amount / balance.to_f * 100).round, 100 ].min + end + + def apply_tagging(tagging) + amount = tagging_transaction_amount(tagging) + return unless amount + + increment!(:allocated_amount, amount) + end + + def reverse_tagging(tagging) + amount = tagging_transaction_amount(tagging) + return unless amount + + decrement!(:allocated_amount, [ amount, allocated_amount ].min) + end + + private + + def sync_from_tag + _, new_tag_id = saved_change_to_tag_id || [ nil, tag_id ] + + # Full recompute: replace current amount with the fresh sum from DB + new_amount = new_tag_id.present? ? tagged_transaction_total(new_tag_id) : 0 + update_column(:allocated_amount, new_amount) + end + + def direction_condition + case fill_direction + when "inflows" then "entries.amount < 0" + when "outflows" then "entries.amount > 0" + else nil + end + end + + def tagged_transaction_total(tag_id) + query = Entry.joins( + "INNER JOIN transactions ON transactions.id = entries.entryable_id + AND entries.entryable_type = 'Transaction'" + ).joins( + "INNER JOIN taggings ON taggings.taggable_id = transactions.id + AND taggings.taggable_type = 'Transaction'" + ).where(entries: { account_id: account_id }) + .where(taggings: { tag_id: tag_id }) + + query = query.where(direction_condition) if direction_condition + query.sum("ABS(entries.amount)") + end + + def tagging_transaction_amount(tagging) + return nil unless tagging.taggable_type == "Transaction" + + amount = tagging.taggable.entry&.amount + return nil unless amount + + case fill_direction + when "inflows" then amount < 0 ? amount.abs : nil + when "outflows" then amount > 0 ? amount : nil + else amount.abs + end + end + + def total_pockets_within_account_balance + return unless account && allocated_amount + + sibling_total = account.pockets.where.not(id: id).sum(:allocated_amount) + if sibling_total + allocated_amount > account.balance + errors.add(:allocated_amount, :exceeds_account_balance, + available: account.balance - sibling_total, + currency: account.currency) + end + end + + def tag_belongs_to_same_family + return unless tag && account + + unless tag.family_id == account.family_id + errors.add(:tag, :wrong_family) + end + end +end diff --git a/app/models/tagging.rb b/app/models/tagging.rb index e608dcdcdc..fed8422b38 100644 --- a/app/models/tagging.rb +++ b/app/models/tagging.rb @@ -1,4 +1,26 @@ class Tagging < ApplicationRecord belongs_to :tag belongs_to :taggable, polymorphic: true + + after_create :fill_linked_pocket + before_destroy :unfill_linked_pocket + + private + + def fill_linked_pocket + linked_pocket&.apply_tagging(self) + end + + def unfill_linked_pocket + linked_pocket&.reverse_tagging(self) + end + + def linked_pocket + return unless taggable_type == "Transaction" + + account = taggable.entry&.account + return unless account + + account.pockets.find_by(tag_id: tag_id) + end end diff --git a/app/views/accounts/pockets/_index.html.erb b/app/views/accounts/pockets/_index.html.erb new file mode 100644 index 0000000000..4baa465cfe --- /dev/null +++ b/app/views/accounts/pockets/_index.html.erb @@ -0,0 +1,57 @@ +<%# locals: (account:) %> + +<% pockets = account.pockets.includes(:tag).order(:name) %> +<% overflow = account.pockets_overflow? %> + +
+ + <%# ── Summary bar ── %> +
+
+

<%= t("pockets.index.total_allocated") %>

+

+ <%= format_money Money.new(account.pockets.sum(:allocated_amount), account.currency) %> +

+
+ + + +
+

<%= t("pockets.index.free_balance") %>

+

"> + <%= format_money Money.new(account.free_balance, account.currency) %> +

+
+ + <% if overflow %> +
+ <%= render DS::Alert.new(message: t("pockets.index.overflow_warning"), variant: :warning) %> +
+ <% end %> + +
+ <%= render DS::Link.new( + text: t("pockets.index.new"), + icon: "plus", + variant: "primary", + href: new_account_pocket_path(account), + frame: :modal + ) %> +
+
+ + <%# ── Cards grid ── %> + <% if pockets.any? %> +
+ <% pockets.each do |pocket| %> + <%= render "pockets/pocket", pocket: pocket, account: account, overflow: overflow %> + <% end %> +
+ <% else %> +
+ <%= icon("wallet", class: "w-8 h-8 text-secondary mx-auto mb-3") %> +

<%= t("pockets.index.empty") %>

+
+ <% end %> + +
diff --git a/app/views/pockets/_form.html.erb b/app/views/pockets/_form.html.erb new file mode 100644 index 0000000000..1e8543190c --- /dev/null +++ b/app/views/pockets/_form.html.erb @@ -0,0 +1,46 @@ +<%# locals: (pocket:, account:, url:, available_tags:) %> + +<%= styled_form_with model: [ account, pocket ], url: url, class: "space-y-4" do |f| %> + <%= f.text_field :name, + label: t("pockets.form.name"), + required: true, + autofocus: true, + placeholder: t("pockets.form.name_placeholder") %> + + <%= f.money_field :allocated_amount, + label: t("pockets.form.allocated_amount"), + required: true, + disable_currency: true %> + + <%= f.collection_select :tag_id, + available_tags, + :id, :name, + { label: t("pockets.form.auto_fill_tag"), prompt: t("pockets.form.no_tag") }, + {} %> + + <% if pocket.tag_id.present? %> + <%= f.select :fill_direction, + Pocket.fill_directions.keys.map { |d| [ t("pockets.form.fill_direction.#{d}"), d ] }, + { label: t("pockets.form.fill_direction_label") }, + {} %> + +
+ <%= icon("zap", size: "sm", class: "shrink-0 mt-0.5 text-warning") %> + <%= t("pockets.form.auto_fill_hint") %> +
+ <% end %> + + <% if account.pockets.any? || pocket.persisted? %> +
+ <%= icon("info", size: "sm", class: "shrink-0") %> + + <%= t("pockets.form.free_balance_hint", + amount: format_money(Money.new(account.free_balance + (pocket.allocated_amount_was || 0), account.currency))) %> + +
+ <% end %> + +
+ <%= f.submit t("pockets.form.save") %> +
+<% end %> diff --git a/app/views/pockets/_pocket.html.erb b/app/views/pockets/_pocket.html.erb new file mode 100644 index 0000000000..36270acc75 --- /dev/null +++ b/app/views/pockets/_pocket.html.erb @@ -0,0 +1,104 @@ +<%# locals: (pocket:, account:, overflow:) %> + +<% color = pocket.display_color %> +<% percent = pocket.allocation_percent(account.balance) %> +<% circumference = 94.25 %> +<% dash = (percent * circumference / 100.0).round(1) %> + +
+ + <%# ── Top row: icon · title · badge · donut ── %> +
+ +
+ <%# Colored circle with initial %> +
+ <%= pocket.name.first.upcase %> +
+ +
+
+

<%= pocket.name %>

+ <% if overflow %> + <%= render DS::Pill.new(label: t("pockets.pocket.overflow_badge"), tone: :warning, marker: false, show_dot: false) %> + <% end %> +
+ + <% if pocket.tag %> +

+ <%= icon("zap", size: "xs", class: "text-warning shrink-0") %> + <%= pocket.tag.name %> +

+ <% else %> +

<%= t("pockets.pocket.manual") %>

+ <% end %> +
+
+ + <%# Donut chart %> +
+ + + <%= percent %>% + +
+
+ + <%# ── Amount ── %> +
+

+ <%= format_money pocket.allocated_amount_money %> +

+

+ <%= t("pockets.pocket.of_balance", balance: format_money(account.balance_money)) %> +

+
+ + <%# ── Footer ── %> +
+ <% if pocket.tag %> +

+ <%= t("pockets.pocket.auto_fills_from", tag: pocket.tag.name) %> +

+ <% else %> +

<%= t("pockets.pocket.no_auto_fill") %>

+ <% end %> + +
+ <% if pocket.tag %> + <%= render DS::Link.new( + variant: "ghost", + icon: "list", + href: transactions_path(q: { tags: [ pocket.tag.name ] }), + title: t("pockets.pocket.view_transactions"), + frame: :_top + ) %> + <% end %> + + <%= render DS::Link.new( + variant: "ghost", + icon: "pencil", + href: edit_account_pocket_path(account, pocket), + frame: :modal + ) %> + + <%= button_to account_pocket_path(account, pocket), + method: :delete, + class: "inline-flex items-center justify-center w-8 h-8 rounded-md text-secondary hover:text-destructive hover:bg-destructive/10 transition-colors", + data: { turbo_confirm: t("pockets.destroy.confirm", name: pocket.name) } do %> + <%= icon("trash-2", size: "sm") %> + <% end %> +
+
+ +
diff --git a/app/views/pockets/edit.html.erb b/app/views/pockets/edit.html.erb new file mode 100644 index 0000000000..afd5d62013 --- /dev/null +++ b/app/views/pockets/edit.html.erb @@ -0,0 +1,10 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> + <%= render "form", + pocket: @pocket, + account: @account, + url: account_pocket_path(@account, @pocket), + available_tags: @available_tags %> + <% end %> +<% end %> diff --git a/app/views/pockets/new.html.erb b/app/views/pockets/new.html.erb new file mode 100644 index 0000000000..0d6abcd47d --- /dev/null +++ b/app/views/pockets/new.html.erb @@ -0,0 +1,10 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> + <%= render "form", + pocket: @pocket, + account: @account, + url: account_pockets_path(@account), + available_tags: @available_tags %> + <% end %> +<% end %> diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index 9aff39d030..3d4f105ffd 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -147,6 +147,20 @@ <% if transaction.transfer.present? %> <%= render "transactions/transfer_match", transaction: transaction %> <% end %> + + <%# Pocket fill indicator %> + <% if defined?(@pocket_by_tag_id) && @pocket_by_tag_id.present? %> + <% pocket = transaction.taggings.find { |tg| @pocket_by_tag_id[tg.tag_id] }&.then { |tg| @pocket_by_tag_id[tg.tag_id] } %> + <% if pocket %> + <%= render DS::Pill.new( + label: pocket.name, + tone: :violet, + marker: false, + icon: "wallet", + title: t("transactions.transaction.fills_pocket", pocket: pocket.name) + ) %> + <% end %> + <% end %> diff --git a/config/locales/models/pocket/en.yml b/config/locales/models/pocket/en.yml new file mode 100644 index 0000000000..e3cb350a58 --- /dev/null +++ b/config/locales/models/pocket/en.yml @@ -0,0 +1,19 @@ +--- +en: + activerecord: + models: + pocket: Pocket + attributes: + pocket: + name: Name + allocated_amount: Allocated amount + currency: Currency + tag: Auto-fill tag + errors: + models: + pocket: + attributes: + allocated_amount: + exceeds_account_balance: "exceeds the available balance (max: %{available} %{currency})" + tag: + wrong_family: "does not belong to this family" diff --git a/config/locales/views/pockets/en.yml b/config/locales/views/pockets/en.yml new file mode 100644 index 0000000000..e57d7a10a6 --- /dev/null +++ b/config/locales/views/pockets/en.yml @@ -0,0 +1,45 @@ +--- +en: + pockets: + index: + new: "New pocket" + total_allocated: "Allocated" + free_balance: "Free balance" + empty: "No pockets yet. Create one to start allocating funds." + overflow_warning: "Allocated pockets exceed the current account balance." + new: + title: "New pocket" + edit: + title: "Edit pocket" + form: + name: "Name" + name_placeholder: "e.g. Emergency fund" + allocated_amount: "Allocated amount" + auto_fill_tag: "Auto-fill from tag" + no_tag: "None (manual only)" + fill_direction_label: "Count transactions" + fill_direction: + inflows: "Deposits only (money coming in)" + outflows: "Expenses only (money going out)" + both: "All tagged transactions" + auto_fill_hint: "Transactions tagged with this tag will automatically update this pocket." + free_balance_hint: "Available to allocate: %{amount}" + save: "Save" + pocket: + overflow_badge: "OVERFLOW" + manual: "Manual" + auto_fills_from: "Auto-fills from \"%{tag}\"" + no_auto_fill: "No auto-fill linked" + of_balance: "of %{balance}" + view_transactions: "View tagged transactions" + create: + success: "Pocket created." + update: + success: "Pocket updated." + destroy: + confirm: "Delete pocket \"%{name}\"?" + success: "Pocket deleted." + accounts: + show: + tabs: + pockets: "Pockets" diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index 147a601d03..2eb1838614 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -147,6 +147,7 @@ en: split: Split split_tooltip: This transaction has been split into multiple entries split_child_tooltip: Part of a split transaction + fills_pocket: "Fills pocket: %{pocket}" merge_duplicate: success: Transactions merged successfully failure: Could not merge transactions diff --git a/config/routes.rb b/config/routes.rb index 53bdc864ab..7c5f614000 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -455,6 +455,7 @@ end resource :sharing, only: [ :show, :update ], controller: "account_sharings" + resources :pockets, only: %i[index new create edit update destroy], shallow: false end resources :account_statements, only: %i[index show create update destroy] do diff --git a/db/migrate/20260603152000_align_pockets_schema.rb b/db/migrate/20260603152000_align_pockets_schema.rb new file mode 100644 index 0000000000..edfc163504 --- /dev/null +++ b/db/migrate/20260603152000_align_pockets_schema.rb @@ -0,0 +1,24 @@ +class AlignPocketsSchema < ActiveRecord::Migration[7.2] + def up + # The pockets table was created by a prior migration that is no longer on disk. + # Its schema used the money-rails pattern (amount_cents/amount_currency). + # This migration aligns it with the rest of the codebase which stores + # monetary values as decimal with a separate string currency column. + remove_column :pockets, :amount_cents + remove_column :pockets, :amount_currency + + add_column :pockets, :allocated_amount, :decimal, precision: 19, scale: 4, null: false, default: "0.0" + add_column :pockets, :currency, :string, null: false, default: "" + + add_check_constraint :pockets, "allocated_amount >= 0", name: "chk_pockets_allocated_amount_non_negative" + end + + def down + remove_check_constraint :pockets, name: "chk_pockets_allocated_amount_non_negative" + remove_column :pockets, :currency + remove_column :pockets, :allocated_amount + + add_column :pockets, :amount_cents, :integer + add_column :pockets, :amount_currency, :string + end +end diff --git a/db/migrate/20260603153000_add_tag_to_pockets.rb b/db/migrate/20260603153000_add_tag_to_pockets.rb new file mode 100644 index 0000000000..36dea05d5b --- /dev/null +++ b/db/migrate/20260603153000_add_tag_to_pockets.rb @@ -0,0 +1,7 @@ +class AddTagToPockets < ActiveRecord::Migration[7.2] + def change + add_reference :pockets, :tag, type: :uuid, foreign_key: true, null: true + add_index :pockets, [ :account_id, :tag_id ], unique: true, where: "tag_id IS NOT NULL", + name: "index_pockets_on_account_and_tag_unique" + end +end diff --git a/db/migrate/20260603160000_add_fill_direction_to_pockets.rb b/db/migrate/20260603160000_add_fill_direction_to_pockets.rb new file mode 100644 index 0000000000..a66f3eae75 --- /dev/null +++ b/db/migrate/20260603160000_add_fill_direction_to_pockets.rb @@ -0,0 +1,8 @@ +class AddFillDirectionToPockets < ActiveRecord::Migration[7.2] + def change + add_column :pockets, :fill_direction, :string, null: false, default: "inflows" + add_check_constraint :pockets, + "fill_direction IN ('inflows', 'outflows', 'both')", + name: "chk_pockets_fill_direction" + end +end diff --git a/db/schema.rb b/db/schema.rb index 7f67f2646f..51b10df335 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_03_160000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1487,6 +1487,22 @@ t.index ["plaid_id"], name: "index_plaid_items_on_plaid_id", unique: true end + create_table "pockets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "account_id", null: false + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.decimal "allocated_amount", precision: 19, scale: 4, default: "0.0", null: false + t.string "currency", default: "", null: false + t.uuid "tag_id" + t.string "fill_direction", default: "inflows", null: false + t.index ["account_id", "tag_id"], name: "index_pockets_on_account_and_tag_unique", unique: true, where: "(tag_id IS NOT NULL)" + t.index ["account_id"], name: "index_pockets_on_account_id" + t.index ["tag_id"], name: "index_pockets_on_tag_id" + t.check_constraint "allocated_amount >= 0::numeric", name: "chk_pockets_allocated_amount_non_negative" + t.check_constraint "fill_direction::text = ANY (ARRAY['inflows'::character varying, 'outflows'::character varying, 'both'::character varying]::text[])", name: "chk_pockets_fill_direction" + end + create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -2127,6 +2143,8 @@ add_foreign_key "oidc_identities", "users" add_foreign_key "plaid_accounts", "plaid_items" add_foreign_key "plaid_items", "families" + add_foreign_key "pockets", "accounts" + add_foreign_key "pockets", "tags" add_foreign_key "recurring_transactions", "accounts", column: "destination_account_id", on_delete: :cascade add_foreign_key "recurring_transactions", "accounts", on_delete: :cascade add_foreign_key "recurring_transactions", "families" diff --git a/test/controllers/pockets_controller_test.rb b/test/controllers/pockets_controller_test.rb new file mode 100644 index 0000000000..f5c899a597 --- /dev/null +++ b/test/controllers/pockets_controller_test.rb @@ -0,0 +1,84 @@ +require "test_helper" + +class PocketsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @account = accounts(:depository) + @pocket = pockets(:emergency_fund) + end + + test "index redirects to account pockets tab" do + get account_pockets_url(@account) + assert_redirected_to account_path(@account, tab: :pockets) + end + + test "new returns success" do + get new_account_pocket_url(@account) + assert_response :success + end + + test "create pocket with valid params" do + assert_difference "Pocket.count", 1 do + post account_pockets_url(@account), params: { + pocket: { name: "New Pocket", allocated_amount: 200 } + } + end + assert_redirected_to account_path(@account, tab: :pockets) + end + + test "create pocket with auto-fill tag" do + tag = tags(:two) + assert_difference "Pocket.count", 1 do + post account_pockets_url(@account), params: { + pocket: { name: "Tagged Pocket", allocated_amount: 100, tag_id: tag.id } + } + end + created = @account.pockets.find_by!(name: "Tagged Pocket") + assert_equal tag.id, created.tag_id + end + + test "create pocket with invalid params returns unprocessable" do + assert_no_difference "Pocket.count" do + post account_pockets_url(@account), params: { + pocket: { name: "", allocated_amount: 200 } + } + end + assert_response :unprocessable_entity + end + + test "create pocket exceeding balance returns unprocessable" do + assert_no_difference "Pocket.count" do + post account_pockets_url(@account), params: { + pocket: { name: "Too Big", allocated_amount: 10_000 } + } + end + assert_response :unprocessable_entity + end + + test "edit returns success" do + get edit_account_pocket_url(@account, @pocket) + assert_response :success + end + + test "update pocket with valid params" do + patch account_pocket_url(@account, @pocket), params: { + pocket: { name: "Renamed", allocated_amount: 800 } + } + assert_redirected_to account_path(@account, tab: :pockets) + assert_equal "Renamed", @pocket.reload.name + assert_equal 800, @pocket.reload.allocated_amount + end + + test "destroy pocket" do + assert_difference "Pocket.count", -1 do + delete account_pocket_url(@account, @pocket) + end + assert_redirected_to account_path(@account, tab: :pockets) + end + + test "cannot access pockets of another family account" do + sign_in users(:empty) + get account_pockets_url(@account) + assert_response :not_found + end +end diff --git a/test/fixtures/pockets.yml b/test/fixtures/pockets.yml new file mode 100644 index 0000000000..5f5241452d --- /dev/null +++ b/test/fixtures/pockets.yml @@ -0,0 +1,13 @@ +emergency_fund: + account: depository + name: Emergency Fund + allocated_amount: 1000 + currency: USD + +vacation: + account: depository + name: Vacation + allocated_amount: 500 + currency: USD + tag: one + fill_direction: both diff --git a/test/models/pocket_test.rb b/test/models/pocket_test.rb new file mode 100644 index 0000000000..b935f05b4a --- /dev/null +++ b/test/models/pocket_test.rb @@ -0,0 +1,264 @@ +require "test_helper" + +class PocketTest < ActiveSupport::TestCase + setup do + @account = accounts(:depository) # balance: 5000 USD + @pocket = pockets(:emergency_fund) # allocated: 1000 USD, no tag + @tagged_pocket = pockets(:vacation) # allocated: 500 USD, tag: one + end + + test "valid pocket saves" do + pocket = @account.pockets.new(name: "Savings", allocated_amount: 100, currency: "USD") + assert pocket.valid? + end + + test "requires name" do + pocket = @account.pockets.new(allocated_amount: 100, currency: "USD") + assert_not pocket.valid? + assert_includes pocket.errors[:name], I18n.t("errors.messages.blank") + end + + test "allocated_amount must be non-negative" do + pocket = @account.pockets.new(name: "Bad", allocated_amount: -1, currency: "USD") + assert_not pocket.valid? + assert pocket.errors[:allocated_amount].any? + end + + test "pocket can be zero" do + pocket = @account.pockets.new(name: "Empty", allocated_amount: 0, currency: "USD") + assert pocket.valid? + end + + test "total pockets cannot exceed account balance on create" do + # account balance is 5000, existing pockets sum to 1500 (1000 + 500) + # adding 3600 would push total to 5100 > 5000 + pocket = @account.pockets.new(name: "Too big", allocated_amount: 3600, currency: "USD") + assert_not pocket.valid? + assert pocket.errors[:allocated_amount].any? + end + + test "total pockets at exactly account balance is valid" do + # existing pockets sum to 1500, account balance is 5000 → max new = 3500 + pocket = @account.pockets.new(name: "Max", allocated_amount: 3500, currency: "USD") + assert pocket.valid? + end + + test "updating a pocket recalculates correctly excluding self" do + # emergency_fund is 1000; vacation is 500 → total 1500 allocated + # increasing emergency_fund to 4500 would still be within 5000 + @pocket.allocated_amount = 4500 + assert @pocket.valid? + end + + test "tag_id must be unique per account" do + pocket = @account.pockets.new(name: "Dupe", allocated_amount: 100, currency: "USD", tag: tags(:one)) + assert_not pocket.valid? + assert pocket.errors[:tag_id].any? + end + + # Account#free_balance and Account#pockets_overflow? + + test "free_balance equals balance minus sum of pockets" do + # 5000 - (1000 + 500) = 3500 + assert_equal 3500, @account.free_balance + end + + test "pockets_overflow? is false when pockets are within balance" do + assert_not @account.pockets_overflow? + end + + test "pockets_overflow? is true when account balance drops below pockets total" do + @account.update_column(:balance, 1000) + assert @account.pockets_overflow? + end + + # Auto-fill via Tagging + + test "creating a tagging fills linked pocket" do + transaction = transactions(:one) + entry = entries(:transaction) + assert_equal @account, entry.account + + assert_difference "@tagged_pocket.reload.allocated_amount", entry.amount.abs do + Tagging.create!(tag: tags(:one), taggable: transaction) + end + end + + test "destroying a tagging unfills linked pocket" do + transaction = transactions(:one) + tagging = Tagging.create!(tag: tags(:one), taggable: transaction) + entry = entries(:transaction) + + assert_difference "@tagged_pocket.reload.allocated_amount", -entry.amount.abs do + tagging.destroy! + end + end + + test "tagging with unlinked tag does not affect any pocket" do + transaction = transactions(:one) + + assert_no_difference "@pocket.reload.allocated_amount" do + Tagging.create!(tag: tags(:two), taggable: transaction) + end + end + + # Retroactive sync when tag is assigned + + test "linking a tag to a pocket retroactively sums existing tagged transactions" do + # Use a fresh account and tag with known transactions so we control the data + fresh_account = Account.create!( + family: families(:dylan_family), + owner: users(:family_admin), + accountable: Depository.new, + name: "Retro Account", + balance: 5000, + currency: "USD", + status: "active" + ) + fresh_tag = families(:dylan_family).tags.create!(name: "RetroTag") + + # Create two deposit transactions (negative = money coming in) and tag them + [ -100, -200 ].each do |amount| + entry = Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "deposit", amount: amount, currency: "USD") + Tagging.create!(tag: fresh_tag, taggable: entry.entryable) + end + + pocket = fresh_account.pockets.create!(name: "Retro Pocket", allocated_amount: 0, currency: "USD") + + assert_changes "pocket.reload.allocated_amount", from: 0, to: 300 do + pocket.update!(tag: fresh_tag) + end + end + + test "changing tag subtracts old contribution and adds new tag sum" do + # Build a controlled scenario: a fresh account with known tagged transactions + fresh_account = Account.create!( + family: families(:dylan_family), + owner: users(:family_admin), + accountable: Depository.new, + name: "Change Tag Account", + balance: 5000, + currency: "USD", + status: "active" + ) + tag_a = families(:dylan_family).tags.create!(name: "TagA") + tag_b = families(:dylan_family).tags.create!(name: "TagB") + + # 2 deposit transactions tagged with tag_a (sum = 150) + [ -100, -50 ].each do |amount| + entry = Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "deposit_a", amount: amount, currency: "USD") + Tagging.create!(tag: tag_a, taggable: entry.entryable) + end + + # 1 deposit transaction tagged with tag_b (sum = 75) + entry_b = Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "deposit_b", amount: -75, currency: "USD") + Tagging.create!(tag: tag_b, taggable: entry_b.entryable) + + # Create pocket linked to tag_a → starts at 150 + pocket = fresh_account.pockets.create!(name: "Switch Pocket", allocated_amount: 0, currency: "USD", + tag: tag_a) + assert_equal 150, pocket.reload.allocated_amount + + # Switch to tag_b: remove 150 (old), add 75 (new) → 75 + pocket.update!(tag: tag_b) + assert_equal 75, pocket.reload.allocated_amount + end + + test "removing a tag clears the tag contribution from allocated amount" do + fresh_account = Account.create!( + family: families(:dylan_family), + owner: users(:family_admin), + accountable: Depository.new, + name: "Remove Tag Account", + balance: 5000, + currency: "USD", + status: "active" + ) + fresh_tag = families(:dylan_family).tags.create!(name: "RemoveTag") + entry = Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "deposit", amount: -80, currency: "USD") + Tagging.create!(tag: fresh_tag, taggable: entry.entryable) + + pocket = fresh_account.pockets.create!(name: "Detach Pocket", allocated_amount: 0, currency: "USD", + tag: fresh_tag) + assert_equal 80, pocket.reload.allocated_amount + + pocket.update!(tag: nil) + assert_equal 0, pocket.reload.allocated_amount + end + + # fill_direction filtering + + test "inflows direction only counts negative amounts" do + fresh_account = Account.create!(family: families(:dylan_family), owner: users(:family_admin), + accountable: Depository.new, name: "Dir Account", balance: 5000, + currency: "USD", status: "active") + tag = families(:dylan_family).tags.create!(name: "DirTag") + + deposit = Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "deposit", amount: -200, currency: "USD") + expense = Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "expense", amount: 50, currency: "USD") + Tagging.create!(tag: tag, taggable: deposit.entryable) + Tagging.create!(tag: tag, taggable: expense.entryable) + + pocket = fresh_account.pockets.create!(name: "Dir Pocket", allocated_amount: 0, currency: "USD", + tag: tag, fill_direction: :inflows) + assert_equal 200, pocket.reload.allocated_amount + end + + test "outflows direction only counts positive amounts" do + fresh_account = Account.create!(family: families(:dylan_family), owner: users(:family_admin), + accountable: Depository.new, name: "Out Account", balance: 5000, + currency: "USD", status: "active") + tag = families(:dylan_family).tags.create!(name: "OutTag") + + deposit = Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "deposit", amount: -200, currency: "USD") + expense = Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "expense", amount: 50, currency: "USD") + Tagging.create!(tag: tag, taggable: deposit.entryable) + Tagging.create!(tag: tag, taggable: expense.entryable) + + pocket = fresh_account.pockets.create!(name: "Out Pocket", allocated_amount: 0, currency: "USD", + tag: tag, fill_direction: :outflows) + assert_equal 50, pocket.reload.allocated_amount + end + + test "changing fill_direction triggers recompute" do + fresh_account = Account.create!(family: families(:dylan_family), owner: users(:family_admin), + accountable: Depository.new, name: "Recomp Account", balance: 5000, + currency: "USD", status: "active") + tag = families(:dylan_family).tags.create!(name: "RecompTag") + Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "deposit", amount: -300, currency: "USD").tap do |e| + Tagging.create!(tag: tag, taggable: e.entryable) + end + Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "expense", amount: 100, currency: "USD").tap do |e| + Tagging.create!(tag: tag, taggable: e.entryable) + end + + pocket = fresh_account.pockets.create!(name: "Recomp Pocket", allocated_amount: 0, currency: "USD", + tag: tag, fill_direction: :inflows) + assert_equal 300, pocket.reload.allocated_amount + + pocket.update!(fill_direction: :both) + assert_equal 400, pocket.reload.allocated_amount + end + + test "destroy cannot push pocket below zero" do + @tagged_pocket.update_column(:allocated_amount, 0) + transaction = transactions(:one) + tagging = Tagging.create!(tag: tags(:one), taggable: transaction) + + # Remove the fill we just applied, then destroy a second tagging + @tagged_pocket.update_column(:allocated_amount, 0) + assert_no_difference "@tagged_pocket.reload.allocated_amount" do + tagging.destroy! + end + end +end From 4a81cdf7f1aea880bdf29a5568c4179e1328f395 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 15:01:17 +0200 Subject: [PATCH 02/18] feat(tags): add warning for linked pockets on tag deletion --- app/models/family/financial_data_reset.rb | 2 ++ app/models/tag.rb | 1 + app/views/tag/deletions/new.html.erb | 6 ++++++ config/locales/views/tag/deletions/en.yml | 3 +++ 4 files changed, 12 insertions(+) diff --git a/app/models/family/financial_data_reset.rb b/app/models/family/financial_data_reset.rb index 021f1eab9f..7ff6df6ee8 100644 --- a/app/models/family/financial_data_reset.rb +++ b/app/models/family/financial_data_reset.rb @@ -151,6 +151,7 @@ def delete_financial_data! scope(:rules).destroy_all scope(:budgets).destroy_all scope(:categories).destroy_all + scope(:pockets).delete_all scope(:tags).destroy_all scope(:merchants).destroy_all delete_provider_items! @@ -283,6 +284,7 @@ def scope_relations budget_categories: BudgetCategory.where(budget_id: budget_ids), categories: Category.where(family_id: family.id), tags: tag_scope, + pockets: Pocket.where(account_id: account_ids), taggings: Tagging.where(tag_id: tag_scope.select(:id)), merchants: FamilyMerchant.where(family_id: family.id), family_merchant_associations: FamilyMerchantAssociation.where(family_id: family.id), diff --git a/app/models/tag.rb b/app/models/tag.rb index 108e1c89c0..bc1c6d267e 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -3,6 +3,7 @@ class Tag < ApplicationRecord has_many :taggings, dependent: :destroy has_many :transactions, through: :taggings, source: :taggable, source_type: "Transaction" has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" + has_many :pockets, dependent: :destroy validates :name, presence: true, uniqueness: { scope: :family } validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true diff --git a/app/views/tag/deletions/new.html.erb b/app/views/tag/deletions/new.html.erb index dcadf6e1d8..059e7564a4 100644 --- a/app/views/tag/deletions/new.html.erb +++ b/app/views/tag/deletions/new.html.erb @@ -2,6 +2,12 @@ <% dialog.with_header(title: t(".delete_tag"), subtitle: t(".explanation", tag_name: @tag.name)) %> <% dialog.with_body do %> + <% if @tag.pockets.any? %> +

+ <%= t(".linked_pockets_warning", count: @tag.pockets.count) %> +

+ <% end %> + <%= styled_form_with url: tag_deletions_path(@tag), data: { turbo: false, diff --git a/config/locales/views/tag/deletions/en.yml b/config/locales/views/tag/deletions/en.yml index 77f5f24737..99176ca4ce 100644 --- a/config/locales/views/tag/deletions/en.yml +++ b/config/locales/views/tag/deletions/en.yml @@ -12,5 +12,8 @@ en: 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." + linked_pockets_warning: + one: "1 pocket is linked to this tag and will be deleted." + other: "%{count} pockets are linked to this tag and will be deleted." replacement_tag_prompt: Select tag tag: Tag From ddad604733d0ddcbe056353b6bc5caf08bdc2fec Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 15:10:06 +0200 Subject: [PATCH 03/18] feat(data_exporter): add pockets CSV export functionality --- test/models/family/data_exporter_test.rb | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index 00d4759d78..c409812e92 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -55,7 +55,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase assert zip_data.is_a?(StringIO) # Check that the zip contains all expected files - expected_files = [ "version.txt", "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "rules.csv", "attachments.json", "all.ndjson" ] + expected_files = [ "version.txt", "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "rules.csv", "pockets.csv", "attachments.json", "all.ndjson" ] Zip::File.open_buffer(zip_data) do |zip| actual_files = zip.entries.map(&:name) @@ -63,6 +63,22 @@ class Family::DataExporterTest < ActiveSupport::TestCase end end + test "exports pockets csv with pocket data" do + pocket = @account.pockets.create!(name: "Export Test Pocket", allocated_amount: 500, currency: "USD") + + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + csv_content = zip.find_entry("pockets.csv").get_input_stream.read + rows = CSV.parse(csv_content, headers: true) + row = rows.find { |r| r["name"] == pocket.name } + + assert_not_nil row + assert_equal @account.name, row["account_name"] + assert_equal "500.0", row["allocated_amount"] + end + end + test "exports attachment manifest metadata without binary payloads" do entry = @account.entries.create!( name: "Receipt Transaction", From 0f6e8164966eb120cce875b70ac0b6e7a843a96a Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 15:27:00 +0200 Subject: [PATCH 04/18] feat(pockets): enforce depository account requirement for pockets --- app/components/UI/account_page.rb | 2 +- app/controllers/pockets_controller.rb | 5 +++++ app/models/pocket.rb | 7 +++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/components/UI/account_page.rb b/app/components/UI/account_page.rb index 6e2e054f80..50dc1a3878 100644 --- a/app/components/UI/account_page.rb +++ b/app/components/UI/account_page.rb @@ -52,7 +52,7 @@ def tabs [ :activity ] end - base_tabs += [ :pockets ] if account.asset? + base_tabs += [ :pockets ] if account.depository? base_tabs + [ :statements ] end diff --git a/app/controllers/pockets_controller.rb b/app/controllers/pockets_controller.rb index 124cade787..3e458e768c 100644 --- a/app/controllers/pockets_controller.rb +++ b/app/controllers/pockets_controller.rb @@ -1,5 +1,6 @@ class PocketsController < ApplicationController before_action :set_account + before_action :require_depository_account before_action :set_pocket, only: %i[edit update destroy] before_action :set_available_tags, only: %i[new create edit update] @@ -65,6 +66,10 @@ def set_account @account = Current.user.accessible_accounts.find(params[:account_id]) end + def require_depository_account + redirect_to account_path(@account), status: :see_other unless @account.depository? + end + def set_pocket @pocket = @account.pockets.find(params[:id]) end diff --git a/app/models/pocket.rb b/app/models/pocket.rb index 47c965fdef..69fc7c362a 100644 --- a/app/models/pocket.rb +++ b/app/models/pocket.rb @@ -7,6 +7,7 @@ class Pocket < ApplicationRecord enum :fill_direction, { inflows: "inflows", outflows: "outflows", both: "both" }, default: :inflows validates :name, :currency, presence: true + validate :account_must_be_depository validates :allocated_amount, numericality: { greater_than_or_equal_to: 0 } validates :tag_id, uniqueness: { scope: :account_id, allow_nil: true } validate :total_pockets_within_account_balance @@ -98,6 +99,12 @@ def total_pockets_within_account_balance end end + def account_must_be_depository + return unless account + + errors.add(:account, :not_depository) unless account.depository? + end + def tag_belongs_to_same_family return unless tag && account From 3652b7792cdf1ef5d3ce02923c25975a4868e244 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 16:15:05 +0200 Subject: [PATCH 05/18] feat(pockets): add migration for pockets table with account reference --- db/migrate/20260603150000_create_pockets.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 db/migrate/20260603150000_create_pockets.rb diff --git a/db/migrate/20260603150000_create_pockets.rb b/db/migrate/20260603150000_create_pockets.rb new file mode 100644 index 0000000000..423d930fa3 --- /dev/null +++ b/db/migrate/20260603150000_create_pockets.rb @@ -0,0 +1,12 @@ +class CreatePockets < ActiveRecord::Migration[7.2] + def change + create_table :pockets, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.references :account, type: :uuid, null: false, foreign_key: true + t.string :name + t.integer :amount_cents + t.string :amount_currency + + t.timestamps + end + end +end From c5584b959a80784be74a4b67402bfd1be33e3f3f Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 16:19:13 +0200 Subject: [PATCH 06/18] feat(data_exporter): remove pocket export functionality --- app/models/family/data_exporter.rb | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 59a2f427aa..6b346ed6ad 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -317,16 +317,6 @@ def generate_ndjson }.to_json end - # Export pockets - @family.accounts.find_each do |account| - account.pockets.find_each do |pocket| - lines << { - type: "Pocket", - data: pocket.as_json - }.to_json - end - end - # Export recurring transactions after accounts and merchants so import can remap dependencies. @family.recurring_transactions.includes(:account, :merchant).find_each do |recurring_transaction| lines << { From 8fd6e7cac5afd5de6d5277fad3d9f5c152f87139 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 16:22:00 +0200 Subject: [PATCH 07/18] feat(pockets): update migration to create pockets table with existence check and drop method --- db/migrate/20260603150000_create_pockets.rb | 8 ++- db/schema.rb | 61 +++++++++++---------- 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/db/migrate/20260603150000_create_pockets.rb b/db/migrate/20260603150000_create_pockets.rb index 423d930fa3..8545fae9fc 100644 --- a/db/migrate/20260603150000_create_pockets.rb +++ b/db/migrate/20260603150000_create_pockets.rb @@ -1,5 +1,7 @@ class CreatePockets < ActiveRecord::Migration[7.2] - def change + def up + return if table_exists?(:pockets) + create_table :pockets, id: :uuid, default: -> { "gen_random_uuid()" } do |t| t.references :account, type: :uuid, null: false, foreign_key: true t.string :name @@ -9,4 +11,8 @@ def change t.timestamps end end + + def down + drop_table :pockets, if_exists: true + end end diff --git a/db/schema.rb b/db/schema.rb index 51b10df335..eed6db3e61 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -42,7 +42,7 @@ t.index ["account_id"], name: "index_account_shares_on_account_id" t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances" t.index ["user_id"], name: "index_account_shares_on_user_id" - t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying, 'read_write'::character varying, 'read_only'::character varying]::text[])", name: "chk_account_shares_permission" + t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission" end create_table "account_statements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -53,6 +53,7 @@ t.string "content_type", limit: 100, null: false t.bigint "byte_size", null: false t.string "checksum", limit: 64, null: false + t.string "content_sha256" t.string "source", default: "manual_upload", null: false t.string "upload_status", default: "stored", null: false t.string "institution_name_hint", limit: 200 @@ -69,7 +70,6 @@ t.jsonb "sanitized_parser_output", default: {}, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "content_sha256" t.index ["account_id", "period_start_on", "period_end_on"], name: "index_account_statements_on_account_period" t.index ["account_id"], name: "index_account_statements_on_account_id" t.index ["family_id", "checksum"], name: "index_account_statements_on_family_checksum" @@ -106,7 +106,7 @@ t.uuid "accountable_id" t.decimal "balance", precision: 19, scale: 4 t.string "currency" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, '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[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.uuid "plaid_account_id" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" @@ -117,7 +117,6 @@ t.string "institution_domain" t.text "notes" t.uuid "owner_id" - t.integer "account_providers_count", default: 0, null: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency" @@ -300,9 +299,9 @@ t.string "institution_domain" t.string "institution_url" t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false - t.boolean "pending_account_setup", default: false, null: false + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.datetime "sync_start_date" t.jsonb "raw_payload" t.text "api_key" @@ -377,8 +376,10 @@ t.string "currency", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["family_id", "start_date", "end_date"], name: "index_budgets_on_family_id_and_start_date_and_end_date", unique: true + t.uuid "user_id" + t.index ["family_id", "start_date", "end_date", "user_id"], name: "index_budgets_on_family_start_end_user", unique: true t.index ["family_id"], name: "index_budgets_on_family_id" + t.index ["user_id"], name: "index_budgets_on_user_id" end create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -388,8 +389,8 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "parent_id" - t.string "classification_unused", default: "expense", null: false t.string "lucide_icon", default: "shapes", null: false + t.string "classification_unused", default: "expense", null: false t.index ["family_id"], name: "index_categories_on_family_id" end @@ -760,13 +761,14 @@ t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } t.boolean "recurring_transactions_disabled", default: false, null: false t.integer "month_start_day", default: 1, null: false - t.string "moniker", default: "Family", null: false t.string "vector_store_id" + t.string "moniker", default: "Family", null: false t.string "assistant_type", default: "builtin", null: false t.string "default_account_sharing", default: "shared", null: false t.string "enabled_currencies", array: true t.datetime "last_sync_all_attempted_at" - t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing" + t.boolean "personal_budgets", default: false, null: false + t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end @@ -885,9 +887,9 @@ t.decimal "current_balance", precision: 19, scale: 4 t.decimal "cash_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: {} - t.jsonb "raw_cash_report_payload", default: [] + t.jsonb "raw_holdings_payload", default: [], null: false + t.jsonb "raw_activities_payload", default: {}, null: false + t.jsonb "raw_cash_report_payload", default: [], null: false t.date "report_date" t.datetime "last_holdings_sync" t.datetime "last_activities_sync" @@ -901,8 +903,8 @@ create_table "ibkr_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "name" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false t.boolean "pending_account_setup", default: false, null: false t.jsonb "raw_payload" t.string "query_id" @@ -966,11 +968,11 @@ t.text "notes" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "exchange_operating_mic" t.string "category_parent" t.string "category_color" t.string "category_classification" t.string "category_icon" - t.string "exchange_operating_mic" t.string "resource_type" t.boolean "active" t.string "effective_date" @@ -998,10 +1000,10 @@ t.index ["id", "family_id"], name: "idx_import_sessions_on_id_family", unique: true t.check_constraint "client_session_id IS NULL OR btrim(client_session_id::text) <> ''::text", name: "chk_import_sessions_client_session_id_present" t.check_constraint "expected_chunks IS NULL OR expected_chunks > 0", name: "chk_import_sessions_expected_chunks_positive" - t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_import_sessions_error_details_object" t.check_constraint "import_type::text = 'SureImport'::text", name: "chk_import_sessions_import_type" - t.check_constraint "status::text = ANY (ARRAY['pending'::character varying, 'importing'::character varying, 'complete'::character varying, 'failed'::character varying]::text[])", name: "chk_import_sessions_status" + t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_import_sessions_error_details_object" t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_import_sessions_summary_object" + t.check_constraint "status::text = ANY (ARRAY['pending'::character varying, 'importing'::character varying, 'complete'::character varying, 'failed'::character varying]::text[])", name: "chk_import_sessions_status" end create_table "import_source_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -1019,10 +1021,10 @@ t.index ["import_session_id"], name: "index_import_source_mappings_on_import_session_id" t.index ["target_type", "target_id"], name: "idx_import_source_mappings_on_target" t.check_constraint "btrim(source_id::text) <> ''::text", name: "chk_import_source_mappings_source_id_present" - t.check_constraint "source_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_source_type" t.check_constraint "btrim(source_type::text) <> ''::text", name: "chk_import_source_mappings_source_type_present" - t.check_constraint "target_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_target_type" t.check_constraint "btrim(target_type::text) <> ''::text", name: "chk_import_source_mappings_target_type_present" + t.check_constraint "source_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_source_type" + t.check_constraint "target_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_target_type" end create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -1055,9 +1057,9 @@ t.string "exchange_operating_mic_col_label" t.string "amount_type_strategy", default: "signed_amount" t.string "amount_type_inflow_value" + t.integer "rows_to_skip", default: 0, null: false t.integer "rows_count", default: 0, null: false t.string "amount_type_identifier_value" - t.integer "rows_to_skip", default: 0, null: false t.text "ai_summary" t.string "document_type" t.jsonb "extracted_data" @@ -1077,9 +1079,9 @@ t.index ["import_session_id"], name: "index_imports_on_import_session_id" t.check_constraint "checksum IS NULL OR length(checksum::text) = 64", name: "chk_imports_checksum_sha256_length" t.check_constraint "client_chunk_id IS NULL OR btrim(client_chunk_id::text) <> ''::text", name: "chk_imports_client_chunk_id_present" - t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_imports_error_details_object" t.check_constraint "import_session_id IS NULL OR checksum IS NOT NULL", name: "chk_imports_session_checksum_present" t.check_constraint "import_session_id IS NULL OR sequence IS NOT NULL", name: "chk_imports_session_sequence_present" + t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_imports_error_details_object" t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_imports_summary_object" t.check_constraint "sequence IS NULL OR sequence > 0", name: "chk_imports_session_sequence_positive" end @@ -1631,7 +1633,7 @@ t.index ["kind"], name: "index_securities_on_kind" t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason" t.index ["price_provider"], name: "index_securities_on_price_provider" - t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind" + t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying::text, 'cash'::character varying::text])", name: "chk_securities_kind" end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -1736,9 +1738,9 @@ t.jsonb "raw_activities_payload", default: [] t.datetime "last_holdings_sync" t.datetime "last_activities_sync" - t.boolean "activities_fetch_pending", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "activities_fetch_pending", default: false t.date "sync_start_date" t.jsonb "raw_balances_payload", default: [] t.index ["snaptrade_item_id", "snaptrade_account_id"], name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", unique: true, where: "(snaptrade_account_id IS NOT NULL)" @@ -1786,9 +1788,9 @@ t.jsonb "raw_transactions_payload" t.string "customer_id" t.string "member_id" + t.string "account_number_mask" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "account_number_mask" t.boolean "manual_sync", default: false, null: false t.index ["account_id"], name: "index_sophtron_accounts_on_account_id" t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true @@ -1812,8 +1814,6 @@ t.string "user_id", null: false t.string "access_key", null: false t.string "base_url" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "customer_id" t.string "customer_name" t.jsonb "raw_customer_payload" @@ -1822,6 +1822,8 @@ t.string "job_status" t.jsonb "raw_job_payload" t.text "last_connection_error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.boolean "manual_sync", default: false, null: false t.uuid "current_job_sophtron_account_id" t.index ["current_job_sophtron_account_id"], name: "index_sophtron_items_on_current_job_sophtron_account_id" @@ -2006,9 +2008,9 @@ t.datetime "set_onboarding_preferences_at" t.datetime "set_onboarding_goals_at" t.string "default_account_order", default: "name_asc" - t.string "ui_layout" t.jsonb "preferences", default: {}, null: false t.string "locale" + t.string "ui_layout" t.uuid "default_account_id" t.string "webauthn_id" t.index ["default_account_id"], name: "index_users_on_default_account_id" @@ -2079,6 +2081,7 @@ add_foreign_key "budget_categories", "budgets" add_foreign_key "budget_categories", "categories" add_foreign_key "budgets", "families" + add_foreign_key "budgets", "users", on_delete: :cascade add_foreign_key "categories", "families" add_foreign_key "chats", "users" add_foreign_key "coinbase_accounts", "coinbase_items" From 57164312e2078c1fdd2846325b6cf358dbf4572a Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 19:46:45 +0200 Subject: [PATCH 08/18] feat(pockets): enhance pocket form with dynamic allocation and fill direction display --- app/controllers/pockets_controller.rb | 5 +++- .../controllers/pocket_form_controller.js | 24 +++++++++++++++++++ app/views/pockets/_form.html.erb | 20 +++++++++------- app/views/pockets/_pocket.html.erb | 3 +++ config/locales/views/pockets/en.yml | 4 ++++ 5 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 app/javascript/controllers/pocket_form_controller.js diff --git a/app/controllers/pockets_controller.rb b/app/controllers/pockets_controller.rb index 3e458e768c..2b5dc89e3d 100644 --- a/app/controllers/pockets_controller.rb +++ b/app/controllers/pockets_controller.rb @@ -84,6 +84,9 @@ def set_available_tags end def pocket_params - params.require(:pocket).permit(:name, :allocated_amount, :tag_id, :fill_direction) + permitted = params.require(:pocket).permit(:name, :allocated_amount, :tag_id, :fill_direction) + # When a tag drives auto-fill, the amount is computed from transactions — ignore any manual input + final_tag_id = permitted.key?(:tag_id) ? permitted[:tag_id].presence : @pocket&.tag_id + final_tag_id.present? ? permitted.except(:allocated_amount) : permitted end end diff --git a/app/javascript/controllers/pocket_form_controller.js b/app/javascript/controllers/pocket_form_controller.js new file mode 100644 index 0000000000..5e5f7fde44 --- /dev/null +++ b/app/javascript/controllers/pocket_form_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["amountField", "tagSelect", "fillDirectionSection"]; + + connect() { + this.#toggle(this.tagSelectTarget.value !== ""); + } + + onTagChange() { + this.#toggle(this.tagSelectTarget.value !== ""); + } + + #toggle(hasTag) { + const input = this.amountFieldTarget.querySelector("input"); + if (input) input.disabled = hasTag; + this.amountFieldTarget.style.opacity = hasTag ? "0.5" : "1"; + this.amountFieldTarget.style.pointerEvents = hasTag ? "none" : ""; + + if (this.hasFillDirectionSectionTarget) { + this.fillDirectionSectionTarget.classList.toggle("hidden", !hasTag); + } + } +} diff --git a/app/views/pockets/_form.html.erb b/app/views/pockets/_form.html.erb index 1e8543190c..fc857b5939 100644 --- a/app/views/pockets/_form.html.erb +++ b/app/views/pockets/_form.html.erb @@ -1,24 +1,28 @@ <%# locals: (pocket:, account:, url:, available_tags:) %> -<%= styled_form_with model: [ account, pocket ], url: url, class: "space-y-4" do |f| %> +<%= styled_form_with model: [ account, pocket ], url: url, class: "space-y-4", + data: { controller: "pocket-form" } do |f| %> <%= f.text_field :name, label: t("pockets.form.name"), required: true, autofocus: true, placeholder: t("pockets.form.name_placeholder") %> - <%= f.money_field :allocated_amount, - label: t("pockets.form.allocated_amount"), - required: true, - disable_currency: true %> +
"> + <%= f.money_field :allocated_amount, + label: t("pockets.form.allocated_amount"), + required: !pocket.tag_id.present?, + disable_currency: true, + disabled: pocket.tag_id.present? %> +
<%= f.collection_select :tag_id, available_tags, :id, :name, { label: t("pockets.form.auto_fill_tag"), prompt: t("pockets.form.no_tag") }, - {} %> + { data: { pocket_form_target: "tagSelect", action: "change->pocket-form#onTagChange" } } %> - <% if pocket.tag_id.present? %> +
space-y-3"> <%= f.select :fill_direction, Pocket.fill_directions.keys.map { |d| [ t("pockets.form.fill_direction.#{d}"), d ] }, { label: t("pockets.form.fill_direction_label") }, @@ -28,7 +32,7 @@ <%= icon("zap", size: "sm", class: "shrink-0 mt-0.5 text-warning") %> <%= t("pockets.form.auto_fill_hint") %>
- <% end %> + <% if account.pockets.any? || pocket.persisted? %>
diff --git a/app/views/pockets/_pocket.html.erb b/app/views/pockets/_pocket.html.erb index 36270acc75..7c3c06caa8 100644 --- a/app/views/pockets/_pocket.html.erb +++ b/app/views/pockets/_pocket.html.erb @@ -30,6 +30,9 @@ <%= icon("zap", size: "xs", class: "text-warning shrink-0") %> <%= pocket.tag.name %>

+

+ <%= t("pockets.pocket.fill_direction.#{pocket.fill_direction}") %> +

<% else %>

<%= t("pockets.pocket.manual") %>

<% end %> diff --git a/config/locales/views/pockets/en.yml b/config/locales/views/pockets/en.yml index e57d7a10a6..18b0d05b45 100644 --- a/config/locales/views/pockets/en.yml +++ b/config/locales/views/pockets/en.yml @@ -32,6 +32,10 @@ en: no_auto_fill: "No auto-fill linked" of_balance: "of %{balance}" view_transactions: "View tagged transactions" + fill_direction: + inflows: "Deposits only" + outflows: "Expenses only" + both: "All transactions" create: success: "Pocket created." update: From bc07712c3e3f587fa4617a662badb503148e72ea Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 22:10:31 +0200 Subject: [PATCH 09/18] feat(pockets): refactor delete button to use DS::Button component for improved styling --- app/views/pockets/_pocket.html.erb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/views/pockets/_pocket.html.erb b/app/views/pockets/_pocket.html.erb index 7c3c06caa8..4850b136a7 100644 --- a/app/views/pockets/_pocket.html.erb +++ b/app/views/pockets/_pocket.html.erb @@ -95,12 +95,13 @@ frame: :modal ) %> - <%= button_to account_pocket_path(account, pocket), + <%= render DS::Button.new( + variant: "ghost", + icon: "trash-2", + href: account_pocket_path(account, pocket), method: :delete, - class: "inline-flex items-center justify-center w-8 h-8 rounded-md text-secondary hover:text-destructive hover:bg-destructive/10 transition-colors", - data: { turbo_confirm: t("pockets.destroy.confirm", name: pocket.name) } do %> - <%= icon("trash-2", size: "sm") %> - <% end %> + data: { turbo_confirm: t("pockets.destroy.confirm", name: pocket.name) } + ) %>
From 6ba6ff2a1999a1d43472e311a694981172c83494 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 22:18:08 +0200 Subject: [PATCH 10/18] feat(pockets): add account management permissions for pocket actions feat(pockets): add toast notification on create --- app/controllers/pockets_controller.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/controllers/pockets_controller.rb b/app/controllers/pockets_controller.rb index 2b5dc89e3d..5d1d624162 100644 --- a/app/controllers/pockets_controller.rb +++ b/app/controllers/pockets_controller.rb @@ -1,6 +1,7 @@ class PocketsController < ApplicationController before_action :set_account before_action :require_depository_account + before_action :require_manage_account, only: %i[new create edit update destroy] before_action :set_pocket, only: %i[edit update destroy] before_action :set_available_tags, only: %i[new create edit update] @@ -52,13 +53,15 @@ def destroy private def render_pocket_streams(notice) + flash.now[:notice] = notice render turbo_stream: [ turbo_stream.replace("modal", ""), turbo_stream.replace( ActionView::RecordIdentifier.dom_id(@account, :pockets_content), partial: "accounts/pockets/index", locals: { account: @account } - ) + ), + *flash_notification_stream_items ] end @@ -70,6 +73,13 @@ def require_depository_account redirect_to account_path(@account), status: :see_other unless @account.depository? end + def require_manage_account + permission = @account.permission_for(Current.user) + unless permission.in?([ :owner, :full_control ]) + redirect_to account_path(@account), alert: t("accounts.not_authorized") + end + end + def set_pocket @pocket = @account.pockets.find(params[:id]) end From a9212785d363e2299b05fa1bd7980c37fd6bc021 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 22:21:36 +0200 Subject: [PATCH 11/18] feat(pockets): document behavior of increment!/decrement! methods in apply_tagging --- app/models/pocket.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/models/pocket.rb b/app/models/pocket.rb index 69fc7c362a..0949985f4e 100644 --- a/app/models/pocket.rb +++ b/app/models/pocket.rb @@ -29,6 +29,11 @@ def allocation_percent(balance) [ (allocated_amount / balance.to_f * 100).round, 100 ].min end + # increment!/decrement! are intentional here: they skip AR callbacks and validations + # (including total_pockets_within_account_balance) to avoid re-triggering the Tagging + # callbacks that called these methods. This means allocated_amount can temporarily exceed + # the account balance under concurrent tagging — pockets_overflow? surfaces that to the user. + # The DB check constraint (chk_pockets_allocated_amount_non_negative) remains the hard floor. def apply_tagging(tagging) amount = tagging_transaction_amount(tagging) return unless amount From c4aa8bcc23326697316ddd50f16bf5ede9c6f038 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 22:24:34 +0200 Subject: [PATCH 12/18] feat(pockets): add recompute_from_tag! method to update allocated_amount based on tag_id --- app/models/pocket.rb | 5 +++++ app/models/tag.rb | 1 + 2 files changed, 6 insertions(+) diff --git a/app/models/pocket.rb b/app/models/pocket.rb index 0949985f4e..4192ffb8c1 100644 --- a/app/models/pocket.rb +++ b/app/models/pocket.rb @@ -29,6 +29,11 @@ def allocation_percent(balance) [ (allocated_amount / balance.to_f * 100).round, 100 ].min end + def recompute_from_tag! + return unless tag_id.present? + update_column(:allocated_amount, tagged_transaction_total(tag_id)) + end + # increment!/decrement! are intentional here: they skip AR callbacks and validations # (including total_pockets_within_account_balance) to avoid re-triggering the Tagging # callbacks that called these methods. This means allocated_amount can temporarily exceed diff --git a/app/models/tag.rb b/app/models/tag.rb index bc1c6d267e..4e9fe7b4b8 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -20,6 +20,7 @@ def replace_and_destroy!(replacement) if replacement taggings.update_all tag_id: replacement.id + replacement.pockets.find_each(&:recompute_from_tag!) end destroy! From 4fc133e755564adb0066e3d96695f56f64c87b32 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 22:27:06 +0200 Subject: [PATCH 13/18] feat(pockets): enhance pocket display with focus visibility for action buttons --- app/views/pockets/_pocket.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/pockets/_pocket.html.erb b/app/views/pockets/_pocket.html.erb index 4850b136a7..aa4f990bfb 100644 --- a/app/views/pockets/_pocket.html.erb +++ b/app/views/pockets/_pocket.html.erb @@ -77,7 +77,7 @@

<%= t("pockets.pocket.no_auto_fill") %>

<% end %> -
+
<% if pocket.tag %> <%= render DS::Link.new( variant: "ghost", From 8c1312e755ae017beeaf25e6195714713448d483 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Thu, 4 Jun 2026 22:35:19 +0200 Subject: [PATCH 14/18] feat(pockets): enforce presence of currency in pockets with a database constraint --- ...161000_enforce_pockets_currency_present.rb | 20 +++++++++++++++++++ db/schema.rb | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260603161000_enforce_pockets_currency_present.rb diff --git a/db/migrate/20260603161000_enforce_pockets_currency_present.rb b/db/migrate/20260603161000_enforce_pockets_currency_present.rb new file mode 100644 index 0000000000..4d9c8829cf --- /dev/null +++ b/db/migrate/20260603161000_enforce_pockets_currency_present.rb @@ -0,0 +1,20 @@ +class EnforcePocketsCurrencyPresent < ActiveRecord::Migration[7.2] + def up + # Backfill rows that received the empty-string default before this constraint + execute <<~SQL + UPDATE pockets + SET currency = accounts.currency + FROM accounts + WHERE pockets.account_id = accounts.id + AND (pockets.currency IS NULL OR pockets.currency = '') + SQL + + change_column_default :pockets, :currency, from: "", to: nil + add_check_constraint :pockets, "currency <> ''", name: "chk_pockets_currency_present" + end + + def down + remove_check_constraint :pockets, name: "chk_pockets_currency_present" + change_column_default :pockets, :currency, from: nil, to: "" + end +end diff --git a/db/schema.rb b/db/schema.rb index eed6db3e61..bf858551fb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_03_160000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_03_161000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1495,13 +1495,14 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.decimal "allocated_amount", precision: 19, scale: 4, default: "0.0", null: false - t.string "currency", default: "", null: false + t.string "currency", null: false t.uuid "tag_id" t.string "fill_direction", default: "inflows", null: false t.index ["account_id", "tag_id"], name: "index_pockets_on_account_and_tag_unique", unique: true, where: "(tag_id IS NOT NULL)" t.index ["account_id"], name: "index_pockets_on_account_id" t.index ["tag_id"], name: "index_pockets_on_tag_id" t.check_constraint "allocated_amount >= 0::numeric", name: "chk_pockets_allocated_amount_non_negative" + t.check_constraint "currency::text <> ''::text", name: "chk_pockets_currency_present" t.check_constraint "fill_direction::text = ANY (ARRAY['inflows'::character varying, 'outflows'::character varying, 'both'::character varying]::text[])", name: "chk_pockets_fill_direction" end From c61f50fcc5297cd842cccef58d3effd493ad1907 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Fri, 5 Jun 2026 07:12:52 +0200 Subject: [PATCH 15/18] feat(pockets): prevent double-counting in tagged transaction totals and enhance tagging behavior --- app/models/pocket.rb | 20 ++++++++++++++----- app/models/tagging.rb | 11 ++++++++++ ...0000_recompute_pocket_allocated_amounts.rb | 9 +++++++++ db/schema.rb | 2 +- test/models/pocket_test.rb | 14 ++++++------- 5 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20260605100000_recompute_pocket_allocated_amounts.rb diff --git a/app/models/pocket.rb b/app/models/pocket.rb index 4192ffb8c1..9f764e046d 100644 --- a/app/models/pocket.rb +++ b/app/models/pocket.rb @@ -72,23 +72,33 @@ def direction_condition end def tagged_transaction_total(tag_id) - query = Entry.joins( + # Use DISTINCT to prevent double-counting when a transaction has multiple taggings + # for the same tag. Filter by currency to avoid mixing FX entries in other currencies. + subq = Entry.joins( "INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'" ).joins( "INNER JOIN taggings ON taggings.taggable_id = transactions.id AND taggings.taggable_type = 'Transaction'" - ).where(entries: { account_id: account_id }) + ).where(entries: { account_id: account_id, currency: currency }) .where(taggings: { tag_id: tag_id }) + .select("DISTINCT entries.id, entries.amount") - query = query.where(direction_condition) if direction_condition - query.sum("ABS(entries.amount)") + subq = subq.where(direction_condition) if direction_condition + + ApplicationRecord.connection.select_value( + "SELECT COALESCE(SUM(ABS(amount)), 0) FROM (#{subq.to_sql}) deduplicated_entries" + ).to_d end def tagging_transaction_amount(tagging) return nil unless tagging.taggable_type == "Transaction" - amount = tagging.taggable.entry&.amount + entry = tagging.taggable.entry + return nil unless entry + return nil unless entry.currency == currency + + amount = entry.amount return nil unless amount case fill_direction diff --git a/app/models/tagging.rb b/app/models/tagging.rb index fed8422b38..0bb409d8c1 100644 --- a/app/models/tagging.rb +++ b/app/models/tagging.rb @@ -8,13 +8,24 @@ class Tagging < ApplicationRecord private def fill_linked_pocket + # Skip if a sibling tagging for this same (tag, transaction) pair already exists — + # duplicate taggings (e.g. from re-imports) must not double-increment the pocket. + return if sibling_tagging_exists? linked_pocket&.apply_tagging(self) end def unfill_linked_pocket + # Only unfill if this was the last tagging for this (tag, transaction) pair. + return if sibling_tagging_exists? linked_pocket&.reverse_tagging(self) end + def sibling_tagging_exists? + self.class.where(tag_id: tag_id, taggable_type: taggable_type, taggable_id: taggable_id) + .where.not(id: id) + .exists? + end + def linked_pocket return unless taggable_type == "Transaction" diff --git a/db/migrate/20260605100000_recompute_pocket_allocated_amounts.rb b/db/migrate/20260605100000_recompute_pocket_allocated_amounts.rb new file mode 100644 index 0000000000..bd03f06d30 --- /dev/null +++ b/db/migrate/20260605100000_recompute_pocket_allocated_amounts.rb @@ -0,0 +1,9 @@ +class RecomputePocketAllocatedAmounts < ActiveRecord::Migration[7.2] + def up + Pocket.where.not(tag_id: nil).find_each(&:recompute_from_tag!) + end + + def down + # Not reversible — there is no way to restore previous (potentially incorrect) values + end +end diff --git a/db/schema.rb b/db/schema.rb index bf858551fb..e559863f48 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_03_161000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_05_100000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" diff --git a/test/models/pocket_test.rb b/test/models/pocket_test.rb index b935f05b4a..85d546d651 100644 --- a/test/models/pocket_test.rb +++ b/test/models/pocket_test.rb @@ -75,19 +75,19 @@ class PocketTest < ActiveSupport::TestCase # Auto-fill via Tagging test "creating a tagging fills linked pocket" do - transaction = transactions(:one) - entry = entries(:transaction) - assert_equal @account, entry.account + # Use a fresh entry so sibling_tagging_exists? finds no pre-existing tagging + entry = Entry.create!(account: @account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "Fresh expense", amount: 10, currency: "USD") assert_difference "@tagged_pocket.reload.allocated_amount", entry.amount.abs do - Tagging.create!(tag: tags(:one), taggable: transaction) + Tagging.create!(tag: tags(:one), taggable: entry.entryable) end end test "destroying a tagging unfills linked pocket" do - transaction = transactions(:one) - tagging = Tagging.create!(tag: tags(:one), taggable: transaction) - entry = entries(:transaction) + entry = Entry.create!(account: @account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "Fresh expense", amount: 10, currency: "USD") + tagging = Tagging.create!(tag: tags(:one), taggable: entry.entryable) assert_difference "@tagged_pocket.reload.allocated_amount", -entry.amount.abs do tagging.destroy! From d33fc1d9cde2f3d9be94ccb2a477280676f8afe0 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Fri, 5 Jun 2026 07:31:28 +0200 Subject: [PATCH 16/18] feat(pockets): refactor tagging methods to use signed delta for adjustments --- app/models/pocket.rb | 48 ++++++++++++++++++++++++-------------- test/models/pocket_test.rb | 8 +++---- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/app/models/pocket.rb b/app/models/pocket.rb index 9f764e046d..31069bc36d 100644 --- a/app/models/pocket.rb +++ b/app/models/pocket.rb @@ -40,17 +40,17 @@ def recompute_from_tag! # the account balance under concurrent tagging — pockets_overflow? surfaces that to the user. # The DB check constraint (chk_pockets_allocated_amount_non_negative) remains the hard floor. def apply_tagging(tagging) - amount = tagging_transaction_amount(tagging) - return unless amount + delta = tagging_transaction_delta(tagging) + return unless delta - increment!(:allocated_amount, amount) + adjust_by(delta) end def reverse_tagging(tagging) - amount = tagging_transaction_amount(tagging) - return unless amount + delta = tagging_transaction_delta(tagging) + return unless delta - decrement!(:allocated_amount, [ amount, allocated_amount ].min) + adjust_by(-delta) end private @@ -72,8 +72,6 @@ def direction_condition end def tagged_transaction_total(tag_id) - # Use DISTINCT to prevent double-counting when a transaction has multiple taggings - # for the same tag. Filter by currency to avoid mixing FX entries in other currencies. subq = Entry.joins( "INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'" @@ -84,14 +82,22 @@ def tagged_transaction_total(tag_id) .where(taggings: { tag_id: tag_id }) .select("DISTINCT entries.id, entries.amount") - subq = subq.where(direction_condition) if direction_condition - - ApplicationRecord.connection.select_value( - "SELECT COALESCE(SUM(ABS(amount)), 0) FROM (#{subq.to_sql}) deduplicated_entries" - ).to_d + if fill_direction == "both" + # Net = incomes - expenses, floored at 0. + # DB convention: income = negative amount, expense = positive → SUM(-amount) gives net. + ApplicationRecord.connection.select_value( + "SELECT GREATEST(0, COALESCE(SUM(-amount), 0)) FROM (#{subq.to_sql}) deduplicated_entries" + ).to_d + else + subq = subq.where(direction_condition) + ApplicationRecord.connection.select_value( + "SELECT COALESCE(SUM(ABS(amount)), 0) FROM (#{subq.to_sql}) deduplicated_entries" + ).to_d + end end - def tagging_transaction_amount(tagging) + # Returns a signed delta: positive = add to pocket, negative = subtract from pocket. + def tagging_transaction_delta(tagging) return nil unless tagging.taggable_type == "Transaction" entry = tagging.taggable.entry @@ -102,9 +108,17 @@ def tagging_transaction_amount(tagging) return nil unless amount case fill_direction - when "inflows" then amount < 0 ? amount.abs : nil - when "outflows" then amount > 0 ? amount : nil - else amount.abs + when "inflows" then amount < 0 ? amount.abs : nil # income only, always positive + when "outflows" then amount > 0 ? amount : nil # expense only, always positive + else -amount # income (neg in DB) → positive delta; expense (pos in DB) → negative delta + end + end + + def adjust_by(delta) + if delta >= 0 + increment!(:allocated_amount, delta) + else + decrement!(:allocated_amount, [ delta.abs, allocated_amount ].min) end end diff --git a/test/models/pocket_test.rb b/test/models/pocket_test.rb index 85d546d651..01a9a79aa3 100644 --- a/test/models/pocket_test.rb +++ b/test/models/pocket_test.rb @@ -75,9 +75,9 @@ class PocketTest < ActiveSupport::TestCase # Auto-fill via Tagging test "creating a tagging fills linked pocket" do - # Use a fresh entry so sibling_tagging_exists? finds no pre-existing tagging + # Use a fresh income entry (amount < 0 in DB) so income fills the pocket entry = Entry.create!(account: @account, entryable: Transaction.new, - date: 1.day.ago.to_date, name: "Fresh expense", amount: 10, currency: "USD") + date: 1.day.ago.to_date, name: "Fresh income", amount: -10, currency: "USD") assert_difference "@tagged_pocket.reload.allocated_amount", entry.amount.abs do Tagging.create!(tag: tags(:one), taggable: entry.entryable) @@ -86,7 +86,7 @@ class PocketTest < ActiveSupport::TestCase test "destroying a tagging unfills linked pocket" do entry = Entry.create!(account: @account, entryable: Transaction.new, - date: 1.day.ago.to_date, name: "Fresh expense", amount: 10, currency: "USD") + date: 1.day.ago.to_date, name: "Fresh income", amount: -10, currency: "USD") tagging = Tagging.create!(tag: tags(:one), taggable: entry.entryable) assert_difference "@tagged_pocket.reload.allocated_amount", -entry.amount.abs do @@ -247,7 +247,7 @@ class PocketTest < ActiveSupport::TestCase assert_equal 300, pocket.reload.allocated_amount pocket.update!(fill_direction: :both) - assert_equal 400, pocket.reload.allocated_amount + assert_equal 200, pocket.reload.allocated_amount # 300 income - 100 expense = 200 end test "destroy cannot push pocket below zero" do From f2d2821567e2f19149fb18cee35372a3ef9b0ee1 Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Fri, 5 Jun 2026 09:28:14 +0200 Subject: [PATCH 17/18] feat(pockets): enhance transaction link with account filtering and add allocation_percent tests --- app/controllers/pockets_controller.rb | 2 +- app/models/tagging.rb | 25 ++++-- app/views/pockets/_pocket.html.erb | 2 +- test/models/pocket_test.rb | 113 ++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 7 deletions(-) diff --git a/app/controllers/pockets_controller.rb b/app/controllers/pockets_controller.rb index 5d1d624162..ce13672663 100644 --- a/app/controllers/pockets_controller.rb +++ b/app/controllers/pockets_controller.rb @@ -55,7 +55,7 @@ def destroy def render_pocket_streams(notice) flash.now[:notice] = notice render turbo_stream: [ - turbo_stream.replace("modal", ""), + turbo_stream.replace("modal", view_context.turbo_frame_tag("modal")), turbo_stream.replace( ActionView::RecordIdentifier.dom_id(@account, :pockets_content), partial: "accounts/pockets/index", diff --git a/app/models/tagging.rb b/app/models/tagging.rb index 0bb409d8c1..4c32ba1378 100644 --- a/app/models/tagging.rb +++ b/app/models/tagging.rb @@ -8,16 +8,26 @@ class Tagging < ApplicationRecord private def fill_linked_pocket - # Skip if a sibling tagging for this same (tag, transaction) pair already exists — - # duplicate taggings (e.g. from re-imports) must not double-increment the pocket. + return unless (pocket = linked_pocket) + # Fast path: skip without acquiring the lock if a sibling already exists. return if sibling_tagging_exists? - linked_pocket&.apply_tagging(self) + + # Re-check under a row lock to prevent concurrent duplicate taggings + # (e.g. parallel re-imports) from double-incrementing the pocket. + pocket.with_lock do + next if sibling_tagging_exists? + pocket.apply_tagging(self) + end end def unfill_linked_pocket - # Only unfill if this was the last tagging for this (tag, transaction) pair. + return unless (pocket = linked_pocket) return if sibling_tagging_exists? - linked_pocket&.reverse_tagging(self) + + pocket.with_lock do + next if sibling_tagging_exists? + pocket.reverse_tagging(self) + end end def sibling_tagging_exists? @@ -29,6 +39,11 @@ def sibling_tagging_exists? def linked_pocket return unless taggable_type == "Transaction" + # taggable.entry traverses the has_one :entry on Transaction. + # For AR-mediated destroys this is always populated (belongs_to dependent: :destroy + # fires as before_destroy, so the Entry row is still present when this runs). + # For raw SQL deletes (delete_all) the entry may be gone; the nil guard below + # ensures we fail silently rather than raising. account = taggable.entry&.account return unless account diff --git a/app/views/pockets/_pocket.html.erb b/app/views/pockets/_pocket.html.erb index aa4f990bfb..d73b54a8fb 100644 --- a/app/views/pockets/_pocket.html.erb +++ b/app/views/pockets/_pocket.html.erb @@ -82,7 +82,7 @@ <%= render DS::Link.new( variant: "ghost", icon: "list", - href: transactions_path(q: { tags: [ pocket.tag.name ] }), + href: transactions_path(q: { tags: [ pocket.tag.name ], accounts: [ account.name ] }), title: t("pockets.pocket.view_transactions"), frame: :_top ) %> diff --git a/test/models/pocket_test.rb b/test/models/pocket_test.rb index 01a9a79aa3..c983337c22 100644 --- a/test/models/pocket_test.rb +++ b/test/models/pocket_test.rb @@ -250,6 +250,119 @@ class PocketTest < ActiveSupport::TestCase assert_equal 200, pocket.reload.allocated_amount # 300 income - 100 expense = 200 end + # allocation_percent + + test "allocation_percent returns correct percentage" do + assert_equal 20, @pocket.allocation_percent(5000) + end + + test "allocation_percent is capped at 100 even when overallocated" do + @pocket.allocated_amount = 9999 + assert_equal 100, @pocket.allocation_percent(100) + end + + test "allocation_percent returns 0 when balance is zero" do + assert_equal 0, @pocket.allocation_percent(0) + end + + test "allocation_percent returns 0 when balance is nil" do + assert_equal 0, @pocket.allocation_percent(nil) + end + + # Validations + + test "account must be a depository" do + pocket = accounts(:credit_card).pockets.new(name: "Bad", allocated_amount: 0, currency: "USD") + assert_not pocket.valid? + assert pocket.errors[:account].any? + end + + test "tag must belong to the same family as the account" do + other_family_tag = families(:empty).tags.create!(name: "Other") + pocket = @account.pockets.new(name: "Bad tag", allocated_amount: 0, currency: "USD", tag: other_family_tag) + assert_not pocket.valid? + assert pocket.errors[:tag].any? + end + + # Currency isolation + + test "tagging an entry with a different currency does not affect pocket" do + entry = Entry.create!(account: @account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "EUR deposit", amount: -50, currency: "EUR") + + assert_no_difference "@tagged_pocket.reload.allocated_amount" do + Tagging.create!(tag: tags(:one), taggable: entry.entryable) + end + end + + # apply_tagging / reverse_tagging in :both mode (vacation pocket) + + test "both direction increments pocket on income" do + entry = Entry.create!(account: @account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "income", amount: -100, currency: "USD") + + assert_difference "@tagged_pocket.reload.allocated_amount", 100 do + Tagging.create!(tag: tags(:one), taggable: entry.entryable) + end + end + + test "both direction decrements pocket on expense" do + @tagged_pocket.update_column(:allocated_amount, 200) + entry = Entry.create!(account: @account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "expense", amount: 60, currency: "USD") + + assert_difference "@tagged_pocket.reload.allocated_amount", -60 do + Tagging.create!(tag: tags(:one), taggable: entry.entryable) + end + end + + # recompute_from_tag! + + test "recompute_from_tag! sets allocated_amount from current tagged transactions" do + fresh_account = Account.create!( + family: families(:dylan_family), owner: users(:family_admin), + accountable: Depository.new, name: "Recompute Account", + balance: 5000, currency: "USD", status: "active" + ) + fresh_tag = families(:dylan_family).tags.create!(name: "RecomputeTag") + entry = Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "salary", amount: -400, currency: "USD") + Tagging.create!(tag: fresh_tag, taggable: entry.entryable) + + pocket = fresh_account.pockets.create!(name: "Recompute Pocket", allocated_amount: 0, currency: "USD", + tag: fresh_tag) + pocket.update_column(:allocated_amount, 0) + pocket.recompute_from_tag! + + assert_equal 400, pocket.reload.allocated_amount + end + + test "destroying an entry via AR decrements the linked pocket" do + fresh_account = Account.create!( + family: families(:dylan_family), owner: users(:family_admin), + accountable: Depository.new, name: "Destroy Chain Account", + balance: 5000, currency: "USD", status: "active" + ) + fresh_tag = families(:dylan_family).tags.create!(name: "DestroyChainTag") + entry = Entry.create!(account: fresh_account, entryable: Transaction.new, + date: 1.day.ago.to_date, name: "salary", amount: -300, currency: "USD") + Tagging.create!(tag: fresh_tag, taggable: entry.entryable) + + pocket = fresh_account.pockets.create!(name: "Destroy Pocket", allocated_amount: 0, currency: "USD", + tag: fresh_tag) + assert_equal 300, pocket.reload.allocated_amount + + assert_difference "pocket.reload.allocated_amount", -300 do + entry.destroy! + end + end + + test "recompute_from_tag! is a no-op when pocket has no tag" do + @pocket.update_column(:allocated_amount, 999) + @pocket.recompute_from_tag! + assert_equal 999, @pocket.reload.allocated_amount + end + test "destroy cannot push pocket below zero" do @tagged_pocket.update_column(:allocated_amount, 0) transaction = transactions(:one) From fabb7fc44d7b0658d7234742f8a2bdba331f0d4b Mon Sep 17 00:00:00 2001 From: julien gourmet Date: Sat, 6 Jun 2026 15:27:13 +0200 Subject: [PATCH 18/18] feat(schema): update schema version and remove account_providers_count column --- db/schema.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 7d4db3deec..2dde5cd70e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_05_100000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -118,7 +118,6 @@ t.text "notes" t.uuid "owner_id" t.datetime "disabled_at" - t.integer "account_providers_count", default: 0, null: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency"