Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8a7657f
feat(pockets): implement pockets feature
jubbakka Jun 4, 2026
4a81cdf
feat(tags): add warning for linked pockets on tag deletion
jubbakka Jun 4, 2026
ddad604
feat(data_exporter): add pockets CSV export functionality
jubbakka Jun 4, 2026
0f6e816
feat(pockets): enforce depository account requirement for pockets
jubbakka Jun 4, 2026
3652b77
feat(pockets): add migration for pockets table with account reference
jubbakka Jun 4, 2026
c5584b9
feat(data_exporter): remove pocket export functionality
jubbakka Jun 4, 2026
8fd6e7c
feat(pockets): update migration to create pockets table with existenc…
jubbakka Jun 4, 2026
5716431
feat(pockets): enhance pocket form with dynamic allocation and fill d…
jubbakka Jun 4, 2026
bc07712
feat(pockets): refactor delete button to use DS::Button component for…
jubbakka Jun 4, 2026
6ba6ff2
feat(pockets): add account management permissions for pocket actions
jubbakka Jun 4, 2026
a921278
feat(pockets): document behavior of increment!/decrement! methods in …
jubbakka Jun 4, 2026
c4aa8bc
feat(pockets): add recompute_from_tag! method to update allocated_amo…
jubbakka Jun 4, 2026
4fc133e
feat(pockets): enhance pocket display with focus visibility for actio…
jubbakka Jun 4, 2026
8c1312e
feat(pockets): enforce presence of currency in pockets with a databas…
jubbakka Jun 4, 2026
c61f50f
feat(pockets): prevent double-counting in tagged transaction totals a…
jubbakka Jun 5, 2026
d33fc1d
feat(pockets): refactor tagging methods to use signed delta for adjus…
jubbakka Jun 5, 2026
f2d2821
feat(pockets): enhance transaction link with account filtering and ad…
jubbakka Jun 5, 2026
0a4199a
Merge branch 'pre-main' into feat/pocket-account
jubbakka Jun 6, 2026
fabb7fc
feat(schema): update schema version and remove account_providers_coun…
jubbakka Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/components/UI/account_page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ def tabs
[ :activity ]
end

base_tabs += [ :pockets ] if account.depository?

base_tabs + [ :statements ]
end

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
102 changes: 102 additions & 0 deletions app/controllers/pockets_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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]

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)
flash.now[:notice] = notice
render turbo_stream: [
turbo_stream.replace("modal", view_context.turbo_frame_tag("modal")),
turbo_stream.replace(
ActionView::RecordIdentifier.dom_id(@account, :pockets_content),
partial: "accounts/pockets/index",
locals: { account: @account }
),
*flash_notification_stream_items
]
end

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

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
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
24 changes: 24 additions & 0 deletions app/javascript/controllers/pocket_form_controller.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
9 changes: 9 additions & 0 deletions app/models/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -497,6 +498,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
Expand Down
25 changes: 25 additions & 0 deletions app/models/family/data_exporter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions app/models/family/financial_data_reset.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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),
Expand Down
149 changes: 149 additions & 0 deletions app/models/pocket.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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
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
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 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
# 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)
delta = tagging_transaction_delta(tagging)
return unless delta

adjust_by(delta)
end

def reverse_tagging(tagging)
delta = tagging_transaction_delta(tagging)
return unless delta

adjust_by(-delta)
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)
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, currency: currency })
.where(taggings: { tag_id: tag_id })
.select("DISTINCT entries.id, entries.amount")

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

# 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
return nil unless entry
return nil unless entry.currency == currency

amount = entry.amount
return nil unless amount

case fill_direction
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

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

unless tag.family_id == account.family_id
errors.add(:tag, :wrong_family)
end
end
end
Loading
Loading