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? %> +
No scheduled transactions yet.
+ <% end %> ++ 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?" } %> +
+