No description
Find a file
2025-11-23 16:48:11 +07:00
internal Version 1 complete 2025-11-22 15:09:24 +07:00
sql Version 1 complete 2025-11-22 15:09:24 +07:00
.dockerignore Create Dockerfile 2025-11-22 15:48:36 +07:00
.gitignore Initial commit 2025-11-15 02:17:48 +07:00
Dockerfile Create Dockerfile 2025-11-22 15:48:36 +07:00
go.mod Version 1 complete 2025-11-22 15:09:24 +07:00
go.sum Version 1 complete 2025-11-22 15:09:24 +07:00
main.go Update error message when env var is not complete 2025-11-23 16:48:11 +07:00
README.md Update README.md 2025-11-22 15:35:15 +07:00
server.go Version 1 complete 2025-11-22 15:09:24 +07:00
sqlc.yaml Initial commit 2025-11-15 02:17:48 +07:00

Pockmon - Expense Tracker API Documentation

Version: 1.0 Base URL: http://localhost:8080 (Development) Authentication: Bearer token (JWT)


Project Structure

pockmon/
├── main.go                              # Application entry point
├── server.go                            # HTTP server setup
├── internal/
│   ├── handlers/                        # HTTP request handlers
│   │   ├── auth/                        # Authentication handlers 
│   │   │   ├── handler.go              # Handler constructor
│   │   │   ├── utils.go                # Helper functions & validation
│   │   │   ├── register.go             # User registration 
│   │   │   ├── login.go                # User login 
│   │   │   ├── refresh.go              # Token refresh 
│   │   │   └── logout.go               # User logout & logout all 
│   │   ├── users/                       # User profile handlers 
│   │   │   ├── handler.go              # Handler constructor
│   │   │   ├── get.go                  # Get user profile 
│   │   │   └── update.go               # Update profile & change password 
│   │   ├── categories/                  # Category handlers 
│   │   │   ├── handler.go              # Handler constructor
│   │   │   ├── utils.go                # Color validation helper
│   │   │   ├── create.go               # Create category 
│   │   │   ├── list.go                 # List categories 
│   │   │   ├── get.go                  # Get category & children 
│   │   │   ├── update.go               # Update category 
│   │   │   └── delete.go               # Delete category 
│   │   ├── accounts/                    # Account handlers 
│   │   │   ├── handler.go              # Handler constructor
│   │   │   ├── utils.go                # Helper functions & validation
│   │   │   ├── create.go               # Create account 
│   │   │   ├── list.go                 # List accounts 
│   │   │   ├── get.go                  # Get account 
│   │   │   ├── update.go               # Update account 
│   │   │   ├── delete.go               # Delete account (soft delete) 
│   │   │   ├── adjust.go               # Adjust account balance 
│   │   │   └── summary.go              # Get account summary 
│   │   ├── transactions/                # Transaction handlers 
│   │   │   ├── handler.go              # Handler constructor
│   │   │   ├── utils.go                # Helper functions & validation
│   │   │   ├── create.go               # Create transaction 
│   │   │   ├── list.go                 # List transactions 
│   │   │   ├── get.go                  # Get transaction 
│   │   │   ├── update.go               # Update transaction 
│   │   │   ├── delete.go               # Delete transaction 
│   │   │   └── bulk.go                 # Bulk create transactions 
│   │   ├── transfers/                   # Transfer handlers 
│   │   │   ├── handler.go              # Handler constructor
│   │   │   ├── utils.go                # Helper functions & validation
│   │   │   ├── create.go               # Create transfer 
│   │   │   ├── list.go                 # List transfers 
│   │   │   ├── get.go                  # Get transfer 
│   │   │   ├── update.go               # Update transfer 
│   │   │   └── delete.go               # Delete transfer 
│   │   ├── recurring/                   # Recurring transaction handlers 
│   │   │   ├── handler.go              # Handler constructor
│   │   │   ├── utils.go                # Helper functions & validation
│   │   │   ├── create.go               # Create recurring transaction 
│   │   │   ├── list.go                 # List recurring transactions 
│   │   │   ├── get.go                  # Get recurring transaction 
│   │   │   ├── update.go               # Update recurring transaction 
│   │   │   ├── delete.go               # Delete recurring transaction 
│   │   │   └── process.go              # Process recurring transaction 
│   │   ├── budgets/                     # Budget handlers 
│   │   │   ├── handler.go              # Handler constructor
│   │   │   ├── utils.go                # Helper functions & validation
│   │   │   ├── create.go               # Create budget 
│   │   │   ├── list.go                 # List budgets 
│   │   │   ├── get.go                  # Get budget 
│   │   │   ├── update.go               # Update budget 
│   │   │   └── delete.go               # Delete budget 
│   │   └── attachments/                 # Attachment handlers 
│   │       ├── handler.go              # Handler constructor
│   │       ├── create.go               # Create attachment 
│   │       ├── list.go                 # List attachments 
│   │       ├── get.go                  # Get attachment 
│   │       └── delete.go               # Delete attachment 
│   ├── models/                          # Data models & types
│   │   ├── config.go                   # Application configuration
│   │   ├── auth.go                     # Authentication types
│   │   ├── user.go                      # User types
│   │   ├── categories.go                # Category types
│   │   ├── accounts.go                  # Account types 
│   │   ├── transactions.go              # Transaction types 
│   │   ├── transfers.go                 # Transfer types 
│   │   ├── recurring_transactions.go    # Recurring transaction types 
│   │   ├── budgets.go                   # Budget types 
│   │   └── attachments.go               # Attachment types 
│   ├── utils/                            # Shared utilities 
│   │   ├── auth.go                      # Auth helper functions 
│   │   ├── utilities.go                 # General utilities (color & currency validation) 
│   │   └── response.go                  # JSON response helpers 
│   ├── middleware/                       # HTTP middleware 
│   │   ├── auth.go                      # JWT authentication middleware
│   │   ├── logger.go                    # Request logging middleware
│   │   ├── cors.go                      # CORS middleware
│   │   ├── recovery.go                  # Panic recovery middleware
│   │   └── chain.go                     # Middleware chaining utility
│   └── database/                        # Database layer (sqlc generated)
│       ├── db.go
│       ├── models.go
│       ├── users.sql.go                 # User queries (10 queries) 
│       ├── refresh_tokens.sql.go        # Refresh token queries (9 queries) 
│       ├── categories.sql.go            # Category queries (8 queries) 
│       ├── accounts.sql.go              # Account queries (17 queries) 
│       ├── transactions.sql.go          # Transaction queries (19 queries) 
│       ├── transfers.sql.go             # Transfer queries (10 queries) 
│       ├── recurring_transactions.sql.go # Recurring transaction queries (13 queries) 
│       ├── budgets.sql.go               # Budget queries (12 queries) 
│       └── attachments.sql.go           # Attachment queries (9 queries) 
├── sql/
│   ├── queries/                         # SQL queries (sqlc)
│   │   ├── users.sql                    # 10 queries 
│   │   ├── refresh_tokens.sql           # 9 queries 
│   │   ├── categories.sql               # 8 queries 
│   │   ├── accounts.sql                 # 17 queries 
│   │   ├── transactions.sql             # 19 queries 
│   │   ├── transfers.sql                # 10 queries 
│   │   ├── recurring_transactions.sql   # 13 queries 
│   │   ├── budgets.sql                  # 12 queries 
│   │   └── attachments.sql              # 9 queries 
│   └── schema/                          # Database migrations (goose)
│       ├── 001_users.sql                # Users table 
│       ├── 002_categories.sql           # Categories table 
│       ├── 003_accounts.sql             # Accounts table 
│       ├── 004_recurring_transactions.sql # Recurring transactions table 
│       ├── 005_transactions.sql         # Transactions table 
│       ├── 006_transfers.sql            # Transfers table 
│       ├── 007_budgets.sql              # Budgets table 
│       ├── 008_goals.sql                # Goals table 
│       ├── 009_attachments.sql          # Attachments table 
│       ├── 010_notifications.sql        # Notifications table 
│       ├── 011_refresh_tokens.sql       # Refresh tokens table 
│       ├── 012_account_adjustments.sql  # Account adjustments table 
│       └── 013_add_categories_updated_at.sql # Add updated_at to categories 
├── .env                                 # Environment variables
├── go.mod
└── go.sum

Tech Stack

  • Language: Go 1.25.4
  • Database: PostgreSQL 15+
  • Migrations: goose (13 schema migrations)
  • Query Builder: sqlc (type-safe Go from SQL)
  • Router: Go stdlib net/http.ServeMux
  • Authentication: Custom JWT implementation using:
    • github.com/golang-jwt/jwt/v5 - JWT signing/validation
    • github.com/alexedwards/argon2id - Password hashing
    • Custom token management in internal/utils/auth.go
  • Password Hashing: Argon2id
  • Refresh Token Hashing: SHA-512
  • UUID Generation: github.com/google/uuid (UUIDv7)
  • Environment Config: github.com/joho/godotenv
  • Database Driver: github.com/lib/pq (PostgreSQL)
  • Decimal Handling: github.com/shopspring/decimal (precise monetary calculations)
  • Currency Validation: golang.org/x/text/currency (ISO 4217 currency code validation)

Token Security

Access Tokens (JWT)

  • Type: JWT (stateless)
  • Expiration: 15 minutes
  • Storage: NOT stored in database
  • Usage: Sent with every API request in Authorization: Bearer <token> header
  • Validation: Signature verification using app secret

Refresh Tokens

  • Type: Random 256-bit token
  • Expiration: 24 hours
  • Storage: SHA-512 hash stored in refresh_tokens table
  • Usage: Sent to /auth/refresh and /auth/logout
  • Revocation: Immediate via database update (is_active = false)
  • Multi-session: Supports multiple active sessions per user (recommended limit: 5-10 devices)
  • Tracking: Stores created_at, last_used_at, revoked_at for audit trail

Table of Contents

  1. Authentication & Users
  2. Categories
  3. Accounts
  4. Transactions
  5. Transfers
  6. Recurring Transactions
  7. Budgets
  8. Goals
  9. Notifications
  10. Attachments
  11. Reports & Analytics
  12. Error Responses
  13. Pagination
  14. Filtering & Sorting
  15. Best Practices
  16. Support
  17. Roadmap

Authentication

All authenticated requests must include the JWT token in the Authorization header:

Authorization: Bearer <token>

1. Authentication & Users

Register New User

POST /auth/register
Content-Type: application/json

Request Body:

{
  "email": "user@example.com",
  "username": "johndoe",
  "password": "securePassword123",
  "full_name": "John Doe",
  "currency_code": "USD"
}

