From 39ba65df77381b3f522c08cd656cee45c8001b50 Mon Sep 17 00:00:00 2001 From: Jose <39016041+jospaquim@users.noreply.github.com> Date: Tue, 13 Jan 2026 04:10:15 -0500 Subject: [PATCH] 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> --- .../api/v1/merchants_controller.rb | 85 +++++++++ app/controllers/api/v1/tags_controller.rb | 130 ++++++++++++++ config/routes.rb | 3 + docs/api/merchants.md | 117 +++++++++++++ docs/api/tags.md | 162 ++++++++++++++++++ 5 files changed, 497 insertions(+) create mode 100644 app/controllers/api/v1/merchants_controller.rb create mode 100644 app/controllers/api/v1/tags_controller.rb create mode 100644 docs/api/merchants.md create mode 100644 docs/api/tags.md diff --git a/app/controllers/api/v1/merchants_controller.rb b/app/controllers/api/v1/merchants_controller.rb new file mode 100644 index 000000000..53df0ac35 --- /dev/null +++ b/app/controllers/api/v1/merchants_controller.rb @@ -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] 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 diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb new file mode 100644 index 000000000..287642930 --- /dev/null +++ b/app/controllers/api/v1/tags_controller.rb @@ -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] 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 diff --git a/config/routes.rb b/config/routes.rb index 5c06caf5b..07a70144e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -296,6 +296,9 @@ Rails.application.routes.draw do # Production API endpoints resources :accounts, 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 :imports, only: [ :index, :show, :create ] resource :usage, only: [ :show ], controller: :usage diff --git a/docs/api/merchants.md b/docs/api/merchants.md new file mode 100644 index 000000000..5e00b3dda --- /dev/null +++ b/docs/api/merchants.md @@ -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 +``` + +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`. diff --git a/docs/api/tags.md b/docs/api/tags.md new file mode 100644 index 000000000..ab040c267 --- /dev/null +++ b/docs/api/tags.md @@ -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 +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 +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 +``` + +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. |