diff --git a/app/controllers/recurring_transactions_controller.rb b/app/controllers/recurring_transactions_controller.rb new file mode 100644 index 000000000..d6b6691d1 --- /dev/null +++ b/app/controllers/recurring_transactions_controller.rb @@ -0,0 +1,58 @@ +class RecurringTransactionsController < ApplicationController + layout "settings" + + def index + @recurring_transactions = Current.family.recurring_transactions + .includes(:merchant) + .order(status: :asc, next_expected_date: :asc) + end + + def identify + count = RecurringTransaction.identify_patterns_for(Current.family) + + respond_to do |format| + format.html do + flash[:notice] = t("recurring_transactions.identified", count: count) + redirect_to recurring_transactions_path + end + end + end + + def cleanup + count = RecurringTransaction.cleanup_stale_for(Current.family) + + respond_to do |format| + format.html do + flash[:notice] = t("recurring_transactions.cleaned_up", count: count) + redirect_to recurring_transactions_path + end + end + end + + def toggle_status + @recurring_transaction = Current.family.recurring_transactions.find(params[:id]) + + if @recurring_transaction.active? + @recurring_transaction.mark_inactive! + message = t("recurring_transactions.marked_inactive") + else + @recurring_transaction.mark_active! + message = t("recurring_transactions.marked_active") + end + + respond_to do |format| + format.html do + flash[:notice] = message + redirect_to recurring_transactions_path + end + end + end + + def destroy + @recurring_transaction = Current.family.recurring_transactions.find(params[:id]) + @recurring_transaction.destroy! + + flash[:notice] = t("recurring_transactions.deleted") + redirect_to recurring_transactions_path + end +end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 707fa5d4f..2036bd168 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -22,6 +22,14 @@ class TransactionsController < ApplicationController ) @pagy, @transactions = pagy(base_scope, limit: per_page) + + # Load projected recurring transactions for next month + @projected_recurring = Current.family.recurring_transactions + .active + .where("next_expected_date <= ? AND next_expected_date >= ?", + 1.month.from_now.to_date, + Date.current) + .includes(:merchant) end def clear_filter diff --git a/app/models/family.rb b/app/models/family.rb index 2380a37df..b618f3785 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -35,6 +35,7 @@ class Family < ApplicationRecord has_many :budget_categories, through: :budgets has_many :llm_usages, dependent: :destroy + has_many :recurring_transactions, dependent: :destroy validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) } diff --git a/app/models/family/sync_complete_event.rb b/app/models/family/sync_complete_event.rb index 4fd0e00f5..c00226072 100644 --- a/app/models/family/sync_complete_event.rb +++ b/app/models/family/sync_complete_event.rb @@ -28,5 +28,12 @@ class Family::SyncCompleteEvent rescue => e Rails.logger.error("Family::SyncCompleteEvent net_worth_chart broadcast failed: #{e.message}\n#{e.backtrace&.join("\n")}") end + + # Identify recurring transaction patterns after sync + begin + RecurringTransaction.identify_patterns_for(family) + rescue => e + Rails.logger.error("Family::SyncCompleteEvent recurring transaction identification failed: #{e.message}\n#{e.backtrace&.join("\n")}") + end end end diff --git a/app/models/merchant.rb b/app/models/merchant.rb index 4a4257bc2..f3a7bc95c 100644 --- a/app/models/merchant.rb +++ b/app/models/merchant.rb @@ -2,6 +2,7 @@ class Merchant < ApplicationRecord TYPES = %w[FamilyMerchant ProviderMerchant].freeze has_many :transactions, dependent: :nullify + has_many :recurring_transactions, dependent: :destroy validates :name, presence: true validates :type, inclusion: { in: TYPES } diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb new file mode 100644 index 000000000..14cb68961 --- /dev/null +++ b/app/models/recurring_transaction.rb @@ -0,0 +1,102 @@ +class RecurringTransaction < ApplicationRecord + include Monetizable + + belongs_to :family + belongs_to :merchant + + monetize :amount + + enum :status, { active: "active", inactive: "inactive" } + + validates :amount, presence: true + validates :currency, presence: true + validates :expected_day_of_month, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 31 } + + scope :for_family, ->(family) { where(family: family) } + scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) } + + # Class methods for identification and cleanup + def self.identify_patterns_for(family) + Identifier.new(family).identify_recurring_patterns + end + + def self.cleanup_stale_for(family) + Cleaner.new(family).cleanup_stale_transactions + end + + # Find matching transactions for this recurring pattern + def matching_transactions + entries = family.entries + .where(entryable_type: "Transaction") + .where(currency: currency) + .where("entries.amount = ?", amount) + .where("EXTRACT(DAY FROM entries.date) BETWEEN ? AND ?", + [ expected_day_of_month - 2, 1 ].max, + [ expected_day_of_month + 2, 31 ].min) + .order(date: :desc) + + # Filter by merchant through the entryable (Transaction) + entries.select do |entry| + entry.entryable.is_a?(Transaction) && entry.entryable.merchant_id == merchant_id + end + end + + # Check if this recurring transaction should be marked inactive + def should_be_inactive? + return false if last_occurrence_date.nil? + last_occurrence_date < 2.months.ago + end + + # Mark as inactive + def mark_inactive! + update!(status: "inactive") + end + + # Mark as active + def mark_active! + update!(status: "active") + end + + # Update based on a new transaction occurrence + def record_occurrence!(transaction_date) + self.last_occurrence_date = transaction_date + self.next_expected_date = calculate_next_expected_date(transaction_date) + self.occurrence_count += 1 + self.status = "active" + save! + end + + # Calculate the next expected date based on the last occurrence + def calculate_next_expected_date(from_date = last_occurrence_date) + # Start with next month + next_month = from_date.next_month + + # Try to use the expected day of month + begin + Date.new(next_month.year, next_month.month, expected_day_of_month) + rescue ArgumentError + # If day doesn't exist in month (e.g., 31st in February), use last day of month + next_month.end_of_month + end + end + + # Get the projected transaction for display + def projected_entry + return nil unless active? + return nil unless next_expected_date.future? + + OpenStruct.new( + date: next_expected_date, + amount: amount, + currency: currency, + merchant: merchant, + recurring: true, + projected: true + ) + end + + private + def monetizable_currency + currency + end +end diff --git a/app/models/recurring_transaction/cleaner.rb b/app/models/recurring_transaction/cleaner.rb new file mode 100644 index 000000000..ea7091051 --- /dev/null +++ b/app/models/recurring_transaction/cleaner.rb @@ -0,0 +1,41 @@ +class RecurringTransaction + class Cleaner + attr_reader :family + + def initialize(family) + @family = family + end + + # Mark recurring transactions as inactive if they haven't occurred in 2+ months + def cleanup_stale_transactions + two_months_ago = 2.months.ago.to_date + + stale_transactions = family.recurring_transactions + .active + .where("last_occurrence_date < ?", two_months_ago) + + stale_count = 0 + stale_transactions.find_each do |recurring_transaction| + # Double-check if there are any recent matching transactions + recent_matches = recurring_transaction.matching_transactions.select { |entry| entry.date >= two_months_ago } + + if recent_matches.empty? + recurring_transaction.mark_inactive! + stale_count += 1 + end + end + + stale_count + end + + # Remove inactive recurring transactions that have been inactive for 6+ months + def remove_old_inactive_transactions + six_months_ago = 6.months.ago + + family.recurring_transactions + .inactive + .where("updated_at < ?", six_months_ago) + .destroy_all + end + end +end diff --git a/app/models/recurring_transaction/identifier.rb b/app/models/recurring_transaction/identifier.rb new file mode 100644 index 000000000..7098b39f3 --- /dev/null +++ b/app/models/recurring_transaction/identifier.rb @@ -0,0 +1,159 @@ +class RecurringTransaction + class Identifier + attr_reader :family + + def initialize(family) + @family = family + end + + # Identify and create/update recurring transactions for the family + def identify_recurring_patterns + three_months_ago = 3.months.ago.to_date + + # Get all transactions from the last 3 months + entries_with_transactions = family.entries + .where(entryable_type: "Transaction") + .where("entries.date >= ?", three_months_ago) + .includes(:entryable) + .to_a + + # Filter to only those with merchants and group by merchant and amount (preserve sign) + grouped_transactions = entries_with_transactions + .select { |entry| entry.entryable.is_a?(Transaction) && entry.entryable.merchant_id.present? } + .group_by { |entry| [ entry.entryable.merchant_id, entry.amount.round(2), entry.currency ] } + + recurring_patterns = [] + + grouped_transactions.each do |(merchant_id, amount, currency), entries| + next if entries.size < 3 # Must have at least 3 occurrences + + # Check if the last occurrence was within the last 45 days + last_occurrence = entries.max_by(&:date) + next if last_occurrence.date < 45.days.ago.to_date + + # Check if transactions occur on similar days (within 5 days of each other) + days_of_month = entries.map { |e| e.date.day }.sort + + # Calculate if days cluster together (standard deviation check) + if days_cluster_together?(days_of_month) + expected_day = calculate_expected_day(days_of_month) + + recurring_patterns << { + merchant_id: merchant_id, + amount: amount, + currency: currency, + expected_day_of_month: expected_day, + last_occurrence_date: last_occurrence.date, + occurrence_count: entries.size, + entries: entries + } + end + end + + # Create or update RecurringTransaction records + recurring_patterns.each do |pattern| + recurring_transaction = family.recurring_transactions.find_or_initialize_by( + merchant_id: pattern[:merchant_id], + amount: pattern[:amount], + currency: pattern[:currency] + ) + + recurring_transaction.assign_attributes( + expected_day_of_month: pattern[:expected_day_of_month], + last_occurrence_date: pattern[:last_occurrence_date], + next_expected_date: calculate_next_expected_date(pattern[:last_occurrence_date], pattern[:expected_day_of_month]), + occurrence_count: pattern[:occurrence_count], + status: "active" + ) + + recurring_transaction.save! + end + + recurring_patterns.size + end + + private + # Check if days cluster together (within ~5 days variance) + # Uses circular distance to handle month-boundary wrapping (e.g., 28, 29, 30, 31, 1, 2) + def days_cluster_together?(days) + return false if days.empty? + + # Calculate median as reference point + median = calculate_expected_day(days) + + # Calculate circular distances from median + circular_distances = days.map { |day| circular_distance(day, median) } + + # Calculate standard deviation of circular distances + mean_distance = circular_distances.sum.to_f / circular_distances.size + variance = circular_distances.map { |dist| (dist - mean_distance)**2 }.sum / circular_distances.size + std_dev = Math.sqrt(variance) + + # Allow up to 5 days standard deviation + std_dev <= 5 + end + + # Calculate circular distance between two days on a 31-day circle + # Examples: + # circular_distance(1, 31) = 2 (wraps around: 31 -> 1 is 1 day forward) + # circular_distance(28, 2) = 5 (wraps: 28, 29, 30, 31, 1, 2) + def circular_distance(day1, day2) + linear_distance = (day1 - day2).abs + wrap_distance = 31 - linear_distance + [ linear_distance, wrap_distance ].min + end + + # Calculate the expected day based on the most common day + # Uses circular rotation to handle month-wrapping sequences (e.g., [29, 30, 31, 1, 2]) + def calculate_expected_day(days) + return days.first if days.size == 1 + + # Convert to 0-indexed (0-30 instead of 1-31) for modular arithmetic + days_0 = days.map { |d| d - 1 } + + # Find the rotation (pivot) that minimizes span, making the cluster contiguous + # This handles month-wrapping sequences like [29, 30, 31, 1, 2] + best_pivot = 0 + min_span = Float::INFINITY + + (0..30).each do |pivot| + rotated = days_0.map { |d| (d - pivot) % 31 } + span = rotated.max - rotated.min + + if span < min_span + min_span = span + best_pivot = pivot + end + end + + # Rotate days using best pivot to create contiguous array + rotated_days = days_0.map { |d| (d - best_pivot) % 31 }.sort + + # Calculate median on rotated, contiguous array + mid = rotated_days.size / 2 + rotated_median = if rotated_days.size.odd? + rotated_days[mid] + else + # For even count, average and round + ((rotated_days[mid - 1] + rotated_days[mid]) / 2.0).round + end + + # Map median back to original day space (unrotate) and convert to 1-indexed + original_day = (rotated_median + best_pivot) % 31 + 1 + + original_day + end + + # Calculate next expected date + def calculate_next_expected_date(last_date, expected_day) + next_month = last_date.next_month + + begin + Date.new(next_month.year, next_month.month, expected_day) + rescue ArgumentError + # If day doesn't exist in month, use last day of month + next_month.end_of_month + end + end + end +end diff --git a/app/views/family_exports/_list.html.erb b/app/views/family_exports/_list.html.erb index 339c0c039..e73889dfb 100644 --- a/app/views/family_exports/_list.html.erb +++ b/app/views/family_exports/_list.html.erb @@ -38,13 +38,13 @@ <%= button_to family_export_path(export), method: :delete, class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", - data: { + data: { turbo_confirm: "Are you sure you want to delete this export? This action cannot be undone.", turbo_frame: "_top" } do %> <%= icon "trash-2", class: "w-5 h-5 text-destructive" %> <% end %> - + <%= link_to download_family_export_path(export), class: "flex items-center gap-2 text-primary hover:text-primary-hover", data: { turbo_frame: "_top" } do %> @@ -56,11 +56,11 @@
<%= icon "alert-circle", class: "w-4 h-4" %>
- + <%= button_to family_export_path(export), method: :delete, class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", - data: { + data: { turbo_confirm: "Are you sure you want to delete this failed export?", turbo_frame: "_top" } do %> diff --git a/app/views/imports/_import.html.erb b/app/views/imports/_import.html.erb index 026e52fb8..bfb411da9 100644 --- a/app/views/imports/_import.html.erb +++ b/app/views/imports/_import.html.erb @@ -38,25 +38,21 @@
- <% if import.complete? || import.revert_failed? %> <%= button_to revert_import_path(import), method: :put, class: "flex items-center gap-2 text-orange-500 hover:text-orange-600", - data: { + data: { turbo_confirm: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time." } do %> <%= icon "rotate-ccw", class: "w-5 h-5 text-destructive" %> <% end %> - - - <% else %> <%= button_to import_path(import), method: :delete, class: "flex items-center gap-2 text-destructive hover:text-destructive-hover", - data: { + data: { turbo_confirm: CustomConfirm.for_resource_deletion("import") } do %> <%= icon "trash-2", class: "w-5 h-5 text-destructive" %> diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index 321d5f809..8e01454bd 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -7,7 +7,7 @@ <%= render partial: "imports/import", collection: @imports.ordered %>
<% end %> - + <%= link_to new_import_path, class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center", data: { turbo_frame: :modal } do %> @@ -27,7 +27,7 @@ <% end %> - + <%= link_to new_family_export_path, class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center", data: { turbo_frame: :modal } do %> diff --git a/app/views/layouts/_dark_mode_check.html.erb b/app/views/layouts/_dark_mode_check.html.erb index 0a0ccac2c..949891f2b 100644 --- a/app/views/layouts/_dark_mode_check.html.erb +++ b/app/views/layouts/_dark_mode_check.html.erb @@ -2,4 +2,4 @@ if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.setAttribute("data-theme", "dark"); } - \ No newline at end of file + diff --git a/app/views/pages/dashboard/_outflows_donut.html.erb b/app/views/pages/dashboard/_outflows_donut.html.erb index cefd472f7..01c52f34c 100644 --- a/app/views/pages/dashboard/_outflows_donut.html.erb +++ b/app/views/pages/dashboard/_outflows_donut.html.erb @@ -37,7 +37,7 @@
- <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(outflows_data[:total], delimiter: ',') %> + <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(outflows_data[:total], delimiter: ",") %>
@@ -47,7 +47,7 @@

<%= category[:name] %>

- <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %> + <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ",") %>