Validation Rules:

  • email: Required, valid email format (regex validated)
  • username: Required, 3-30 characters, alphanumeric + underscore/hyphen only
  • password: Required, minimum 8 characters
  • full_name: Required
  • currency_code: Optional, defaults to "IDR" if not provided, must be valid ISO 4217 currency code (e.g., USD, EUR, GBP, JPY, IDR)

Response (201 Created):

{
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "user@example.com",
  "username": "johndoe",
  "full_name": "John Doe",
  "currency_code": "USD",
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "a1b2c3d4e5f6g7h8i9j0...",
  "created_at": "2025-11-14T10:00:00Z"
}

Error Responses:

  • 400 Bad Request: Invalid email format, username format, password too short, or invalid currency code
  • 409 Conflict: User with this email or username already exists
  • 500 Internal Server Error: Server error during registration

Notes:

  • user_id is a UUIDv7
  • token is a JWT access token with 15-minute expiration
  • refresh_token is a 256-bit random token with 24-hour expiration (stored as SHA-512 hash in database)
  • Password is hashed using Argon2id before storage
  • Refresh token is automatically created and returned

Login

POST /auth/login
Content-Type: application/json

Request Body:

{
  "username": "johndoe",
  "password": "securePassword123"
}

Response (200 OK):

{
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "user@example.com",
  "username": "johndoe",
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "a1b2c3d4e5f6g7h8i9j0...",
  "expires_in": 900
}

Validation Rules:

  • username: Required
  • password: Required

Error Responses:

  • 400 Bad Request: Missing username or password, invalid request body
  • 401 Unauthorized: Invalid username or password (same message for both to prevent user enumeration)
  • 500 Internal Server Error: Server error during login

Security Features:

  • Timing attack protection: Password check runs even if user doesn't exist (using dummy hash)
  • User enumeration prevention: Same error message for "user not found" and "wrong password"
  • Password validation: Uses constant-time comparison via Argon2id

Notes:

  • Login uses username (not email) for authentication
  • expires_in returns seconds until access token expires (900 = 15 minutes)
  • A new refresh token is created on every login
  • Multiple sessions supported (user can be logged in on multiple devices)
  • No session limit enforced (recommended: add 5-10 device limit)

Refresh Token

POST /auth/refresh
Authorization: Bearer <refresh_token>

Response (200 OK):

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "b2c3d4e5f6g7h8i9j0k1...",
  "expires_in": 900
}

Error Responses:

  • 401 Unauthorized: Missing, invalid, expired, or revoked refresh token
  • 500 Internal Server Error: Server error during token refresh

How it works:

  1. Client sends refresh token in Authorization: Bearer <refresh_token> header
  2. Server hashes the token using SHA-512
  3. Server validates token using ValidateRefreshToken query:
    • Checks token exists (token_hash match)
    • Verifies token is active (is_active = true)
    • Verifies not revoked (revoked_at IS NULL)
    • Verifies not expired (expires_at > NOW())
  4. Server revokes old refresh token (token rotation for security)
  5. Server generates new refresh token (24-hour expiration)
  6. Server generates and returns new JWT access token (15 min expiration)
  7. Returns both new access token and new refresh token

Security Features:

  • Token rotation: Old refresh token is revoked, new one issued (prevents token reuse)
  • Refresh tokens stored as SHA-512 hashes (never plain text)
  • Single DB query for validation (efficient and secure)
  • Tokens can be revoked immediately via database update
  • Tokens expire after 24 hours
  • Multiple active sessions supported (multi-device login)

Notes:

  • Returns both a new access token AND a new refresh token
  • Implements token rotation strategy (old token revoked automatically)
  • Use sql/queries/refresh_tokens.sql queries for token management

Logout

POST /auth/logout
Authorization: Bearer <refresh_token>

Response (204 No Content)

Error Responses:

  • 401 Unauthorized: Missing or invalid refresh token
  • 500 Internal Server Error: Server error during logout

How it works:

  1. Client sends refresh token in Authorization: Bearer <refresh_token> header
  2. Server hashes the refresh token using SHA-512
  3. Server revokes the refresh token using RevokeRefreshTokenByHash query
  4. Server returns 204 No Content

Notes:

  • Revoking the refresh token invalidates the user's session
  • The access token will still be valid until it expires (15 minutes)
  • For immediate logout, client should also discard the access token
  • Only revokes the specific refresh token (single device logout)
  • For "logout all devices", see the endpoint below

Logout All Devices

POST /auth/logout/all
Authorization: Bearer <refresh_token>

Response (204 No Content)

Error Responses:

  • 401 Unauthorized: Missing or invalid refresh token
  • 500 Internal Server Error: Server error during logout

How it works:

  1. Client sends refresh token in Authorization: Bearer <refresh_token> header
  2. Server validates the refresh token to ensure it's valid and belongs to a user
  3. Server revokes ALL refresh tokens for that user using RevokeAllUserRefreshTokens query
  4. Server returns 204 No Content

Notes:

  • Logs out the user from ALL devices simultaneously
  • Invalidates all active refresh tokens for the user
  • Access tokens will still be valid until they expire (15 minutes)
  • Useful for "sign out everywhere" functionality or security incidents
  • User must login again on all devices

Get Current User Profile

GET /users/me
Authorization: Bearer <token>

Response (200 OK):

{
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "user@example.com",
  "username": "johndoe",
  "full_name": "John Doe",
  "currency_code": "USD",
  "created_at": "2025-11-14T10:00:00Z",
  "last_login": "2025-11-14T15:30:00Z"
}

Error Responses:

  • 401 Unauthorized: Invalid or missing JWT token

Notes:

  • User ID extracted from JWT token context (middleware)
  • Password hash never included in response
  • Returns actual database values

Update User Profile

PUT /users/me
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "full_name": "John Smith",
  "currency_code": "EUR"
}

Response (200 OK):

{
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "user@example.com",
  "username": "johndoe",
  "full_name": "John Smith",
  "currency_code": "EUR",
  "updated_at": "2025-11-14T16:00:00Z"
}

Validation Rules:

  • full_name: Optional
  • currency_code: Optional, must be valid ISO 4217 currency code if provided

Error Responses:

  • 400 Bad Request: Invalid request body or invalid currency code
  • 401 Unauthorized: Invalid or missing JWT token
  • 500 Internal Server Error: Database error

Notes:

  • Both fields are optional (uses COALESCE in SQL)
  • Returns updated values from database (source of truth)
  • Email and username cannot be changed via this endpoint
  • Currency code validation ensures only valid ISO 4217 codes are accepted

Change Password

PATCH /users/me
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "current_password": "oldPassword123",
  "new_password": "newPassword456"
}

Response (204 No Content)

Error Responses:

  • 400 Bad Request: Invalid request body or password too short (min 8 chars)
  • 401 Unauthorized: Current password is incorrect or invalid JWT
  • 500 Internal Server Error: Database error

Security Features:

  • Validates new password (minimum 8 characters)
  • Verifies current password before allowing change
  • Hashes new password with Argon2id
  • Optimized single-query password verification
  • Does NOT revoke existing refresh tokens (user stays logged in)

Notes:

  • Password validation uses same rules as registration
  • User remains logged in after password change
  • Consider revoking all refresh tokens for maximum security (add RevokeAllUserRefreshTokens call)

2. Categories

List Categories

GET /categories?type=expense
Authorization: Bearer <token>

Query Parameters:

  • type (optional): expense | income - filter by category type

Response (200 OK):

{
  "data": [
    {
      "category_id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Food & Dining",
      "type": "expense",
      "color": "#FF5733",
      "icon": "restaurant",
      "parent_category_id": null,
      "is_system": true,
      "created_at": "2025-11-14T10:00:00Z",
      "updated_at": "2025-11-14T10:00:00Z"
    },
    {
      "category_id": "660e8400-e29b-41d4-a716-446655440001",
      "name": "Groceries",
      "type": "expense",
      "color": "#4CAF50",
      "icon": "shopping_cart",
      "parent_category_id": "550e8400-e29b-41d4-a716-446655440000",
      "is_system": false,
      "created_at": "2025-11-14T10:00:00Z",
      "updated_at": "2025-11-14T10:00:00Z"
    }
  ],
  "total": 2
}

Notes:

  • Returns all categories for the authenticated user
  • Filter by type parameter to get only expense or income categories
  • Categories are sorted by name (ascending)
  • category_id is UUIDv7
  • is_system indicates if category is protected (cannot be modified/deleted)
  • parent_category_id is null for top-level categories

Get Category

GET /categories/{category_id}
Authorization: Bearer <token>

Response (200 OK):

{
  "category_id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "Food & Dining",
  "type": "expense",
  "color": "#FF5733",
  "icon": "restaurant",
  "parent_category_id": null,
  "is_system": true,
  "created_at": "2025-11-14T10:00:00Z",
  "updated_at": "2025-11-14T10:00:00Z"
}

Error Responses:

  • 401 Unauthorized: Invalid or missing JWT token
  • 404 Not Found: Category not found or doesn't belong to user

Features:

  • Validates user ownership
  • Returns 404 if category not found or doesn't belong to user
  • Handles optional fields (color, icon, parent_category_id)
  • UUID-based category identification
  • updated_at is automatically managed by database trigger

Get Category Children

GET /categories/{category_id}/children
Authorization: Bearer <token>

Response (200 OK):

{
  "data": [
    {
      "category_id": "660e8400-e29b-41d4-a716-446655440001",
      "name": "Groceries",
      "type": "expense",
      "color": "#4CAF50",
      "icon": "shopping_cart",
      "parent_category_id": "550e8400-e29b-41d4-a716-446655440000",
      "is_system": false,
      "created_at": "2025-11-14T10:00:00Z",
      "updated_at": "2025-11-14T10:00:00Z"
    },
    {
      "category_id": "660e8400-e29b-41d4-a716-446655440002",
      "name": "Restaurants",
      "type": "expense",
      "color": "#FF9800",
      "icon": "restaurant",
      "parent_category_id": "550e8400-e29b-41d4-a716-446655440000",
      "is_system": false,
      "created_at": "2025-11-14T10:00:00Z",
      "updated_at": "2025-11-14T10:00:00Z"
    }
  ],
  "total": 2
}

Error Responses:

  • 400 Bad Request: Invalid category UUID format
  • 401 Unauthorized: Invalid or missing JWT token

Features:

  • Returns all subcategories (children) of a parent category
  • Validates user ownership (only returns children belonging to the user)
  • Returns empty array if category has no children
  • Sorted by name (ascending)
  • Useful for building hierarchical category trees

Create Category

