* Initial split transaction support

* Add support to unsplit and edit split

* Update show.html.erb

* FIX address reviews

* Improve UX

* Update show.html.erb

* Reviews

* Update edit.html.erb

* Add parent category to dialog

* Update en.yml

* Add UI indication to totals

* FIX ui update

* Add category select like rest of app

* Add split ui

* Add settings configuration for split transactions

- Adds a new settings section for appearance changes
- Also adds extra checks for delete and API calls
- Also adds checks for parent/child changes

* fixes

- split transactions dark mode fix
- add split transactions to context menu

* Update entry.rb

1. New validation split_child_date_matches_parent — prevents saving a split child with a date different from its parent. This is the root-cause fix that
   protects all flows at once.
  2. Bulk update guard — bulk_update! now strips :date from attributes when processing split children, preventing the validation from raising and silently
   skipping the date change instead.

* N+1 fix for split_parent?

* Update entry.rb

  Problem: In bulk_update!, when a split child has :date removed from attrs (line 432) and the remaining attrs is empty (e.g., the bulk update only
  changed the date), entry.update! {} still ran as a no-op. But lock_saved_attributes! and mark_user_modified! at lines 443-444 executed unconditionally,
  incorrectly marking untouched split children as user-modified and opting them out of future syncs.

  Fix:
  1. Added a changed flag to track whether any actual modification happened
  2. Wrapped entry.update! in an if attrs.present? check so no-op updates are skipped
  3. Gated lock_saved_attributes! and mark_user_modified! behind if changed, so they only run when the entry was actually modified (either via attribute
  update or tag update)

* fixes

1. Indentation in show.html.erb Settings section — The split button block and delete block had extra indentation making them appear nested inside guard
  blocks they weren't part of. Fixed to match actual nesting.
  2. Skip @split_parents query when grouping is off — The controller now only loads split parent entries when show_split_grouped? is true, saving a query
  with joins when the feature is disabled.
This commit is contained in:
soky srm
2026-03-22 12:02:58 +01:00
committed by GitHub
parent 61ee9d34cf
commit 0cda69ebb0
23 changed files with 360 additions and 103 deletions

View File

@@ -105,6 +105,16 @@ class Api::V1::TransactionsController < Api::V1::BaseController
end
def update
if @entry.split_child?
render json: { error: "validation_failed", message: "Split child transactions cannot be edited directly. Use the split editor." }, status: :unprocessable_entity
return
end
if @entry.split_parent? && split_financial_fields_changed?
render json: { error: "validation_failed", message: "Split parent amount, date, and type cannot be changed directly. Use the split editor." }, status: :unprocessable_entity
return
end
Entry.transaction do
if @entry.update(entry_params_for_update)
# Handle tags separately - only when explicitly provided in the request
@@ -141,6 +151,11 @@ end
end
def destroy
if @entry.split_child?
render json: { error: "validation_failed", message: "Split child transactions cannot be deleted individually." }, status: :unprocessable_entity
return
end
@entry.destroy!
@entry.sync_account_later
@@ -313,6 +328,12 @@ end
params[:transaction].key?(:tag_ids)
end
def split_financial_fields_changed?
params.dig(:transaction, :amount).present? ||
params.dig(:transaction, :date).present? ||
params.dig(:transaction, :nature).present?
end
def calculate_signed_amount
amount = transaction_params[:amount].to_f
nature = transaction_params[:nature]

View File

@@ -0,0 +1,18 @@
class Settings::AppearancesController < ApplicationController
layout "settings"
def show
@user = Current.user
end
def update
@user = Current.user
@user.transaction do
@user.lock!
updated_prefs = (@user.preferences || {}).deep_dup
updated_prefs["show_split_grouped"] = params.dig(:user, :show_split_grouped) == "1"
@user.update!(preferences: updated_prefs)
end
redirect_to settings_appearance_path
end
end

View File

