Files
sure/app/controllers/api/v1/recurring_transactions_controller.rb
T
Brandon 686205c0ff feat(bills): schema and domain core for the bills subsystem (#3201)
* feat(bills): schema and domain core for the bills subsystem

First of three chunks carved out of #3083. This one carries the schema and the
domain layer: no bills pages, no calendar feed, no assistant tools. Nothing here
is reachable from the UI yet, so it changes no user-visible behavior on its own.

Schema, in a single migration with a full down:

- recurrence_rules, recurring_occurrences, recurring_allocations,
  recurring_price_changes and recurring_match_rejections
- bill columns on recurring_transactions (bill_type, payment_url, autopay,
  notes, anchor and end conditions, weekend adjustment, dedup scope)
- the four data backfills, in their original order

Domain layer:

- Schedule, the pure date PORO every cadence resolves through, and
  FrequencyPreset for the labels
- OccurrenceGenerator, Matcher, Allocator, PriceChangeDetector, Classifier,
  DeclaredBill, HistoryBackfiller and PaycheckPlanner
- Pipeline, tying detection to generation, plus the nightly job and rake task

Existing detection code changed in three places, each a bug this schema exposes:

- Cleaner used a flat two-month staleness threshold, which silently retired
  every quarterly and annual series
- SubscriptionAuditGenerator used a flat 45-day overdue threshold, meaningless
  at both ends of the frequency range
- CashFlowWarningGenerator read one projected entry per series, which only
  equalled the monthly amount because every series was monthly; weekly bills
  were under-counted fourfold in its 30-day projection

The JSON API travels with the model rather than the UI, because the status enum
widens here. The API accepts only active and inactive on write; suggested,
paused and ended are lifecycle states owned by detection, so the documented
enum stays truthful.

Uniqueness keys gain dedup_scope alongside amount, never instead of it: a
series that is not price-forked carries a blank scope, so amount is what keeps
two different prices apart.

Suite 7,550 runs, 0 failures. Rubocop and brakeman clean. Eager loading
verified, and the migration reverses and re-applies. Includes the first review round: orphan repair matches income and refuses coincidental twins, session imports persist occurrence mappings across chunks, semimonthly anchors canonicalize, classifier keywords match whole words, and the down refuses rather than failing when price-forked rows exist.

* Address second review round

Bound the cross-currency default allocation by the entry leftover and the
occurrence remainder, matching the same-currency path. Let keyword stems
carry a suffix again after the word-boundary fix silenced them. Skip an
incoherent recurrence rule row instead of rolling back the whole import.
Check rollback collisions per restored index so a refusal cannot land
after the bills tables are dropped. Replay the closed_at test through a
real second import. Preload the orphan repair associations and move the
allocator errors to locale keys.

* Match index NULL semantics in the rollback collision checks

GROUP BY treats NULLs as equal but the restored unique indexes do not:
account_id is nullable and indexed, so two accountless rows can never
collide under any of them. Excluding NULL accounts keeps the guard from
refusing a rollback PostgreSQL can perform. Verified live both ways:
accountless duplicates roll back, a real collision still refuses.

* Address maintainer review

Scope the payable debt-destination subquery to the row and its family
instead of scanning every account in the installation. Batch the cash
flow generator remaining-amount sums into one grouped query, matching
the two sibling sites. Enforce both window bounds in the after_count
branch so a future-anchored plan cannot leak past the requested end
date. Skip the explicit regeneration when the day column change will
fire the model callback anyway. Add the missing locale entry for the
allocation currency validation.
2026-08-31 23:41:38 +02:00

297 lines
9.3 KiB
Ruby

# frozen_string_literal: true
class Api::V1::RecurringTransactionsController < Api::V1::BaseController
include Pagy::Backend
# The model knows more statuses (suggested, paused, ended), but those are
# lifecycle states owned by detection and the web UI; the API writes only
# the two it documents.
WRITABLE_STATUSES = %w[active inactive].freeze
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, :recurrence_rules)
.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 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?
validate_status_write_param(recurring_transaction)
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
validate_status_write_param(recurring_transaction)
end
# A blank status falls through to the model's presence validation.
def validate_status_write_param(recurring_transaction)
status = recurring_transaction_input[:status]
return if status.blank? || status.to_s.in?(WRITABLE_STATUSES)
recurring_transaction.errors.add(:status, :inclusion)
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,
:payment_url,
:autopay,
:notes
)
end
def recurring_transaction_update_params
params.require(:recurring_transaction).permit(
:status,
:expected_day_of_month,
:next_expected_date,
:payment_url,
:autopay,
:notes
)
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