POST /categories
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "name": "Groceries",
  "type": "expense",
  "color": "#4CAF50",
  "icon": "shopping_cart",
  "parent_category_id": "550e8400-e29b-41d4-a716-446655440000"
}

Validation Rules:

  • name: Required
  • type: Required, must be expense or income
  • color: Optional
  • icon: Optional
  • parent_category_id: Optional UUID

Response (201 Created):

{
  "category_id": "660e8400-e29b-41d4-a716-446655440001",
  "name": "Groceries",
  "type": "expense",
  "color": "#4CAF50",
  "icon": "shopping_cart",
  "parent_category_id": "550e8400-e29b-41d4-a716-446655440000",
  "is_system": false,
  "created_at": "2025-11-14T16:00:00Z",
  "updated_at": "2025-11-14T16:00:00Z"
}

Error Responses:

  • 400 Bad Request: Missing required fields or invalid type
  • 409 Conflict: Category with same name and type already exists for this user
  • 500 Internal Server Error: Database error

Features:

  • Validates required fields (name, type)
  • Validates hex color code format (#RRGGBB, case-insensitive)
  • Checks for duplicate category names per user and type
  • Automatically generates UUIDv7 for category_id
  • Sets is_system to false for user-created categories
  • Supports parent-child category hierarchy
  • created_at and updated_at are set to the same value on creation

Update Category

PUT /categories/{category_id}
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "name": "Grocery Shopping",
  "color": "#66BB6A",
  "icon": "cart"
}

Response (200 OK):

{
  "category_id": "660e8400-e29b-41d4-a716-446655440001",
  "name": "Grocery Shopping",
  "type": "expense",
  "color": "#66BB6A",
  "icon": "cart",
  "parent_category_id": "550e8400-e29b-41d4-a716-446655440000",
  "is_system": false,
  "created_at": "2025-11-14T16:00:00Z",
  "updated_at": "2025-11-14T17:30:00Z"
}

Error Responses:

  • 400 Bad Request: Invalid hex color code
  • 401 Unauthorized: Invalid or missing JWT token
  • 404 Not Found: Invalid category UUID
  • 500 Internal Server Error: Database error or trying to update system category

Features:

  • All fields are optional (uses COALESCE in SQL)
  • Validates hex color code format if provided (#RRGGBB, case-insensitive)
  • System categories (is_system = true) cannot be updated (enforced at database level)
  • User can only update their own categories
  • Validates user ownership
  • updated_at is automatically updated by database trigger on every UPDATE

Delete Category

DELETE /categories/{category_id}
Authorization: Bearer <token>

Response (204 No Content)

Error Responses:

  • 400 Bad Request: Category has children (cannot delete parent categories)
  • 401 Unauthorized: Invalid or missing JWT token
  • 404 Not Found: Invalid category UUID
  • 500 Internal Server Error: Database error or trying to delete system category

Features:

  • System categories (is_system = true) cannot be deleted (enforced at database level)
  • Categories with children cannot be deleted (must delete children first)
  • Only the category owner can delete their categories
  • Returns 204 No Content on successful deletion
  • Follows HTTP DELETE idempotency (deleting non-existent category returns 204)

3. Accounts

List Accounts

GET /accounts?is_active=true&type=bank
Authorization: Bearer <token>

Query Parameters:

  • is_active (optional): true - filter by active accounts only
  • type (optional): cash | bank | credit_card | debit_card | investment | loan - filter by account type

Supported Query Combinations:

  • No parameters - returns all user accounts
  • ?is_active=true - returns only active accounts
  • ?type=bank - returns only bank accounts
  • Other combinations return 400 Bad Request

Response (200 OK):

{
  "data": [
    {
      "account_id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Main Checking",
      "type": "bank",
      "currency_code": "USD",
      "initial_balance": 5000.00,
      "current_balance": 7543.21,
      "include_in_total": true,
      "color": "#2196F3",
      "icon": "account_balance",
      "notes": "",
      "is_active": true,
      "created_at": "2025-11-14T10:00:00Z",
      "updated_at": "2025-11-14T10:00:00Z"
    }
  ],
  "total": 5
}

Get Account

GET /accounts/{account_id}
Authorization: Bearer <token>

Response (200 OK):

{
  "account_id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "Main Checking",
  "type": "bank",
  "currency_code": "USD",
  "initial_balance": 5000.00,
  "current_balance": 7543.21,
  "include_in_total": true,
  "color": "#2196F3",
  "icon": "account_balance",
  "notes": "Primary checking account",
  "is_active": true,
  "created_at": "2025-11-14T10:00:00Z",
  "updated_at": "2025-11-14T10:00:00Z"
}

Create Account

POST /accounts
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "name": "Savings Account",
  "type": "bank",
  "currency_code": "USD",
  "initial_balance": 10000.00,
  "color": "#4CAF50",
  "icon": "savings",
  "notes": "Emergency fund",
  "include_in_total": true
}

Validation Rules:

  • name: Required
  • type: Required, must be cash | bank | credit_card | debit_card | investment | loan
  • currency_code: Optional, defaults to "IDR", must be valid ISO 4217 currency code if provided
  • initial_balance: Optional, defaults to 0.00
  • color: Optional, must be valid hex code (#RRGGBB)
  • icon: Optional
  • notes: Optional
  • include_in_total: Optional, defaults to true

Response (201 Created):

{
  "account_id": "660e8400-e29b-41d4-a716-446655440001",
  "name": "Savings Account",
  "type": "bank",
  "currency_code": "USD",
  "initial_balance": 10000.00,
  "current_balance": 10000.00,
  "include_in_total": true,
  "color": "#4CAF50",
  "icon": "savings",
  "notes": "Emergency fund",
  "is_active": true,
  "created_at": "2025-11-14T16:00:00Z",
  "updated_at": "2025-11-14T10:00:00Z"
}

Update Account

PUT /accounts/{account_id}
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "name": "Emergency Savings",
  "notes": "6 months emergency fund"
}

Response (200 OK):

{
  "account_id": "660e8400-e29b-41d4-a716-446655440001",
  "name": "Emergency Savings",
  "type": "bank",
  "currency_code": "USD",
  "initial_balance": 10000.00,
  "current_balance": 10000.00,
  "include_in_total": true,
  "color": "#4CAF50",
  "icon": "savings",
  "notes": "6 months emergency fund",
  "is_active": true,
  "created_at": "2025-11-14T16:00:00Z",
  "updated_at": "2025-11-14T10:00:00Z"
}

Get Account Summary

GET /accounts/summary
Authorization: Bearer <token>

Response (200 OK):

{
  "total_balance": 25543.21,
  "total_accounts": 5,
  "active_accounts": 4,
  "inactive_accounts": 1,
  "balances_by_currency": [
    {
      "currency_code": "USD",
      "total_balance": 20000.00,
      "account_count": 3
    },
    {
      "currency_code": "EUR",
      "total_balance": 5543.21,
      "account_count": 2
    }
  ],
  "accounts_by_type": [
    {
      "type": "bank",
      "count": 2,
      "total_balance": 18000.00
    },
    {
      "type": "cash",
      "count": 1,
      "total_balance": 2000.00
    },
    {
      "type": "credit_card",
      "count": 1,
      "total_balance": -2500.00
    }
  ],
  "net_worth": {
    "assets": 28043.21,
    "liabilities": 2500.00,
    "net": 25543.21
  }
}

Features:

  • Overall financial summary across all accounts
  • Total balance calculation (only accounts with include_in_total = true)
  • Account counts (total, active, inactive)
  • Balance breakdown by currency code
  • Account counts and balances grouped by type
  • Net worth calculation (assets - liabilities)
  • Asset accounts: cash, bank, debit_card, investment
  • Liability accounts: credit_card, loan

Delete Account

DELETE /accounts/{account_id}
Authorization: Bearer <token>

Response (204 No Content)


Adjust Account Balance

POST /accounts/{account_id}/adjust
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "adjustment_amount": 150.00,
  "reason": "Bank interest earned",
  "adjustment_date": "2025-11-14"
}

Validation Rules:

  • adjustment_amount: Required, cannot be zero (positive for increase, negative for decrease)
  • reason: Optional, description of the adjustment
  • adjustment_date: Optional, ISO 8601 date-time format (defaults to current date/time if not provided)

Response (200 OK):

{
  "account_id": "550e8400-e29b-41d4-a716-446655440000",
  "previous_balance": 7543.21,
  "new_balance": 7693.21,
  "adjustment_amount": 150.00,
  "adjusted_at": "2025-11-14T17:00:00Z"
}

Notes:

  • Use positive amounts to increase balance (e.g., interest earned, deposits)
  • Use negative amounts to decrease balance (e.g., bank fees, corrections)
  • This updates the current_balance field only, not initial_balance
  • Useful for manual adjustments outside of regular transactions
  • Audit Trail: Every adjustment is recorded in the account_adjustments table with:
    • Adjustment amount, previous balance, new balance
    • Optional reason for the adjustment
    • Adjustment date (defaults to today if not provided)
    • Full history maintained for compliance and tracking

4. Transactions

List Transactions

GET /transactions?start_date=2025-11-01&end_date=2025-11-30&type=expense&account_id=550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <token>

Query Parameters:

  • start_date (optional): YYYY-MM-DD
  • end_date (optional): YYYY-MM-DD
  • type (optional): expense | income (NOT transfer - transfers are separate)
  • account_id (optional): UUID
  • category_id (optional): UUID
  • tags (optional): comma-separated tags
  • min_amount (optional): decimal
  • max_amount (optional): decimal
  • search (optional): search in description/notes/payee (case-insensitive)
  • page (optional): integer (default: 1)
  • limit (optional): integer (default: 50, max: 100)
  • sort (optional): field to sort by (transaction_date, amount, description, created_at)
  • order (optional): asc | desc (default: desc)

Response (200 OK):

{
  "data": [
    {
      "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
      "account_id": "660e8400-e29b-41d4-a716-446655440001",
      "account_name": "Main Checking",
      "category_id": "770e8400-e29b-41d4-a716-446655440002",
      "category_name": "Groceries",
      "type": "expense",
      "amount": 125.50,
      "currency_code": "USD",
      "transaction_date": "2025-11-14T00:00:00Z",
      "description": "Weekly grocery shopping",
      "notes": "Bought items for the week",
      "payee": "Whole Foods",
      "location": "Seattle, WA",
      "tags": ["groceries", "weekly"],
      "receipt_url": "https://storage.example.com/receipts/123.jpg",
      "is_recurring": false,
      "created_at": "2025-11-14T10:00:00Z",
      "updated_at": "2025-11-14T10:00:00Z"
    }
  ],
  "total": 1,
  "pagination": {
    "current_page": 1,
    "total_pages": 10,
    "total_items": 487,
    "items_per_page": 50,
    "has_next": true,
    "has_previous": false
  },
  "summary": {
    "total_expense": 3542.50,
    "total_income": 5000.00,
    "net": 1457.50
  }
}