@@ -27,6 +27,30 @@ class TransactionsController < ApplicationController
@pagy, @transactions = pagy(base_scope, limit: safe_per_page)
# Preload split parent data
entry_ids = @transactions.map { |t| t.entry.id }
# Load split parent entries for grouped display (only when grouping is enabled)
@split_parents = if Current.user.show_split_grouped?
split_parent_ids = @transactions.filter_map { |t| t.entry.parent_entry_id }.uniq
if split_parent_ids.any?
Entry.where(id: split_parent_ids)
.includes(:account, entryable: [ :category, :merchant ])
.index_by(&:id)
else
{}
end
else
{}
end
# Preload which entries on this page are split parents (have children) to avoid N+1
@split_parent_entry_ids = if entry_ids.any?
Entry.where(parent_entry_id: entry_ids).distinct.pluck(:parent_entry_id).to_set
else
Set.new
end
# Load projected recurring transactions for next 10 days
@projected_recurring = Current.family.recurring_transactions
.active

View File

@@ -83,6 +83,8 @@ class UsersController < ApplicationController
redirect_to goals_onboarding_path
when "trial"
redirect_to trial_onboarding_path
when "appearance"
redirect_to settings_appearance_path, notice: notice
when "ai_prompts"
redirect_to settings_ai_prompts_path, notice: notice
else

View File

@@ -1,4 +1,28 @@
module EntriesHelper
SplitGroup = Data.define(:parent, :children)
def group_split_entries(entries, split_parents)
return entries if split_parents.blank?
result = []
seen_parent_ids = Set.new
entries.each do |entry|
if entry.split_child? && split_parents[entry.parent_entry_id]
parent_id = entry.parent_entry_id
next if seen_parent_ids.include?(parent_id)
seen_parent_ids.add(parent_id)
children = entries.select { |e| e.parent_entry_id == parent_id }
result << SplitGroup.new(parent: split_parents[parent_id], children: children)
else
result << entry
end
end
result
end
def entries_by_date(entries, totals: false)
transfer_groups = entries.group_by do |entry|
# Only check for transfer if it's a transaction

View File

@@ -4,6 +4,7 @@ module SettingsHelper
{ name: "Accounts", path: :accounts_path },
{ name: "Bank Sync", path: :settings_bank_sync_path },
{ name: "Preferences", path: :settings_preferences_path },
{ name: "Appearance", path: :settings_appearance_path },
{ name: "Profile Info", path: :settings_profile_path },
{ name: "Security", path: :settings_security_path },
{ name: "Payment", path: :settings_payment_path, condition: :not_self_hosted? },

View File

