Add full CRUD support for merchants in the API

Expand the merchants API from read-only (index/show) to full CRUD with
create, update, and destroy actions. Uses API key auth with proper scope
authorization (read for index/show, read_write for create/update/destroy)
and family-based isolation.

- Add create/update/destroy actions to MerchantsController
- Update routes to include all CRUD actions
- Enrich merchant JSON response with color, logo_url, website_url fields
- Fix FamilyMerchant#set_default_color to only set when blank (was
  unconditionally overriding color on every validation)
- Rewrite tests to use API key auth pattern with read/read_write scopes
- Add rswag OpenAPI spec and regenerate docs
- Add MerchantDetail/MerchantCollection schemas to swagger_helper

https://claude.ai/code/session_01G39SUd6QEv5nUusPvjFhmh
This commit is contained in:
Claude
2026-02-13 15:25:25 +00:00
parent 32b01165c9
commit 932bb12634
7 changed files with 952 additions and 106 deletions

View File

@@ -2,28 +2,14 @@
module Api
module V1
# API v1 endpoint for merchants
# Provides read-only access to family and provider merchants
#
# @example List all merchants
# GET /api/v1/merchants
#
# @example Get a specific merchant
# GET /api/v1/merchants/:id
#
class MerchantsController < BaseController
before_action -> { authorize_scope!(:read) }
before_action -> { authorize_scope!(:read) }, only: %i[index show]
before_action -> { authorize_scope!(:read_write) }, only: %i[create update destroy]
before_action :set_merchant, only: %i[show update destroy]
# List all merchants available to the family
#
# Returns both family-owned merchants and provider merchants
# that are assigned to the family's transactions.
#
# @return [Array<Hash>] JSON array of merchant objects
def index
family = current_resource_owner.family
# Single query with OR conditions - more efficient than Ruby deduplication
family_merchant_ids = family.merchants.select(:id)
provider_merchant_ids = family.transactions.select(:merchant_id)
@@ -34,50 +20,59 @@ module Api
.alphabetically
render json: @merchants.map { |m| merchant_json(m) }
rescue StandardError => e
Rails.logger.error("API Merchants Error: #{e.message}")
render json: { error: "Failed to fetch merchants" }, status: :internal_server_error
end
# Get a specific merchant by ID
#
# Returns a merchant if it belongs to the family or is assigned
# to any of the family's transactions.
#
# @param id [String] The merchant ID
# @return [Hash] JSON merchant object or error
def show
render json: merchant_json(@merchant)
end
def create
family = current_resource_owner.family
@merchant = family.merchants.new(merchant_params)
@merchant = family.merchants.find_by(id: params[:id]) ||
Merchant.joins(transactions: :entry)
.where(entries: { account_id: family.accounts.select(:id) })
.distinct
.find_by(id: params[:id])
if @merchant.save
render json: merchant_json(@merchant), status: :created
else
render json: { error: @merchant.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
end
if @merchant
def update
if @merchant.update(merchant_params)
render json: merchant_json(@merchant)
else
render json: { error: "Merchant not found" }, status: :not_found
render json: { error: @merchant.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
rescue StandardError => e
Rails.logger.error("API Merchant Show Error: #{e.message}")
render json: { error: "Failed to fetch merchant" }, status: :internal_server_error
end
def destroy
@merchant.destroy!
head :no_content
end
private
# Serialize a merchant to JSON format
#
# @param merchant [Merchant] The merchant to serialize
# @return [Hash] JSON-serializable hash
def set_merchant
family = current_resource_owner.family
@merchant = family.merchants.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Merchant not found" }, status: :not_found
end
def merchant_params
params.require(:merchant).permit(:name, :color, :website_url)
end
def merchant_json(merchant)
{
id: merchant.id,
name: merchant.name,
type: merchant.type,
created_at: merchant.created_at,
updated_at: merchant.updated_at
color: merchant.color,
logo_url: merchant.logo_url,
website_url: merchant.website_url,
created_at: merchant.created_at.iso8601,
updated_at: merchant.updated_at.iso8601
}
end
end