diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb
index 5de8aa24a..684b5ce0a 100644
--- a/app/controllers/goals_controller.rb
+++ b/app/controllers/goals_controller.rb
@@ -122,12 +122,13 @@ class GoalsController < ApplicationController
render :edit, status: :unprocessable_entity
end
+ # Deletable from any state. Destroying a goal cascades only to its own
+ # goal_accounts / goal_pledges — and GoalPledge#clear_matched_transaction_extra
+ # unstamps the pledge id it wrote onto a matched transaction. No account,
+ # balance, entry or transaction is removed, so the archive-first gate this
+ # used to enforce bought no safety; it only hid the action behind a two-step
+ # flow no other Sure resource requires.
def destroy
- unless @goal.archived?
- redirect_to goal_path(@goal), alert: t(".archive_first")
- return
- end
-
@goal.destroy!
redirect_to goals_path, notice: t(".success")
end
diff --git a/app/helpers/custom_confirm.rb b/app/helpers/custom_confirm.rb
index f2d59a009..b4bc52be6 100644
--- a/app/helpers/custom_confirm.rb
+++ b/app/helpers/custom_confirm.rb
@@ -2,13 +2,24 @@
# default browser confirm API via Turbo.
class CustomConfirm
class << self
+ # `body` is the one field the dialog renders as HTML — confirm_dialog_controller
+ # assigns it to innerHTML so bodies such as accounts' `confirm_body_html` can
+ # carry markup, while title and button label go through textContent. Every
+ # caller passes a user-named record here, so the name is escaped on its way
+ # into the body: without it a record named "" executes
+ # as soon as someone opens the confirmation.
+ # `titleize` / `downcase` are English-shaped and stay applied to the record
+ # name so the English copy is unchanged; a locale that needs different
+ # casing can absorb it in its own string.
def for_resource_deletion(resource_name, high_severity: false)
new(
destructive: true,
high_severity: high_severity,
- title: "Delete #{resource_name.titleize}?",
- body: "Are you sure you want to delete #{resource_name.downcase}? This is not reversible.",
- btn_text: "Delete #{resource_name.titleize}"
+ title: I18n.t("shared.custom_confirm.resource_deletion_title", resource: resource_name.titleize),
+ # Escaped, unlike the other two: this is the only field the dialog
+ # renders as HTML.
+ body: I18n.t("shared.custom_confirm.resource_deletion_body", resource: ERB::Util.html_escape(resource_name.downcase)),
+ btn_text: I18n.t("shared.custom_confirm.resource_deletion_btn_text", resource: resource_name.titleize)
)
end
end
diff --git a/app/models/goal.rb b/app/models/goal.rb
index e0a2598ed..bc0142035 100644
--- a/app/models/goal.rb
+++ b/app/models/goal.rb
@@ -476,6 +476,27 @@ class Goal < ApplicationRecord
end
end
+ # Confirm dialog for deleting this goal, shared by the show-page kebab and
+ # the index card kebab so the two can't drift. Spelled out rather than using
+ # CustomConfirm.for_resource_deletion, whose generic "This is not reversible"
+ # reads scarier than a goal delete is: only the goal, its account links and
+ # its pledge history go — the accounts, balances and transactions behind it
+ # are untouched.
+ def deletion_confirm
+ CustomConfirm.new(
+ destructive: true,
+ high_severity: true,
+ title: I18n.t("goals.show.confirm_delete_title"),
+ # Escaped: the dialog assigns `body` to innerHTML (so bodies like the
+ # accounts' confirm_body_html can carry
), so a goal named
+ # "" would otherwise run when a family member opens
+ # the confirmation. Only `body` needs this — the dialog sets its title and
+ # button label with textContent.
+ body: I18n.t("goals.show.confirm_delete_body", name: ERB::Util.html_escape(name)),
+ btn_text: I18n.t("goals.show.confirm_delete_cta")
+ )
+ end
+
# Single-line state summary rendered between the header and the ring on
# the show page. Replaces the stacked catch-up alert + inline status pill;
# carries the same actionable copy without owning a CTA. Returns nil when
diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb
index 5a79115a2..36c46d99f 100644
--- a/app/views/goals/show.html.erb
+++ b/app/views/goals/show.html.erb
@@ -79,17 +79,17 @@
<% if @goal.may_reopen? %>
<% menu.with_item(variant: "button", text: t(".reopen"), icon: "rotate-ccw", href: reopen_goal_path(@goal), method: :patch) %>
<% end %>
- <% if @goal.archived? %>
- <% menu.with_item(
- variant: "button",
- text: t(".delete"),
- icon: "trash-2",
- href: goal_path(@goal),
- method: :delete,
- destructive: true,
- confirm: CustomConfirm.for_resource_deletion(@goal.name, high_severity: true)
- ) %>
- <% end %>
+ <%# Available in any state — see GoalsController#destroy for why there's
+ no archive-first gate. %>
+ <% menu.with_item(
+ variant: "button",
+ text: t(".delete"),
+ icon: "trash-2",
+ href: goal_path(@goal),
+ method: :delete,
+ destructive: true,
+ confirm: @goal.deletion_confirm
+ ) %>
<% end %>
diff --git a/config/locales/views/goals/de.yml b/config/locales/views/goals/de.yml
index a00cdca1d..261af0cfb 100644
--- a/config/locales/views/goals/de.yml
+++ b/config/locales/views/goals/de.yml
@@ -16,7 +16,6 @@ de:
create:
success: Ziel erstellt.
destroy:
- archive_first: Archivier das Ziel, bevor du es löschst.
success: Ziel gelöscht.
edit:
heading: Ziel bearbeiten
@@ -185,7 +184,10 @@ de:
confirm_complete_body_short: Du stehst bei %{progress} %, also %{saved} von %{target}. Wenn du abschließt, wird dieser Stand als dein Ergebnis festgehalten statt des ursprünglichen Ziels. Weiter, oder lieber schließen und das Ziel anpassen?
confirm_complete_cta: Als erreicht markieren
confirm_complete_title: Dieses Ziel als erreicht markieren?
- delete: Endgültig löschen
+ confirm_delete_body: "%{name} und der zugehörige Verlauf der Zusagen werden endgültig entfernt. Deine verknüpften Konten, Salden und Transaktionen sind davon nicht betroffen. Wenn du es stattdessen als Beleg behalten willst, archivier es."
+ confirm_delete_cta: Ziel löschen
+ confirm_delete_title: Dieses Ziel löschen?
+ delete: Löschen
edit: Bearbeiten
empty:
body: Überweis Geld auf dein verknüpftes Konto. Sure erkennt es beim nächsten Sync. Oder aktualisier den Saldo deines manuellen Kontos.
diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml
index cfe336a6c..e3524b78f 100644
--- a/config/locales/views/goals/en.yml
+++ b/config/locales/views/goals/en.yml
@@ -77,7 +77,6 @@ en:
success: Goal updated.
destroy:
success: Goal deleted.
- archive_first: Archive the goal before deleting it.
pause:
success: Goal paused.
invalid_transition: Goal can't be paused from its current state.
@@ -104,7 +103,7 @@ en:
archive: Archive
unarchive: Restore
reopen: Reopen goal
- delete: Delete permanently
+ delete: Delete
record_pledge_cta: Record pledge
pledge_just_transferred: Log a transfer you made
pledge_just_saved: Log money you set aside
@@ -172,6 +171,9 @@ en:
confirm_archive_title: Archive this goal?
confirm_archive_body: Archived goals disappear from the main list. You can restore them later.
confirm_archive_cta: Archive
+ confirm_delete_title: Delete this goal?
+ confirm_delete_body: "%{name} and its pledge history are removed for good. Your linked accounts, balances and transactions are not affected. To keep it as a record instead, archive it."
+ confirm_delete_cta: Delete goal
paused_banner:
title: This goal is paused
body: Resume it to keep tracking your progress.
diff --git a/config/locales/views/goals/fr.yml b/config/locales/views/goals/fr.yml
index 840659ef9..e7d44ea6b 100644
--- a/config/locales/views/goals/fr.yml
+++ b/config/locales/views/goals/fr.yml
@@ -16,7 +16,6 @@ fr:
create:
success: Objectif créé.
destroy:
- archive_first: Archivez l'objectif avant de le supprimer.
success: Objectif supprimé.
edit:
heading: Modifier l'objectif
@@ -176,11 +175,14 @@ fr:
confirm_archive_body: Les objectifs archivés disparaissent de la liste principale. Vous pourrez les restaurer plus tard.
confirm_archive_cta: Archiver
confirm_archive_title: Archiver cet objectif ?
+ confirm_delete_title: "Supprimer cet objectif ?"
+ confirm_delete_body: "%{name} et son historique de versements sont supprimés définitivement. Vos comptes liés, soldes et transactions ne sont pas affectés. Pour en garder une trace, archivez-le plutôt."
+ confirm_delete_cta: "Supprimer l'objectif"
confirm_complete_body: Il quittera la liste En cours. Vous pourrez toujours l'archiver ou le restaurer plus tard.
confirm_complete_body_short: Vous êtes à %{progress}%, %{saved} sur %{target}. Marquer comme terminé enregistrera cela comme votre réussite à la place de l'objectif initial. Continuer, ou fermer ceci et ajuster l'objectif à la place ?
confirm_complete_cta: Marquer comme terminé
confirm_complete_title: Marquer cet objectif comme terminé ?
- delete: Supprimer définitivement
+ delete: Supprimer
edit: Modifier
empty:
body: Effectuez un virement sur votre compte lié. Sure le détectera lors de la prochaine synchronisation. Ou mettez à jour manuellement le solde du compte.
diff --git a/config/locales/views/goals/it.yml b/config/locales/views/goals/it.yml
index 71b8531fd..09beb57b8 100644
--- a/config/locales/views/goals/it.yml
+++ b/config/locales/views/goals/it.yml
@@ -76,7 +76,6 @@ it:
success: Obiettivo aggiornato.
destroy:
success: Obiettivo eliminato.
- archive_first: Archivia l'obiettivo prima di eliminarlo.
pause:
success: Obiettivo messo in pausa.
invalid_transition: L'obiettivo non può essere messo in pausa dal suo stato attuale.
@@ -103,7 +102,7 @@ it:
archive: Archivia
unarchive: Ripristina
reopen: Riapri obiettivo
- delete: Elimina definitivamente
+ delete: Elimina
record_pledge_cta: Registra impegno
pledge_just_transferred: Registra un trasferimento effettuato
pledge_just_saved: Registra denaro messo da parte
@@ -171,6 +170,9 @@ it:
confirm_archive_title: Archiviare questo obiettivo?
confirm_archive_body: Gli obiettivi archiviati scompaiono dalla lista principale. Puoi ripristinarli in seguito.
confirm_archive_cta: Archivia
+ confirm_delete_title: "Eliminare questo obiettivo?"
+ confirm_delete_body: "%{name} e la sua cronologia dei versamenti vengono eliminati definitivamente. I conti collegati, i saldi e le transazioni non vengono toccati. Per conservarlo come traccia, archivialo invece."
+ confirm_delete_cta: "Elimina obiettivo"
paused_banner:
title: Questo obiettivo è in pausa
body: Riprendi per continuare a monitorare i tuoi progressi.
diff --git a/config/locales/views/goals/pl.yml b/config/locales/views/goals/pl.yml
index 4e7e87acd..4106dbd42 100644
--- a/config/locales/views/goals/pl.yml
+++ b/config/locales/views/goals/pl.yml
@@ -86,7 +86,6 @@ pl:
success: Cel zaktualizowany.
destroy:
success: Cel usunięty.
- archive_first: Zarchiwizuj cel przed jego usunięciem.
pause:
success: Cel wstrzymany.
invalid_transition: Cel nie może być wstrzymany z bieżącego stanu.
@@ -113,7 +112,7 @@ pl:
archive: Archiwizuj
unarchive: Przywróć
reopen: Otwórz ponownie
- delete: Usuń na stałe
+ delete: Usuń
record_pledge_cta: Zapisz zobowiązanie
pledge_just_transferred: Zarejestruj wykonany przelew
pledge_just_saved: Zarejestruj odłożone pieniądze
@@ -183,6 +182,9 @@ pl:
confirm_archive_title: Zarchiwizować ten cel?
confirm_archive_body: Zarchiwizowane cele znikną z głównej listy. Możesz je później przywrócić.
confirm_archive_cta: Archiwizuj
+ confirm_delete_title: "Usunąć ten cel?"
+ confirm_delete_body: "%{name} i jego historia wpłat zostaną trwale usunięte. Powiązane konta, salda i transakcje pozostaną bez zmian. Aby zachować go jako zapis, zarchiwizuj go zamiast usuwać."
+ confirm_delete_cta: "Usuń cel"
paused_banner:
title: Ten cel jest wstrzymany
body: Wznów go, aby kontynuować śledzenie postępów.
diff --git a/config/locales/views/goals/ru.yml b/config/locales/views/goals/ru.yml
index 5ab84f351..42a1323d1 100644
--- a/config/locales/views/goals/ru.yml
+++ b/config/locales/views/goals/ru.yml
@@ -16,7 +16,6 @@ ru:
create:
success: Цель создана.
destroy:
- archive_first: Сначала архивируйте цель перед удалением.
success: Цель удалена.
edit:
heading: Редактировать цель
@@ -194,6 +193,9 @@ ru:
confirm_archive_body: Архивные цели скрываются из списка. Их можно восстановить позже.
confirm_archive_cta: Архивировать
confirm_archive_title: Архивировать цель?
+ confirm_delete_title: "Удалить эту цель?"
+ confirm_delete_body: "%{name} и история взносов будут удалены безвозвратно. Связанные счета, балансы и транзакции не затрагиваются. Чтобы сохранить её как запись, заархивируйте её вместо удаления."
+ confirm_delete_cta: "Удалить цель"
confirm_complete_body: Цель исчезнет из активных.
confirm_complete_body_short: Вы достигли %{progress}%, %{saved} из %{target}.
confirm_complete_cta: Отметить выполненной
diff --git a/config/locales/views/goals/tr.yml b/config/locales/views/goals/tr.yml
index 961d60fd0..b44516e19 100644
--- a/config/locales/views/goals/tr.yml
+++ b/config/locales/views/goals/tr.yml
@@ -16,7 +16,6 @@ tr:
create:
success: Hedef oluşturuldu.
destroy:
- archive_first: Hedefi silmeden önce arşivleyin.
success: Hedef silindi.
edit:
heading: Hedefi düzenle
@@ -183,6 +182,9 @@ tr:
geri yükleyebilirsiniz.
confirm_archive_cta: Arşivle
confirm_archive_title: Bu hedef arşivlensin mi?
+ confirm_delete_title: "Bu hedef silinsin mi?"
+ confirm_delete_body: "%{name} ve taahhüt geçmişi kalıcı olarak silinir. Bağlı hesaplarınız, bakiyeleriniz ve işlemleriniz etkilenmez. Kayıt olarak saklamak için bunun yerine arşivleyin."
+ confirm_delete_cta: "Hedefi sil"
confirm_complete_body: Devam Eden listesinden çıkar. Daha sonra yine de arşivleyebilir
veya geri yükleyebilirsiniz.
confirm_complete_body_short: "%{progress}%'desiniz, %{target} hedefinin %{saved}
@@ -190,7 +192,7 @@ tr:
yerine başarınız olarak kaydeder. Devam edin ya da bunu kapatıp hedefi ayarlayın."
confirm_complete_cta: Tamamlandı olarak işaretle
confirm_complete_title: Bu hedef tamamlandı olarak işaretlensin mi?
- delete: Kalıcı olarak sil
+ delete: Sil
edit: Düzenle
empty:
body: Bağlı hesabınıza bir transfer yapın. Sure bir sonraki eşitlemede bunu
diff --git a/config/locales/views/goals/zh-CN.yml b/config/locales/views/goals/zh-CN.yml
index 6727a0f71..fedb67925 100644
--- a/config/locales/views/goals/zh-CN.yml
+++ b/config/locales/views/goals/zh-CN.yml
@@ -76,7 +76,6 @@ zh-CN:
success: 目标已更新。
destroy:
success: 目标已删除。
- archive_first: 删除前请先归档该目标。
pause:
success: 目标已暂停。
invalid_transition: 目标当前状态不能暂停。
@@ -103,7 +102,7 @@ zh-CN:
archive: 归档
unarchive: 恢复
reopen: 重新开启目标
- delete: 永久删除
+ delete: 删除
record_pledge_cta: 记录承诺
pledge_just_transferred: 记录你完成的转账
pledge_just_saved: 记录你留出的资金
@@ -171,6 +170,9 @@ zh-CN:
confirm_archive_title: 归档此目标?
confirm_archive_body: 归档后的目标会从主列表隐藏。之后可以恢复。
confirm_archive_cta: 归档
+ confirm_delete_title: "删除此目标?"
+ confirm_delete_body: "%{name} 及其存款记录将被永久删除。你的关联账户、余额和交易不受影响。如果想保留记录,请改为归档。"
+ confirm_delete_cta: "删除目标"
paused_banner:
title: 此目标已暂停
body: 恢复它以继续跟踪进度。
diff --git a/config/locales/views/goals/zh-TW.yml b/config/locales/views/goals/zh-TW.yml
index c79a6ee22..ba2d0c8a6 100644
--- a/config/locales/views/goals/zh-TW.yml
+++ b/config/locales/views/goals/zh-TW.yml
@@ -76,7 +76,6 @@ zh-TW:
success: 目標已更新。
destroy:
success: 目標已刪除。
- archive_first: 請先封存目標再刪除。
pause:
success: 目標已暫停。
invalid_transition: 目標在目前的狀態下無法暫停。
@@ -103,7 +102,7 @@ zh-TW:
archive: 封存
unarchive: 還原
reopen: 重新開啟目標
- delete: 永久刪除
+ delete: 刪除
record_pledge_cta: 記錄投入
pledge_just_transferred: 記錄一筆您已完成的轉帳
pledge_just_saved: 記錄一筆您已存起來的錢
@@ -171,6 +170,9 @@ zh-TW:
confirm_archive_title: 要封存這個目標嗎?
confirm_archive_body: 封存的目標不會出現在主清單中,您之後可以再還原。
confirm_archive_cta: 封存
+ confirm_delete_title: 要刪除這個目標嗎?
+ confirm_delete_body: "%{name} 和它的投入紀錄會被永久移除。您已連結的帳戶、餘額與交易都不會受到影響。若想保留成紀錄,請改為封存。"
+ confirm_delete_cta: 刪除目標
paused_banner:
title: 這個目標已暫停
body: 恢復它就能繼續追蹤您的進度。
diff --git a/config/locales/views/shared/ca.yml b/config/locales/views/shared/ca.yml
index 66e3f7bd6..3cd96803b 100644
--- a/config/locales/views/shared/ca.yml
+++ b/config/locales/views/shared/ca.yml
@@ -16,6 +16,9 @@ ca:
default_body: Aquesta acció no es pot desfer.
default_btn_text: Confirma
default_title: Segur que vols continuar?
+ resource_deletion_title: "Voleu eliminar %{resource}?"
+ resource_deletion_body: "Segur que voleu eliminar %{resource}? Aquesta acció no es pot desfer."
+ resource_deletion_btn_text: "Elimina %{resource}"
exchange_rate_tabs:
calculate_rate_tab: Calcular la taxa FX
convert_tab: Convertir amb la taxa FX
diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml
index 056cb521e..9d7606cf2 100644
--- a/config/locales/views/shared/en.yml
+++ b/config/locales/views/shared/en.yml
@@ -29,6 +29,9 @@ en:
default_title: "Are you sure?"
default_body: "This is not reversible."
default_btn_text: "Confirm"
+ resource_deletion_title: "Delete %{resource}?"
+ resource_deletion_body: "Are you sure you want to delete %{resource}? This is not reversible."
+ resource_deletion_btn_text: "Delete %{resource}"
family_moniker:
group_plural: Groups
group_singular: Group
diff --git a/config/locales/views/shared/fr.yml b/config/locales/views/shared/fr.yml
index ed1652a13..06d54e075 100644
--- a/config/locales/views/shared/fr.yml
+++ b/config/locales/views/shared/fr.yml
@@ -15,6 +15,9 @@ fr:
default_body: Ceci n’est pas réversible.
default_btn_text: Confirmer
default_title: Êtes-vous sûr?
+ resource_deletion_title: "Supprimer %{resource} ?"
+ resource_deletion_body: "Êtes-vous sûr de vouloir supprimer %{resource} ? Ceci n’est pas réversible."
+ resource_deletion_btn_text: "Supprimer %{resource}"
exchange_rate_tabs:
calculate_rate_tab: Calculer le taux FX
convert_tab: Convertir avec le taux FX
diff --git a/config/locales/views/shared/hu.yml b/config/locales/views/shared/hu.yml
index 33191380c..50d7fd000 100644
--- a/config/locales/views/shared/hu.yml
+++ b/config/locales/views/shared/hu.yml
@@ -25,6 +25,9 @@ hu:
default_title: "Biztosan?"
default_body: "Ez a művelet nem vonható vissza."
default_btn_text: "Megerősítés"
+ resource_deletion_title: "Törli a következőt: %{resource}?"
+ resource_deletion_body: "Biztosan törli a következőt: %{resource}? Ez a művelet nem vonható vissza."
+ resource_deletion_btn_text: "%{resource} törlése"
family_moniker:
singular: Háztartás
plural: Háztartások
diff --git a/config/locales/views/shared/it.yml b/config/locales/views/shared/it.yml
index c67223cb2..aef40f7e0 100644
--- a/config/locales/views/shared/it.yml
+++ b/config/locales/views/shared/it.yml
@@ -29,6 +29,9 @@ it:
default_title: "Sei sicuro?"
default_body: "Questa operazione non è reversibile."
default_btn_text: "Conferma"
+ resource_deletion_title: "Eliminare %{resource}?"
+ resource_deletion_body: "Sei sicuro di voler eliminare %{resource}? Questa operazione non è reversibile."
+ resource_deletion_btn_text: "Elimina %{resource}"
family_moniker:
group_plural: Gruppi
group_singular: Gruppo
diff --git a/config/locales/views/shared/ru.yml b/config/locales/views/shared/ru.yml
index c4c3f9cb8..bb42f4337 100644
--- a/config/locales/views/shared/ru.yml
+++ b/config/locales/views/shared/ru.yml
@@ -16,6 +16,9 @@ ru:
default_body: Это действие необратимо.
default_btn_text: Подтвердить
default_title: Вы уверены?
+ resource_deletion_title: "Удалить %{resource}?"
+ resource_deletion_body: "Вы уверены, что хотите удалить %{resource}? Это действие необратимо."
+ resource_deletion_btn_text: "Удалить %{resource}"
exchange_rate_tabs:
calculate_rate_tab: Рассчитать курс
convert_tab: Конвертировать по курсу
diff --git a/config/locales/views/shared/tr.yml b/config/locales/views/shared/tr.yml
index 09579e0dd..03a069a30 100644
--- a/config/locales/views/shared/tr.yml
+++ b/config/locales/views/shared/tr.yml
@@ -16,6 +16,9 @@ tr:
default_body: Bu işlem geri alınamaz.
default_btn_text: Onayla
default_title: Emin misiniz?
+ resource_deletion_title: "%{resource} silinsin mi?"
+ resource_deletion_body: "%{resource} ögesini silmek istediğinize emin misiniz? Bu işlem geri alınamaz."
+ resource_deletion_btn_text: "%{resource} sil"
exchange_rate_tabs:
calculate_rate_tab: Döviz Kuru Hesapla
convert_tab: Döviz Kuru ile Dönüştür
diff --git a/config/locales/views/shared/vi.yml b/config/locales/views/shared/vi.yml
index db485cb38..cb27d9428 100644
--- a/config/locales/views/shared/vi.yml
+++ b/config/locales/views/shared/vi.yml
@@ -25,6 +25,9 @@ vi:
default_title: "Bạn có chắc không?"
default_body: "Thao tác này không thể hoàn tác."
default_btn_text: "Xác nhận"
+ resource_deletion_title: "Xóa %{resource}?"
+ resource_deletion_body: "Bạn có chắc muốn xóa %{resource} không? Thao tác này không thể hoàn tác."
+ resource_deletion_btn_text: "Xóa %{resource}"
family_moniker:
group_plural: Nhóm
group_singular: Nhóm
diff --git a/config/locales/views/shared/zh-CN.yml b/config/locales/views/shared/zh-CN.yml
index 5f2d3499f..1e285dac4 100644
--- a/config/locales/views/shared/zh-CN.yml
+++ b/config/locales/views/shared/zh-CN.yml
@@ -25,6 +25,9 @@ zh-CN:
default_title: 确定吗?
default_body: 此操作不可逆。
default_btn_text: 确认
+ resource_deletion_title: "删除%{resource}?"
+ resource_deletion_body: "确定要删除%{resource}吗?此操作不可逆。"
+ resource_deletion_btn_text: "删除%{resource}"
family_moniker:
group_plural: 组
group_singular: 组
diff --git a/docs/llm-guides/goals.md b/docs/llm-guides/goals.md
index 7592d917d..ccd6966c3 100644
--- a/docs/llm-guides/goals.md
+++ b/docs/llm-guides/goals.md
@@ -44,7 +44,7 @@ Model layer:
Controllers / routes:
-- `app/controllers/goals_controller.rb` — index / show / new / create / edit / update / destroy / pause / resume / complete / archive / unarchive.
+- `app/controllers/goals_controller.rb` — index / show / new / create / edit / update / destroy (any state) / pause / resume / complete / archive / unarchive.
- `app/controllers/goal_pledges_controller.rb` — new / create / renew / destroy.
- `config/routes.rb` — `resources :goals do resources :pledges ... member { patch :renew } end`.
@@ -117,6 +117,17 @@ The AASM `state` is independent. Read `Goal#display_status` (not `#status`)
to get the right pill label: it returns the AASM state when it's not
`:active`, otherwise falls through to `#status`.
+`archived` is a "keep it as a record" state, **not** a prerequisite for
+deleting. `GoalsController#destroy` accepts a goal in any state. The only
+affordance is the kebab on the show page — index cards deliberately carry
+no actions, so the card stays a single click target. A delete cascades
+only to `goal_accounts` and `goal_pledges`;
+`GoalPledge#clear_matched_transaction_extra` unstamps
+`extra["goal"]["pledge_id"]` from any transaction a matched pledge
+claimed. No account, balance, entry or transaction is removed. Reuse
+`Goal#deletion_confirm` rather than building a second `CustomConfirm` at
+a new call site.
+
`Goal#pace` is the rolling 90-day net inflow into the linked accounts,
divided by three. The query joins `entries` with `transactions`
(valuations excluded by join shape), drops excluded entries, and drops
diff --git a/test/controllers/goals_controller_test.rb b/test/controllers/goals_controller_test.rb
index cfc5c6d5b..cd2023d62 100644
--- a/test/controllers/goals_controller_test.rb
+++ b/test/controllers/goals_controller_test.rb
@@ -1,6 +1,8 @@
require "test_helper"
class GoalsControllerTest < ActionDispatch::IntegrationTest
+ include EntriesTestHelper
+
setup do
@user = users(:family_admin)
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
@@ -210,6 +212,23 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest
assert fresh.reload.active?
end
+ # The reported bug: Delete rendered only when the goal was archived, so an
+ # active goal had no delete affordance anywhere in the UI. The kebab is the
+ # only route to it, so assert the form is actually in the markup per state —
+ # a 200 alone would not have caught the original miss.
+ test "show exposes delete for a goal in every state" do
+ delete_form = "form[action='#{goal_path(@goal)}'] input[name='_method'][value='delete']"
+
+ %w[active paused completed archived].each do |state|
+ @goal.update_column(:state, state)
+
+ get goal_url(@goal)
+
+ assert_response :success
+ assert_select delete_form, 1, "no delete affordance on a #{state} goal"
+ end
+ end
+
# A goal whose last funding account is deleted survives with zero links and
# fails `must_have_at_least_one_linked_account` from then on. Editing is the
# only way back, so update must validate the accounts the user SUBMITTED,
@@ -240,14 +259,16 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest
assert_equal "active", orphan.reload.state
end
- test "destroy on non-archived is rejected" do
- assert_no_difference "Goal.count" do
+ test "destroy deletes an active goal and cascades to its links and pledges" do
+ assert_difference -> { Goal.count } => -1,
+ -> { GoalAccount.count } => -2,
+ -> { GoalPledge.count } => -2 do
delete goal_url(@goal)
end
- assert_redirected_to goal_path(@goal)
+ assert_redirected_to goals_path
end
- test "destroy on archived deletes" do
+ test "destroy deletes an archived goal" do
@goal.archive!
assert_difference "Goal.count", -1 do
delete goal_url(@goal)
@@ -255,6 +276,23 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to goals_path
end
+ # The one thing a goal delete reaches outside its own tables: a matched
+ # pledge stamps `extra["goal"]["pledge_id"]` onto the transaction it claimed,
+ # and GoalPledge#clear_matched_transaction_extra must unstamp it on the way
+ # out. The transaction itself must survive untouched.
+ test "destroy unstamps the transaction a matched pledge claimed" do
+ txn = create_transaction(account: @connected, amount: -300).entryable
+ pledge = goal_pledges(:matched_transfer)
+ txn.update!(extra: { "goal" => { "pledge_id" => pledge.id } })
+ pledge.update_column(:matched_transaction_id, txn.id)
+
+ delete goal_url(@goal)
+
+ assert_redirected_to goals_path
+ assert Transaction.exists?(txn.id), "deleting a goal must not delete the transaction"
+ assert_nil txn.reload.extra.dig("goal", "pledge_id")
+ end
+
test "index KPI swaps to 'All caught up' when every tracked goal is reached" do
family = users(:family_admin).family
family.goals.destroy_all
diff --git a/test/helpers/custom_confirm_test.rb b/test/helpers/custom_confirm_test.rb
new file mode 100644
index 000000000..7fc7c6be1
--- /dev/null
+++ b/test/helpers/custom_confirm_test.rb
@@ -0,0 +1,50 @@
+require "test_helper"
+
+class CustomConfirmTest < ActiveSupport::TestCase
+ # `confirm_dialog_controller` assigns `body` to innerHTML so bodies like
+ # accounts' `confirm_body_html` can carry markup. Every caller passes a
+ # user-named record, so an account named "
" would run
+ # when someone opened the confirmation.
+ test "escapes the resource name in the HTML-rendered body" do
+ data = CustomConfirm.for_resource_deletion("
").to_data_attribute
+
+ assert_includes data[:body], "<img src=x onerror=alert(1)>"
+ assert_no_match(/
, 1, "expected more than just English to define this block"
+
+ localized.each do |locale|
+ body = I18n.t("shared.custom_confirm.resource_deletion_body", resource: "Wedding", locale: locale)
+ assert_includes body, "Wedding", "#{locale} dropped the %{resource} interpolation"
+ assert I18n.t("shared.custom_confirm.resource_deletion_title", resource: "x", locale: locale).present?
+ assert I18n.t("shared.custom_confirm.resource_deletion_btn_text", resource: "x", locale: locale).present?
+ end
+ end
+
+ test "high severity picks the destructive button variant" do
+ assert_equal "destructive", CustomConfirm.for_resource_deletion("rule", high_severity: true).to_data_attribute[:variant]
+ assert_equal "outline-destructive", CustomConfirm.for_resource_deletion("rule").to_data_attribute[:variant]
+ end
+end
diff --git a/test/models/goal_test.rb b/test/models/goal_test.rb
index e6af35e84..3c9da94cf 100644
--- a/test/models/goal_test.rb
+++ b/test/models/goal_test.rb
@@ -14,6 +14,38 @@ class GoalTest < ActiveSupport::TestCase
assert @goal.valid?
end
+ # The confirm dialog assigns `body` to innerHTML, so an unescaped goal name
+ # would execute when a family member opens the delete confirmation.
+ test "deletion_confirm escapes the goal name in the dialog body" do
+ @goal.name = "
"
+
+ body = @goal.deletion_confirm.to_data_attribute[:body]
+
+ assert_includes body, "<img src=x onerror=alert(1)>"
+ assert_no_match(/
=, 7, "goal locale files disappeared — check the glob"
+
+ locales.each do |locale|
+ body = I18n.t("goals.show.confirm_delete_body", name: "Wedding", locale: locale, fallback: false, default: nil)
+ assert body, "#{locale} is missing goals.show.confirm_delete_body"
+ assert_includes body, "Wedding", "#{locale} dropped the %{name} interpolation"
+
+ %w[confirm_delete_title confirm_delete_cta].each do |key|
+ assert I18n.t("goals.show.#{key}", locale: locale, fallback: false, default: nil).present?,
+ "#{locale} is missing goals.show.#{key}"
+ end
+ end
+ end
+
# The days-left segment is returned separately so the view can keep it
# unbroken; joined into one string it wrapped after "days" on a phone.
test "header_summary_parts keeps the days-left phrase in its own segment" do