<%= category[:percentage] %>%

@@ -75,7 +75,7 @@ <%= category[:name] %>
- <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %> + <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ",") %> <%= category[:percentage] %>%
<% end %> diff --git a/app/views/recurring_transactions/_projected_transaction.html.erb b/app/views/recurring_transactions/_projected_transaction.html.erb new file mode 100644 index 000000000..44ed7d185 --- /dev/null +++ b/app/views/recurring_transactions/_projected_transaction.html.erb @@ -0,0 +1,50 @@ +<%# locals: (recurring_transaction:) %> + +
+
+
+ <%= content_tag :div, class: ["flex items-center gap-2"] do %> + <% if recurring_transaction.merchant&.logo_url.present? %> + <%= image_tag recurring_transaction.merchant.logo_url, + class: "w-6 h-6 rounded-full", + loading: "lazy" %> + <% else %> + <%= render DS::FilledIcon.new( + variant: :text, + text: recurring_transaction.merchant.name, + size: "sm", + rounded: true + ) %> + <% end %> + +
+
+
+
+ <%= recurring_transaction.merchant.name %> +
+ +
+ + <%= t("recurring_transactions.projected") %> + +
+
+ +
+ <%= t("recurring_transactions.expected_on", date: l(recurring_transaction.next_expected_date, format: :short)) %> +
+
+
+ <% end %> +
+
+ + + +
+ <%= content_tag :p, format_money(-recurring_transaction.amount_money), class: ["font-medium", recurring_transaction.amount.negative? ? "text-success" : "text-subdued"] %> +
+
diff --git a/app/views/recurring_transactions/index.html.erb b/app/views/recurring_transactions/index.html.erb new file mode 100644 index 000000000..5f8958f36 --- /dev/null +++ b/app/views/recurring_transactions/index.html.erb @@ -0,0 +1,140 @@ +
+
+