@@ -143,13 +143,13 @@ export default class extends Controller {
if (balanced) {
this.remainingTarget.classList.remove("text-destructive")
this.remainingTarget.classList.add("text-success")
container.classList.remove("border-destructive", "bg-red-25")
container.classList.add("border-green-200", "bg-green-25")
container.classList.remove("border-destructive", "bg-red-tint-10")
container.classList.add("border-green-200", "bg-green-tint-10")
} else {
this.remainingTarget.classList.remove("text-success")
this.remainingTarget.classList.add("text-destructive")
container.classList.remove("border-green-200", "bg-green-25")
container.classList.add("border-destructive", "bg-red-25")
container.classList.remove("border-green-200", "bg-green-tint-10")
container.classList.add("border-destructive", "bg-red-tint-10")
}
this.errorTarget.classList.toggle("hidden", balanced)

View File

@@ -21,6 +21,7 @@ class Entry < ApplicationRecord
validates :external_id, uniqueness: { scope: [ :account_id, :source ] }, if: -> { external_id.present? && source.present? }
validate :cannot_unexclude_split_parent
validate :split_child_date_matches_parent
before_destroy :prevent_individual_child_deletion, if: :split_child?
@@ -423,10 +424,19 @@ class Entry < ApplicationRecord
transaction do
all.each do |entry|
changed = false
# Update standard attributes
if bulk_attributes.present?
bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present?
entry.update! bulk_attributes
attrs = bulk_attributes.dup
attrs.delete(:date) if entry.split_child?
if attrs.present?
attrs[:entryable_attributes] = attrs[:entryable_attributes].dup if attrs[:entryable_attributes].present?
attrs[:entryable_attributes][:id] = entry.entryable_id if attrs[:entryable_attributes].present?
entry.update! attrs
changed = true
end
end
# Handle tags separately - only when explicitly requested
@@ -434,10 +444,13 @@ class Entry < ApplicationRecord
entry.transaction.tag_ids = tag_ids
entry.transaction.save!
entry.entryable.lock_attr!(:tag_ids) if entry.transaction.tags.any?
changed = true
end
entry.lock_saved_attributes!
entry.mark_user_modified!
if changed
entry.lock_saved_attributes!
entry.mark_user_modified!
end
end
end
@@ -453,6 +466,14 @@ class Entry < ApplicationRecord
errors.add(:excluded, "cannot be toggled off for a split transaction")
end
def split_child_date_matches_parent
return unless split_child? && date_changed?
return unless parent_entry.present?
return if date == parent_entry.date
errors.add(:date, "must match the parent transaction date for split children")
end
def prevent_individual_child_deletion
return if destroyed_by_association || unsplitting

View File

@@ -316,6 +316,10 @@ class User < ApplicationRecord
preferences&.dig("transactions_collapsed_sections", section_key) == true
end
def show_split_grouped?
preferences&.dig("show_split_grouped") != false
end
def update_transactions_preferences(prefs)
transaction do
lock!

View File

@@ -65,6 +65,16 @@
<% end %>
<% end %>
<% if @transaction.splittable? %>
<%= link_to new_transaction_split_path(@transaction.entry),
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover",
data: { turbo_frame: "modal" } do %>
<%= icon("split") %>
<p><%= t("splits.show.button") %></p>
<% end %>
<% end %>
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
<div class="flex items-center gap-2">
<%= form_with url: transaction_path(@transaction.entry),

View File

@@ -0,0 +1,8 @@
<%# locals: (split_group:) %>
<div class="split-group">
<%= render "transactions/split_parent_row", entry: split_group.parent %>
<% split_group.children.each do |child_entry| %>
<%= render partial: child_entry.entryable.to_partial_path,
locals: { entry: child_entry, in_split_group: true } %>
<% end %>
</div>

View File

@@ -6,6 +6,7 @@ nav_sections = [
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
{ label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" },
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
{ label: t(".appearance_label"), path: settings_appearance_path, icon: "palette" },
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
{ label: t(".payment_label"), path: settings_payment_path, icon: "circle-dollar-sign", if: !self_hosted? && Current.family.can_manage_subscription? }

View File

@@ -0,0 +1,50 @@
<%= content_for :page_title, t(".page_title") %>
<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
<div data-controller="theme" data-theme-user-preference-value="<%= @user.theme %>">
<%= form_with model: @user, class: "flex flex-col md:flex-row justify-between items-center gap-4", id: "theme_form",
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
<%= form.hidden_field :redirect_to, value: "appearance" %>
<% theme_option_class = "text-center transition-all duration-200 p-3 rounded-lg hover:bg-surface-hover cursor-pointer [&:has(input:checked)]:bg-surface-hover [&:has(input:checked)]:border [&:has(input:checked)]:border-primary [&:has(input:checked)]:shadow-xs" %>
<% [
{ value: "light", image: "light-mode-preview.png" },
{ value: "dark", image: "dark-mode-preview.png" },
{ value: "system", image: "system-mode-preview.png" }
].each do |theme| %>
<%= form.label :"theme_#{theme[:value]}", class: "group" do %>
<div class="<%= theme_option_class %>">
<%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "max-h-44 mb-2") %>
<div class="<%= theme[:value] == "system" ? "flex items-center gap-2 justify-center" : "text-sm font-medium text-primary" %>">
<%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only",
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change", action: "theme#updateTheme" } %>
<%= t(".theme_#{theme[:value]}") %>
</div>
</div>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
<%= settings_section title: t(".transactions_title"), subtitle: t(".transactions_subtitle") do %>
<div>
<%= form_with url: settings_appearance_path, method: :patch,
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".split_grouped_title") %></h4>
<p class="text-secondary"><%= t(".split_grouped_description") %></p>
</div>
<%= render DS::Toggle.new(
id: "user_show_split_grouped",
name: "user[show_split_grouped]",
checked: @user.show_split_grouped?,
data: { auto_submit_form_target: "auto" }
) %>
</div>
<% end %>
</div>
<% end %>

View File

@@ -56,31 +56,3 @@
<% end %>
</div>
<% end %>
<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
<div data-controller="theme" data-theme-user-preference-value="<%= @user.theme %>">
<%= form_with model: @user, class: "flex flex-col md:flex-row justify-between items-center gap-4", id: "theme_form",
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<% theme_option_class = "text-center transition-all duration-200 p-3 rounded-lg hover:bg-surface-hover cursor-pointer [&:has(input:checked)]:bg-surface-hover [&:has(input:checked)]:border [&:has(input:checked)]:border-primary [&:has(input:checked)]:shadow-xs" %>
<% [
{ value: "light", image: "light-mode-preview.png" },
{ value: "dark", image: "dark-mode-preview.png" },
{ value: "system", image: "system-mode-preview.png" }
].each do |theme| %>
<%= form.label :"theme_#{theme[:value]}", class: "group" do %>
<div class="<%= theme_option_class %>">
<%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "max-h-44 mb-2") %>
<div class="<%= theme[:value] == "system" ? "flex items-center gap-2 justify-center" : "text-sm font-medium text-primary" %>">
<%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only",
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change", action: "theme#updateTheme" } %>
<%= t(".theme_#{theme[:value]}") %>
</div>
</div>
<% end %>
<% end %>
<% end %>
</div>
<% end %>

