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

@@ -357,6 +357,44 @@ components:
format: uuid
name:
type: string
MerchantDetail:
type: object
required:
- id
- name
- type
- created_at
- updated_at
properties:
id:
type: string
format: uuid
name:
type: string
type:
type: string
enum:
- FamilyMerchant
- ProviderMerchant
color:
type: string
nullable: true
logo_url:
type: string
nullable: true
website_url:
type: string
nullable: true
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
MerchantCollection:
type: array
items:
"$ref": "#/components/schemas/MerchantDetail"
Tag:
type: object
required:
@@ -798,10 +836,10 @@ components:
format: date
qty:
type: string
description: Quantity as string (JSON number or string from API)
description: Quantity of shares held
price:
type: string
description: Price as string (JSON number or string from API)
description: Formatted price per share
amount:
type: string
currency:
@@ -875,6 +913,302 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/AccountCollection"
"/api/v1/auth/signup":
post:
summary: Sign up a new user
tags:
- Auth
parameters: []
responses:
'201':
description: user created
content:
application/json:
schema:
type: object
properties:
access_token:
type: string
refresh_token:
type: string
token_type:
type: string
expires_in:
type: integer
created_at:
type: integer
user:
type: object
properties:
id:
type: string
format: uuid
email:
type: string
first_name:
type: string
last_name:
type: string
'422':
description: validation error
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'403':
description: invite code required or invalid
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
user:
type: object
properties:
email:
type: string
format: email
description: User email address
password:
type: string
description: Password (min 8 chars, mixed case, number, special
char)
first_name:
type: string
last_name:
type: string
required:
- email
- password
device:
type: object
properties:
device_id:
type: string
description: Unique device identifier
device_name:
type: string
description: Human-readable device name
device_type:
type: string
description: Device type (e.g. ios, android)
os_version:
type: string
app_version:
type: string
required:
- device_id
- device_name
- device_type
- os_version
- app_version
invite_code:
type: string
nullable: true
description: Invite code (required when invites are enforced)
required:
- user
- device
required: true
"/api/v1/auth/login":
post:
summary: Log in with email and password
tags:
- Auth
parameters: []
responses:
'200':
description: login successful
content:
application/json:
schema:
type: object
properties:
access_token:
type: string
refresh_token:
type: string
token_type:
type: string
expires_in:
type: integer
created_at:
type: integer
user:
type: object
properties:
id:
type: string
format: uuid
email:
type: string
first_name:
type: string
last_name:
type: string
'401':
description: invalid credentials or MFA required
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
email:
type: string
format: email
password:
type: string
otp_code:
type: string
nullable: true
description: TOTP code if MFA is enabled
device:
type: object
properties:
device_id:
type: string
device_name:
type: string
device_type:
type: string
os_version:
type: string
app_version:
type: string
required:
- device_id
- device_name
- device_type
- os_version
- app_version
required:
- email
- password
- device
required: true
"/api/v1/auth/sso_exchange":
post:
summary: Exchange mobile SSO authorization code for tokens
tags:
- Auth
description: Exchanges a one-time authorization code (received via deep link
after mobile SSO) for OAuth tokens. The code is single-use and expires after
5 minutes.
parameters: []
responses:
'200':
description: tokens issued
content:
application/json:
schema:
type: object
properties:
access_token:
type: string
refresh_token:
type: string
token_type:
type: string
expires_in:
type: integer
created_at:
type: integer
user:
type: object
properties:
id:
type: string
format: uuid
email:
type: string
first_name:
type: string
last_name:
type: string
'401':
description: invalid or expired code
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
code:
type: string
description: One-time authorization code from mobile SSO callback
required:
- code
required: true
"/api/v1/auth/refresh":
post:
summary: Refresh an access token
tags:
- Auth
parameters: []
responses:
'200':
description: token refreshed
content:
application/json:
schema:
type: object
properties:
access_token:
type: string
refresh_token:
type: string
token_type:
type: string
expires_in:
type: integer
created_at:
type: integer
'401':
description: invalid refresh token
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'400':
description: missing refresh token
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
refresh_token:
type: string
description: The refresh token from a previous login or refresh
device:
type: object
properties:
device_id:
type: string
required:
- device_id
required:
- refresh_token
- device
required: true
"/api/v1/categories":
get:
summary: List categories
@@ -1247,8 +1581,8 @@ paths:
parameters:
- name: id
in: path
description: Holding ID
required: true
description: Holding ID
schema:
type: string
get:
@@ -1449,6 +1783,138 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/merchants":
get:
summary: List merchants
tags:
- Merchants
security:
- apiKeyAuth: []
responses:
'200':
description: merchants listed
content:
application/json:
schema:
"$ref": "#/components/schemas/MerchantCollection"
post:
summary: Create merchant
tags:
- Merchants
security:
- apiKeyAuth: []
parameters: []
responses:
'201':
description: merchant created with auto-assigned color
content:
application/json:
schema:
"$ref": "#/components/schemas/MerchantDetail"
'422':
description: validation error - duplicate name
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
merchant:
type: object
properties:
name:
type: string
description: Merchant name (required)
color:
type: string
description: Hex color code (optional, auto-assigned if not
provided)
website_url:
type: string
description: Website URL (optional)
required:
- name
required:
- merchant
required: true
"/api/v1/merchants/{id}":
parameters:
- name: id
in: path
required: true
description: Merchant ID
schema:
type: string
get:
summary: Retrieve a merchant
tags:
- Merchants
security:
- apiKeyAuth: []
responses:
'200':
description: merchant retrieved
content:
application/json:
schema:
"$ref": "#/components/schemas/MerchantDetail"
'404':
description: merchant not found
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
patch:
summary: Update a merchant
tags:
- Merchants
security:
- apiKeyAuth: []
parameters: []
responses:
'200':
description: merchant updated
content:
application/json:
schema:
"$ref": "#/components/schemas/MerchantDetail"
'404':
description: merchant not found
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
properties:
merchant:
type: object
properties:
name:
type: string
color:
type: string
website_url:
type: string
required: true
delete:
summary: Delete a merchant
tags:
- Merchants
security:
- apiKeyAuth: []
responses:
'204':
description: merchant deleted
'404':
description: merchant not found
"/api/v1/tags":
get:
summary: List tags
@@ -1732,8 +2198,8 @@ paths:
parameters:
- name: id
in: path
description: Trade ID
required: true
description: Trade ID
schema:
type: string
get:
@@ -1767,6 +2233,7 @@ paths:
- Trades
security:
- apiKeyAuth: []
parameters: []
responses:
'200':
description: trade updated
@@ -1780,12 +2247,6 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'422':
description: validation error
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
@@ -1794,34 +2255,30 @@ paths:
properties:
trade:
type: object
description: Flat params; controller builds internal structure. When qty/price are updated, type or nature controls sign; if omitted, existing trade direction is preserved.
properties:
date:
type: string
format: date
name:
type: string
amount:
qty:
type: number
price:
type: number
currency:
type: string
notes:
type: string
nature:
type: string
enum:
- inflow
- outflow
type:
type: string
enum:
- buy
- sell
description: Determines sign when qty/price are updated.
qty:
type: number
price:
type: number
nature:
type: string
enum:
- inflow
- outflow
name:
type: string
notes:
type: string
currency:
type: string
investment_activity_label:
type: string
category_id:
@@ -2136,6 +2593,8 @@ paths:
items:
type: string
format: uuid
description: Array of tag IDs to assign. Omit to preserve existing
tags; use [] to clear all tags.
required: true
delete:
summary: Delete a transaction