<%= t("recurring_transactions.title") %>

+
+ <%= render DS::Link.new( + text: t("recurring_transactions.identify_patterns"), + icon: "search", + variant: "outline", + href: identify_recurring_transactions_path, + method: :post + ) %> + <%= render DS::Link.new( + text: t("recurring_transactions.cleanup_stale"), + icon: "trash-2", + variant: "outline", + href: cleanup_recurring_transactions_path, + method: :post + ) %> +
+
+ +
+
+ <%= icon "info", class: "w-5 h-5 text-link mt-0.5 flex-shrink-0" %> +
+

<%= t("recurring_transactions.info.title") %>

+

<%= t("recurring_transactions.info.manual_description") %>

+

<%= t("recurring_transactions.info.automatic_description") %>

+
    + <% t("recurring_transactions.info.triggers").each do |trigger| %> +
  • <%= trigger %>
  • + <% end %> +
+
+
+
+ +
+ <% if @recurring_transactions.empty? %> +
+
+ <%= icon "repeat", size: "xl" %> +
+

<%= t("recurring_transactions.empty.title") %>

+

<%= t("recurring_transactions.empty.description") %>

+ <%= render DS::Link.new( + text: t("recurring_transactions.identify_patterns"), + icon: "search", + variant: "primary", + href: identify_recurring_transactions_path, + method: :post + ) %> +
+ <% else %> +
+
+

