feat(api): add recurring transaction endpoints (#1600)

* feat(api): add recurring transaction endpoints

* fix(api): return validation errors for recurring writes

* fix(api): harden recurring transaction request handling

* fix(api): require writable recurring account access

* fix(api): default null recurring manual flag

* fix(api): tighten recurring transaction contracts

* test(api): align recurring transaction fixtures

* docs(api): regenerate recurring transaction OpenAPI
This commit is contained in:
ghost
2026-05-01 13:21:34 -06:00
committed by GitHub
parent 783309188f
commit b710b55124
11 changed files with 1701 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! "recurring_transaction", recurring_transaction: @recurring_transaction

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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],

View File

@@ -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

View File

@@ -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