mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 13:34:58 +00:00
Refs #895, discussion #1224. Adds a "Mark as recurring" entry point on the transfer detail drawer that creates a `RecurringTransaction` carrying both source and destination accounts. The recurring index, settings toggle (`recurring_transactions_disabled`), and projected upcoming feed all light up automatically once the data shape is there. Schema: * `destination_account_id` nullable FK to accounts. `on_delete: :cascade` matches #20251030172500's precedent for accounts FKs. The existing `account_id` FK is widened to cascade in the same migration so Family destruction with a recurring transfer doesn't FK-violate. * Two predicate-partitioned partial unique indexes per shape: non-transfer rows (`destination_account_id IS NULL`, original 5-column shape preserved) and transfer rows (6-column shape including the destination). Postgres treats NULLs as distinct in unique indexes, so widening would have broken non-transfer dedupe. * Two CHECK constraints enforcing transfer invariants in PostgreSQL: `chk_recurring_txns_transfer_requires_source` (destination implies source) and `chk_recurring_txns_transfer_distinct_accounts` (destination cannot equal source). Per CLAUDE.md "Enforce null checks, unique indexes, and simple validations in the database schema for PostgreSQL". * `Account` gains an `inbound_recurring_transfers` inverse so the destroy chain reaches both ends. Controller / behaviour: * `transfers#mark_as_recurring` mirrors `transactions#mark_as_recurring`: i18n flashes (4 new keys: transfer_marked_as_recurring, transfer_already_exists, transfer_creation_failed, transfer_feature_disabled), `respond_to format.html`, `redirect_back_or_to transactions_path`, server-side gate on `recurring_transactions_disabled?`, and rescue both `RecordInvalid` and `RecordNotUnique` for the race window between the dedupe `find_by` and `create_from_transfer`. The `StandardError` rescue now logs the exception (class, message, transfer/family/user ids) before surfacing the generic flash so production failures aren't context-less. * `RecurringTransaction.accessible_by(user)` now requires destination_account_id (when present) to be in the user's accessible set, so a recurring transfer never leaks to a user without access to BOTH endpoints. * Model validation gains a `destination_account.blank?` branch in `transfer_endpoints_consistent` so a dangling `destination_account_id` (referenced row destroyed) surfaces as a normal validation error instead of an FK exception on save. * `Identifier` filter for transfer-kind transactions moved into SQL. UI: * Recurring index table and projected feed render transfer rows with the existing letter-avatar and the row's `name` field ("Transfer to {destination}"). No special pill or icon -- every row in `/recurring_transactions` is recurring by definition. Amount column on transfers uses `text-secondary` (muted-but-live) instead of the income/expense colour, since transfers are zero-net for the family. Out of scope (called out in the PR body): * Auto-creation of future Transfer rows on a schedule (discussion #1224's primary ask). Behaviour change vs the current projection-only model. * Auto-identification of recurring transfer pairs in `Identifier`. * Frequency model richer than `expected_day_of_month`. * `Cleaner` for recurring transfers (issue #1590 tracks this). Tests: * `RecurringTransaction#transfer?` predicate (with / without destination). * `transfer_endpoints_consistent`: rejects same source and destination, rejects dangling destination_account_id, rejects cross-family destination. * `RecurringTransaction.create_from_transfer` happy path; multi-currency variant stores source-side currency. * `projected_entry` exposes source / destination on transfer rows. * `Identifier` skips transfer-kind transactions; creates a pattern from expense halves while ignoring co-resident transfer halves. * Destroying the destination account cascades to inbound recurring transfers (FK + AR association). * Unique partial index still de-duplicates non-transfer rows after the destination_account_id widening. * `transfers#mark_as_recurring` happy path, idempotent on second call, rejected when `recurring_transactions_disabled`. Suite: 3261 / 0 / 0 / 24 on the latest upstream/main. Lint clean. Brakeman clean. Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
66 lines
2.9 KiB
Plaintext
66 lines
2.9 KiB
Plaintext
<%# locals: (recurring_transaction:) %>
|
|
|
|
<div class="group flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4">
|
|
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 min-w-0">
|
|
<div class="max-w-full">
|
|
<%= content_tag :div, class: ["flex items-center gap-3 lg:gap-4"] do %>
|
|
<% if recurring_transaction.merchant.present? %>
|
|
<% if recurring_transaction.merchant.logo_url.present? %>
|
|
<%= image_tag Setting.transform_brand_fetch_url(recurring_transaction.merchant.logo_url),
|
|
class: "w-9 h-9 rounded-full",
|
|
loading: "lazy" %>
|
|
<% else %>
|
|
<%= render DS::FilledIcon.new(
|
|
variant: :text,
|
|
text: recurring_transaction.merchant.name,
|
|
size: "lg",
|
|
rounded: true
|
|
) %>
|
|
<% end %>
|
|
<% else %>
|
|
<%= render DS::FilledIcon.new(
|
|
variant: :text,
|
|
text: recurring_transaction.name,
|
|
size: "lg",
|
|
rounded: true
|
|
) %>
|
|
<% end %>
|
|
|
|
<div class="truncate">
|
|
<div class="space-y-0.5">
|
|
<div class="flex items-center gap-1 min-w-0">
|
|
<div class="truncate flex-shrink">
|
|
<%= recurring_transaction.merchant.present? ? recurring_transaction.merchant.name : recurring_transaction.name %>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1 flex-shrink-0">
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-tint-10 text-link">
|
|
<%= t("recurring_transactions.projected") %>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-secondary text-xs font-normal">
|
|
<%= days_left = (recurring_transaction.next_expected_date.to_date - Time.zone.today).to_i
|
|
days_left.zero? ? t("recurring_transactions.expected_today") : t("recurring_transactions.expected_in", count: days_left) %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="hidden lg:flex items-center gap-1 col-span-2">
|
|
<span class="text-xs text-secondary"><%= t("recurring_transactions.recurring") %></span>
|
|
</div>
|
|
|
|
<div class="col-span-2 ml-auto text-right shrink-0">
|
|
<% display_amount = recurring_transaction.manual? && recurring_transaction.expected_amount_avg.present? ? recurring_transaction.expected_amount_avg : recurring_transaction.amount %>
|
|
<% if recurring_transaction.transfer? %>
|
|
<%= content_tag :p, format_money(Money.new(display_amount.abs, recurring_transaction.currency)), class: [ "font-medium", "privacy-sensitive", "text-secondary" ] %>
|
|
<% else %>
|
|
<%= content_tag :p, format_money(-Money.new(display_amount, recurring_transaction.currency)), class: ["font-medium", "privacy-sensitive", display_amount.negative? ? "text-success" : "text-subdued"] %>
|
|
<% end %>
|
|
</div>
|
|
</div>
|