mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 21:04:12 +00:00
* feat(api): support idempotent valuation writes * fix(api): clarify valuation upsert status * docs(api): document nested valuation upserts * docs(api): clarify valuation upsert semantics * docs(api): clarify valuation upsert signaling
306 lines
8.6 KiB
Ruby
306 lines
8.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Api::V1::ValuationsController < Api::V1::BaseController
|
|
include Pagy::Backend
|
|
|
|
InvalidFilterError = Class.new(StandardError)
|
|
BOOLEAN_PARAM = ActiveModel::Type::Boolean.new
|
|
|
|
before_action :ensure_read_scope, only: [ :index, :show ]
|
|
before_action :ensure_write_scope, only: [ :create, :update ]
|
|
before_action :set_valuation, only: [ :show, :update ]
|
|
|
|
def index
|
|
family = current_resource_owner.family
|
|
accessible_account_ids = family.accounts.accessible_by(current_resource_owner).select(:id)
|
|
valuations_query = family.entries
|
|
.where(entryable_type: "Valuation", account_id: accessible_account_ids)
|
|
.includes(:account, :entryable)
|
|
|
|
valuations_query = apply_filters(valuations_query).reverse_chronological
|
|
@per_page = safe_per_page_param
|
|
|
|
@pagy, @entries = pagy(
|
|
valuations_query,
|
|
page: safe_page_param,
|
|
limit: @per_page
|
|
)
|
|
|
|
render :index
|
|
rescue InvalidFilterError => e
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: e.message,
|
|
errors: [ e.message ]
|
|
}, status: :unprocessable_entity
|
|
rescue => e
|
|
Rails.logger.error "ValuationsController#index error: #{e.message}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
|
|
render json: {
|
|
error: "internal_server_error",
|
|
message: "An unexpected error occurred"
|
|
}, status: :internal_server_error
|
|
end
|
|
|
|
def show
|
|
render :show
|
|
rescue => e
|
|
Rails.logger.error "ValuationsController#show error: #{e.message}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
|
|
render json: {
|
|
error: "internal_server_error",
|
|
message: "An unexpected error occurred"
|
|
}, status: :internal_server_error
|
|
end
|
|
|
|
def create
|
|
unless valuation_account_id.present?
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: "Account ID is required",
|
|
errors: [ "Account ID is required" ]
|
|
}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
unless valuation_params[:amount].present?
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: "Amount is required",
|
|
errors: [ "Amount is required" ]
|
|
}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
unless valuation_params[:date].present?
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: "Date is required",
|
|
errors: [ "Date is required" ]
|
|
}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
account = current_resource_owner.family.accounts.find(valuation_account_id)
|
|
requested_upsert = upsert_requested?
|
|
existing_write = false
|
|
|
|
create_success = false
|
|
error_payload = nil
|
|
|
|
ActiveRecord::Base.transaction do
|
|
account.lock! if requested_upsert
|
|
existing_write = account.entries.valuations.exists?(date: valuation_params[:date]) if requested_upsert
|
|
|
|
# upsert=true only affects response status; reconciliation owns write behavior.
|
|
result = account.create_reconciliation(
|
|
balance: valuation_params[:amount],
|
|
date: valuation_params[:date]
|
|
)
|
|
|
|
unless result.success?
|
|
error_payload = {
|
|
error: "validation_failed",
|
|
message: "Valuation could not be created",
|
|
errors: [ result.error_message ]
|
|
}
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
|
|
@entry = account.entries.valuations.find_by!(date: valuation_params[:date])
|
|
@valuation = @entry.entryable
|
|
|
|
if valuation_params.key?(:notes)
|
|
unless @entry.update(notes: valuation_params[:notes])
|
|
error_payload = {
|
|
error: "validation_failed",
|
|
message: "Valuation could not be created",
|
|
errors: @entry.errors.full_messages
|
|
}
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
end
|
|
|
|
create_success = true
|
|
end
|
|
|
|
unless create_success
|
|
render json: error_payload, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
render :show, status: requested_upsert && existing_write ? :ok : :created
|
|
|
|
rescue ActiveRecord::RecordNotFound
|
|
render json: {
|
|
error: "not_found",
|
|
message: "Account or valuation entry not found"
|
|
}, status: :not_found
|
|
rescue => e
|
|
Rails.logger.error "ValuationsController#create error: #{e.message}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
|
|
render json: {
|
|
error: "internal_server_error",
|
|
message: "An unexpected error occurred"
|
|
}, status: :internal_server_error
|
|
end
|
|
|
|
def update
|
|
if valuation_params[:date].present? || valuation_params[:amount].present?
|
|
unless valuation_params[:date].present? && valuation_params[:amount].present?
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: "Both amount and date are required when updating reconciliation",
|
|
errors: [ "Amount and date must both be provided" ]
|
|
}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
update_success = false
|
|
error_payload = nil
|
|
updated_entry = nil
|
|
|
|
ActiveRecord::Base.transaction do
|
|
result = @entry.account.update_reconciliation(
|
|
@entry,
|
|
balance: valuation_params[:amount],
|
|
date: valuation_params[:date]
|
|
)
|
|
|
|
unless result.success?
|
|
error_payload = {
|
|
error: "validation_failed",
|
|
message: "Valuation could not be updated",
|
|
errors: [ result.error_message ]
|
|
}
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
|
|
updated_entry = @entry.account.entries.valuations.find_by!(date: valuation_params[:date])
|
|
|
|
if valuation_params.key?(:notes)
|
|
unless updated_entry.update(notes: valuation_params[:notes])
|
|
error_payload = {
|
|
error: "validation_failed",
|
|
message: "Valuation could not be updated",
|
|
errors: updated_entry.errors.full_messages
|
|
}
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
end
|
|
|
|
update_success = true
|
|
end
|
|
|
|
unless update_success
|
|
render json: error_payload, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
@entry = updated_entry
|
|
@valuation = @entry.entryable
|
|
render :show
|
|
else
|
|
if valuation_params.key?(:notes)
|
|
unless @entry.update(notes: valuation_params[:notes])
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: "Valuation could not be updated",
|
|
errors: @entry.errors.full_messages
|
|
}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
end
|
|
@entry.reload
|
|
@valuation = @entry.entryable
|
|
render :show
|
|
end
|
|
|
|
rescue => e
|
|
Rails.logger.error "ValuationsController#update error: #{e.message}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
|
|
render json: {
|
|
error: "internal_server_error",
|
|
message: "An unexpected error occurred"
|
|
}, status: :internal_server_error
|
|
end
|
|
|
|
private
|
|
|
|
def set_valuation
|
|
@entry = current_resource_owner.family
|
|
.entries
|
|
.where(entryable_type: "Valuation")
|
|
.find(params[:id])
|
|
@valuation = @entry.entryable
|
|
rescue ActiveRecord::RecordNotFound
|
|
render json: {
|
|
error: "not_found",
|
|
message: "Valuation not found"
|
|
}, status: :not_found
|
|
end
|
|
|
|
def ensure_read_scope
|
|
authorize_scope!(:read)
|
|
end
|
|
|
|
def ensure_write_scope
|
|
authorize_scope!(:write)
|
|
end
|
|
|
|
def apply_filters(query)
|
|
if params[:account_id].present?
|
|
raise InvalidFilterError, "account_id must be a valid UUID" unless valid_uuid?(params[:account_id])
|
|
|
|
query = query.where(account_id: params[:account_id])
|
|
end
|
|
query = query.where("entries.date >= ?", parse_date_param(:start_date)) if params[:start_date].present?
|
|
query = query.where("entries.date <= ?", parse_date_param(:end_date)) if params[:end_date].present?
|
|
query
|
|
end
|
|
|
|
def parse_date_param(key)
|
|
Date.iso8601(params[key].to_s)
|
|
rescue ArgumentError
|
|
raise InvalidFilterError, "#{key} must be an ISO 8601 date"
|
|
end
|
|
|
|
def valid_uuid?(value)
|
|
value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
|
|
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
|
|
|
|
def valuation_account_id
|
|
params.dig(:valuation, :account_id)
|
|
end
|
|
|
|
def valuation_params
|
|
params.require(:valuation).permit(:amount, :date, :notes)
|
|
end
|
|
|
|
def upsert_requested?
|
|
raw_value = params.key?(:upsert) ? params[:upsert] : params.dig(:valuation, :upsert)
|
|
|
|
BOOLEAN_PARAM.cast(raw_value)
|
|
end
|
|
end
|