mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 15:15:01 +00:00
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:
281
app/controllers/api/v1/recurring_transactions_controller.rb
Normal file
281
app/controllers/api/v1/recurring_transactions_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
12
app/views/api/v1/recurring_transactions/index.json.jbuilder
Normal file
12
app/views/api/v1/recurring_transactions/index.json.jbuilder
Normal 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
|
||||
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "recurring_transaction", recurring_transaction: @recurring_transaction
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
401
spec/requests/api/v1/recurring_transactions_spec.rb
Normal file
401
spec/requests/api/v1/recurring_transactions_spec.rb
Normal 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
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user