553 lines
17 KiB
YAML
553 lines
17 KiB
YAML
openapi: 3.0.3
|
|
info:
|
|
title: SkyMoney API
|
|
version: 0.1.0
|
|
description: |
|
|
Fastify backend for budgeting/allocations.
|
|
Authentication uses secure httpOnly session cookies (Fastify JWT). During tests
|
|
or local development you can set `AUTH_DISABLED=1` to use the legacy `x-user-id`
|
|
header for impersonation, but production relies on the session cookie.
|
|
|
|
servers:
|
|
- url: http://localhost:8080
|
|
|
|
tags:
|
|
- name: Health
|
|
- name: Dashboard
|
|
- name: Income
|
|
- name: Transactions
|
|
- name: VariableCategories
|
|
- name: FixedPlans
|
|
|
|
paths:
|
|
/health:
|
|
get:
|
|
tags: [Health]
|
|
summary: Liveness check
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
responses:
|
|
'200':
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/HealthOk' }
|
|
|
|
/health/db:
|
|
get:
|
|
tags: [Health]
|
|
summary: DB health + latency
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
responses:
|
|
'200':
|
|
description: DB OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/DbHealth' }
|
|
|
|
/dashboard:
|
|
get:
|
|
tags: [Dashboard]
|
|
summary: Aggregated dashboard data
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
responses:
|
|
'200':
|
|
description: Dashboard payload
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/DashboardResponse' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
|
|
/income:
|
|
post:
|
|
tags: [Income]
|
|
summary: Create income event and allocate funds
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/IncomeRequest' }
|
|
responses:
|
|
'200':
|
|
description: Allocation result
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/IncomeAllocationResponse' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
|
|
/income/preview:
|
|
post:
|
|
tags: [Income]
|
|
summary: Preview allocation of a hypothetical income amount
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/IncomeRequest' }
|
|
responses:
|
|
'200':
|
|
description: Preview
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/IncomePreviewResponse' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
|
|
/transactions:
|
|
get:
|
|
tags: [Transactions]
|
|
summary: List transactions with filters and pagination
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
- in: query
|
|
name: from
|
|
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
|
|
description: Inclusive start date (YYYY-MM-DD)
|
|
- in: query
|
|
name: to
|
|
schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
|
|
description: Inclusive end date (YYYY-MM-DD)
|
|
- in: query
|
|
name: kind
|
|
schema: { $ref: '#/components/schemas/TransactionKind' }
|
|
- in: query
|
|
name: q
|
|
schema: { type: string }
|
|
description: Simple search (currently numeric amount match)
|
|
- in: query
|
|
name: page
|
|
schema: { type: integer, minimum: 1, default: 1 }
|
|
- in: query
|
|
name: limit
|
|
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
|
|
responses:
|
|
'200':
|
|
description: List
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/TransactionList' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
post:
|
|
tags: [Transactions]
|
|
summary: Create a transaction
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/TransactionCreate' }
|
|
responses:
|
|
'200':
|
|
description: Created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Transaction' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
|
|
/variable-categories:
|
|
post:
|
|
tags: [VariableCategories]
|
|
summary: Create a variable category (sum of percents must be 100)
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/VariableCategoryCreate' }
|
|
responses:
|
|
'200':
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
|
|
/variable-categories/{id}:
|
|
patch:
|
|
tags: [VariableCategories]
|
|
summary: Update a variable category (sum of percents must be 100)
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/VariableCategoryPatch' }
|
|
responses:
|
|
'200':
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
delete:
|
|
tags: [VariableCategories]
|
|
summary: Delete a variable category (sum of percents must remain 100)
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
'200':
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
|
|
/fixed-plans:
|
|
post:
|
|
tags: [FixedPlans]
|
|
summary: Create a fixed plan
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/FixedPlanCreate' }
|
|
responses:
|
|
'200':
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
|
|
/fixed-plans/{id}:
|
|
patch:
|
|
tags: [FixedPlans]
|
|
summary: Update a fixed plan (fundedCents cannot exceed totalCents)
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/FixedPlanPatch' }
|
|
responses:
|
|
'200':
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
delete:
|
|
tags: [FixedPlans]
|
|
summary: Delete a fixed plan
|
|
parameters:
|
|
- $ref: '#/components/parameters/UserId'
|
|
- $ref: '#/components/parameters/RequestId'
|
|
- in: path
|
|
name: id
|
|
required: true
|
|
schema: { type: string }
|
|
responses:
|
|
'200':
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/OkResponse' }
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'500':
|
|
$ref: '#/components/responses/InternalError'
|
|
|
|
components:
|
|
parameters:
|
|
UserId:
|
|
in: header
|
|
name: x-user-id
|
|
required: false
|
|
description: |
|
|
Dev/test-only tenant selector when AUTH_DISABLED=1. Production requests rely
|
|
on the session cookie instead and should omit this header.
|
|
schema: { type: string }
|
|
description: Override the stubbed user id for the request.
|
|
RequestId:
|
|
in: header
|
|
name: x-request-id
|
|
required: false
|
|
schema: { type: string, maxLength: 64 }
|
|
description: Custom request id (echoed back by server).
|
|
|
|
responses:
|
|
BadRequest:
|
|
description: Validation or guard failed
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
|
NotFound:
|
|
description: Resource not found
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
|
InternalError:
|
|
description: Unexpected server error
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
|
|
|
schemas:
|
|
HealthOk:
|
|
type: object
|
|
properties:
|
|
ok: { type: boolean, const: true }
|
|
required: [ok]
|
|
|
|
DbHealth:
|
|
type: object
|
|
properties:
|
|
ok: { type: boolean, const: true }
|
|
nowISO: { type: string, format: date-time }
|
|
latencyMs: { type: integer, minimum: 0 }
|
|
required: [ok, nowISO, latencyMs]
|
|
|
|
OkResponse:
|
|
type: object
|
|
properties:
|
|
ok: { type: boolean, const: true }
|
|
required: [ok]
|
|
|
|
ErrorResponse:
|
|
type: object
|
|
properties:
|
|
ok: { type: boolean, const: false }
|
|
code: { type: string }
|
|
message: { type: string }
|
|
requestId: { type: string }
|
|
required: [ok, code, message, requestId]
|
|
|
|
VariableCategory:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
userId: { type: string }
|
|
name: { type: string }
|
|
percent: { type: integer, minimum: 0, maximum: 100 }
|
|
isSavings: { type: boolean }
|
|
priority: { type: integer, minimum: 0 }
|
|
balanceCents:
|
|
type: integer
|
|
description: Current balance; may be omitted or 0 when not loaded.
|
|
required: [id, userId, name, percent, isSavings, priority]
|
|
|
|
FixedPlan:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
userId: { type: string }
|
|
name: { type: string }
|
|
totalCents: { type: integer, minimum: 0 }
|
|
fundedCents: { type: integer, minimum: 0 }
|
|
priority: { type: integer, minimum: 0 }
|
|
dueOn: { type: string, format: date-time }
|
|
cycleStart: { type: string, format: date-time }
|
|
required: [id, userId, name, priority, dueOn]
|
|
|
|
TransactionKind:
|
|
type: string
|
|
enum: [variable_spend, fixed_payment]
|
|
|
|
Transaction:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
userId: { type: string }
|
|
kind: { $ref: '#/components/schemas/TransactionKind' }
|
|
amountCents: { type: integer, minimum: 0 }
|
|
occurredAt: { type: string, format: date-time }
|
|
categoryId: { type: string, nullable: true }
|
|
planId: { type: string, nullable: true }
|
|
required: [id, userId, kind, amountCents, occurredAt]
|
|
|
|
TransactionList:
|
|
type: object
|
|
properties:
|
|
items:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/Transaction' }
|
|
page: { type: integer, minimum: 1 }
|
|
limit: { type: integer, minimum: 1, maximum: 100 }
|
|
total: { type: integer, minimum: 0 }
|
|
required: [items, page, limit, total]
|
|
|
|
TransactionCreate:
|
|
type: object
|
|
properties:
|
|
kind: { $ref: '#/components/schemas/TransactionKind' }
|
|
amountCents: { type: integer, minimum: 1 }
|
|
occurredAtISO: { type: string, format: date-time }
|
|
categoryId: { type: string, nullable: true }
|
|
planId: { type: string, nullable: true }
|
|
required: [kind, amountCents, occurredAtISO]
|
|
|
|
DashboardResponse:
|
|
type: object
|
|
properties:
|
|
totals:
|
|
type: object
|
|
properties:
|
|
incomeCents: { type: integer, minimum: 0 }
|
|
variableBalanceCents: { type: integer, minimum: 0 }
|
|
fixedRemainingCents: { type: integer, minimum: 0 }
|
|
required: [incomeCents, variableBalanceCents, fixedRemainingCents]
|
|
percentTotal: { type: integer, minimum: 0, maximum: 100 }
|
|
variableCategories:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/VariableCategory' }
|
|
fixedPlans:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/FixedPlan' }
|
|
recentTransactions:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/Transaction' }
|
|
required: [totals, percentTotal, variableCategories, fixedPlans, recentTransactions]
|
|
|
|
IncomeRequest:
|
|
type: object
|
|
properties:
|
|
amountCents: { type: integer, minimum: 0 }
|
|
required: [amountCents]
|
|
|
|
AllocationItem:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
name: { type: string }
|
|
amountCents: { type: integer, minimum: 0 }
|
|
required: [id, name, amountCents]
|
|
|
|
IncomePreviewResponse:
|
|
type: object
|
|
properties:
|
|
fixed:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/AllocationItem' }
|
|
variable:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/AllocationItem' }
|
|
unallocatedCents: { type: integer, minimum: 0 }
|
|
required: [fixed, variable, unallocatedCents]
|
|
|
|
IncomeAllocationResponse:
|
|
type: object
|
|
description: >
|
|
Shape returned by allocateIncome. Tests expect:
|
|
fixedAllocations, variableAllocations, remainingUnallocatedCents.
|
|
Additional fields may be present.
|
|
properties:
|
|
fixedAllocations:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/AllocationItem' }
|
|
variableAllocations:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/AllocationItem' }
|
|
remainingUnallocatedCents: { type: integer, minimum: 0 }
|
|
additionalProperties: true
|
|
|
|
VariableCategoryCreate:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
percent: { type: integer, minimum: 0, maximum: 100 }
|
|
isSavings: { type: boolean }
|
|
priority: { type: integer, minimum: 0 }
|
|
required: [name, percent, isSavings, priority]
|
|
|
|
VariableCategoryPatch:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
percent: { type: integer, minimum: 0, maximum: 100 }
|
|
isSavings: { type: boolean }
|
|
priority: { type: integer, minimum: 0 }
|
|
additionalProperties: false
|
|
|
|
FixedPlanCreate:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
totalCents: { type: integer, minimum: 0 }
|
|
fundedCents: { type: integer, minimum: 0 }
|
|
priority: { type: integer, minimum: 0 }
|
|
dueOn: { type: string, format: date-time }
|
|
cycleStart: { type: string, format: date-time }
|
|
required: [name, totalCents, priority, dueOn]
|
|
|
|
FixedPlanPatch:
|
|
type: object
|
|
properties:
|
|
name: { type: string }
|
|
totalCents: { type: integer, minimum: 0 }
|
|
fundedCents: { type: integer, minimum: 0 }
|
|
priority: { type: integer, minimum: 0 }
|
|
dueOn: { type: string, format: date-time }
|
|
cycleStart: { type: string, format: date-time }
|
|
additionalProperties: false
|