feat(api): support idempotent valuation writes (#1637)

* 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
This commit is contained in:
ghost
2026-05-04 10:51:48 -06:00
committed by GitHub
parent ddaf42c96c
commit 05ef8bd9e7
4 changed files with 133 additions and 2 deletions

View File

@@ -4,6 +4,7 @@ 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 ]
@@ -83,11 +84,17 @@ class Api::V1::ValuationsController < Api::V1::BaseController
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]
@@ -124,7 +131,7 @@ class Api::V1::ValuationsController < Api::V1::BaseController
return
end
render :show, status: :created
render :show, status: requested_upsert && existing_write ? :ok : :created
rescue ActiveRecord::RecordNotFound
render json: {
@@ -289,4 +296,10 @@ class Api::V1::ValuationsController < Api::V1::BaseController
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

View File

@@ -4549,6 +4549,12 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/Valuation"
'200':
description: existing valuation upserted
content:
application/json:
schema:
"$ref": "#/components/schemas/Valuation"
'422':
description: validation error - missing date
content:
@@ -4584,10 +4590,22 @@ paths:
notes:
type: string
description: Additional notes
upsert:
type: boolean
description: Nested alternative to the top-level response-status
flag. Top-level upsert takes precedence when both are provided.
required:
- account_id
- amount
- date
upsert:
type: boolean
description: Response-status signal only. When true and a same-account
same-date valuation exists before the request, the endpoint returns
200 OK instead of 201 Created. The underlying reconciliation write
path is unchanged; this flag does not add duplicate-prevention
or safe-retry guarantees beyond existing same-date reconciliation
behavior.
required:
- valuation
required: true

View File

@@ -121,9 +121,17 @@ RSpec.describe 'API V1 Valuations', type: :request do
account_id: { type: :string, format: :uuid, description: 'Account ID (required)' },
amount: { type: :number, description: 'Valuation amount (required)' },
date: { type: :string, format: :date, description: 'Valuation date (required)' },
notes: { type: :string, description: 'Additional notes' }
notes: { type: :string, description: 'Additional notes' },
upsert: {
type: :boolean,
description: 'Nested alternative to the top-level response-status flag. Top-level upsert takes precedence when both are provided.'
}
},
required: %w[account_id amount date]
},
upsert: {
type: :boolean,
description: 'Response-status signal only. When true and a same-account same-date valuation exists before the request, the endpoint returns 200 OK instead of 201 Created. The underlying reconciliation write path is unchanged; this flag does not add duplicate-prevention or safe-retry guarantees beyond existing same-date reconciliation behavior.'
}
},
required: %w[valuation]
@@ -145,6 +153,23 @@ RSpec.describe 'API V1 Valuations', type: :request do
run_test!
end
response '200', 'existing valuation upserted' do
schema '$ref' => '#/components/schemas/Valuation'
let(:body) do
{
upsert: true,
valuation: {
account_id: account.id,
amount: 15000.00,
date: Date.current.to_s
}
}
end
run_test!
end
response '422', 'validation error - missing account_id' do
schema '$ref' => '#/components/schemas/ErrorResponse'

View File

@@ -143,6 +143,81 @@ class Api::V1::ValuationsControllerTest < ActionDispatch::IntegrationTest
assert_equal @account.id, response_data["account"]["id"]
end
test "should upsert valuation for same account and date when requested" do
existing_entry = @valuation.entry
valuation_params = {
upsert: "true",
valuation: {
account_id: existing_entry.account.id,
amount: 12_345.67,
date: existing_entry.date,
notes: "API correction"
}
}
assert_no_difference("@family.entries.valuations.count") do
post api_v1_valuations_url,
params: valuation_params,
headers: api_headers(@api_key)
end
assert_response :ok
response_data = JSON.parse(response.body)
assert_equal existing_entry.id, response_data["id"]
assert_equal existing_entry.date.to_s, response_data["date"]
assert_equal "API correction", response_data["notes"]
assert_equal BigDecimal("12345.67"), existing_entry.reload.amount
end
test "should create valuation when upsert is requested without an existing same-date valuation" do
valuation_date = Date.current + 3.days
valuation_params = {
upsert: "true",
valuation: {
account_id: @account.id,
amount: 9876.54,
date: valuation_date,
notes: "New API valuation"
}
}
assert_difference("@family.entries.valuations.count", 1) do
post api_v1_valuations_url,
params: valuation_params,
headers: api_headers(@api_key)
end
assert_response :created
response_data = JSON.parse(response.body)
assert_equal valuation_date.to_s, response_data["date"]
assert_equal "New API valuation", response_data["notes"]
end
test "should accept nested upsert flag for same-date valuation writes" do
existing_entry = @valuation.entry
valuation_params = {
valuation: {
account_id: existing_entry.account.id,
amount: 22_222.22,
date: existing_entry.date,
notes: "Nested upsert correction",
upsert: "true"
}
}
assert_no_difference("@family.entries.valuations.count") do
post api_v1_valuations_url,
params: valuation_params,
headers: api_headers(@api_key)
end
assert_response :ok
response_data = JSON.parse(response.body)
assert_equal existing_entry.id, response_data["id"]
assert_equal "Nested upsert correction", response_data["notes"]
assert_equal BigDecimal("22222.22"), existing_entry.reload.amount
end
test "should reject create with read-only API key" do
valuation_params = {
valuation: {