diff --git a/app/controllers/api/v1/transactions_controller.rb b/app/controllers/api/v1/transactions_controller.rb
index 08ca81cee..4af4ca5c2 100644
--- a/app/controllers/api/v1/transactions_controller.rb
+++ b/app/controllers/api/v1/transactions_controller.rb
@@ -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]
diff --git a/app/controllers/settings/appearances_controller.rb b/app/controllers/settings/appearances_controller.rb
new file mode 100644
index 000000000..ae2282443
--- /dev/null
+++ b/app/controllers/settings/appearances_controller.rb
@@ -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
diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb
index a1441439a..096c313e5 100644
--- a/app/controllers/transactions_controller.rb
+++ b/app/controllers/transactions_controller.rb
@@ -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
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 252cd114d..fd0c33f6e 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -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
diff --git a/app/helpers/entries_helper.rb b/app/helpers/entries_helper.rb
index 1c3340ae0..87a3fc571 100644
--- a/app/helpers/entries_helper.rb
+++ b/app/helpers/entries_helper.rb
@@ -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
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 78364ab7b..79a3c248a 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -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? },
diff --git a/app/javascript/controllers/split_transaction_controller.js b/app/javascript/controllers/split_transaction_controller.js
index e338368af..bce070bf6 100644
--- a/app/javascript/controllers/split_transaction_controller.js
+++ b/app/javascript/controllers/split_transaction_controller.js
@@ -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)
diff --git a/app/models/entry.rb b/app/models/entry.rb
index 2ebb7d3d1..6c90114d2 100644
--- a/app/models/entry.rb
+++ b/app/models/entry.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index 9c6a1882c..4cb17c6bd 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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!
diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb
index 02ec05a7f..bdfc387eb 100644
--- a/app/views/category/dropdowns/show.html.erb
+++ b/app/views/category/dropdowns/show.html.erb
@@ -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") %>
+
+
<%= t("splits.show.button") %>
+ <% end %>
+ <% end %>
+
<%= form_with url: transaction_path(@transaction.entry),
diff --git a/app/views/entries/_split_group.html.erb b/app/views/entries/_split_group.html.erb
new file mode 100644
index 000000000..cf182f8be
--- /dev/null
+++ b/app/views/entries/_split_group.html.erb
@@ -0,0 +1,8 @@
+<%# locals: (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 %>
+
diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb
index 90608e652..bc4f9dcd0 100644
--- a/app/views/settings/_settings_nav.html.erb
+++ b/app/views/settings/_settings_nav.html.erb
@@ -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? }
diff --git a/app/views/settings/appearances/show.html.erb b/app/views/settings/appearances/show.html.erb
new file mode 100644
index 000000000..c2e9e703e
--- /dev/null
+++ b/app/views/settings/appearances/show.html.erb
@@ -0,0 +1,50 @@
+<%= content_for :page_title, t(".page_title") %>
+
+<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
+
+ <%= 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 %>
+
+ <%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "max-h-44 mb-2") %>
+
">
+ <%= 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]}") %>
+
+
+ <% end %>
+ <% end %>
+ <% end %>
+
+<% end %>
+
+<%= settings_section title: t(".transactions_title"), subtitle: t(".transactions_subtitle") do %>
+
+ <%= form_with url: settings_appearance_path, method: :patch,
+ class: "p-3",
+ data: { controller: "auto-submit-form" } do |f| %>
+
+
+
<%= t(".split_grouped_title") %>
+
<%= t(".split_grouped_description") %>
+
+ <%= 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" }
+ ) %>
+
+ <% end %>
+
+<% end %>
diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb
index a9f44e029..eee21b87e 100644
--- a/app/views/settings/preferences/show.html.erb
+++ b/app/views/settings/preferences/show.html.erb
@@ -56,31 +56,3 @@
<% end %>
<% end %>
-
-<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
-
- <%= 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 %>
-
- <%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "max-h-44 mb-2") %>
-
">
- <%= 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]}") %>
-
-
- <% end %>
- <% end %>
- <% end %>
-
-<% end %>
diff --git a/app/views/splits/edit.html.erb b/app/views/splits/edit.html.erb
index a762df288..73afc37ac 100644
--- a/app/views/splits/edit.html.erb
+++ b/app/views/splits/edit.html.erb
@@ -86,7 +86,7 @@
<%# Remaining balance indicator %>
-
+
<%= t("splits.new.remaining") %>
diff --git a/app/views/splits/new.html.erb b/app/views/splits/new.html.erb
index 8aae475b1..d4d101eaf 100644
--- a/app/views/splits/new.html.erb
+++ b/app/views/splits/new.html.erb
@@ -77,7 +77,7 @@
<%# Remaining balance indicator %>
-
+
<%= t("splits.new.remaining") %>
diff --git a/app/views/transactions/_list.html.erb b/app/views/transactions/_list.html.erb
index 4c8d22713..8506bdf85 100644
--- a/app/views/transactions/_list.html.erb
+++ b/app/views/transactions/_list.html.erb
@@ -39,7 +39,19 @@
<%= 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 %>
diff --git a/app/views/transactions/_split_parent_row.html.erb b/app/views/transactions/_split_parent_row.html.erb
new file mode 100644
index 000000000..70b44ebd2
--- /dev/null
+++ b/app/views/transactions/_split_parent_row.html.erb
@@ -0,0 +1,69 @@
+<%# locals: (entry:) %>
+<% transaction = entry.entryable %>
+
+
+
+ <%# Empty space where checkbox would be, for alignment %>
+
+
+
+
+
+ <% 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 %>
+
+ <%= render DS::FilledIcon.new(
+ variant: :text,
+ text: entry.name,
+ size: "lg",
+ rounded: true
+ ) %>
+
+ <% end %>
+
+
+
+
+
+
+ <%= link_to entry.name,
+ entry_path(entry),
+ data: { turbo_frame: "drawer", turbo_prefetch: false },
+ class: "hover:underline" %>
+
+
+
+
+ <%= icon "split", size: "sm", color: "current" %>
+ <%= t("transactions.split_parent_row.split_label") %>
+
+
+
+
+
+ <% if transaction.merchant&.present? %>
+ <%= transaction.merchant.name %> •
+ <% end %>
+
+ <%= link_to entry.account.name,
+ account_path(entry.account, tab: "transactions"),
+ data: { turbo_frame: "_top" },
+ class: "hover:underline" %>
+
+
+
+
+
+
+
+
+
+
+
+
+ <%= content_tag :p, format_money(-entry.amount_money) %>
+
+
diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb
index 84ccb453b..db9f4c169 100644
--- a/app/views/transactions/_transaction.html.erb
+++ b/app/views/transactions/_transaction.html.erb
@@ -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 %>
-
">
+
<%= "pl-8 lg:pl-12" if in_split_group %>">
<%= 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? %>
">
<%= icon "split", size: "sm", color: "current" %>
<%= t("transactions.transaction.split") %>
<% end %>
- <% if entry.split_child? %>
+ <% if entry.split_child? && !in_split_group %>
">
<%= icon "corner-down-right", size: "sm", color: "current" %>
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index a42486d87..294e2c829 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -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 %>
+ <% 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? %>
<%= 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 %>
<%= 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? %>
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
@@ -301,40 +305,44 @@
<% end %>
<% end %>
-
- <%= 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| %>
-
-
-
<%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %>
-
<%= t(".one_time_description") %>
+ <% unless @entry.split_child? %>
+
+ <%= 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| %>
+
+
+
<%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %>
+
<%= t(".one_time_description") %>
+
+ <%= ef.toggle :kind, {
+ checked: @entry.transaction.one_time?,
+ data: { auto_submit_form_target: "auto" }
+ }, "one_time", "standard" %>
- <%= ef.toggle :kind, {
- checked: @entry.transaction.one_time?,
- data: { auto_submit_form_target: "auto" }
- }, "one_time", "standard" %>
-
+ <% end %>
<% end %>
- <% end %>
- <%# Split Transaction %>
- <% if @entry.transaction.splittable? %>
-
-
-
<%= t("splits.show.button_title") %>
-
<%= t("splits.show.button_description") %>
-
- <%= render DS::Link.new(
- text: t("splits.show.button"),
- icon: "split",
- variant: "outline",
- href: new_transaction_split_path(@entry),
- frame: :modal
- ) %>
+
+ <% end %>
+ <%# Split Transaction %>
+ <% if @entry.transaction.splittable? %>
+
+
+
<%= t("splits.show.button_title") %>
+
<%= t("splits.show.button_description") %>
- <% end %>
+ <%= render DS::Link.new(
+ text: t("splits.show.button"),
+ icon: "split",
+ variant: "outline",
+ href: new_transaction_split_path(@entry),
+ frame: :modal
+ ) %>
+
+ <% end %>
+ <% unless @entry.split_child? %>
Transfer or Debt Payment?
@@ -396,23 +404,24 @@
frame: "_top"
) %>
- <%# Delete Transaction Form - hidden for split children %>
- <% unless @entry.split_child? %>
-
-
-
<%= t(".delete_title") %>
-
<%= t(".delete_subtitle") %>
-
- <%= 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? %>
+
+
+
<%= t(".delete_title") %>
+
<%= t(".delete_subtitle") %>
- <% 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"
+ ) %>
+
+ <% end %>
<% end %>
<% end %>
diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml
index a4db0706a..d66cb7882 100644
--- a/config/locales/views/settings/en.yml
+++ b/config/locales/views/settings/en.yml
@@ -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
diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml
index bd91c1c16..836aabff3 100644
--- a/config/locales/views/transactions/en.yml
+++ b/config/locales/views/transactions/en.yml
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index 017c15897..14eca4b64 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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