diff --git a/app/controllers/credit_cards/scheduled_transactions_controller.rb b/app/controllers/credit_cards/scheduled_transactions_controller.rb new file mode 100644 index 00000000..bfc21c47 --- /dev/null +++ b/app/controllers/credit_cards/scheduled_transactions_controller.rb @@ -0,0 +1,101 @@ +module CreditCards + class ScheduledTransactionsController < ApplicationController + before_action :set_credit_card + before_action :set_scheduled_transaction, only: [:edit, :update, :destroy] + before_action :load_form_dependencies, only: [:new, :create, :edit, :update] + + def new + @scheduled_transaction = @credit_card.scheduled_transactions.new(currency: @credit_card.currency) + end + + def create + @scheduled_transaction = @credit_card.scheduled_transactions.new(scheduled_transaction_params) + if @scheduled_transaction.save + flash.now[:notice] = 'Scheduled transaction was successfully created.' + respond_to do |format| + format.html { redirect_to credit_card_path(@credit_card) } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.prepend("scheduled_transactions_for_#{@credit_card.id}", partial: "credit_cards/scheduled_transactions/scheduled_transaction", locals: { credit_card: @credit_card, scheduled_transaction: @scheduled_transaction }), + turbo_stream.replace("new_scheduled_transaction_for_#{@credit_card.id}", ""), # Clear the form + *flash_notification_stream_items # Assuming you have this helper for flash messages + ] + end + end + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @scheduled_transaction.update(scheduled_transaction_params) + flash.now[:notice] = 'Scheduled transaction was successfully updated.' + respond_to do |format| + format.html { redirect_to credit_card_path(@credit_card) } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace(@scheduled_transaction, partial: "credit_cards/scheduled_transactions/scheduled_transaction", locals: { credit_card: @credit_card, scheduled_transaction: @scheduled_transaction }), + *flash_notification_stream_items + ] + end + end + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @scheduled_transaction.destroy + flash.now[:notice] = 'Scheduled transaction was successfully destroyed.' + respond_to do |format| + format.html { redirect_to credit_card_path(@credit_card) } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.remove(@scheduled_transaction), + *flash_notification_stream_items + ] + end + end + end + + private + + def set_credit_card + # Assuming Account model is used for credit cards + @credit_card = Current.family.accounts.find(params[:credit_card_id]) + # You might want to add an authorization check here to ensure the account is indeed a credit card + # or that the user is allowed to manage scheduled transactions for it. + # e.g., redirect_to root_path, alert: "Not a valid credit card account" unless @credit_card.accountable_type == "CreditCard" + end + + def set_scheduled_transaction + @scheduled_transaction = @credit_card.scheduled_transactions.find(params[:id]) + end + + def load_form_dependencies + @categories = Current.family.categories.expenses.alphabetically + @merchants = Current.family.merchants.alphabetically + end + + def scheduled_transaction_params + params.require(:scheduled_transaction).permit( + :description, + :amount, + :currency, + :frequency, + :installments, + # :current_installment, # Typically not directly set by user + :next_occurrence_date, + :end_date, + :category_id, + :merchant_id + ).tap do |p| + # Ensure currency is set if not provided, defaulting to account's currency + p[:currency] ||= @credit_card.currency + end + end + end +end diff --git a/app/jobs/create_transactions_from_scheduled_job.rb b/app/jobs/create_transactions_from_scheduled_job.rb new file mode 100644 index 00000000..205b4d0d --- /dev/null +++ b/app/jobs/create_transactions_from_scheduled_job.rb @@ -0,0 +1,130 @@ +class CreateTransactionsFromScheduledJob < ApplicationJob + queue_as :default + + def perform(*args) + today = Date.current + due_transactions = ScheduledTransaction.where("next_occurrence_date <= ?", today) + + due_transactions.each do |scheduled_transaction| + ActiveRecord::Base.transaction do + create_transaction_from_scheduled(scheduled_transaction, today) + update_scheduled_transaction(scheduled_transaction, today) + rescue StandardError => e + Rails.logger.error "Error processing scheduled transaction #{scheduled_transaction.id}: #{e.message}" + # Optionally, re-raise the error if you want the job to retry + # raise e + end + end + end + + private + + def create_transaction_from_scheduled(scheduled_transaction, date) + account = scheduled_transaction.account + # Assuming scheduled transactions are expenses, store amount as negative + amount = -scheduled_transaction.amount.abs + + entry_attributes = { + account_id: account.id, + name: scheduled_transaction.description, + amount: amount, + currency: scheduled_transaction.currency, + date: date, + entryable_attributes: { + category_id: scheduled_transaction.category_id, + merchant_id: scheduled_transaction.merchant_id + # tag_ids could be added here if scheduled transactions support tags + } + } + + entry = account.entries.new(entry_attributes) + entry.entryable_type = "Transaction" # Explicitly set entryable_type + + unless entry.save + Rails.logger.error "Failed to create transaction for scheduled transaction #{scheduled_transaction.id}: #{entry.errors.full_messages.join(', ')}" + raise ActiveRecord::Rollback # Rollback transaction if entry creation fails + end + end + + def update_scheduled_transaction(scheduled_transaction, current_date) + if scheduled_transaction.installments.present? && scheduled_transaction.installments > 0 + scheduled_transaction.current_installment += 1 + if scheduled_transaction.current_installment >= scheduled_transaction.installments + scheduled_transaction.destroy! + return + end + end + + next_date = calculate_next_occurrence(scheduled_transaction.next_occurrence_date, scheduled_transaction.frequency, current_date) + scheduled_transaction.next_occurrence_date = next_date + + if scheduled_transaction.end_date.present? && next_date > scheduled_transaction.end_date + if scheduled_transaction.installments.blank? || (scheduled_transaction.installments.present? && scheduled_transaction.current_installment < scheduled_transaction.installments) + # If it's a recurring transaction (not installment-based) or an installment-based one that hasn't completed all installments, + # but the next occurrence is past the end_date, destroy it. + scheduled_transaction.destroy! + return + end + end + + # If next_occurrence_date was in the past, ensure it's set to a future date + # This can happen if the job hasn't run for a while. + while scheduled_transaction.next_occurrence_date <= current_date && !scheduled_transaction.destroyed? + scheduled_transaction.next_occurrence_date = calculate_next_occurrence(scheduled_transaction.next_occurrence_date, scheduled_transaction.frequency, current_date) + if scheduled_transaction.end_date.present? && scheduled_transaction.next_occurrence_date > scheduled_transaction.end_date + scheduled_transaction.destroy! + return + end + end + + scheduled_transaction.save! unless scheduled_transaction.destroyed? + end + + def calculate_next_occurrence(current_next_date, frequency, processing_date) + # If current_next_date is in the past, start calculations from processing_date + # to ensure the next occurrence is in the future. + base_date = [current_next_date, processing_date].max + + case frequency.downcase + when 'daily' + base_date + 1.day + when 'weekly' + base_date + 1.week + when 'monthly' + calculate_next_monthly_date(base_date) + when 'yearly' + base_date + 1.year + # Add other frequencies as needed, e.g., 'bi-weekly', 'quarterly' + # when 'bi-weekly' + # base_date + 2.weeks + else + # Default or unknown frequency, maybe set to a distant future date or raise error + Rails.logger.warn "Unknown frequency: #{frequency} for scheduled transaction. Defaulting to 1 month." + calculate_next_monthly_date(base_date) + end + end + + def calculate_next_monthly_date(base_date) + # Attempt to advance by one month + next_month_date = base_date + 1.month + + # If the day of the month changed due to varying month lengths (e.g., Jan 31 to Feb 28), + # it means the original day doesn't exist in the next month. + # In such cases, Rails' `+ 1.month` correctly lands on the last day of that shorter month. + # If we want to stick to the original day of the month where possible, + # and it's not the end of the month, we might need more complex logic. + # However, for most common scenarios (e.g., payment on the 1st, 15th), `+ 1.month` is fine. + # If the scheduled day was, say, the 31st, and next month is February, it will become Feb 28th/29th. + # If the next month after that is March, `+ 1.month` from Feb 28th will be March 28th, not 31st. + # The current simple approach is generally acceptable. + # For more precise "day of month" sticking, one might do: + # desired_day = base_date.day + # current_date = base_date + # loop do + # current_date += 1.month + # break if current_date.day == desired_day || current_date.end_of_month.day < desired_day + # end + # return current_date + next_month_date + end +end diff --git a/app/models/scheduled_transaction.rb b/app/models/scheduled_transaction.rb new file mode 100644 index 00000000..ffe70901 --- /dev/null +++ b/app/models/scheduled_transaction.rb @@ -0,0 +1,5 @@ +class ScheduledTransaction < ApplicationRecord + belongs_to :account + belongs_to :category, optional: true + belongs_to :merchant, optional: true +end diff --git a/app/views/credit_cards/scheduled_transactions/_form.html.erb b/app/views/credit_cards/scheduled_transactions/_form.html.erb new file mode 100644 index 00000000..5dfade26 --- /dev/null +++ b/app/views/credit_cards/scheduled_transactions/_form.html.erb @@ -0,0 +1,62 @@ +<%# Locals: form (FormBuilder), credit_card (Account object), scheduled_transaction (ScheduledTransaction object) %> +<%= form_with(model: scheduled_transaction, url: scheduled_transaction.new_record? ? credit_card_scheduled_transactions_path(credit_card) : credit_card_scheduled_transaction_path(credit_card, scheduled_transaction), local: true) do |form| %> + <% if scheduled_transaction.errors.any? %> +
+