<%= t("recurring_transactions.title") %>

+ · +

<%= @recurring_transactions.count %>

+
+ +
+ + + + + + + + + + + + + + <% @recurring_transactions.each do |recurring_transaction| %> + "> + + + + + + + + + <% end %> + +
<%= t("recurring_transactions.table.merchant") %><%= t("recurring_transactions.table.amount") %><%= t("recurring_transactions.table.expected_day") %><%= t("recurring_transactions.table.next_date") %><%= t("recurring_transactions.table.last_occurrence") %><%= t("recurring_transactions.table.status") %><%= t("recurring_transactions.table.actions") %>
+
+ <% if recurring_transaction.merchant&.logo_url.present? %> + <%= image_tag recurring_transaction.merchant.logo_url, + class: "w-6 h-6 rounded-full", + loading: "lazy" %> + <% else %> + <%= render DS::FilledIcon.new( + variant: :text, + text: recurring_transaction.merchant.name, + size: "sm", + rounded: true + ) %> + <% end %> + <%= recurring_transaction.merchant.name %> +
+
"> + <%= format_money(-recurring_transaction.amount_money) %> + + <%= t("recurring_transactions.day_of_month", day: recurring_transaction.expected_day_of_month) %> + + <%= l(recurring_transaction.next_expected_date, format: :short) %> + + <%= l(recurring_transaction.last_occurrence_date, format: :short) %> + + <% if recurring_transaction.active? %> + + <%= t("recurring_transactions.status.active") %> + + <% else %> + + <%= t("recurring_transactions.status.inactive") %> + + <% end %> + +
+ <%= link_to toggle_status_recurring_transaction_path(recurring_transaction), + data: { turbo_method: :post }, + class: "text-secondary hover:text-primary" do %> + <%= icon recurring_transaction.active? ? "pause" : "play", size: "sm" %> + <% end %> + <%= link_to recurring_transaction_path(recurring_transaction), + data: { turbo_method: :delete, turbo_confirm: t("recurring_transactions.confirm_delete") }, + class: "text-secondary hover:text-destructive" do %> + <%= icon "trash-2", size: "sm" %> + <% end %> +
+
+
+
+ <% end %> +
+
diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 43510e849..95d241b32 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -17,7 +17,8 @@ nav_sections = [ { label: t(".categories_label"), path: categories_path, icon: "shapes" }, { label: t(".tags_label"), path: tags_path, icon: "tags" }, { label: t(".rules_label"), path: rules_path, icon: "git-branch" }, - { label: t(".merchants_label"), path: family_merchants_path, icon: "store" } + { label: t(".merchants_label"), path: family_merchants_path, icon: "store" }, + { label: t(".recurring_transactions_label"), path: recurring_transactions_path, icon: "repeat" } ] }, ( diff --git a/app/views/settings/ai_prompts/show.html.erb b/app/views/settings/ai_prompts/show.html.erb index 9a494d4c4..6847b0481 100644 --- a/app/views/settings/ai_prompts/show.html.erb +++ b/app/views/settings/ai_prompts/show.html.erb @@ -32,7 +32,7 @@

<%= t(".main_system_prompt.subtitle") %>

- +
@@ -60,7 +60,7 @@

<%= t(".transaction_categorizer.subtitle") %>

- +
@@ -88,7 +88,7 @@

<%= t(".merchant_detector.subtitle") %>

- +
@@ -105,4 +105,4 @@
- \ No newline at end of file + diff --git a/app/views/settings/bank_sync/_provider_link.html.erb b/app/views/settings/bank_sync/_provider_link.html.erb index 8a2d524cd..41a69d9ff 100644 --- a/app/views/settings/bank_sync/_provider_link.html.erb +++ b/app/views/settings/bank_sync/_provider_link.html.erb @@ -3,13 +3,13 @@ <%# Assign distinct colors to each provider %> <% provider_colors = { "Lunch Flow" => "#6471eb", - "Plaid" => "#4da568", + "Plaid" => "#4da568", "SimpleFin" => "#e99537" } %> <% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %> -<%= link_to provider_link[:path], - target: provider_link[:target], +<%= link_to provider_link[:path], + target: provider_link[:target], rel: provider_link[:rel], class: "flex justify-between items-center p-4 bg-container hover:bg-container-hover transition-colors" do %>
@@ -28,5 +28,3 @@ <%= icon("arrow-right", size: "sm", class: "text-secondary") %>
<% end %> - - diff --git a/app/views/settings/bank_sync/show.html.erb b/app/views/settings/bank_sync/show.html.erb index 56f634743..51c42bfcb 100644 --- a/app/views/settings/bank_sync/show.html.erb +++ b/app/views/settings/bank_sync/show.html.erb @@ -1,6 +1,5 @@ <%= content_for :page_title, "Bank Sync" %> -
<% if @providers.any? %>
@@ -25,5 +24,3 @@
<% end %>
- - diff --git a/app/views/trades/_form.html.erb b/app/views/trades/_form.html.erb index cb248ece9..034e890bf 100644 --- a/app/views/trades/_form.html.erb +++ b/app/views/trades/_form.html.erb @@ -50,7 +50,7 @@ <% if %w[buy sell].include?(type) %> <%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any", required: true %> - <%= form.money_field :price, label: t(".price"), step: 'any', precision: 10, required: true %> + <%= form.money_field :price, label: t(".price"), step: "any", precision: 10, required: true %> <% end %> diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 8fa5860cc..6f8a0a486 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -56,7 +56,7 @@ <%= render "transactions/selection_bar" %> - <% if @pagy.count > 0 %> + <% if @pagy.count > 0 || (@projected_recurring.any? && @q.blank?) %>