View File

@@ -86,7 +86,7 @@
</button>
<%# Remaining balance indicator %>
<div data-split-transaction-target="remainingContainer" class="p-3 rounded-lg border border-secondary text-sm">
<div data-split-transaction-target="remainingContainer" class="p-3 rounded-lg border border-secondary bg-container text-sm">
<div class="flex items-center justify-between">
<span class="text-secondary font-medium"><%= t("splits.new.remaining") %></span>
<span data-split-transaction-target="remaining" class="font-semibold text-primary">

View File

@@ -77,7 +77,7 @@
</button>
<%# Remaining balance indicator %>
<div data-split-transaction-target="remainingContainer" class="p-3 rounded-lg border border-destructive bg-red-25 text-sm">
<div data-split-transaction-target="remainingContainer" class="p-3 rounded-lg border border-destructive bg-red-tint-10 text-sm">
<div class="flex items-center justify-between">
<span class="text-secondary font-medium"><%= t("splits.new.remaining") %></span>
<span data-split-transaction-target="remaining" class="font-semibold text-destructive">

View File

@@ -39,7 +39,19 @@
<div class="space-y-6">
<%= entries_by_date(@transactions.map(&:entry), totals: true) do |entries| %>
<%= render entries %>
<% if Current.user.show_split_grouped? %>
<% group_split_entries(entries, @split_parents).each do |item| %>
<% if item.is_a?(EntriesHelper::SplitGroup) %>
<%= render "entries/split_group", split_group: item %>
<% else %>
<%= render item %>
<% end %>
<% end %>
<% else %>
<% entries.each do |entry| %>
<%= render entry %>
<% end %>
<% end %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,69 @@
<%# locals: (entry:) %>
<% transaction = entry.entryable %>
<div class="group flex lg:grid lg:grid-cols-12 items-center text-sm font-medium p-3 lg:p-4 opacity-50 text-secondary">
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 min-w-0">
<%# Empty space where checkbox would be, for alignment %>
<div class="hidden lg:block w-4 shrink-0"></div>
<div class="max-w-full">
<div class="flex items-center gap-3 lg:gap-4">
<div class="hidden lg:flex">
<% if transaction.merchant&.logo_url.present? %>
<%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url),
class: "w-9 h-9 rounded-full border border-secondary",
loading: "lazy" %>
<% else %>
<div class="hidden lg:flex">
<%= render DS::FilledIcon.new(
variant: :text,
text: entry.name,
size: "lg",
rounded: true
) %>
</div>
<% end %>
</div>
<div class="truncate">
<div class="space-y-0.5">
<div class="flex items-center gap-1 min-w-0">
<div class="truncate flex-shrink">
<%= link_to entry.name,
entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline" %>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-accent bg-accent/10 text-accent">
<%= icon "split", size: "sm", color: "current" %>
<%= t("transactions.split_parent_row.split_label") %>
</span>
</div>
</div>
<div class="text-secondary text-xs font-normal">
<% if transaction.merchant&.present? %>
<span class="hidden lg:inline truncate"><%= transaction.merchant.name %> • </span>
<% end %>
<span class="text-secondary hidden lg:inline">
<%= link_to entry.account.name,
account_path(entry.account, tab: "transactions"),
data: { turbo_frame: "_top" },
class: "hover:underline" %>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="hidden md:flex items-center gap-1 col-span-2">
</div>
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto flex items-center justify-end gap-2">
<%= content_tag :p, format_money(-entry.amount_money) %>
</div>
</div>

