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