Error Responses:

  • 401 Unauthorized: Invalid or missing JWT token
  • 500 Internal Server Error: Database error

Notes:

  • UUIDs used for transaction_id, account_id, category_id (UUIDv7 format)
  • total field shows the count of items in the current page
  • Summary is only included when both start_date and end_date are provided
  • All filters can be combined for precise querying
  • Pagination metadata includes has_next and has_previous flags
  • Text search is case-insensitive and searches across description, notes, and payee
  • Tags filter matches if transaction has ANY of the specified tags
  • Currency code is inherited from the account
  • Results sorted by transaction_date DESC by default

Get Transaction

GET /transactions/{transaction_id}
Authorization: Bearer <token>

Response (200 OK):

{
  "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
  "account_id": "660e8400-e29b-41d4-a716-446655440001",
  "account_name": "Main Checking",
  "category_id": "770e8400-e29b-41d4-a716-446655440002",
  "category_name": "Groceries",
  "type": "expense",
  "amount": 125.50,
  "currency_code": "USD",
  "transaction_date": "2025-11-14T00:00:00Z",
  "description": "Weekly grocery shopping",
  "notes": "Bought items for the week",
  "payee": "Whole Foods",
  "location": "Seattle, WA",
  "tags": ["groceries", "weekly"],
  "receipt_url": "https://storage.example.com/receipts/123.jpg",
  "is_recurring": false,
  "created_at": "2025-11-14T10:00:00Z",
  "updated_at": "2025-11-14T10:00:00Z"
}

Error Responses:

  • 401 Unauthorized: Invalid or missing JWT token
  • 404 Not Found: Transaction not found, invalid UUID, or doesn't belong to user
  • 500 Internal Server Error: Database error

Notes:

  • Includes account_name and category_name from joined tables
  • Only returns transaction if it belongs to the authenticated user
  • Invalid UUID in path returns 404 (not 400) for semantic correctness
  • Attachments feature not yet implemented (future enhancement)

Create Transaction

POST /transactions
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "account_id": "660e8400-e29b-41d4-a716-446655440001",
  "category_id": "770e8400-e29b-41d4-a716-446655440002",
  "type": "expense",
  "amount": 125.50,
  "transaction_date": "2025-11-14",
  "description": "Weekly grocery shopping",
  "notes": "Bought items for the week",
  "payee": "Whole Foods",
  "location": "Seattle, WA",
  "tags": ["groceries", "weekly"],
  "receipt_url": "https://storage.example.com/receipts/123.jpg"
}

Validation Rules:

  • account_id: Required, must be valid UUID and belong to user, account must be active
  • category_id: Required, must be valid UUID and belong to user
  • type: Required, must be expense or income
  • amount: Required, must be greater than 0
  • transaction_date: Required, YYYY-MM-DD format
  • description: Required
  • notes: Optional
  • payee: Optional
  • location: Optional
  • tags: Optional, array of strings (will be normalized to lowercase)
  • receipt_url: Optional

Response (201 Created):

{
  "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
  "account_id": "660e8400-e29b-41d4-a716-446655440001",
  "category_id": "770e8400-e29b-41d4-a716-446655440002",
  "type": "expense",
  "amount": 125.50,
  "currency_code": "USD",
  "transaction_date": "2025-11-14T00:00:00Z",
  "description": "Weekly grocery shopping",
  "notes": "Bought items for the week",
  "payee": "Whole Foods",
  "location": "Seattle, WA",
  "tags": ["groceries", "weekly"],
  "receipt_url": "https://storage.example.com/receipts/123.jpg",
  "is_recurring": false,
  "created_at": "2025-11-14T10:00:00Z",
  "updated_at": "2025-11-14T10:00:00Z"
}

Error Responses:

  • 400 Bad Request: Missing required fields, invalid type, amount <= 0, invalid date format
  • 401 Unauthorized: Invalid or missing JWT token
  • 403 Forbidden: Account or category doesn't belong to user, account is inactive, type mismatch with category
  • 500 Internal Server Error: Database error

Notes:

  • Automatically updates account balance (expense decreases, income increases)
  • Currency code inherited from account
  • Transaction type must match category type (expense category → expense transaction)
  • Uses database transaction for atomicity (both transaction record and balance update succeed or fail together)
  • Tags are normalized (trimmed and converted to lowercase)
  • UUIDv7 generated for transaction_id

Update Transaction

PUT /transactions/{transaction_id}
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "amount": 135.75,
  "description": "Weekly grocery shopping - corrected amount",
  "tags": ["groceries", "weekly", "organic"]
}

Validation Rules:

  • At least one field must be provided
  • category_id: Optional, must be valid UUID and belong to user if provided
  • type: Optional, must be expense or income if provided
  • amount: Optional, must be greater than 0 if provided
  • transaction_date: Optional, YYYY-MM-DD format if provided
  • description: Optional
  • notes: Optional (can be set to null)
  • payee: Optional (can be set to null)
  • location: Optional (can be set to null)
  • tags: Optional, array of strings
  • receipt_url: Optional (can be set to null)

Response (200 OK):

{
  "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
  "account_id": "660e8400-e29b-41d4-a716-446655440001",
  "category_id": "770e8400-e29b-41d4-a716-446655440002",
  "type": "expense",
  "amount": 135.75,
  "currency_code": "USD",
  "transaction_date": "2025-11-14T00:00:00Z",
  "description": "Weekly grocery shopping - corrected amount",
  "notes": "Bought items for the week",
  "payee": "Whole Foods",
  "location": "Seattle, WA",
  "tags": ["groceries", "weekly", "organic"],
  "receipt_url": "https://storage.example.com/receipts/123.jpg",
  "is_recurring": false,
  "created_at": "2025-11-14T10:00:00Z",
  "updated_at": "2025-11-14T11:00:00Z"
}

Error Responses:

  • 400 Bad Request: No fields provided, invalid type, amount <= 0, invalid date format
  • 401 Unauthorized: Invalid or missing JWT token
  • 403 Forbidden: Category doesn't belong to user, type mismatch with new category
  • 404 Not Found: Transaction not found, invalid UUID, or doesn't belong to user
  • 500 Internal Server Error: Database error

Notes:

  • Automatically recalculates account balance when amount or type changes
  • Handles complex scenarios:
    • Amount change: Adjusts balance by the difference
    • Type change (expense → income or vice versa): Reverses old effect and applies new effect
    • Both amount and type change: Combines both calculations
  • Uses database transaction for atomicity
  • Account cannot be changed (use delete + create instead)
  • If category changes, validates new category ownership and type match
  • Tags are normalized (trimmed and converted to lowercase)
  • Invalid UUID in path returns 404 (not 400) for semantic correctness

Delete Transaction

DELETE /transactions/{transaction_id}
Authorization: Bearer <token>

Response (204 No Content)

Error Responses:

  • 401 Unauthorized: Invalid or missing JWT token
  • 404 Not Found: Transaction not found, invalid UUID, or doesn't belong to user
  • 500 Internal Server Error: Database error

Notes:

  • Automatically reverses the account balance change (expense refunds, income deducts)
  • Uses database transaction for atomicity (both deletion and balance reversal succeed or fail together)
  • Hard delete (transaction is permanently removed from database)
  • Returns 204 even if transaction doesn't exist (idempotent operation)
  • Invalid UUID in path returns 404 (not 400) for semantic correctness

Bulk Create Transactions

POST /transactions/bulk
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "transactions": [
    {
      "account_id": "660e8400-e29b-41d4-a716-446655440001",
      "category_id": "770e8400-e29b-41d4-a716-446655440002",
      "type": "expense",
      "amount": 50.00,
      "transaction_date": "2025-11-14",
      "description": "Coffee"
    },
    {
      "account_id": "660e8400-e29b-41d4-a716-446655440001",
      "category_id": "770e8400-e29b-41d4-a716-446655440003",
      "type": "expense",
      "amount": 25.00,
      "transaction_date": "2025-11-14",
      "description": "Parking"
    }
  ]
}

Validation Rules:

  • transactions: Required, array with 1-100 transactions
  • Each transaction follows the same validation rules as single create

Response (201 Created):

{
  "created": 2,
  "failed": 0,
  "transactions": [
    {
      "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
      "account_id": "660e8400-e29b-41d4-a716-446655440001",
      "category_id": "770e8400-e29b-41d4-a716-446655440002",
      "type": "expense",
      "amount": 50.00,
      "currency_code": "USD",
      "transaction_date": "2025-11-14T00:00:00Z",
      "description": "Coffee",
      "is_recurring": false,
      "created_at": "2025-11-14T10:00:00Z",
      "updated_at": "2025-11-14T10:00:00Z"
    },
    {
      "transaction_id": "660e8400-e29b-41d4-a716-446655440001",
      "account_id": "660e8400-e29b-41d4-a716-446655440001",
      "category_id": "770e8400-e29b-41d4-a716-446655440003",
      "type": "expense",
      "amount": 25.00,
      "currency_code": "USD",
      "transaction_date": "2025-11-14T00:00:00Z",
      "description": "Parking",
      "is_recurring": false,
      "created_at": "2025-11-14T10:00:00Z",
      "updated_at": "2025-11-14T10:00:00Z"
    }
  ],
  "errors": []
}

Response (201 Created - Partial Success):

{
  "created": 1,
  "failed": 1,
  "transactions": [
    {
      "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
      "amount": 50.00,
      "description": "Coffee"
    }
  ],
  "errors": [
    {
      "index": 1,
      "message": "category doesn't belong to user"
    }
  ]
}

Error Responses:

  • 400 Bad Request: Empty array, more than 100 transactions, or all transactions failed
  • 401 Unauthorized: Invalid or missing JWT token

Notes:

  • Partial success is supported - some transactions may succeed while others fail
  • Returns 201 if at least one transaction was created
  • Returns 400 if all transactions failed
  • Each transaction is processed independently in its own database transaction
  • Account balances are updated for successful transactions
  • Errors include the index of the failed transaction for easy identification
  • Maximum 100 transactions per bulk request

5. Transfers

Transfer money between accounts with automatic transaction creation and balance updates. Supports multi-currency transfers with exchange rates.

List Transfers

GET /transfers?start_date=2025-11-01&end_date=2025-11-30
Authorization: Bearer <token>