<%= pluralize(scheduled_transaction.errors.count, "error") %> prohibited this scheduled transaction from being saved:

+ +
+ <% end %> + +
+ <%= form.label :description %> + <%= form.text_field :description %> +
+ +
+ <%= form.label :amount %> + <%= form.number_field :amount, step: 0.01 %> +
+ +
+ <%= form.label :currency %> + <%= form.text_field :currency, value: scheduled_transaction.currency || credit_card.currency %> +
+ +
+ <%= form.label :frequency %> + <%= form.select :frequency, options_for_select([['Daily', 'daily'], ['Weekly', 'weekly'], ['Monthly', 'monthly'], ['Yearly', 'yearly']], scheduled_transaction.frequency) %> +
+ +
+ <%= form.label :next_occurrence_date %> + <%= form.date_select :next_occurrence_date %> +
+ +
+ <%= form.label :installments %> + <%= form.number_field :installments %> +
+ +
+ <%= form.label :end_date %> + <%= form.date_select :end_date, include_blank: true %> +
+ +
+ <%= form.label :category_id %> + <%= form.collection_select :category_id, Current.family.categories.expenses.alphabetically, :id, :name_with_parent, { include_blank: true } %> +
+ +
+ <%= form.label :merchant_id %> + <%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { include_blank: true } %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/credit_cards/scheduled_transactions/_list.html.erb b/app/views/credit_cards/scheduled_transactions/_list.html.erb new file mode 100644 index 00000000..12d0b0e6 --- /dev/null +++ b/app/views/credit_cards/scheduled_transactions/_list.html.erb @@ -0,0 +1,16 @@ +<%# Locals: credit_card (Account object), scheduled_transactions (collection) %> +
+
+ <%= link_to "New Scheduled Transaction", new_credit_card_scheduled_transaction_path(credit_card), class: "btn btn-primary" %> +
+ + <% if scheduled_transactions.any? %> +
+ <% scheduled_transactions.each do |st| %> + <%= render 'credit_cards/scheduled_transactions/scheduled_transaction', scheduled_transaction: st, credit_card: credit_card %> + <% end %> +
+ <% else %> +

