diff --git a/app/controllers/api/v1/recurring_transactions_controller.rb b/app/controllers/api/v1/recurring_transactions_controller.rb new file mode 100644 index 000000000..2d367c880 --- /dev/null +++ b/app/controllers/api/v1/recurring_transactions_controller.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +class Api::V1::RecurringTransactionsController < Api::V1::BaseController + include Pagy::Backend + + UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i + + before_action :ensure_read_scope, only: %i[index show] + before_action :ensure_write_scope, only: %i[create update destroy] + before_action :set_readable_recurring_transaction, only: :show + before_action :set_writable_recurring_transaction, only: %i[update destroy] + + def index + return render_invalid_account_filter if params[:account_id].present? && !valid_uuid?(params[:account_id]) + + @per_page = safe_per_page_param + recurring_transactions_query = read_recurring_transactions_scope + .includes(:account, :merchant) + .order(status: :asc, next_expected_date: :asc) + + recurring_transactions_query = apply_filters(recurring_transactions_query) + + @pagy, @recurring_transactions = pagy( + recurring_transactions_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue => e + Rails.logger.error "RecurringTransactionsController#index error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Internal server error" + }, status: :internal_server_error + end + + def show + render :show + rescue => e + Rails.logger.error "RecurringTransactionsController#show error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Internal server error" + }, status: :internal_server_error + end + + def create + @recurring_transaction = current_resource_owner.family.recurring_transactions.new( + recurring_transaction_create_attributes + ) + validate_create_write_params(@recurring_transaction) + + if @recurring_transaction.errors.empty? && @recurring_transaction.save + render :show, status: :created + else + render json: { + error: "validation_failed", + message: "Recurring transaction could not be created", + errors: @recurring_transaction.errors.full_messages + }, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotFound + raise + rescue ActionController::ParameterMissing, ArgumentError => e + render json: { + error: "validation_failed", + message: e.message + }, status: :unprocessable_entity + rescue ActiveRecord::RecordNotUnique + render json: { + error: "conflict", + message: "Recurring transaction already exists" + }, status: :conflict + rescue => e + Rails.logger.error "RecurringTransactionsController#create error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Internal server error" + }, status: :internal_server_error + end + + def update + @recurring_transaction.assign_attributes(recurring_transaction_update_attributes) + validate_update_write_params(@recurring_transaction) + + if @recurring_transaction.errors.empty? && @recurring_transaction.save + render :show + else + render json: { + error: "validation_failed", + message: "Recurring transaction could not be updated", + errors: @recurring_transaction.errors.full_messages + }, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotFound + raise + rescue ActionController::ParameterMissing, ArgumentError => e + render json: { + error: "validation_failed", + message: e.message + }, status: :unprocessable_entity + rescue ActiveRecord::RecordNotUnique + render json: { + error: "conflict", + message: "Recurring transaction already exists" + }, status: :conflict + rescue => e + Rails.logger.error "RecurringTransactionsController#update error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Internal server error" + }, status: :internal_server_error + end + + def destroy + @recurring_transaction.destroy! + + render json: { message: "Recurring transaction deleted successfully" }, status: :ok + rescue ActiveRecord::RecordNotFound + raise + rescue => e + Rails.logger.error "RecurringTransactionsController#destroy error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + error: "internal_server_error", + message: "Internal server error" + }, status: :internal_server_error + end + + private + def set_readable_recurring_transaction + @recurring_transaction = find_recurring_transaction(read_recurring_transactions_scope) + end + + def set_writable_recurring_transaction + @recurring_transaction = find_recurring_transaction(write_recurring_transactions_scope) + end + + def find_recurring_transaction(scope) + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + scope.includes(:account, :merchant).find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def ensure_write_scope + authorize_scope!(:write) + end + + def read_recurring_transactions_scope + current_resource_owner.family.recurring_transactions.accessible_by(current_resource_owner) + end + + def write_recurring_transactions_scope + scope = current_resource_owner.family.recurring_transactions + writable_account_ids = current_resource_owner.family.accounts.writable_by(current_resource_owner).select(:id) + scope.where(account_id: writable_account_ids).or(scope.where(account_id: nil)) + end + + def apply_filters(query) + query = query.where(status: params[:status]) if params[:status].present? + if params[:account_id].present? + return query.none unless valid_uuid?(params[:account_id]) + + query = query.where(account_id: params[:account_id]) + end + query + end + + def recurring_transaction_create_attributes + attrs = recurring_transaction_create_params.to_h.symbolize_keys + attrs[:manual] = true if attrs[:manual].nil? + input = recurring_transaction_input + + attrs[:account] = writable_account(input[:account_id]) if input.key?(:account_id) + attrs[:merchant] = family_merchant(input[:merchant_id]) if input.key?(:merchant_id) + + attrs + end + + def recurring_transaction_update_attributes + recurring_transaction_update_params.to_h.symbolize_keys + end + + def writable_account(account_id) + return nil if account_id.blank? + raise ActiveRecord::RecordNotFound, "Account not found" unless valid_uuid?(account_id) + + current_resource_owner.family.accounts.writable_by(current_resource_owner).find_by(id: account_id) || + raise(ActiveRecord::RecordNotFound, "Account not found") + end + + def family_merchant(merchant_id) + return nil if merchant_id.blank? + raise ActiveRecord::RecordNotFound, "Merchant not found" unless valid_uuid?(merchant_id) + + current_resource_owner.family.merchants.find_by(id: merchant_id) || + raise(ActiveRecord::RecordNotFound, "Merchant not found") + end + + def valid_uuid?(value) + value.to_s.match?(UUID_REGEX) + end + + def validate_create_write_params(recurring_transaction) + input = recurring_transaction_input + recurring_transaction.errors.add(:last_occurrence_date, :blank) if input[:last_occurrence_date].blank? + recurring_transaction.errors.add(:next_expected_date, :blank) if input[:next_expected_date].blank? + end + + def validate_update_write_params(recurring_transaction) + input = recurring_transaction_input + if input.key?(:next_expected_date) && input[:next_expected_date].blank? + recurring_transaction.errors.add(:next_expected_date, :blank) + end + end + + def recurring_transaction_input + params.require(:recurring_transaction) + end + + def render_invalid_account_filter + render json: { + error: "validation_failed", + message: "account_id must be a valid UUID" + }, status: :unprocessable_entity + end + + def recurring_transaction_create_params + params.require(:recurring_transaction).permit( + :name, + :amount, + :currency, + :expected_day_of_month, + :last_occurrence_date, + :next_expected_date, + :status, + :occurrence_count, + :manual, + :expected_amount_min, + :expected_amount_max, + :expected_amount_avg + ) + end + + def recurring_transaction_update_params + params.require(:recurring_transaction).permit( + :status, + :expected_day_of_month, + :next_expected_date + ) + end + + def safe_page_param + page = params[:page].to_i + page > 0 ? page : 1 + end + + def safe_per_page_param + per_page = params[:per_page].to_i + case per_page + when 1..100 + per_page + else + 25 + end + end +end diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb index eb2784ef5..0b30e6bd7 100644 --- a/app/models/recurring_transaction.rb +++ b/app/models/recurring_transaction.rb @@ -15,6 +15,8 @@ class RecurringTransaction < ApplicationRecord 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 } + validates :status, presence: true, inclusion: { in: statuses.keys } + validates :occurrence_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validate :merchant_or_name_present validate :amount_variance_consistency diff --git a/app/views/api/v1/recurring_transactions/_recurring_transaction.json.jbuilder b/app/views/api/v1/recurring_transactions/_recurring_transaction.json.jbuilder new file mode 100644 index 000000000..c103ed53b --- /dev/null +++ b/app/views/api/v1/recurring_transactions/_recurring_transaction.json.jbuilder @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +json.id recurring_transaction.id +json.amount recurring_transaction.amount_money.format +money_to_minor_units = lambda do |money| + (money.amount * money.currency.minor_unit_conversion).round(0).to_i if money +end +json.amount_cents money_to_minor_units.call(recurring_transaction.amount_money) +json.currency recurring_transaction.currency +json.expected_day_of_month recurring_transaction.expected_day_of_month +json.last_occurrence_date recurring_transaction.last_occurrence_date +json.next_expected_date recurring_transaction.next_expected_date +json.status recurring_transaction.status +json.occurrence_count recurring_transaction.occurrence_count +json.name recurring_transaction.name +json.manual recurring_transaction.manual +json.expected_amount_min recurring_transaction.expected_amount_min_money&.format +json.expected_amount_min_cents money_to_minor_units.call(recurring_transaction.expected_amount_min_money) +json.expected_amount_max recurring_transaction.expected_amount_max_money&.format +json.expected_amount_max_cents money_to_minor_units.call(recurring_transaction.expected_amount_max_money) +json.expected_amount_avg recurring_transaction.expected_amount_avg_money&.format +json.expected_amount_avg_cents money_to_minor_units.call(recurring_transaction.expected_amount_avg_money) +json.created_at recurring_transaction.created_at.iso8601 +json.updated_at recurring_transaction.updated_at.iso8601 + +if recurring_transaction.account.present? + json.account do + json.id recurring_transaction.account.id + json.name recurring_transaction.account.name + json.account_type recurring_transaction.account.accountable_type&.underscore + end +else + json.account nil +end + +if recurring_transaction.merchant.present? + json.merchant do + json.id recurring_transaction.merchant.id + json.name recurring_transaction.merchant.name + end +else + json.merchant nil +end diff --git a/app/views/api/v1/recurring_transactions/index.json.jbuilder b/app/views/api/v1/recurring_transactions/index.json.jbuilder new file mode 100644 index 000000000..98a9c795d --- /dev/null +++ b/app/views/api/v1/recurring_transactions/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.recurring_transactions @recurring_transactions do |recurring_transaction| + json.partial! "recurring_transaction", recurring_transaction: recurring_transaction +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/recurring_transactions/show.json.jbuilder b/app/views/api/v1/recurring_transactions/show.json.jbuilder new file mode 100644 index 000000000..f96ab77de --- /dev/null +++ b/app/views/api/v1/recurring_transactions/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "recurring_transaction", recurring_transaction: @recurring_transaction diff --git a/config/routes.rb b/config/routes.rb index b68f3c801..6f91f4130 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -429,6 +429,7 @@ Rails.application.routes.draw do resources :trades, only: [ :index, :show, :create, :update, :destroy ] resources :holdings, only: [ :index, :show ] resources :valuations, only: [ :index, :create, :update, :show ] + resources :recurring_transactions, only: [ :index, :show, :create, :update, :destroy ] resources :imports, only: [ :index, :show, :create ] resource :usage, only: [ :show ], controller: :usage resource :balance_sheet, only: [ :show ], controller: :balance_sheet diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6edcccac3..345f81e70 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -612,6 +612,100 @@ components: other_account: "$ref": "#/components/schemas/Account" nullable: true + RecurringTransaction: + type: object + required: + - id + - amount + - amount_cents + - currency + - expected_day_of_month + - last_occurrence_date + - next_expected_date + - status + - occurrence_count + - manual + - created_at + - updated_at + properties: + id: + type: string + format: uuid + amount: + type: string + amount_cents: + type: integer + description: Amount in currency minor units + currency: + type: string + expected_day_of_month: + type: integer + minimum: 1 + maximum: 31 + last_occurrence_date: + type: string + format: date + next_expected_date: + type: string + format: date + status: + type: string + enum: + - active + - inactive + occurrence_count: + type: integer + minimum: 0 + name: + type: string + nullable: true + manual: + type: boolean + expected_amount_min: + type: string + nullable: true + expected_amount_min_cents: + type: integer + nullable: true + description: Minimum expected amount in currency minor units + expected_amount_max: + type: string + nullable: true + expected_amount_max_cents: + type: integer + nullable: true + description: Maximum expected amount in currency minor units + expected_amount_avg: + type: string + nullable: true + expected_amount_avg_cents: + type: integer + nullable: true + description: Average expected amount in currency minor units + account: + "$ref": "#/components/schemas/Account" + nullable: true + merchant: + "$ref": "#/components/schemas/Merchant" + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + RecurringTransactionCollection: + type: object + required: + - recurring_transactions + - pagination + properties: + recurring_transactions: + type: array + items: + "$ref": "#/components/schemas/RecurringTransaction" + pagination: + "$ref": "#/components/schemas/Pagination" Transaction: type: object required: @@ -2348,6 +2442,290 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/recurring_transactions": + get: + summary: List recurring transactions + tags: + - Recurring Transactions + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: status + in: query + required: false + description: Filter by recurring status + schema: + type: string + enum: + - active + - inactive + - name: account_id + in: query + required: false + description: Filter by account ID + schema: + type: string + format: uuid + responses: + '200': + description: recurring transactions listed + content: + application/json: + schema: + "$ref": "#/components/schemas/RecurringTransactionCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: validation error - malformed account filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + post: + summary: Create recurring transaction + tags: + - Recurring Transactions + security: + - apiKeyAuth: [] + parameters: [] + responses: + '201': + description: recurring transaction created + content: + application/json: + schema: + "$ref": "#/components/schemas/RecurringTransaction" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden - requires read_write scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: account not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: validation error - negative occurrence count + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + recurring_transaction: + type: object + properties: + account_id: + type: string + format: uuid + nullable: true + merchant_id: + type: string + format: uuid + nullable: true + name: + type: string + nullable: true + amount: + type: number + currency: + type: string + expected_day_of_month: + type: integer + minimum: 1 + maximum: 31 + last_occurrence_date: + type: string + format: date + next_expected_date: + type: string + format: date + status: + type: string + enum: + - active + - inactive + occurrence_count: + type: integer + minimum: 0 + manual: + type: boolean + expected_amount_min: + type: number + nullable: true + expected_amount_max: + type: number + nullable: true + expected_amount_avg: + type: number + nullable: true + required: + - amount + - currency + - expected_day_of_month + - last_occurrence_date + - next_expected_date + anyOf: + - required: + - name + - required: + - merchant_id + required: + - recurring_transaction + required: true + "/api/v1/recurring_transactions/{id}": + parameters: + - name: id + in: path + required: true + description: Recurring transaction ID + schema: + type: string + get: + summary: Retrieve recurring transaction + tags: + - Recurring Transactions + security: + - apiKeyAuth: [] + responses: + '200': + description: recurring transaction retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/RecurringTransaction" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: recurring transaction not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + patch: + summary: Update recurring transaction + tags: + - Recurring Transactions + security: + - apiKeyAuth: [] + parameters: [] + responses: + '200': + description: recurring transaction updated + content: + application/json: + schema: + "$ref": "#/components/schemas/RecurringTransaction" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden - requires read_write scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: recurring transaction not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: validation error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + recurring_transaction: + type: object + properties: + status: + type: string + enum: + - active + - inactive + expected_day_of_month: + type: integer + minimum: 1 + maximum: 31 + next_expected_date: + type: string + format: date + required: true + delete: + summary: Delete recurring transaction + tags: + - Recurring Transactions + security: + - apiKeyAuth: [] + responses: + '200': + description: recurring transaction deleted + content: + application/json: + schema: + "$ref": "#/components/schemas/SuccessMessage" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden - requires read_write scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: recurring transaction not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/rules": get: summary: List rules @@ -2415,7 +2793,6 @@ paths: description: Rule ID schema: type: string - format: uuid get: summary: Retrieve a rule tags: diff --git a/spec/requests/api/v1/recurring_transactions_spec.rb b/spec/requests/api/v1/recurring_transactions_spec.rb new file mode 100644 index 000000000..aedfd0df1 --- /dev/null +++ b/spec/requests/api/v1/recurring_transactions_spec.rb @@ -0,0 +1,401 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Recurring Transactions', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:read_only_api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'Read Only Docs Key', + key: key, + scopes: %w[read], + source: 'mobile' + ) + end + + let(:'X-Api-Key') { api_key.plain_key } + + let(:account) do + Account.create!( + family: family, + owner: user, + name: 'Checking Account', + balance: 1000, + currency: 'USD', + accountable: Depository.create! + ) + end + + let(:merchant) { family.merchants.create!(name: 'Streaming Service') } + + let!(:recurring_transaction) do + family.recurring_transactions.create!( + account: account, + merchant: merchant, + amount: 19.99, + currency: 'USD', + expected_day_of_month: 15, + last_occurrence_date: Date.new(2026, 4, 15), + next_expected_date: Date.new(2026, 5, 15), + status: 'active', + occurrence_count: 3, + manual: true + ) + end + + path '/api/v1/recurring_transactions' do + get 'List recurring transactions' do + tags 'Recurring Transactions' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + parameter name: :page, in: :query, type: :integer, required: false, + description: 'Page number (default: 1)' + parameter name: :per_page, in: :query, type: :integer, required: false, + description: 'Items per page (default: 25, max: 100)' + parameter name: :status, in: :query, required: false, + description: 'Filter by recurring status', + schema: { type: :string, enum: %w[active inactive] } + parameter name: :account_id, in: :query, required: false, description: 'Filter by account ID', + schema: { type: :string, format: :uuid } + + response '200', 'recurring transactions listed' do + schema '$ref' => '#/components/schemas/RecurringTransactionCollection' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '422', 'validation error - malformed account filter' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:account_id) { 'not-a-uuid' } + + run_test! + end + end + + post 'Create recurring transaction' do + tags 'Recurring Transactions' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + recurring_transaction: { + type: :object, + properties: { + account_id: { type: :string, format: :uuid, nullable: true }, + merchant_id: { type: :string, format: :uuid, nullable: true }, + name: { type: :string, nullable: true }, + amount: { type: :number }, + currency: { type: :string }, + expected_day_of_month: { type: :integer, minimum: 1, maximum: 31 }, + last_occurrence_date: { type: :string, format: :date }, + next_expected_date: { type: :string, format: :date }, + status: { type: :string, enum: %w[active inactive] }, + occurrence_count: { type: :integer, minimum: 0 }, + manual: { type: :boolean }, + expected_amount_min: { type: :number, nullable: true }, + expected_amount_max: { type: :number, nullable: true }, + expected_amount_avg: { type: :number, nullable: true } + }, + required: %w[amount currency expected_day_of_month last_occurrence_date next_expected_date], + anyOf: [ + { required: %w[name] }, + { required: %w[merchant_id] } + ] + } + }, + required: %w[recurring_transaction] + } + + let(:body) do + { + recurring_transaction: { + account_id: account.id, + name: 'Gym Membership', + amount: 49.99, + currency: 'USD', + expected_day_of_month: 1, + last_occurrence_date: '2026-04-01', + next_expected_date: '2026-05-01' + } + } + end + + response '201', 'recurring transaction created' do + schema '$ref' => '#/components/schemas/RecurringTransaction' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'forbidden - requires read_write scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { read_only_api_key.plain_key } + + run_test! + end + + response '404', 'account not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + recurring_transaction: { + account_id: SecureRandom.uuid, + name: 'Gym Membership', + amount: 49.99, + currency: 'USD', + expected_day_of_month: 1, + last_occurrence_date: '2026-04-01', + next_expected_date: '2026-05-01' + } + } + end + + run_test! + end + + response '422', 'validation error - missing merchant or name' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + recurring_transaction: { + account_id: account.id, + amount: 49.99, + currency: 'USD', + expected_day_of_month: 1, + last_occurrence_date: '2026-04-01', + next_expected_date: '2026-05-01' + } + } + end + + run_test! + end + + response '422', 'validation error - nil status' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + recurring_transaction: { + account_id: account.id, + name: 'Gym Membership', + amount: 49.99, + currency: 'USD', + expected_day_of_month: 1, + last_occurrence_date: '2026-04-01', + next_expected_date: '2026-05-01', + status: nil + } + } + end + + run_test! + end + + response '422', 'validation error - negative occurrence count' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + recurring_transaction: { + account_id: account.id, + name: 'Gym Membership', + amount: 49.99, + currency: 'USD', + expected_day_of_month: 1, + last_occurrence_date: '2026-04-01', + next_expected_date: '2026-05-01', + occurrence_count: -1 + } + } + end + + run_test! + end + end + end + + path '/api/v1/recurring_transactions/{id}' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Recurring transaction ID' + + get 'Retrieve recurring transaction' do + tags 'Recurring Transactions' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { recurring_transaction.id } + + response '200', 'recurring transaction retrieved' do + schema '$ref' => '#/components/schemas/RecurringTransaction' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '404', 'recurring transaction not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + + patch 'Update recurring transaction' do + tags 'Recurring Transactions' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + + let(:id) { recurring_transaction.id } + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + recurring_transaction: { + type: :object, + properties: { + status: { type: :string, enum: %w[active inactive] }, + expected_day_of_month: { type: :integer, minimum: 1, maximum: 31 }, + next_expected_date: { type: :string, format: :date } + } + } + } + } + let(:body) { { recurring_transaction: { status: 'inactive' } } } + + response '200', 'recurring transaction updated' do + schema '$ref' => '#/components/schemas/RecurringTransaction' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'forbidden - requires read_write scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { read_only_api_key.plain_key } + + run_test! + end + + response '404', 'recurring transaction not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + + response '422', 'validation error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) { { recurring_transaction: { expected_day_of_month: 32 } } } + + run_test! + end + end + + delete 'Delete recurring transaction' do + tags 'Recurring Transactions' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { recurring_transaction.id } + + response '200', 'recurring transaction deleted' do + schema '$ref' => '#/components/schemas/SuccessMessage' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'forbidden - requires read_write scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { read_only_api_key.plain_key } + + run_test! + end + + response '404', 'recurring transaction not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 9ad0e61cc..cd94929a0 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -392,6 +392,44 @@ RSpec.configure do |config| other_account: { '$ref' => '#/components/schemas/Account', nullable: true } } }, + RecurringTransaction: { + type: :object, + required: %w[id amount amount_cents currency expected_day_of_month last_occurrence_date next_expected_date status occurrence_count manual created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + amount: { type: :string }, + amount_cents: { type: :integer, description: 'Amount in currency minor units' }, + currency: { type: :string }, + expected_day_of_month: { type: :integer, minimum: 1, maximum: 31 }, + last_occurrence_date: { type: :string, format: :date }, + next_expected_date: { type: :string, format: :date }, + status: { type: :string, enum: %w[active inactive] }, + occurrence_count: { type: :integer, minimum: 0 }, + name: { type: :string, nullable: true }, + manual: { type: :boolean }, + expected_amount_min: { type: :string, nullable: true }, + expected_amount_min_cents: { type: :integer, nullable: true, description: 'Minimum expected amount in currency minor units' }, + expected_amount_max: { type: :string, nullable: true }, + expected_amount_max_cents: { type: :integer, nullable: true, description: 'Maximum expected amount in currency minor units' }, + expected_amount_avg: { type: :string, nullable: true }, + expected_amount_avg_cents: { type: :integer, nullable: true, description: 'Average expected amount in currency minor units' }, + account: { '$ref' => '#/components/schemas/Account', nullable: true }, + merchant: { '$ref' => '#/components/schemas/Merchant', nullable: true }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + RecurringTransactionCollection: { + type: :object, + required: %w[recurring_transactions pagination], + properties: { + recurring_transactions: { + type: :array, + items: { '$ref' => '#/components/schemas/RecurringTransaction' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, Transaction: { type: :object, required: %w[id date amount currency name classification account tags created_at updated_at], diff --git a/test/controllers/api/v1/recurring_transactions_controller_test.rb b/test/controllers/api/v1/recurring_transactions_controller_test.rb new file mode 100644 index 000000000..f03f08639 --- /dev/null +++ b/test/controllers/api/v1/recurring_transactions_controller_test.rb @@ -0,0 +1,509 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::RecurringTransactionsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + @account = accounts(:depository) + @merchant = @family.merchants.create!(name: "Streaming Service") + + @user.api_keys.active.destroy_all + @api_key = ApiKey.create!( + user: @user, + name: "Test Read-Write Key", + scopes: [ "read_write" ], + source: "web", + display_key: "test_rw_#{SecureRandom.hex(8)}" + ) + @read_only_api_key = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + display_key: "test_read_#{SecureRandom.hex(8)}", + source: "mobile" + ) + + @recurring_transaction = @family.recurring_transactions.create!( + account: @account, + merchant: @merchant, + amount: 19.99, + currency: "USD", + expected_day_of_month: 15, + last_occurrence_date: Date.new(2026, 4, 15), + next_expected_date: Date.new(2026, 5, 15), + status: "active", + occurrence_count: 3, + manual: true + ) + end + + test "should list recurring transactions" do + get api_v1_recurring_transactions_url, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data.key?("recurring_transactions") + assert response_data.key?("pagination") + assert_includes response_data["recurring_transactions"].map { |item| item["id"] }, @recurring_transaction.id + end + + test "should require authentication when listing recurring transactions" do + get api_v1_recurring_transactions_url + + assert_response :unauthorized + end + + test "should show recurring transaction" do + get api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_equal @recurring_transaction.id, response_data["id"] + assert_equal 1999, response_data["amount_cents"] + assert response_data.key?("expected_amount_min_cents") + assert response_data.key?("expected_amount_max_cents") + assert response_data.key?("expected_amount_avg_cents") + assert_equal @account.id, response_data["account"]["id"] + assert_equal @merchant.id, response_data["merchant"]["id"] + end + + test "should not mutate recurring transaction on read only shared account" do + member = users(:family_member) + member.api_keys.active.destroy_all + member_api_key = ApiKey.create!( + user: member, + name: "Member Read-Write Key", + scopes: [ "read_write" ], + source: "web", + display_key: "test_member_rw_#{SecureRandom.hex(8)}" + ) + read_only_account = accounts(:credit_card) + recurring_transaction = @family.recurring_transactions.create!( + account: read_only_account, + name: "Read Only Shared Subscription", + amount: 9.99, + currency: "USD", + expected_day_of_month: 5, + last_occurrence_date: Date.new(2026, 4, 5), + next_expected_date: Date.new(2026, 5, 5), + status: "active", + occurrence_count: 2, + manual: true + ) + + get api_v1_recurring_transaction_url(recurring_transaction), headers: api_headers(member_api_key) + assert_response :success + + patch api_v1_recurring_transaction_url(recurring_transaction), + params: { recurring_transaction: { status: "inactive" } }, + headers: api_headers(member_api_key) + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + + assert_no_difference("@family.recurring_transactions.count") do + delete api_v1_recurring_transaction_url(recurring_transaction), headers: api_headers(member_api_key) + end + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "should return not found for missing recurring transaction" do + get api_v1_recurring_transaction_url(SecureRandom.uuid), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "should return not found for malformed recurring transaction id" do + get api_v1_recurring_transaction_url("not-a-uuid"), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "should reject malformed account filter" do + get api_v1_recurring_transactions_url, + params: { account_id: "not-a-uuid" }, + headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "should require authentication when showing recurring transaction" do + get api_v1_recurring_transaction_url(@recurring_transaction) + + assert_response :unauthorized + end + + test "should create recurring transaction" do + assert_difference("@family.recurring_transactions.count", 1) do + post api_v1_recurring_transactions_url, + params: valid_recurring_transaction_params, + headers: api_headers(@api_key) + end + + assert_response :created + response_data = JSON.parse(response.body) + assert_equal "Gym Membership", response_data["name"] + assert_equal 4999, response_data["amount_cents"] + assert_equal true, response_data["manual"] + end + + test "should default null manual to true when creating recurring transaction" do + params = valid_recurring_transaction_params.deep_dup + params[:recurring_transaction][:manual] = nil + + assert_difference("@family.recurring_transactions.count", 1) do + post api_v1_recurring_transactions_url, + params: params, + headers: api_headers(@api_key) + end + + assert_response :created + response_data = JSON.parse(response.body) + assert_equal true, response_data["manual"] + end + + test "should require authentication when creating recurring transaction" do + post api_v1_recurring_transactions_url, params: valid_recurring_transaction_params + + assert_response :unauthorized + end + + test "should reject create with read-only API key" do + post api_v1_recurring_transactions_url, + params: valid_recurring_transaction_params, + headers: api_headers(@read_only_api_key) + + assert_response :forbidden + end + + test "should reject create without recurring transaction wrapper" do + assert_no_difference("@family.recurring_transactions.count") do + post api_v1_recurring_transactions_url, + params: { name: "Gym Membership" }, + headers: api_headers(@api_key) + end + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "should reject create with malformed account id" do + params = valid_recurring_transaction_params.deep_dup + params[:recurring_transaction][:account_id] = "not-a-uuid" + + assert_no_difference("@family.recurring_transactions.count") do + post api_v1_recurring_transactions_url, + params: params, + headers: api_headers(@api_key) + end + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "should reject create with malformed merchant id" do + params = valid_recurring_transaction_params.deep_dup + params[:recurring_transaction].delete(:name) + params[:recurring_transaction][:merchant_id] = "not-a-uuid" + + assert_no_difference("@family.recurring_transactions.count") do + post api_v1_recurring_transactions_url, + params: params, + headers: api_headers(@api_key) + end + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "should reject create without name or merchant" do + params = valid_recurring_transaction_params.deep_dup + params[:recurring_transaction].delete(:name) + + post api_v1_recurring_transactions_url, + params: params, + headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "should reject create without required dates" do + params = valid_recurring_transaction_params.deep_dup + params[:recurring_transaction].delete(:last_occurrence_date) + params[:recurring_transaction].delete(:next_expected_date) + + assert_no_difference("@family.recurring_transactions.count") do + post api_v1_recurring_transactions_url, + params: params, + headers: api_headers(@api_key) + end + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + assert_includes response_data["errors"], "Last occurrence date can't be blank" + assert_includes response_data["errors"], "Next expected date can't be blank" + end + + test "should reject create with nil status" do + params = valid_recurring_transaction_params.deep_dup + params[:recurring_transaction][:status] = nil + + assert_no_difference("@family.recurring_transactions.count") do + post api_v1_recurring_transactions_url, + params: params, + headers: api_headers(@api_key) + end + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + assert_includes response_data["errors"], "Status can't be blank" + end + + test "should reject create with negative occurrence count" do + params = valid_recurring_transaction_params.deep_dup + params[:recurring_transaction][:occurrence_count] = -1 + + assert_no_difference("@family.recurring_transactions.count") do + post api_v1_recurring_transactions_url, + params: params, + headers: api_headers(@api_key) + end + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + assert_includes response_data["errors"], "Occurrence count must be greater than or equal to 0" + end + + test "should return conflict when creating duplicate recurring transaction" do + params = { + recurring_transaction: { + account_id: @account.id, + merchant_id: @merchant.id, + amount: @recurring_transaction.amount.to_s, + currency: @recurring_transaction.currency, + expected_day_of_month: 15, + last_occurrence_date: "2026-04-15", + next_expected_date: "2026-05-15" + } + } + + # The unique index intentionally ignores recurrence dates; matching family, + # account, merchant, amount, and currency is enough to conflict. + assert_no_difference("@family.recurring_transactions.count") do + post api_v1_recurring_transactions_url, + params: params, + headers: api_headers(@api_key) + end + + assert_response :conflict + response_data = JSON.parse(response.body) + assert_equal "conflict", response_data["error"] + assert_equal "Recurring transaction already exists", response_data["message"] + end + + test "should update recurring transaction" do + patch api_v1_recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { status: "inactive", expected_day_of_month: 16 } }, + headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_equal "inactive", response_data["status"] + assert_equal 16, response_data["expected_day_of_month"] + end + + test "should require authentication when updating recurring transaction" do + patch api_v1_recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { status: "inactive" } } + + assert_response :unauthorized + end + + test "should reject update with read-only API key" do + patch api_v1_recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { status: "inactive" } }, + headers: api_headers(@read_only_api_key) + + assert_response :forbidden + end + + test "should reject update without recurring transaction wrapper" do + patch api_v1_recurring_transaction_url(@recurring_transaction), + params: { status: "inactive" }, + headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "should reject update with invalid status" do + patch api_v1_recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { status: "paused" } }, + headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "should reject update with nil status" do + patch api_v1_recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { status: nil } }, + headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + assert_includes response_data["errors"], "Status can't be blank" + end + + test "should reject update with nil next expected date" do + patch api_v1_recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { next_expected_date: nil } }, + headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + assert_includes response_data["errors"], "Next expected date can't be blank" + end + + test "should ignore internal fields on update" do + patch api_v1_recurring_transaction_url(@recurring_transaction), + params: { + recurring_transaction: { + status: "inactive", + occurrence_count: 99, + manual: false, + amount: 1.23 + } + }, + headers: api_headers(@api_key) + + assert_response :success + @recurring_transaction.reload + assert_equal "inactive", @recurring_transaction.status + assert_equal 3, @recurring_transaction.occurrence_count + assert_equal true, @recurring_transaction.manual + assert_equal 19.99, @recurring_transaction.amount.to_f + end + + test "should return not found when updating missing recurring transaction" do + patch api_v1_recurring_transaction_url(SecureRandom.uuid), + params: { recurring_transaction: { status: "inactive" } }, + headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "should reject invalid recurring transaction update" do + patch api_v1_recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { expected_day_of_month: 32 } }, + headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "should destroy recurring transaction" do + assert_difference("@family.recurring_transactions.count", -1) do + delete api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@api_key) + end + + assert_response :ok + end + + test "should require authentication when destroying recurring transaction" do + delete api_v1_recurring_transaction_url(@recurring_transaction) + + assert_response :unauthorized + end + + test "should reject destroy with read-only API key" do + delete api_v1_recurring_transaction_url(@recurring_transaction), headers: api_headers(@read_only_api_key) + + assert_response :forbidden + end + + test "should return not found when destroying missing recurring transaction" do + delete api_v1_recurring_transaction_url(SecureRandom.uuid), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "should not create recurring transaction for another family account" do + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_account = Account.create!( + family: other_family, + name: "Other Checking", + currency: "USD", + classification: "asset", + accountable: Depository.create!, + balance: 0 + ) + + post api_v1_recurring_transactions_url, + params: { + recurring_transaction: { + account_id: other_account.id, + name: "Gym Membership", + amount: 49.99, + currency: "USD", + expected_day_of_month: 1, + last_occurrence_date: "2026-04-01", + next_expected_date: "2026-05-01" + } + }, + headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + private + + def valid_recurring_transaction_params + { + recurring_transaction: { + account_id: @account.id, + name: "Gym Membership", + amount: 49.99, + currency: "USD", + expected_day_of_month: 1, + last_occurrence_date: "2026-04-01", + next_expected_date: "2026-05-01", + status: "active", + occurrence_count: 1 + } + } + end + + def api_headers(api_key) + { "X-Api-Key" => api_key.plain_key } + end +end diff --git a/test/models/recurring_transaction_test.rb b/test/models/recurring_transaction_test.rb index 7b1b506f6..6c469eb83 100644 --- a/test/models/recurring_transaction_test.rb +++ b/test/models/recurring_transaction_test.rb @@ -9,6 +9,39 @@ class RecurringTransactionTest < ActiveSupport::TestCase @family.recurring_transactions.destroy_all end + test "status is required" do + recurring = @family.recurring_transactions.build( + account: @account, + merchant: @merchant, + amount: 29.99, + currency: "USD", + expected_day_of_month: 15, + last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, + status: nil + ) + + assert_not recurring.valid? + assert_includes recurring.errors[:status], "can't be blank" + end + + test "occurrence count cannot be negative" do + recurring = @family.recurring_transactions.build( + account: @account, + merchant: @merchant, + amount: 29.99, + currency: "USD", + expected_day_of_month: 15, + last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, + status: "active", + occurrence_count: -1 + ) + + assert_not recurring.valid? + assert_includes recurring.errors[:occurrence_count], "must be greater than or equal to 0" + end + test "identify_patterns_for creates recurring transactions for patterns with 3+ occurrences" do # Create a series of transactions with same merchant and amount on similar days # Use dates within the last 3 months: today, 1 month ago, 2 months ago