Query Parameters:

  • start_date (optional): YYYY-MM-DD - Filter transfers from this date
  • end_date (optional): YYYY-MM-DD - Filter transfers up to this date
  • from_account_id (optional): UUID - Filter by source account
  • to_account_id (optional): UUID - Filter by destination account
  • page (optional): integer (default: 1) - Page number
  • limit (optional): integer (default: 50, max: 100) - Items per page

Response (200 OK):

{
  "data": [
    {
      "transfer_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
      "user_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
      "from_account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
      "from_account_name": "Main Checking",
      "from_currency_code": "USD",
      "to_account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c10",
      "to_account_name": "Savings Account",
      "to_currency_code": "USD",
      "from_transaction_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c11",
      "to_transaction_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c12",
      "amount": 500.00,
      "exchange_rate": 1.0,
      "converted_amount": 500.00,
      "transfer_date": "2025-11-14T00:00:00Z",
      "description": "Monthly savings transfer",
      "created_at": "2025-11-14T10:00:00Z"
    }
  ],
  "total": 1,
  "pagination": {
    "current_page": 1,
    "total_pages": 1,
    "total_items": 1,
    "items_per_page": 50,
    "has_next": false,
    "has_previous": false
  }
}

Notes:

  • total field shows the count of items in the current page
  • Each transfer creates two linked transactions (expense from source, income to destination)
  • For same-currency transfers, exchange_rate must be 1.0
  • converted_amount = amount × exchange_rate
  • Both accounts must be active and owned by the user

Get Transfer

GET /transfers/{transfer_id}
Authorization: Bearer <token>

Path Parameters:

  • transfer_id: UUID

Response (200 OK):

{
  "transfer_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "user_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "from_account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "from_account_name": "Main Checking",
  "from_currency_code": "USD",
  "to_account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c10",
  "to_account_name": "Savings Account",
  "to_currency_code": "USD",
  "from_transaction_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c11",
  "to_transaction_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c12",
  "amount": 500.00,
  "exchange_rate": 1.0,
  "converted_amount": 500.00,
  "transfer_date": "2025-11-14T00:00:00Z",
  "description": "Monthly savings transfer",
  "created_at": "2025-11-14T10:00:00Z"
}

Error Responses:

  • 404 Not Found - Transfer not found or invalid UUID

Create Transfer

POST /transfers
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "from_account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "to_account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c10",
  "amount": 500.00,
  "transfer_date": "2025-11-14",
  "description": "Monthly savings transfer",
  "exchange_rate": 1.0
}

Validation Rules:

  • from_account_id: Required, must be a valid UUID and owned by user
  • to_account_id: Required, must be a valid UUID and owned by user
  • Both accounts must be active
  • from_account_id and to_account_id must be different
  • amount: Required, must be > 0
  • transfer_date: Optional, defaults to current date, format: YYYY-MM-DD
  • description: Optional
  • exchange_rate: Optional, defaults to 1.0, must be > 0
    • If both accounts have same currency, must be 1.0
    • If currencies differ, can be any positive value

Response (201 Created):

{
  "transfer_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "user_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "from_account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "from_account_name": "Main Checking",
  "from_currency_code": "USD",
  "to_account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c10",
  "to_account_name": "Savings Account",
  "to_currency_code": "USD",
  "from_transaction_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c11",
  "to_transaction_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c12",
  "amount": 500.00,
  "exchange_rate": 1.0,
  "converted_amount": 500.00,
  "transfer_date": "2025-11-14T00:00:00Z",
  "description": "Monthly savings transfer",
  "created_at": "2025-11-14T10:00:00Z"
}

What Happens:

  1. Creates expense transaction from from_account_id with amount
  2. Creates income transaction to to_account_id with converted amount
  3. Creates transfer record linking both transactions
  4. Updates both account balances atomically
  5. Both transactions are categorized as "Transfer" (system category)

Error Responses:

  • 400 Bad Request - Invalid input or validation error
  • 403 Forbidden - Account not owned by user or account inactive
  • 404 Not Found - Account not found

Update Transfer

PUT /transfers/{transfer_id}
Authorization: Bearer <token>
Content-Type: application/json

Path Parameters:

  • transfer_id: UUID

Request Body:

{
  "amount": 600.00,
  "transfer_date": "2025-11-15",
  "description": "Updated savings transfer",
  "exchange_rate": 1.0
}

Validation Rules:

  • At least one field must be provided
  • amount: Optional, must be > 0 if provided
  • transfer_date: Optional, format: YYYY-MM-DD
  • description: Optional
  • exchange_rate: Optional, must be > 0 if provided
    • If both accounts have same currency, must be 1.0
    • If currencies differ, can be any positive value

Response (200 OK):

{
  "transfer_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "user_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "from_account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "from_account_name": "Main Checking",
  "from_currency_code": "USD",
  "to_account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c10",
  "to_account_name": "Savings Account",
  "to_currency_code": "USD",
  "from_transaction_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c11",
  "to_transaction_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c12",
  "amount": 600.00,
  "exchange_rate": 1.0,
  "converted_amount": 600.00,
  "transfer_date": "2025-11-15T00:00:00Z",
  "description": "Updated savings transfer",
  "created_at": "2025-11-14T10:00:00Z"
}

What Happens:

  1. Updates transfer record with new values
  2. Updates both linked transactions with new amounts if amount or exchange_rate changed
  3. Recalculates and adjusts both account balances if amount or exchange_rate changed

Error Responses:

  • 400 Bad Request - Invalid input or validation error
  • 404 Not Found - Transfer not found or invalid UUID

Delete Transfer

DELETE /transfers/{transfer_id}
Authorization: Bearer <token>

Path Parameters:

  • transfer_id: UUID

Response (204 No Content)

What Happens:

  1. Deletes the transfer record
  2. Deletes both linked transactions
  3. Reverses both account balance changes atomically

Error Responses:

  • 403 Forbidden - Transfer not owned by user
  • 404 Not Found - Transfer not found or invalid UUID

6. Recurring Transactions

List Recurring Transactions

GET /recurring-transactions?is_active=true
Authorization: Bearer <token>

Query Parameters:

  • is_active (optional): true | false
  • type (optional): expense | income
  • frequency (optional): daily | weekly | biweekly | monthly | quarterly | yearly
  • account_id (optional): UUID

Response (200 OK):

{
  "data": [
    {
      "recurring_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
      "account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
      "account_name": "Main Checking",
      "category_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
      "category_name": "Rent",
      "type": "expense",
      "amount": 1500.00,
      "currency_code": "USD",
      "description": "Monthly rent payment",
      "frequency": "monthly",
      "start_date": "2025-01-01T00:00:00Z",
      "end_date": null,
      "next_due_date": "2025-12-01T00:00:00Z",
      "last_processed_date": "2025-11-01T00:00:00Z",
      "is_active": true,
      "auto_create": true,
      "created_at": "2025-01-01T10:00:00Z",
      "updated_at": "2025-11-01T10:00:00Z"
    }
  ],
  "total": 8
}

Notes:

  • All filters can be combined (e.g., ?is_active=true&type=expense&frequency=monthly)
  • Filtering is done in Go code after fetching from database
  • Results sorted by next_due_date ASC, then created_at DESC
  • Returns total count of items in current response

Get Recurring Transaction

GET /recurring-transactions/{recurring_id}
Authorization: Bearer <token>

Response (200 OK):

{
  "recurring_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "account_name": "Main Checking",
  "category_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "category_name": "Rent",
  "type": "expense",
  "amount": 1500.00,
  "currency_code": "USD",
  "description": "Monthly rent payment",
  "frequency": "monthly",
  "start_date": "2025-01-01T00:00:00Z",
  "end_date": null,
  "next_due_date": "2025-12-01T00:00:00Z",
  "last_processed_date": "2025-11-01T00:00:00Z",
  "is_active": true,
  "auto_create": true,
  "created_at": "2025-01-01T10:00:00Z",
  "updated_at": "2025-11-01T10:00:00Z"
}

Error Responses:

  • 404 Not Found - Recurring transaction not found or doesn't belong to user

Notes:

  • Includes account_name and category_name from joined tables
  • Only returns recurring transaction if it belongs to the authenticated user

Create Recurring Transaction

POST /recurring-transactions
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "category_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "type": "expense",
  "amount": 1500.00,
  "description": "Monthly rent payment",
  "frequency": "monthly",
  "start_date": "2025-01-01",
  "end_date": "2025-12-31",
  "auto_create": true
}

Validation Rules:

  • account_id: Required, must be valid UUID and belong to user, account must be active
  • category_id: Required, must be valid UUID and belong to user
  • type: Required, must be expense or income
  • amount: Required, must be greater than 0
  • description: Required
  • frequency: Required, must be one of: daily, weekly, biweekly, monthly, quarterly, yearly
  • start_date: Required, YYYY-MM-DD format
  • end_date: Optional, YYYY-MM-DD format, must be after start_date
  • auto_create: Optional, defaults to false

Response (201 Created):

{
  "recurring_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "account_name": "Main Checking",
  "category_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "category_name": "Rent",
  "type": "expense",
  "amount": 1500.00,
  "currency_code": "USD",
  "description": "Monthly rent payment",
  "frequency": "monthly",
  "start_date": "2025-01-01T00:00:00Z",
  "end_date": "2025-12-31T00:00:00Z",
  "next_due_date": "2025-02-01T00:00:00Z",
  "last_processed_date": null,
  "is_active": true,
  "auto_create": true,
  "created_at": "2025-11-14T10:00:00Z",
  "updated_at": "2025-11-14T10:00:00Z"
}

Error Responses:

  • 400 Bad Request - Missing required fields, invalid type, amount <= 0, invalid frequency, invalid date format
  • 403 Forbidden - Account or category doesn't belong to user, account is inactive, type mismatch with category

Notes:

  • Currency code inherited from account
  • Transaction type must match category type (expense category → expense transaction)
  • next_due_date is calculated from start_date based on frequency
  • UUIDv7 generated for recurring_id
  • Account and category names included in response

Update Recurring Transaction

PUT /recurring-transactions/{recurring_id}
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "amount": 1600.00,
  "description": "Monthly rent payment - increased",
  "is_active": true
}

Validation Rules:

  • At least one field must be provided
  • category_id: Optional, must be valid UUID and belong to user if provided
  • amount: Optional, must be greater than 0 if provided
  • description: Optional
  • frequency: Optional, must be one of: daily, weekly, biweekly, monthly, quarterly, yearly
  • end_date: Optional, YYYY-MM-DD format
  • is_active: Optional, boolean
  • auto_create: Optional, boolean