No scheduled transactions yet.

+ <% end %> +
diff --git a/app/views/credit_cards/scheduled_transactions/_scheduled_transaction.html.erb b/app/views/credit_cards/scheduled_transactions/_scheduled_transaction.html.erb new file mode 100644 index 00000000..1804ae08 --- /dev/null +++ b/app/views/credit_cards/scheduled_transactions/_scheduled_transaction.html.erb @@ -0,0 +1,35 @@ +<%# Locals: scheduled_transaction, credit_card (Account object) %> +
+

+ Description: + <%= scheduled_transaction.description %> +

+

+ Amount: + <%= number_to_currency(scheduled_transaction.amount, unit: scheduled_transaction.currency) %> +

+

+ Frequency: + <%= scheduled_transaction.frequency&.humanize %> +

+

+ Next Occurrence: + <%= l(scheduled_transaction.next_occurrence_date) if scheduled_transaction.next_occurrence_date? %> +

+ <% if scheduled_transaction.installments.present? && scheduled_transaction.installments > 0 %> +

+ Installments: + <%= scheduled_transaction.current_installment %> / <%= scheduled_transaction.installments %> +

+ <% end %> + <% if scheduled_transaction.end_date.present? %> +

+ Ends On: + <%= l(scheduled_transaction.end_date) %> +

+ <% end %> +

+ <%= link_to "Edit", edit_credit_card_scheduled_transaction_path(credit_card, scheduled_transaction) %> | + <%= link_to "Delete", credit_card_scheduled_transaction_path(credit_card, scheduled_transaction), data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %> +