View File

@@ -1,10 +1,10 @@
<%# locals: (entry:, balance_trend: nil, view_ctx: "global") %>
<%# locals: (entry:, balance_trend: nil, view_ctx: "global", in_split_group: false) %>
<% transaction = entry.entryable %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= turbo_frame_tag dom_id(transaction) do %>
<div class="group flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4 <%= entry.excluded ? "opacity-50 text-secondary" : "" %>">
<div class="group flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4 <%= entry.excluded ? "opacity-50 text-secondary" : "" %> <%= "pl-8 lg:pl-12" if in_split_group %>">
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 min-w-0">
<%= check_box_tag dom_id(entry, "selection"),
@@ -102,13 +102,13 @@
<% end %>
<%# Split indicator %>
<% if entry.split_parent? %>
<% if @split_parent_entry_ids ? @split_parent_entry_ids.include?(entry.id) : entry.split_parent? %>
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-accent bg-accent/10 text-accent" title="<%= t("transactions.transaction.split_tooltip") %>">
<%= icon "split", size: "sm", color: "current" %>
<%= t("transactions.transaction.split") %>
</span>
<% end %>
<% if entry.split_child? %>
<% if entry.split_child? && !in_split_group %>
<span class="text-secondary" title="<%= t("transactions.transaction.split_child_tooltip") %>">
<%= icon "corner-down-right", size: "sm", color: "current" %>
</span>

View File

@@ -47,31 +47,33 @@
<%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) %>
<% dialog.with_section(title: t(".overview"), open: true) do %>
<div>
<% split_locked = @entry.split_child? || @entry.split_parent? %>
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_field :name,
label: t(".name_label"),
disabled: @entry.split_child?,
"data-auto-submit-form-target": "auto" %>
<%= f.date_field :date,
label: t(".date_label"),
max: Date.current,
disabled: @entry.linked?,
disabled: @entry.linked? || split_locked,
"data-auto-submit-form-target": "auto" %>
<% unless @entry.transaction.transfer? %>
<div class="flex items-center gap-2">
<%= f.select :nature,
[["Expense", "outflow"], ["Income", "inflow"]],
{ container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" },
{ data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %>
{ data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? || split_locked } %>
<%= f.money_field :amount, label: t(".amount"),
container_class: "w-2/3",
auto_submit: true,
min: 0,
value: @entry.amount.abs,
disabled: @entry.linked?,
disable_currency: @entry.linked? %>
disabled: @entry.linked? || split_locked,
disable_currency: @entry.linked? || split_locked %>
</div>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id,
@@ -79,7 +81,7 @@
:id, :name,
{ label: t(".category_label"),
class: "text-subdued", include_blank: t(".uncategorized"),
variant: :badge, searchable: true },
variant: :badge, searchable: true, disabled: @entry.split_child? },
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
@@ -105,14 +107,15 @@
:id, :name,
{ include_blank: t(".none"),
label: t(".merchant_label"),
class: "text-subdued", variant: :logo, searchable: true },
class: "text-subdued", variant: :logo, searchable: true, disabled: @entry.split_child? },
"data-auto-submit-form-target": "auto" %>
<%= ef.select :tag_ids,
Current.family.tags.alphabetically.pluck(:name, :id),
{
include_blank: t(".none"),
multiple: true,
label: t(".tags_label")
label: t(".tags_label"),
disabled: @entry.split_child?
},
{ "data-controller": "multi-select", "data-auto-submit-form-target": "auto" } %>
<% end %>
@@ -121,6 +124,7 @@
label: t(".note_label"),
placeholder: t(".note_placeholder"),
rows: 5,
disabled: @entry.split_child?,
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
@@ -260,7 +264,7 @@
<% end %>
<% dialog.with_section(title: t(".settings")) do %>
<% unless @entry.split_parent? %>
<% unless @entry.split_parent? || @entry.split_child? %>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
@@ -301,40 +305,44 @@
<% end %>
</div>
<% end %>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %></h4>
<p class="text-secondary"><%= t(".one_time_description") %></p>
<% unless @entry.split_child? %>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %></h4>
<p class="text-secondary"><%= t(".one_time_description") %></p>
</div>
<%= ef.toggle :kind, {
checked: @entry.transaction.one_time?,
data: { auto_submit_form_target: "auto" }
}, "one_time", "standard" %>
</div>
<%= ef.toggle :kind, {
checked: @entry.transaction.one_time?,
data: { auto_submit_form_target: "auto" }
}, "one_time", "standard" %>
</div>
<% end %>
<% end %>
<% end %>
<%# Split Transaction %>
<% if @entry.transaction.splittable? %>
<div class="flex items-center justify-between gap-4 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t("splits.show.button_title") %></h4>
<p class="text-secondary"><%= t("splits.show.button_description") %></p>
</div>
<%= render DS::Link.new(
text: t("splits.show.button"),
icon: "split",
variant: "outline",
href: new_transaction_split_path(@entry),
frame: :modal
) %>
</div>
<% end %>
<%# Split Transaction %>
<% if @entry.transaction.splittable? %>
<div class="flex items-center justify-between gap-4 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t("splits.show.button_title") %></h4>
<p class="text-secondary"><%= t("splits.show.button_description") %></p>
</div>
<% end %>
<%= render DS::Link.new(
text: t("splits.show.button"),
icon: "split",
variant: "outline",
href: new_transaction_split_path(@entry),
frame: :modal
) %>
</div>
<% end %>
<% unless @entry.split_child? %>
<div class="flex items-center justify-between gap-4 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary">Transfer or Debt Payment?</h4>
@@ -396,23 +404,24 @@
frame: "_top"
) %>
</div>
<%# Delete Transaction Form - hidden for split children %>
<% unless @entry.split_child? %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".delete_title") %></h4>
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
</div>
<%= render DS::Button.new(
text: t(".delete"),
variant: "outline-destructive",
href: entry_path(@entry),
method: :delete,
confirm: CustomConfirm.for_resource_deletion("transaction"),
frame: "_top"
) %>
<% end %>
<%# Delete Transaction Form - hidden for split children %>
<% unless @entry.split_child? %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".delete_title") %></h4>
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
</div>
<% end %>
<%= render DS::Button.new(
text: t(".delete"),
variant: "outline-destructive",
href: entry_path(@entry),
method: :delete,
confirm: CustomConfirm.for_resource_deletion("transaction"),
frame: "_top"
) %>
</div>
<% end %>
</div>
<% end %>
<% end %>