Response (200 OK):

{
  "recurring_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "account_name": "Main Checking",
  "category_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "category_name": "Rent",
  "type": "expense",
  "amount": 1600.00,
  "currency_code": "USD",
  "description": "Monthly rent payment - increased",
  "frequency": "monthly",
  "start_date": "2025-01-01T00:00:00Z",
  "end_date": "2025-12-31T00:00:00Z",
  "next_due_date": "2025-12-01T00:00:00Z",
  "last_processed_date": "2025-11-01T00:00:00Z",
  "is_active": true,
  "auto_create": true,
  "created_at": "2025-01-01T10:00:00Z",
  "updated_at": "2025-11-14T11:00:00Z"
}

Error Responses:

  • 400 Bad Request - No fields provided, invalid amount, invalid frequency
  • 403 Forbidden - Category doesn't belong to user, type mismatch with new category
  • 404 Not Found - Recurring transaction not found or doesn't belong to user

Notes:

  • Account cannot be changed (delete and recreate instead)
  • If category changes, validates new category ownership and type match
  • All other fields retain previous values if not provided

Delete Recurring Transaction

DELETE /recurring-transactions/{recurring_id}
Authorization: Bearer <token>

Response (204 No Content)

Error Responses:

  • 404 Not Found - Recurring transaction not found or doesn't belong to user

Notes:

  • Hard delete (recurring transaction is permanently removed)
  • Does NOT delete previously created transactions from this recurring template
  • Returns 204 even if recurring transaction doesn't exist (idempotent operation)

Process Recurring Transaction

Create a transaction instance from a recurring transaction template. This is the endpoint you call to manually execute a recurring transaction and create an actual transaction record.

POST /recurring-transactions/{recurring_id}/process
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "transaction_date": "2025-11-14"
}

Validation Rules:

  • transaction_date: Optional, YYYY-MM-DD format, defaults to next_due_date if not provided
  • Recurring transaction must be active (is_active = true)

Response (201 Created):

{
  "transaction_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c11",
  "recurring_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "amount": 1500.00,
  "transaction_date": "2025-11-14T00:00:00Z",
  "created_at": "2025-11-14T10:00:00Z"
}

What Happens:

  1. Creates a new transaction record with all details from recurring template
  2. Updates account balance (expense decreases, income increases)
  3. Sets transaction's is_recurring = true flag
  4. Links transaction to recurring template via recurring_transaction_id
  5. Updates recurring transaction's next_due_date based on frequency
  6. Updates recurring transaction's last_processed_date to transaction date
  7. All operations happen atomically in a database transaction

Error Responses:

  • 400 Bad Request - Recurring transaction is not active, invalid date format
  • 404 Not Found - Recurring transaction not found or doesn't belong to user

Notes:

  • If transaction_date is not provided, uses the recurring transaction's next_due_date
  • The new transaction is linked to the recurring transaction for audit purposes
  • Account balance is immediately updated
  • next_due_date calculation examples:
    • Monthly on Nov 1 → Dec 1
    • Weekly on Mon → next Mon
    • Quarterly on Jan 1 → Apr 1
  • This endpoint can be called manually or by a scheduled background job for auto_create = true recurring transactions

Use Cases:

  • Manual processing: User wants to record a recurring payment now
  • Scheduled processing: Background job processes all due recurring transactions with auto_create = true
  • Custom date processing: User wants to process a recurring transaction on a different date than scheduled

7. Budgets

List Budgets

GET /budgets?is_active=true&period=monthly
Authorization: Bearer <token>

Query Parameters:

  • is_active (optional): true | false
  • period (optional): daily | weekly | monthly | quarterly | yearly
  • category_id (optional): UUID

Response (200 OK):

{
  "data": [
    {
      "budget_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
      "category_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
      "category_name": "Groceries",
      "name": "Monthly Grocery Budget",
      "amount": 600.00,
      "period": "monthly",
      "start_date": "2025-11-01T00:00:00Z",
      "end_date": "2025-11-30T00:00:00Z",
      "alert_threshold": 80.0,
      "is_active": true,
      "spent": 425.50,
      "remaining": 174.50,
      "percentage_used": 70.92,
      "created_at": "2025-11-01T10:00:00Z"
    }
  ],
  "total": 1,
  "summary": {
    "total_budgeted": 3500.00,
    "total_spent": 2847.32,
    "total_remaining": 652.68,
    "overall_percentage": 81.35
  }
}

Notes:

  • budget_id and category_id are UUIDs (UUIDv7 format)
  • start_date and end_date are returned as ISO 8601 timestamps
  • total shows the count of items returned in the current page
  • summary is only included when filters return multiple budgets
  • Budgets are restricted to expense categories only

Get Budget

GET /budgets/{budget_id}
Authorization: Bearer <token>

Response (200 OK):

{
  "budget_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "category_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "category_name": "Groceries",
  "name": "Monthly Grocery Budget",
  "amount": 600.00,
  "period": "monthly",
  "start_date": "2025-11-01T00:00:00Z",
  "end_date": "2025-11-30T00:00:00Z",
  "alert_threshold": 80.0,
  "is_active": true,
  "spent": 425.50,
  "remaining": 174.50,
  "percentage_used": 70.92,
  "transactions": [
    {
      "transaction_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
      "amount": 125.50,
      "transaction_date": "2025-11-14T00:00:00Z",
      "description": "Weekly grocery shopping"
    }
  ],
  "created_at": "2025-11-01T10:00:00Z"
}

Notes:

  • Returns detailed budget information with all transactions for the budget period
  • transactions array contains all expense transactions matching the category within the date range
  • All dates are returned as ISO 8601 timestamps

Create Budget

POST /budgets
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "category_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "name": "Monthly Grocery Budget",
  "amount": 600.00,
  "period": "monthly",
  "start_date": "2025-11-01",
  "end_date": "2025-11-30",
  "alert_threshold": 80.0
}

Validation Rules:

  • category_id: Required, must be valid UUID, must belong to user, must be an expense category (budgets only work with expense categories)
  • name: Required
  • amount: Required, must be greater than 0
  • period: Required, must be one of: daily, weekly, monthly, quarterly, yearly
  • start_date: Required, YYYY-MM-DD format
  • end_date: Optional, YYYY-MM-DD format (if not provided, budget remains active indefinitely)
  • alert_threshold: Optional, percentage value (defaults to 80.0 if not provided)

Response (201 Created):

{
  "budget_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "category_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "category_name": "Groceries",
  "name": "Monthly Grocery Budget",
  "amount": 600.00,
  "period": "monthly",
  "start_date": "2025-11-01T00:00:00Z",
  "end_date": "2025-11-30T00:00:00Z",
  "alert_threshold": 80.0,
  "is_active": true,
  "created_at": "2025-11-14T10:00:00Z"
}

Notes:

  • Budget is automatically set to active (is_active: true)
  • Only expense categories are allowed for budgets
  • Category ownership is validated before creation

Update Budget

PUT /budgets/{budget_id}
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "amount": 700.00,
  "alert_threshold": 75.0,
  "is_active": true
}

Validation Rules:

  • All fields are optional
  • name: String
  • amount: Must be greater than 0 if provided
  • period: Must be one of: daily, weekly, monthly, quarterly, yearly
  • start_date: YYYY-MM-DD format
  • end_date: YYYY-MM-DD format
  • alert_threshold: Percentage value
  • is_active: Boolean - use to activate/deactivate budget

Response (200 OK):

{
  "budget_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "category_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "category_name": "Groceries",
  "name": "Monthly Grocery Budget",
  "amount": 700.00,
  "period": "monthly",
  "start_date": "2025-11-01T00:00:00Z",
  "end_date": "2025-11-30T00:00:00Z",
  "alert_threshold": 75.0,
  "is_active": true,
  "created_at": "2025-11-01T10:00:00Z"
}

Notes:

  • Category cannot be changed after creation
  • Only fields provided in the request are updated
  • All dates are returned as ISO 8601 timestamps

Delete Budget

DELETE /budgets/{budget_id}
Authorization: Bearer <token>

Response (204 No Content)


8. Goals

List Goals

GET /goals?is_completed=false
Authorization: Bearer <token>

Query Parameters:

  • is_completed (optional): true | false
  • goal_type (optional): savings | debt_payoff | investment | purchase | other
  • account_id (optional): UUID

Response (200 OK):

{
  "data": [
    {
      "goal_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
      "user_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
      "account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
      "account_name": "Savings Account",
      "name": "Emergency Fund",
      "description": "6 months living expenses",
      "target_amount": 30000.00,
      "current_amount": 15000.00,
      "currency_code": "USD",
      "target_date": "2026-06-30",
      "goal_type": "savings",
      "progress_percentage": 50.0,
      "is_completed": false,
      "created_at": "2025-01-01T10:00:00Z",
      "updated_at": "2025-11-14T10:00:00Z"
    }
  ],
  "total": 1
}

Notes:

  • goal_id, user_id, and account_id are UUIDs (UUIDv7 format)
  • currency_code is inherited from the linked account, or defaults to "IDR" if no account is linked
  • total shows the count of items returned in the current page

Get Goal

GET /goals/{goal_id}
Authorization: Bearer <token>

Response (200 OK):

{
  "goal_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "user_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "account_name": "Savings Account",
  "name": "Emergency Fund",
  "description": "6 months living expenses",
  "target_amount": 30000.00,
  "current_amount": 15000.00,
  "currency_code": "USD",
  "target_date": "2026-06-30",
  "goal_type": "savings",
  "progress_percentage": 50.0,
  "remaining_amount": 15000.00,
  "days_remaining": 228,
  "monthly_target": 625.00,
  "is_completed": false,
  "created_at": "2025-01-01T10:00:00Z",
  "updated_at": "2025-11-14T10:00:00Z"
}

Notes:

  • Returns detailed metrics including remaining_amount, days_remaining, and monthly_target
  • monthly_target is calculated based on remaining amount and days remaining
  • All IDs are UUIDs (UUIDv7 format)

Create Goal

POST /goals
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "name": "Emergency Fund",
  "description": "6 months living expenses",
  "target_amount": 30000.00,
  "current_amount": 15000.00,
  "target_date": "2026-06-30",
  "goal_type": "savings"
}

Validation Rules:

  • name: Required
  • target_amount: Required, must be greater than 0
  • goal_type: Required, must be one of: savings, debt_payoff, investment, purchase, other
  • account_id: Optional, must be valid UUID and belong to user, account must be active
  • description: Optional
  • current_amount: Optional, defaults to 0
  • target_date: Optional, YYYY-MM-DD format

Response (201 Created):

