| internal | ||
| sql | ||
| .dockerignore | ||
| .gitignore | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| README.md | ||
| server.go | ||
| sqlc.yaml | ||
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/validationgithub.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_tokenstable - Usage: Sent to
/auth/refreshand/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_atfor audit trail
Table of Contents
- Authentication & Users
- Categories
- Accounts
- Transactions
- Transfers
- Recurring Transactions
- Budgets
- Goals
- Notifications
- Attachments
- Reports & Analytics
- Error Responses
- Pagination
- Filtering & Sorting
- Best Practices
- Support
- 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 onlypassword: Required, minimum 8 charactersfull_name: Requiredcurrency_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 code409 Conflict: User with this email or username already exists500 Internal Server Error: Server error during registration
Notes:
user_idis a UUIDv7tokenis a JWT access token with 15-minute expirationrefresh_tokenis 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: Requiredpassword: Required
Error Responses:
400 Bad Request: Missing username or password, invalid request body401 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(notemail) for authentication expires_inreturns 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 token500 Internal Server Error: Server error during token refresh
How it works:
- Client sends refresh token in
Authorization: Bearer <refresh_token>header - Server hashes the token using SHA-512
- Server validates token using
ValidateRefreshTokenquery:- Checks token exists (
token_hashmatch) - Verifies token is active (
is_active = true) - Verifies not revoked (
revoked_at IS NULL) - Verifies not expired (
expires_at > NOW())
- Checks token exists (
- Server revokes old refresh token (token rotation for security)
- Server generates new refresh token (24-hour expiration)
- Server generates and returns new JWT access token (15 min expiration)
- 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.sqlqueries for token management
Logout
POST /auth/logout
Authorization: Bearer <refresh_token>
Response (204 No Content)
Error Responses:
401 Unauthorized: Missing or invalid refresh token500 Internal Server Error: Server error during logout
How it works:
- Client sends refresh token in
Authorization: Bearer <refresh_token>header - Server hashes the refresh token using SHA-512
- Server revokes the refresh token using
RevokeRefreshTokenByHashquery - 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 token500 Internal Server Error: Server error during logout
How it works:
- Client sends refresh token in
Authorization: Bearer <refresh_token>header - Server validates the refresh token to ensure it's valid and belongs to a user
- Server revokes ALL refresh tokens for that user using
RevokeAllUserRefreshTokensquery - 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: Optionalcurrency_code: Optional, must be valid ISO 4217 currency code if provided
Error Responses:
400 Bad Request: Invalid request body or invalid currency code401 Unauthorized: Invalid or missing JWT token500 Internal Server Error: Database error
Notes:
- Both fields are optional (uses
COALESCEin 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 JWT500 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
RevokeAllUserRefreshTokenscall)
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
typeparameter to get only expense or income categories - Categories are sorted by name (ascending)
category_idis UUIDv7is_systemindicates if category is protected (cannot be modified/deleted)parent_category_idis 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 token404 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_atis 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 format401 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: Requiredtype: Required, must beexpenseorincomecolor: Optionalicon: Optionalparent_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 type409 Conflict: Category with same name and type already exists for this user500 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_systemto false for user-created categories - Supports parent-child category hierarchy
created_atandupdated_atare 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 code401 Unauthorized: Invalid or missing JWT token404 Not Found: Invalid category UUID500 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_atis 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 token404 Not Found: Invalid category UUID500 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 onlytype(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: Requiredtype: Required, must becash|bank|credit_card|debit_card|investment|loancurrency_code: Optional, defaults to "IDR", must be valid ISO 4217 currency code if providedinitial_balance: Optional, defaults to 0.00color: Optional, must be valid hex code (#RRGGBB)icon: Optionalnotes: Optionalinclude_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 adjustmentadjustment_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_balancefield only, notinitial_balance - Useful for manual adjustments outside of regular transactions
- Audit Trail: Every adjustment is recorded in the
account_adjustmentstable 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-DDend_date(optional): YYYY-MM-DDtype(optional):expense|income(NOTtransfer- transfers are separate)account_id(optional): UUIDcategory_id(optional): UUIDtags(optional): comma-separated tagsmin_amount(optional): decimalmax_amount(optional): decimalsearch(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 token500 Internal Server Error: Database error
Notes:
- UUIDs used for transaction_id, account_id, category_id (UUIDv7 format)
totalfield shows the count of items in the current page- Summary is only included when both
start_dateandend_dateare provided - All filters can be combined for precise querying
- Pagination metadata includes
has_nextandhas_previousflags - 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 token404 Not Found: Transaction not found, invalid UUID, or doesn't belong to user500 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 activecategory_id: Required, must be valid UUID and belong to usertype: Required, must beexpenseorincomeamount: Required, must be greater than 0transaction_date: Required, YYYY-MM-DD formatdescription: Requirednotes: Optionalpayee: Optionallocation: Optionaltags: 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 format401 Unauthorized: Invalid or missing JWT token403 Forbidden: Account or category doesn't belong to user, account is inactive, type mismatch with category500 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 providedtype: Optional, must beexpenseorincomeif providedamount: Optional, must be greater than 0 if providedtransaction_date: Optional, YYYY-MM-DD format if provideddescription: Optionalnotes: Optional (can be set to null)payee: Optional (can be set to null)location: Optional (can be set to null)tags: Optional, array of stringsreceipt_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 format401 Unauthorized: Invalid or missing JWT token403 Forbidden: Category doesn't belong to user, type mismatch with new category404 Not Found: Transaction not found, invalid UUID, or doesn't belong to user500 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 token404 Not Found: Transaction not found, invalid UUID, or doesn't belong to user500 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 failed401 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 dateend_date(optional): YYYY-MM-DD - Filter transfers up to this datefrom_account_id(optional): UUID - Filter by source accountto_account_id(optional): UUID - Filter by destination accountpage(optional): integer (default: 1) - Page numberlimit(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:
totalfield 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_ratemust 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 userto_account_id: Required, must be a valid UUID and owned by user- Both accounts must be active
from_account_idandto_account_idmust be differentamount: Required, must be > 0transfer_date: Optional, defaults to current date, format: YYYY-MM-DDdescription: Optionalexchange_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:
- Creates expense transaction from
from_account_idwith amount - Creates income transaction to
to_account_idwith converted amount - Creates transfer record linking both transactions
- Updates both account balances atomically
- Both transactions are categorized as "Transfer" (system category)
Error Responses:
400 Bad Request- Invalid input or validation error403 Forbidden- Account not owned by user or account inactive404 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 providedtransfer_date: Optional, format: YYYY-MM-DDdescription: Optionalexchange_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:
- Updates transfer record with new values
- Updates both linked transactions with new amounts if amount or exchange_rate changed
- Recalculates and adjusts both account balances if amount or exchange_rate changed
Error Responses:
400 Bad Request- Invalid input or validation error404 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:
- Deletes the transfer record
- Deletes both linked transactions
- Reverses both account balance changes atomically
Error Responses:
403 Forbidden- Transfer not owned by user404 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|falsetype(optional):expense|incomefrequency(optional):daily|weekly|biweekly|monthly|quarterly|yearlyaccount_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
totalcount 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 activecategory_id: Required, must be valid UUID and belong to usertype: Required, must beexpenseorincomeamount: Required, must be greater than 0description: Requiredfrequency: Required, must be one of:daily,weekly,biweekly,monthly,quarterly,yearlystart_date: Required, YYYY-MM-DD formatend_date: Optional, YYYY-MM-DD format, must be after start_dateauto_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 format403 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_dateis calculated fromstart_datebased 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 providedamount: Optional, must be greater than 0 if provideddescription: Optionalfrequency: Optional, must be one of:daily,weekly,biweekly,monthly,quarterly,yearlyend_date: Optional, YYYY-MM-DD formatis_active: Optional, booleanauto_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 frequency403 Forbidden- Category doesn't belong to user, type mismatch with new category404 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 tonext_due_dateif 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:
- Creates a new transaction record with all details from recurring template
- Updates account balance (expense decreases, income increases)
- Sets transaction's
is_recurring = trueflag - Links transaction to recurring template via
recurring_transaction_id - Updates recurring transaction's
next_due_datebased on frequency - Updates recurring transaction's
last_processed_dateto transaction date - All operations happen atomically in a database transaction
Error Responses:
400 Bad Request- Recurring transaction is not active, invalid date format404 Not Found- Recurring transaction not found or doesn't belong to user
Notes:
- If
transaction_dateis not provided, uses the recurring transaction'snext_due_date - The new transaction is linked to the recurring transaction for audit purposes
- Account balance is immediately updated
next_due_datecalculation 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 = truerecurring 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|falseperiod(optional):daily|weekly|monthly|quarterly|yearlycategory_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_idandcategory_idare UUIDs (UUIDv7 format)start_dateandend_dateare returned as ISO 8601 timestampstotalshows the count of items returned in the current pagesummaryis 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
transactionsarray 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: Requiredamount: Required, must be greater than 0period: Required, must be one of:daily,weekly,monthly,quarterly,yearlystart_date: Required, YYYY-MM-DD formatend_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: Stringamount: Must be greater than 0 if providedperiod: Must be one of:daily,weekly,monthly,quarterly,yearlystart_date: YYYY-MM-DD formatend_date: YYYY-MM-DD formatalert_threshold: Percentage valueis_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|falsegoal_type(optional):savings|debt_payoff|investment|purchase|otheraccount_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, andaccount_idare UUIDs (UUIDv7 format)currency_codeis inherited from the linked account, or defaults to "IDR" if no account is linkedtotalshows 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, andmonthly_target monthly_targetis 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: Requiredtarget_amount: Required, must be greater than 0goal_type: Required, must be one of:savings,debt_payoff,investment,purchase,otheraccount_id: Optional, must be valid UUID and belong to user, account must be activedescription: Optionalcurrent_amount: Optional, defaults to 0target_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_codeis automatically inherited from the linked account's currency- If no account is linked, defaults to "IDR"
goal_idis a UUIDv7 generated automatically- Account ownership and active status are validated if
account_idis 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 providedname: Stringtarget_amount: Must be greater than 0 if providedcurrent_amount: Numeric valuetarget_date: YYYY-MM-DD formatgoal_type: Must be one of:savings,debt_payoff,investment,purchase,otheris_completed: Boolean - set totrueto 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_dateis automatically set to the current date - Auto-completion occurs when
current_amountreaches or exceedstarget_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|falsetype(optional):budget_alert|bill_reminder|goal_milestone|low_balance|otherpage(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_id404 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_atDESC (newest first) file_sizeis 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 userfile_name: Required, original filenamecontent_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/jpegimage/pngimage/webpapplication/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 large404 Not Found- Transaction not found or doesn't belong to user
Notes:
- Pre-signed URL expires in 15 minutes (900 seconds)
upload_idis 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 /attachmentswith theupload_idto save metadata
Upload Flow:
- Call
POST /attachments/upload-urlto get pre-signed URL and upload_id - Upload file directly to the pre-signed URL using HTTP PUT
- Call
POST /attachmentswith the returnedupload_idto 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 fromPOST /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- Missingupload_id403 Forbidden- Upload doesn't belong to user404 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_idis 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_idis a UUIDv7 generated automaticallyuploaded_atis 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_categoriesalways reflects expense categories (percentage relative to total expense for the range)monthly_cashflowreturns one entry per month betweenstart_dateandend_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) orincomelimit(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:
percentageis relative to the total for the requestedtypecategory_idis omitted for uncategorized transactions
Monthly Trends
GET /reports/monthly-trends?months=6
Authorization: Bearer <token>
Query Parameters:
months(optional): Number of months to include whenstart_dateis 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_dateis 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_idcategory_nametotal(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_idaccount_nametotal_expensetotal_incometransaction_count
Use this export to analyze which accounts drive most activity within the selected window.
Monthly Trends CSV
GET /reports/export/monthly-trends?months=12
Authorization: Bearer <token>
Accept: text/csv
Columns:
month(first day of the month)total_incometotal_expensenet
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 typecategory_id: filter by categoryaccount_id: filter by accountstart_date,end_date: date range filteringmin_amount,max_amount: amount range filteringsearch: text search in relevant fieldsis_active: filter active/inactive itemstags: 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) ordesc(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
- Use HTTPS: All API requests must use HTTPS
- 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
- 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
- Use pagination: Don't request all data at once
- Filter data: Use query parameters to request only needed data
- Cache responses: Cache GET requests when appropriate
- Handle errors gracefully: Always check status codes and error responses
- Validate input: Validate data before sending to API
- Use batch operations: Use bulk endpoints when creating multiple resources
- Security:
- Never send refresh tokens with regular API requests
- Only send refresh tokens to
/auth/refreshand/auth/logout - Implement "Logout from all devices" using
RevokeAllUserRefreshTokens - Monitor
last_used_atfor 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