feat: Add Merchants and Tags API v1 Endpoints (#620)

* Add files via upload

Signed-off-by: Jose <39016041+jospaquim@users.noreply.github.com>

* Add merchants and tags resources to routes

Signed-off-by: Jose <39016041+jospaquim@users.noreply.github.com>

* update

* update spaces

* fix: Apply CodeRabbit suggestions and add YARD documentation

* docs: Add API documentation for merchants and tags endpoints

* fix: Address CodeRabbit feedback on documentation

---------

Signed-off-by: Jose <39016041+jospaquim@users.noreply.github.com>
This commit is contained in:
Jose
2026-01-13 04:10:15 -05:00
committed by GitHub
parent 7c3af7d85e
commit 39ba65df77
5 changed files with 497 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
# frozen_string_literal: true
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 :ensure_read_scope
# 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)
@merchants = Merchant
.where(id: family_merchant_ids)
.or(Merchant.where(id: provider_merchant_ids, type: "ProviderMerchant"))
.distinct
.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
family = current_resource_owner.family
@merchant = family.merchants.find_by(id: params[:id]) ||
Merchant.joins(:transactions)
.where(transactions: { account_id: family.accounts.select(:id) })
.distinct
.find_by(id: params[:id])
if @merchant
render json: merchant_json(@merchant)
else
render json: { error: "Merchant not found" }, status: :not_found
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
private
# Serialize a merchant to JSON format
#
# @param merchant [Merchant] The merchant to serialize
# @return [Hash] JSON-serializable hash
def merchant_json(merchant)
{
id: merchant.id,
name: merchant.name,
type: merchant.type,
created_at: merchant.created_at,
updated_at: merchant.updated_at
}
end
end
end
end

View File

@@ -0,0 +1,130 @@
# frozen_string_literal: true
module Api
module V1
# API v1 endpoint for tags
# Provides full CRUD operations for family tags
#
# @example List all tags
# GET /api/v1/tags
#
# @example Create a new tag
# POST /api/v1/tags
# { "tag": { "name": "WhiteHouse", "color": "#3b82f6" } }
#
class TagsController < BaseController
before_action :ensure_read_scope, only: %i[index show]
before_action :ensure_write_scope, only: %i[create update destroy]
before_action :set_tag, only: %i[show update destroy]
# List all tags belonging to the family
#
# @return [Array<Hash>] JSON array of tag objects sorted alphabetically
def index
family = current_resource_owner.family
@tags = family.tags.alphabetically
render json: @tags.map { |t| tag_json(t) }
rescue StandardError => e
Rails.logger.error("API Tags Error: #{e.message}")
render json: { error: "Failed to fetch tags" }, status: :internal_server_error
end
# Get a specific tag by ID
#
# @param id [String] The tag ID
# @return [Hash] JSON tag object
def show
render json: tag_json(@tag)
rescue StandardError => e
Rails.logger.error("API Tag Show Error: #{e.message}")
render json: { error: "Failed to fetch tag" }, status: :internal_server_error
end
# Create a new tag for the family
#
# @param name [String] Tag name (required)
# @param color [String] Hex color code (optional, auto-assigned if not provided)
# @return [Hash] JSON tag object with status 201
def create
family = current_resource_owner.family
@tag = family.tags.new(tag_params)
# Assign random color if not provided
@tag.color ||= Tag::COLORS.sample
if @tag.save
render json: tag_json(@tag), status: :created
else
render json: { error: @tag.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
rescue StandardError => e
Rails.logger.error("API Tag Create Error: #{e.message}")
render json: { error: "Failed to create tag" }, status: :internal_server_error
end
# Update an existing tag
#
# @param id [String] The tag ID
# @param name [String] New tag name (optional)
# @param color [String] New hex color code (optional)
# @return [Hash] JSON tag object
def update
if @tag.update(tag_params)
render json: tag_json(@tag)
else
render json: { error: @tag.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
rescue StandardError => e
Rails.logger.error("API Tag Update Error: #{e.message}")
render json: { error: "Failed to update tag" }, status: :internal_server_error
end
# Delete a tag
#
# @param id [String] The tag ID
# @return [nil] Empty response with status 204
def destroy
@tag.destroy!
head :no_content
rescue StandardError => e
Rails.logger.error("API Tag Destroy Error: #{e.message}")
render json: { error: "Failed to delete tag" }, status: :internal_server_error
end
private
# Find and set the tag from params
#
# @raise [ActiveRecord::RecordNotFound] if tag not found
# @return [Tag] The found tag
def set_tag
family = current_resource_owner.family
@tag = family.tags.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Tag not found" }, status: :not_found
end
# Strong parameters for tag creation/update
#
# @return [ActionController::Parameters] Permitted parameters
def tag_params
params.require(:tag).permit(:name, :color)
end
# Serialize a tag to JSON format
#
# @param tag [Tag] The tag to serialize
# @return [Hash] JSON-serializable hash
def tag_json(tag)
{
id: tag.id,
name: tag.name,
color: tag.color,
created_at: tag.created_at,
updated_at: tag.updated_at
}
end
end
end
end

View File

@@ -296,6 +296,9 @@ Rails.application.routes.draw do
# Production API endpoints # Production API endpoints
resources :accounts, only: [ :index, :show ] resources :accounts, only: [ :index, :show ]
resources :categories, only: [ :index, :show ] resources :categories, only: [ :index, :show ]
resources :merchants, only: %i[index show]
resources :tags, only: %i[index show create update destroy]
resources :transactions, only: [ :index, :show, :create, :update, :destroy ] resources :transactions, only: [ :index, :show, :create, :update, :destroy ]
resources :imports, only: [ :index, :show, :create ] resources :imports, only: [ :index, :show, :create ]
resource :usage, only: [ :show ], controller: :usage resource :usage, only: [ :show ], controller: :usage

117
docs/api/merchants.md Normal file
View File

@@ -0,0 +1,117 @@
# Merchants API
The Merchants API allows external applications to retrieve merchants within Sure. Merchants represent payees or vendors associated with transactions.
## Generated OpenAPI specification
- The source of truth for the documentation lives in [`spec/requests/api/v1/merchants_spec.rb`](../../spec/requests/api/v1/merchants_spec.rb). These specs authenticate against the Rails stack, exercise every merchant endpoint, and capture real response shapes.
- Regenerate the OpenAPI document with:
```sh
SWAGGER_DRY_RUN=0 bundle exec rspec spec/requests --format Rswag::Specs::SwaggerFormatter
```
The task compiles the request specs and writes the result to [`docs/api/openapi.yaml`](openapi.yaml).
- Run just the documentation specs with:
```sh
bundle exec rspec spec/requests/api/v1/merchants_spec.rb
```
## Authentication requirements
All merchant endpoints require an OAuth2 access token or API key that grants the `read` scope.
## Available endpoints
| Endpoint | Scope | Description |
| --- | --- | --- |
| `GET /api/v1/merchants` | `read` | List all merchants available to the family. |
| `GET /api/v1/merchants/{id}` | `read` | Retrieve a single merchant by ID. |
Refer to the generated [`openapi.yaml`](openapi.yaml) for request/response schemas, reusable components, and security definitions.
## Merchant types
Sure supports two types of merchants:
| Type | Description |
| --- | --- |
| `FamilyMerchant` | Merchants created and owned by the family. |
| `ProviderMerchant` | Merchants from external providers (e.g., Plaid) assigned to transactions. |
The `GET /api/v1/merchants` endpoint returns both types: all family merchants plus any provider merchants that are assigned to the family's transactions.
## Merchant object
A merchant response includes:
```json
{
"id": "uuid",
"name": "Whole Foods",
"type": "FamilyMerchant",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
## Listing merchants
Example request:
```http
GET /api/v1/merchants
Authorization: Bearer <access_token>
```
Example response:
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "Amazon",
"type": "FamilyMerchant",
"created_at": "2024-01-10T08:00:00Z",
"updated_at": "2024-01-10T08:00:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"name": "Starbucks",
"type": "ProviderMerchant",
"created_at": "2024-01-12T14:30:00Z",
"updated_at": "2024-01-12T14:30:00Z"
}
]
```
## Using merchants with transactions
When creating or updating transactions, you can assign a merchant using the `merchant_id` field:
```json
{
"transaction": {
"account_id": "uuid",
"date": "2024-01-15",
"amount": 75.50,
"name": "Coffee",
"nature": "expense",
"merchant_id": "550e8400-e29b-41d4-a716-446655440002"
}
}
```
## Error responses
Errors conform to the shared `ErrorResponse` schema in the OpenAPI document:
```json
{
"error": "Human readable error message"
}
```
Common error codes include `unauthorized`, `not_found`, and `internal_server_error`.

162
docs/api/tags.md Normal file
View File

@@ -0,0 +1,162 @@
# Tags API
The Tags API allows external applications to manage tags within Sure. Tags provide a flexible way to categorize and label transactions beyond the standard category system.
## Generated OpenAPI specification
- The source of truth for the documentation lives in [`spec/requests/api/v1/tags_spec.rb`](../../spec/requests/api/v1/tags_spec.rb). These specs authenticate against the Rails stack, exercise every tag endpoint, and capture real response shapes.
- Regenerate the OpenAPI document with:
```sh
SWAGGER_DRY_RUN=0 bundle exec rspec spec/requests --format Rswag::Specs::SwaggerFormatter
```
The task compiles the request specs and writes the result to [`docs/api/openapi.yaml`](openapi.yaml).
- Run just the documentation specs with:
```sh
bundle exec rspec spec/requests/api/v1/tags_spec.rb
```
## Authentication requirements
| Operation | Scope Required |
| --- | --- |
| List/View tags | `read` |
| Create/Update/Delete tags | `write` |
## Available endpoints
| Endpoint | Scope | Description |
| --- | --- | --- |
| `GET /api/v1/tags` | `read` | List all tags belonging to the family. |
| `GET /api/v1/tags/{id}` | `read` | Retrieve a single tag by ID. |
| `POST /api/v1/tags` | `write` | Create a new tag. |
| `PATCH /api/v1/tags/{id}` | `write` | Update an existing tag. |
| `DELETE /api/v1/tags/{id}` | `write` | Permanently delete a tag. |
Refer to the generated [`openapi.yaml`](openapi.yaml) for request/response schemas, reusable components, and security definitions.
## Tag object
A tag response includes:
```json
{
"id": "uuid",
"name": "Essential",
"color": "#3b82f6",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
## Available colors
Sure provides a predefined set of colors for tags. If no color is specified when creating a tag, one will be randomly assigned from this palette:
```text
#e99537, #4da568, #6471eb, #db5a54, #df4e92,
#c44fe9, #eb5429, #61c9ea, #805dee, #6b7c93
```
## Creating tags
Example request:
```http
POST /api/v1/tags
Authorization: Bearer <access_token>
Content-Type: application/json
{
"tag": {
"name": "Business",
"color": "#6471eb"
}
}
```
The `color` field is optional. If omitted, a random color from the predefined palette will be assigned.
Example response (201 Created):
```json
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "Business",
"color": "#6471eb",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
## Updating tags
Example request:
```http
PATCH /api/v1/tags/550e8400-e29b-41d4-a716-446655440001
Authorization: Bearer <access_token>
Content-Type: application/json
{
"tag": {
"name": "Work Expenses",
"color": "#4da568"
}
}
```
Both `name` and `color` are optional in update requests.
## Deleting tags
Example request:
```http
DELETE /api/v1/tags/550e8400-e29b-41d4-a716-446655440001
Authorization: Bearer <access_token>
```
Returns `204 No Content` on success.
## Using tags with transactions
Tags can be assigned to transactions using the `tag_ids` array field. A transaction can have multiple tags:
```json
{
"transaction": {
"account_id": "uuid",
"date": "2024-01-15",
"amount": 150.00,
"name": "Team lunch",
"nature": "expense",
"tag_ids": [
"550e8400-e29b-41d4-a716-446655440001",
"550e8400-e29b-41d4-a716-446655440002"
]
}
}
```
## Error responses
Errors conform to the shared `ErrorResponse` schema in the OpenAPI document:
```json
{
"error": "Human readable error message"
}
```
Common error codes include:
| Status | Error | Description |
| --- | --- | --- |
| 401 | `unauthorized` | Invalid or missing access token. |
| 404 | `not_found` | Tag not found or does not belong to the family. |
| 422 | `validation_failed` | Invalid input (e.g., duplicate name, missing required field). |
| 500 | `internal_server_error` | Unexpected server error. |