{
  "goal_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "user_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "name": "Emergency Fund",
  "description": "6 months living expenses",
  "target_amount": 30000.00,
  "current_amount": 15000.00,
  "currency_code": "USD",
  "target_date": "2026-06-30",
  "goal_type": "savings",
  "progress_percentage": 50.0,
  "is_completed": false,
  "created_at": "2025-11-14T10:00:00Z",
  "updated_at": "2025-11-14T10:00:00Z"
}

Notes:

  • currency_code is automatically inherited from the linked account's currency
  • If no account is linked, defaults to "IDR"
  • goal_id is a UUIDv7 generated automatically
  • Account ownership and active status are validated if account_id is provided

Update Goal

PUT /goals/{goal_id}
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "current_amount": 18000.00,
  "is_completed": false
}

Validation Rules:

  • All fields are optional
  • account_id: Must be valid UUID and belong to user, account must be active if provided
  • name: String
  • target_amount: Must be greater than 0 if provided
  • current_amount: Numeric value
  • target_date: YYYY-MM-DD format
  • goal_type: Must be one of: savings, debt_payoff, investment, purchase, other
  • is_completed: Boolean - set to true to mark goal as completed

Response (200 OK):

{
  "goal_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0d",
  "user_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0e",
  "account_id": "01936a4e-8f9a-7b2c-a5d3-4e6f7a8b9c0f",
  "name": "Emergency Fund",
  "target_amount": 30000.00,
  "current_amount": 18000.00,
  "currency_code": "USD",
  "goal_type": "savings",
  "progress_percentage": 60.0,
  "is_completed": false,
  "created_at": "2025-11-14T10:00:00Z",
  "updated_at": "2025-11-14T11:00:00Z"
}

Notes:

  • To mark a goal as completed, set is_completed: true
  • When a goal is marked as completed, completed_date is automatically set to the current date
  • Auto-completion occurs when current_amount reaches or exceeds target_amount

Delete Goal

DELETE /goals/{goal_id}
Authorization: Bearer <token>

Response (204 No Content)


9. Notifications

List Notifications

GET /notifications?is_read=false&type=budget_alert&page=1&limit=20
Authorization: Bearer <token>

Query Parameters:

  • is_read (optional): true | false
  • type (optional): budget_alert | bill_reminder | goal_milestone | low_balance | other
  • page (optional): integer (default: 1)
  • limit (optional): integer (default: 20, max: 100)

Response (200 OK):

{
  "data": [
    {
      "notification_id": "9d0af706-5e9a-4eef-a7a1-9d5c4847355d",
      "type": "budget_alert",
      "title": "Budget Alert: Groceries",
      "message": "You've spent 85% of your grocery budget this month",
      "is_read": false,
      "related_id": "f1f12c05-93bd-4f34-a62f-a13c5d1b6e8a",
      "created_at": "2025-11-14T10:00:00Z"
    }
  ],
  "total": 1,
  "pagination": {
    "current_page": 1,
    "total_pages": 1,
    "total_items": 1,
    "items_per_page": 20,
    "has_next": false,
    "has_previous": false
  },
  "unread_count": 8
}

Get Notification

GET /notifications/{notification_id}
Authorization: Bearer <token>

Response (200 OK):

{
  "notification_id": "9d0af706-5e9a-4eef-a7a1-9d5c4847355d",
  "type": "goal_milestone",
  "title": "Goal Milestone Reached",
  "message": "You've hit 50% of your Emergency Fund goal!",
  "is_read": false,
  "related_id": "3b4f6c35-410a-47d1-b3b2-5116d6fb02f5",
  "created_at": "2025-11-14T10:00:00Z"
}

Mark Notification as Read

PATCH /notifications/{notification_id}/read
Authorization: Bearer <token>

Response (200 OK):

{
  "notification_id": "9d0af706-5e9a-4eef-a7a1-9d5c4847355d",
  "is_read": true
}

Mark All Notifications as Read

POST /notifications/read-all
Authorization: Bearer <token>

Response (200 OK):

{
  "marked_read": 5
}

Delete Notification

DELETE /notifications/{notification_id}
Authorization: Bearer <token>

Response (204 No Content)


10. Attachments

List Attachments

GET /attachments?transaction_id={transaction_id}
Authorization: Bearer <token>

Query Parameters:

  • transaction_id (required): UUID - Filter attachments by transaction

Response (200 OK):

{
  "data": [
    {
      "attachment_id": "9d0af706-5e9a-4eef-a7a1-9d5c4847355d",
      "transaction_id": "f1f12c05-93bd-4f34-a62f-a13c5d1b6e8a",
      "file_name": "receipt.jpg",
      "file_type": "image/jpeg",
      "file_size": 245678,
      "uploaded_at": "2025-11-14T10:00:00Z"
    }
  ],
  "total": 1
}

Error Responses:

  • 400 Bad Request - Missing or invalid transaction_id
  • 404 Not Found - Transaction not found or doesn't belong to user

Notes:

  • Returns all attachments for a specific transaction
  • Only returns attachments for transactions owned by the authenticated user
  • Results sorted by uploaded_at DESC (newest first)
  • file_size is in bytes
  • To download a file, use the download URL endpoint

Get Attachment

GET /attachments/{attachment_id}
Authorization: Bearer <token>

Path Parameters:

  • attachment_id: UUID

Response (200 OK):

{
  "attachment_id": "9d0af706-5e9a-4eef-a7a1-9d5c4847355d",
  "transaction_id": "f1f12c05-93bd-4f34-a62f-a13c5d1b6e8a",
  "file_name": "receipt.jpg",
  "file_type": "image/jpeg",
  "file_size": 245678,
  "uploaded_at": "2025-11-14T10:00:00Z"
}

Error Responses:

  • 404 Not Found - Attachment not found or doesn't belong to user

Notes:

  • Returns single attachment details
  • Validates attachment ownership via transaction ownership
  • To download the file, use the download URL endpoint

Generate Upload URL (Pre-signed URL)

Get a pre-signed URL to upload a file directly to S3-compatible storage.

POST /attachments/upload-url
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "transaction_id": "f1f12c05-93bd-4f34-a62f-a13c5d1b6e8a",
  "file_name": "receipt.jpg",
  "content_type": "image/jpeg",
  "file_size": 245678
}

Validation Rules:

  • transaction_id: Required, must be valid UUID and belong to user
  • file_name: Required, original filename
  • content_type: Required, MIME type (must be in allowed types list)
  • file_size: Optional, file size in bytes (validated against max upload size)

Allowed File Types (configurable via ALLOWED_FILE_TYPES env var):

  • image/jpeg
  • image/png
  • image/webp
  • application/pdf

Max Upload Size: 10MB (configurable via MAX_UPLOAD_SIZE env var)

Response (200 OK):

{
  "upload_url": "https://s3.amazonaws.com/bucket/attachments/2025/11/uuid-receipt.jpg?X-Amz-Algorithm=...",
  "upload_id": "9f8e7d6c-5b4a-3c2d-1e0f-9a8b7c6d5e4f",
  "expires_in": 900
}

Error Responses:

  • 400 Bad Request - Missing fields, file type not allowed, or file too large
  • 404 Not Found - Transaction not found or doesn't belong to user

Notes:

  • Pre-signed URL expires in 15 minutes (900 seconds)
  • upload_id is a temporary token that expires after 15 minutes
  • File path is auto-generated internally with format: attachments/YYYY/MM/uuid-filename
  • After uploading to the pre-signed URL, call POST /attachments with the upload_id to save metadata

Upload Flow:

  1. Call POST /attachments/upload-url to get pre-signed URL and upload_id
  2. Upload file directly to the pre-signed URL using HTTP PUT
  3. Call POST /attachments with the returned upload_id to save metadata

Example Upload (Step 2):

curl -X PUT "https://s3.amazonaws.com/bucket/..." \
  -H "Content-Type: image/jpeg" \
  --data-binary @receipt.jpg

Create Attachment (Save Metadata)

Save attachment metadata after uploading file to S3.

POST /attachments
Authorization: Bearer <token>
Content-Type: application/json

Request Body:

{
  "upload_id": "9f8e7d6c-5b4a-3c2d-1e0f-9a8b7c6d5e4f",
  "file_size": 245678
}

Validation Rules:

  • upload_id: Required, the upload ID returned from POST /attachments/upload-url (expires after 15 minutes)
  • file_size: Optional, actual file size in bytes

Response (201 Created):

{
  "attachment_id": "9d0af706-5e9a-4eef-a7a1-9d5c4847355d",
  "transaction_id": "f1f12c05-93bd-4f34-a62f-a13c5d1b6e8a",
  "file_name": "receipt.jpg",
  "file_type": "image/jpeg",
  "file_size": 245678,
  "uploaded_at": "2025-11-14T10:00:00Z"
}

Error Responses:

  • 400 Bad Request - Missing upload_id
  • 403 Forbidden - Upload doesn't belong to user
  • 404 Not Found - Upload ID not found or expired (15 minute expiration)

Notes:

  • This endpoint creates the attachment metadata AFTER file is uploaded to S3
  • Call this after successfully uploading to the pre-signed URL
  • upload_id is validated and linked to transaction_id, file_name, and file_path internally
  • The upload tracker ensures the file was uploaded by the authenticated user
  • attachment_id is a UUIDv7 generated automatically
  • uploaded_at is set automatically to current timestamp
  • Upload ID is automatically deleted after successful attachment creation

Generate Download URL (Pre-signed URL)

Get a pre-signed URL to download a file directly from S3-compatible storage.

GET /attachments/{attachment_id}/download-url
Authorization: Bearer <token>

Path Parameters:

  • attachment_id: UUID - The attachment to download

Response (200 OK):

{
  "download_url": "https://s3.amazonaws.com/bucket/attachments/2025/11/uuid-receipt.jpg?X-Amz-Algorithm=...",
  "expires_in": 3600
}

Error Responses:

  • 404 Not Found - Attachment not found or doesn't belong to user

Notes:

  • Pre-signed URL expires in 1 hour (3600 seconds)
  • The download URL allows direct access to the file in S3
  • Validates attachment ownership via transaction ownership
  • File path is kept internal - only the pre-signed URL is exposed

Example Download:

# First, get the download URL
curl -X GET "https://api.example.com/attachments/{attachment_id}/download-url" \
  -H "Authorization: Bearer <token>"

# Then, download the file using the returned download_url
curl -o receipt.jpg "https://s3.amazonaws.com/bucket/...?X-Amz-Algorithm=..."

Delete Attachment

DELETE /attachments/{attachment_id}
Authorization: Bearer <token>

Path Parameters:

  • attachment_id: UUID