View File

@@ -26,6 +26,18 @@ en:
page_title: Payments
subscription_subtitle: Update your credit card details
subscription_title: Manage contributions
appearances:
show:
page_title: Appearance
theme_title: Theme
theme_subtitle: Choose a preferred theme for the app
theme_dark: Dark
theme_light: Light
theme_system: System
transactions_title: Transactions
transactions_subtitle: Customize how transactions are displayed
split_grouped_title: Group split transactions
split_grouped_description: Show split transactions grouped under their parent in the transaction list. When off, split children appear as individual rows.
preferences:
show:
country: Country
@@ -38,11 +50,6 @@ en:
language: Language
language_auto: Browser language
page_title: Preferences
theme_dark: Dark
theme_light: Light
theme_subtitle: Choose a preferred theme for the app
theme_system: System
theme_title: Theme
timezone: Timezone
month_start_day: Budget month starts on
month_start_day_hint: Set when your budget month starts (e.g., payday)
@@ -142,6 +149,7 @@ en:
transactions_section_title: Transactions
whats_new_label: What's new
api_keys_label: API Key
appearance_label: Appearance
bank_sync_label: Bank Sync
settings_nav_link_large:
next: Next

View File

@@ -81,6 +81,8 @@ en:
potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting.
merge_duplicate: Yes, merge them
keep_both: No, keep both
split_parent_row:
split_label: "Split"
transaction:
pending: Pending
pending_tooltip: Pending transaction — may change when posted

View File

@@ -167,6 +167,7 @@ Rails.application.routes.draw do
namespace :settings do
resource :profile, only: [ :show, :destroy ]
resource :preferences, only: :show
resource :appearance, only: %i[show update]
resource :hosting, only: %i[show update] do
delete :clear_cache, on: :collection
delete :disconnect_external_assistant, on: :collection