mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 20:14:08 +00:00
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:
85
app/controllers/api/v1/merchants_controller.rb
Normal file
85
app/controllers/api/v1/merchants_controller.rb
Normal 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
|
||||||
130
app/controllers/api/v1/tags_controller.rb
Normal file
130
app/controllers/api/v1/tags_controller.rb
Normal 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
|
||||||
@@ -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
117
docs/api/merchants.md
Normal 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
162
docs/api/tags.md
Normal 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. |
|
||||||
Reference in New Issue
Block a user