Response (204 No Content)

Error Responses:

  • 404 Not Found - Attachment not found or doesn't belong to user

Notes:

  • Deletes the attachment metadata from database
  • Does NOT delete the actual file from storage (implement file cleanup separately)
  • Returns 204 even if attachment doesn't exist (idempotent operation)
  • Validates attachment ownership via transaction ownership

11. Reports & Analytics

Advanced reporting endpoints expose aggregated insights derived from the transactions ledger. All report endpoints accept optional start_date and end_date query parameters in YYYY-MM-DD format (inclusive) and enforce a maximum range of 366 days. When no range is provided, the server defaults to the last 30 days, except for the monthly trends endpoint which defaults to 6 months.

Overview Dashboard

GET /reports/overview?start_date=2025-01-01&end_date=2025-01-31&category_limit=5&account_limit=5
Authorization: Bearer <token>

Query Parameters:

  • start_date (optional): Beginning of the date range (defaults to 30 days ago)
  • end_date (optional): End of the date range (defaults to today)
  • category_limit (optional): Top expense categories to return (default: 5, max: 25)
  • account_limit (optional): Top accounts to return (default: 5, max: 25)

Response (200 OK):

{
  "range": {
    "start_date": "2025-01-01T00:00:00Z",
    "end_date": "2025-01-31T00:00:00Z"
  },
  "totals": {
    "total_income": 12500,
    "total_expense": 8400,
    "net": 4100,
    "average_daily_expense": 271,
    "average_daily_income": 403
  },
  "top_categories": [
    {
      "category_id": "7ae09c38-86d5-4de9-9f67-2f1a8a9621b0",
      "category_name": "Rent",
      "total": 3000,
      "percentage": 0.357,
      "transaction_count": 1
    }
  ],
  "top_accounts": [
    {
      "account_id": "1d2a98c7-36ae-4d61-a54d-24c56355f1cb",
      "account_name": "Visa Platinum",
      "total_expense": 4200,
      "total_income": 0,
      "transaction_count": 17
    }
  ],
  "monthly_cashflow": [
    {
      "month": "2025-01-01",
      "total_income": 12500,
      "total_expense": 8400,
      "net": 4100
    }
  ]
}

Notes:

  • top_categories always reflects expense categories (percentage relative to total expense for the range)
  • monthly_cashflow returns one entry per month between start_date and end_date

Category Breakdown

GET /reports/categories?type=expense&limit=10&start_date=2025-01-01&end_date=2025-01-31
Authorization: Bearer <token>

Query Parameters:

  • type (optional): expense (default) or income
  • limit (optional): Number of categories to return (default: 20, max: 100)
  • start_date / end_date: Optional range overrides (max 366 days)

Response (200 OK):

{
  "range": {
    "start_date": "2025-01-01T00:00:00Z",
    "end_date": "2025-01-31T00:00:00Z"
  },
  "type": "expense",
  "total": 8400,
  "categories": [
    {
      "category_id": "7ae09c38-86d5-4de9-9f67-2f1a8a9621b0",
      "category_name": "Rent",
      "total": 3000,
      "percentage": 0.357,
      "transaction_count": 1
    },
    {
      "category_name": "Uncategorized",
      "total": 200,
      "percentage": 0.024,
      "transaction_count": 2
    }
  ]
}

Notes:

  • percentage is relative to the total for the requested type
  • category_id is omitted for uncategorized transactions

GET /reports/monthly-trends?months=6
Authorization: Bearer <token>

Query Parameters:

  • months (optional): Number of months to include when start_date is omitted (default: 6, max: 12)
  • start_date / end_date (optional): Explicit range overrides

Response (200 OK):

{
  "range": {
    "start_date": "2024-08-01T00:00:00Z",
    "end_date": "2025-01-31T00:00:00Z"
  },
  "monthly": [
    {
      "month": "2024-08-01",
      "total_income": 11000,
      "total_expense": 7600,
      "net": 3400
    },
    {
      "month": "2024-09-01",
      "total_income": 11800,
      "total_expense": 8800,
      "net": 3000
    }
  ]
}

Notes:

  • Dates use the first day of each month for clarity
  • The endpoint automatically truncates to the requested number of months when start_date is omitted

CSV Export Endpoints

All CSV export endpoints require authentication and stream data with Content-Type: text/csv. Pass the same query parameters as their JSON counterparts to control the date range and limits. Files include the requested date range in their filenames for easier organization.

Category Breakdown CSV

GET /reports/export/categories?type=expense&limit=100&start_date=2025-01-01&end_date=2025-01-31
Authorization: Bearer <token>
Accept: text/csv

Columns:

  • category_id
  • category_name
  • total (formatted with two decimals)
  • percentage (percentage of the selected type total, e.g., 35.70%)
  • transaction_count

Sample Output:

category_id,category_name,total,percentage,transaction_count
7ae09c38-86d5-4de9-9f67-2f1a8a9621b0,Rent,3000.00,35.70%,1
,Uncategorized,200.00,2.40%,2

Account Activity CSV

GET /reports/export/accounts?limit=200&start_date=2025-01-01&end_date=2025-01-31
Authorization: Bearer <token>
Accept: text/csv

Columns:

  • account_id
  • account_name
  • total_expense
  • total_income
  • transaction_count

Use this export to analyze which accounts drive most activity within the selected window.

GET /reports/export/monthly-trends?months=12
Authorization: Bearer <token>
Accept: text/csv

Columns:

  • month (first day of the month)
  • total_income
  • total_expense
  • net

The CSV mirrors the monthly trends JSON endpoint and respects start_date, end_date, or months filters.


12. Error Responses

All endpoints use the shared helpers in internal/utils/response.go. Errors are emitted directly through utils.WriteJSONError, which produces a flat payload containing the HTTP status code, a human-friendly message, and optional structured details derived from utils.BuildErrorDetails.

{
  "code": 401,
  "message": "Unauthorized",
  "details": {
    "reason": "unauthorized",
    "action": "authenticate"
  }
}

HTTP Status Codes

Status Code Description
200 OK - Request successful
201 Created - Resource created successfully
204 No Content - Request successful, no content to return
400 Bad Request - Invalid request parameters
401 Unauthorized - Missing or invalid authentication token
403 Forbidden - Authenticated but not authorized
404 Not Found - Resource not found
409 Conflict - Resource conflict (e.g., duplicate entry)
422 Unprocessable Entity - Validation error
429 Too Many Requests - Rate limit exceeded
500 Internal Server Error - Server error

Common Error Examples

Authentication Error:

{
  "code": 401,
  "message": "Invalid or expired token",
  "details": {
    "reason": "authentication_error",
    "issue": "token_invalid_or_expired"
  }
}

Validation Error:

{
  "code": 400,
  "message": "Amount must be greater than 0",
  "details": {
    "reason": "validation_error",
    "field": "amount",
    "requirement": "greater than 0"
  }
}

Not Found Error:

{
  "code": 404,
  "message": "Transaction not found",
  "details": {
    "reason": "not_found",
    "resource": "transaction"
  }
}

Business Logic Error:

{
  "code": 422,
  "message": "Account balance insufficient for transfer",
  "details": {
    "reason": "operation_failed",
    "operation": "transfer",
    "detail": "Account balance insufficient for transfer"
  }
}

Validation Error (Missing Required Fields):

{
  "code": 400,
  "message": "email, username, and password are required",
  "details": {
    "reason": "validation_error",
    "fields": ["email", "username", "password"],
    "issue": "required"
  }
}

Validation Error (Invalid Format):

{
  "code": 400,
  "message": "invalid email format",
  "details": {
    "reason": "validation_error",
    "field": "email_format",
    "issue": "invalid_format",
    "detail": "email format"
  }
}

Validation Error (Limit Constraint):

{
  "code": 400,
  "message": "Maximum 50 categories allowed",
  "details": {
    "reason": "limit_error",
    "limit": 50,
    "resource": "categories",
    "detail": "Maximum 50 categories allowed"
  }
}

13. Pagination

List endpoints support pagination using page-based navigation:

GET /transactions?page=1&limit=50

Query Parameters:

  • page (optional): integer, page number starting from 1 (default: 1)
  • limit (optional): integer, items per page (default: 50, max: 100)

Response includes pagination metadata:

{
  "data": [...],
  "pagination": {
    "current_page": 1,
    "total_pages": 10,
    "total_items": 487,
    "items_per_page": 50,
    "has_next": true,
    "has_previous": false
  }
}

14. Filtering & Sorting

Filtering

Most list endpoints support filtering using query parameters:

GET /transactions?type=expense&category_id=5&min_amount=100&max_amount=500

Common filter parameters:

  • type: filter by transaction type
  • category_id: filter by category
  • account_id: filter by account
  • start_date, end_date: date range filtering
  • min_amount, max_amount: amount range filtering
  • search: text search in relevant fields
  • is_active: filter active/inactive items
  • tags: filter by tags (comma-separated)

Sorting

Use sort and order parameters to control result ordering:

GET /transactions?sort=amount&order=desc

Parameters:

  • sort: field name to sort by (e.g., amount, transaction_date, created_at)
  • order: asc (ascending) or desc (descending, default)

Date Ranges

Many endpoints support date range filtering:

GET /transactions?start_date=2025-11-01&end_date=2025-11-30

Date format: YYYY-MM-DD


15. Best Practices

  1. Use HTTPS: All API requests must use HTTPS
  2. Store tokens securely:
    • Store access token in memory (not localStorage)
    • Store refresh token in httpOnly cookie or secure storage
    • Never expose tokens in client-side code or logs
  3. Token refresh strategy:
    • Refresh access token before it expires (use interceptors)
    • Handle 401 errors by attempting token refresh
    • Logout user if refresh token is also expired/invalid
  4. Use pagination: Don't request all data at once
  5. Filter data: Use query parameters to request only needed data
  6. Cache responses: Cache GET requests when appropriate
  7. Handle errors gracefully: Always check status codes and error responses
  8. Validate input: Validate data before sending to API
  9. Use batch operations: Use bulk endpoints when creating multiple resources
  10. Security:
    • Never send refresh tokens with regular API requests
    • Only send refresh tokens to /auth/refresh and /auth/logout
    • Implement "Logout from all devices" using RevokeAllUserRefreshTokens
    • Monitor last_used_at for suspicious activity

16. Roadmap

The following features are planned but not yet available in the API/server implementation:

  • Global search endpoint beyond per-resource filters
  • Webhook registration and signature verification
  • Rate limiting middleware with tier-based quotas
  • Automated recurring-transaction processing job

Last Updated: November 14, 2025
Version: 1.0