+
diff --git a/app/views/credit_cards/scheduled_transactions/edit.html.erb b/app/views/credit_cards/scheduled_transactions/edit.html.erb new file mode 100644 index 00000000..04e9fa89 --- /dev/null +++ b/app/views/credit_cards/scheduled_transactions/edit.html.erb @@ -0,0 +1,6 @@ +

Editing Scheduled Transaction for <%= @credit_card.name %>

+ +<%= render 'form', credit_card: @credit_card, scheduled_transaction: @scheduled_transaction %> + +<%= link_to 'Show', credit_card_scheduled_transaction_path(@credit_card, @scheduled_transaction) %> | +<%= link_to 'Back to Credit Card', credit_card_path(@credit_card) %> diff --git a/app/views/credit_cards/scheduled_transactions/new.html.erb b/app/views/credit_cards/scheduled_transactions/new.html.erb new file mode 100644 index 00000000..8d8fa3af --- /dev/null +++ b/app/views/credit_cards/scheduled_transactions/new.html.erb @@ -0,0 +1,5 @@ +

New Scheduled Transaction for <%= @credit_card.name %>

+ +<%= render 'form', credit_card: @credit_card, scheduled_transaction: @scheduled_transaction %> + +<%= link_to 'Back to Credit Card', credit_card_path(@credit_card) %> diff --git a/app/views/credit_cards/show.html.erb b/app/views/credit_cards/show.html.erb index 4ba5252b..7e9b8612 100644 --- a/app/views/credit_cards/show.html.erb +++ b/app/views/credit_cards/show.html.erb @@ -3,4 +3,5 @@ tabs: render("accounts/show/tabs", account: @account, tabs: [ { key: "activity", contents: render("accounts/show/activity", account: @account) }, { key: "overview", contents: render("credit_cards/overview", account: @account) }, + { key: "scheduled", label: "Scheduled Transactions", contents: render("credit_cards/scheduled_transactions/list", credit_card: @account, scheduled_transactions: @account.scheduled_transactions.order(:next_occurrence_date)) } ]) %> diff --git a/config/routes.rb b/config/routes.rb index ec9e2cce..15fd12cb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -166,7 +166,9 @@ Rails.application.routes.draw do resources :investments, except: :index resources :properties, except: :index resources :vehicles, except: :index - resources :credit_cards, except: :index + resources :credit_cards, except: :index do + resources :scheduled_transactions, only: [:new, :create, :edit, :update, :destroy], controller: 'credit_cards/scheduled_transactions' + end resources :loans, except: :index resources :cryptos, except: :index resources :other_assets, except: :index diff --git a/db/migrate/20250524135137_create_scheduled_transactions.rb b/db/migrate/20250524135137_create_scheduled_transactions.rb new file mode 100644 index 00000000..82701364 --- /dev/null +++ b/db/migrate/20250524135137_create_scheduled_transactions.rb @@ -0,0 +1,21 @@ +class CreateScheduledTransactions < ActiveRecord::Migration[7.2] + def change + create_table :scheduled_transactions, id: :uuid do |t| + t.references :account, null: false, foreign_key: true, type: :uuid + t.references :category, null: true, foreign_key: true, type: :uuid + t.references :merchant, null: true, foreign_key: true, type: :uuid + t.string :description + t.decimal :amount + t.string :currency + t.string :frequency + t.integer :installments + t.integer :current_installment, default: 0 + t.date :next_occurrence_date + t.date :end_date + + t.timestamps + end + + add_index :scheduled_transactions, :next_occurrence_date + end +end diff --git a/test/fixtures/scheduled_transactions.yml b/test/fixtures/scheduled_transactions.yml new file mode 100644 index 00000000..e4f1f042 --- /dev/null +++ b/test/fixtures/scheduled_transactions.yml @@ -0,0 +1,27 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + account: one + category: one + merchant: one + description: MyString + amount: 9.99 + currency: MyString + frequency: MyString + installments: 1 + current_installment: 1 + next_occurrence_date: 2025-05-24 + end_date: 2025-05-24 + +two: + account: two + category: two + merchant: two + description: MyString + amount: 9.99 + currency: MyString + frequency: MyString + installments: 1 + current_installment: 1 + next_occurrence_date: 2025-05-24 + end_date: 2025-05-24 diff --git a/test/models/scheduled_transaction_test.rb b/test/models/scheduled_transaction_test.rb new file mode 100644 index 00000000..ada2a226 --- /dev/null +++ b/test/models/scheduled_transaction_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ScheduledTransactionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end