diff --git a/api/docs.go b/api/docs.go index 9ccc4313..36f57b4c 100644 --- a/api/docs.go +++ b/api/docs.go @@ -2930,18 +2930,50 @@ const docTemplate = `{ } } }, - "/version": { + "/v4": { "get": { - "description": "Returns the software version of the API", + "description": "Returns general information about the v4 API", "tags": [ - "General" + "v4" ], - "summary": "API version", + "summary": "v4 API", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/version.Response" + "$ref": "#/definitions/v4.Response" + } + } + } + }, + "delete": { + "description": "Permanently deletes all resources", + "tags": [ + "v4" + ], + "summary": "Delete everything", + "parameters": [ + { + "type": "string", + "description": "Confirmation to delete all resources. Must have the value 'yes-please-delete-everything'", + "name": "confirm", + "in": "query" + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" } } } @@ -2949,7 +2981,157 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "General" + "v4" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/accounts": { + "get": { + "description": "Returns a list of accounts", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "List accounts", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Filter by budget ID", + "name": "budget", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the account on-budget?", + "name": "onBudget", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the account external?", + "name": "external", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the account archived?", + "name": "archived", + "in": "query" + }, + { + "type": "string", + "description": "Search for this text in name and note", + "name": "search", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Account returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Accounts to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.AccountListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.AccountListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.AccountListResponse" + } + } + } + }, + "post": { + "description": "Creates new accounts", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Creates accounts", + "parameters": [ + { + "description": "Accounts", + "name": "accounts", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.AccountEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.AccountCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.AccountCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.AccountCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.AccountCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Accounts" ], "summary": "Allowed HTTP verbs", "responses": { @@ -2958,140 +3140,4618 @@ const docTemplate = `{ } } } - } - }, - "definitions": { - "httperrors.HTTPError": { + }, + "/v4/accounts/computed": { + "post": { + "description": "Returns calculated data for the account, e.g. balances", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Get Account data", + "parameters": [ + { + "description": "Time and IDs of requested accounts", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.AccountComputedRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.AccountComputedDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.AccountComputedDataResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.AccountComputedDataResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.AccountComputedDataResponse" + } + } + } + } + }, + "/v4/accounts/{id}": { + "get": { + "description": "Returns a specific account", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Get account", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + } + } + }, + "delete": { + "description": "Deletes an account", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Delete account", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Accounts" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Updates an account. Only values to be updated need to be specified.", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Update account", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Account", + "name": "account", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.AccountEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + } + } + } + }, + "/v4/accounts/{id}/recent-envelopes": { + "get": { + "description": "Returns a list of objects representing recent envelopes", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Get recent envelopes", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.RecentEnvelopesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.RecentEnvelopesResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.RecentEnvelopesResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.RecentEnvelopesResponse" + } + } + } + } + }, + "/v4/budgets": { + "get": { + "description": "Returns a list of budgets", + "produces": [ + "application/json" + ], + "tags": [ + "Budgets" + ], + "summary": "List budgets", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Filter by currency", + "name": "currency", + "in": "query" + }, + { + "type": "string", + "description": "Search for this text in name and note", + "name": "search", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Budget returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Budgets to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.BudgetListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.BudgetListResponse" + } + } + } + }, + "post": { + "description": "Creates a new budget", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Budgets" + ], + "summary": "Create budget", + "parameters": [ + { + "description": "Budget", + "name": "budget", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.BudgetEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.BudgetCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.BudgetCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.BudgetCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Budgets" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/budgets/{id}": { + "get": { + "description": "Returns a specific budget", + "produces": [ + "application/json" + ], + "tags": [ + "Budgets" + ], + "summary": "Get budget", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + } + } + }, + "delete": { + "description": "Deletes a budget", + "tags": [ + "Budgets" + ], + "summary": "Delete budget", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Budgets" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Update an existing budget. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Budgets" + ], + "summary": "Update budget", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Budget", + "name": "budget", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.BudgetEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + } + } + } + }, + "/v4/categories": { + "get": { + "description": "Returns a list of categories", + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Get categories", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Filter by budget ID", + "name": "budget", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the category archived?", + "name": "archived", + "in": "query" + }, + { + "type": "string", + "description": "Search for this text in name and note", + "name": "search", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Category returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Categories to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.CategoryListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.CategoryListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.CategoryListResponse" + } + } + } + }, + "post": { + "description": "Creates a new category", + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Create category", + "parameters": [ + { + "description": "Categories", + "name": "categories", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.CategoryEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.CategoryCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.CategoryCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.CategoryCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.CategoryCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Categories" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/categories/{id}": { + "get": { + "description": "Returns a specific category", + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Get category", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + } + } + }, + "delete": { + "description": "Deletes a category", + "tags": [ + "Categories" + ], + "summary": "Delete category", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Categories" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Update an existing category. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Update category", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Category", + "name": "category", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.CategoryEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + } + } + } + }, + "/v4/envelopes": { + "get": { + "description": "Returns a list of envelopes", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Get envelopes", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Filter by category ID", + "name": "category", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the envelope archived?", + "name": "archived", + "in": "query" + }, + { + "type": "string", + "description": "Search for this text in name and note", + "name": "search", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Envelope returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Envelopes to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.EnvelopeListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.EnvelopeListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.EnvelopeListResponse" + } + } + } + }, + "post": { + "description": "Creates a new envelope", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Create envelope", + "parameters": [ + { + "description": "Envelopes", + "name": "envelope", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.EnvelopeEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.EnvelopeCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.EnvelopeCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.EnvelopeCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.EnvelopeCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Envelopes" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/envelopes/{id}": { + "get": { + "description": "Returns a specific Envelope", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Get Envelope", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + } + } + }, + "delete": { + "description": "Deletes an envelope", + "tags": [ + "Envelopes" + ], + "summary": "Delete envelope", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Envelopes" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Updates an existing envelope. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Update envelope", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Envelope", + "name": "envelope", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.EnvelopeEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + } + } + } + }, + "/v4/envelopes/{id}/{month}": { + "get": { + "description": "Returns configuration for a specific month", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Get MonthConfig", + "parameters": [ + { + "type": "string", + "description": "ID of the Envelope", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Envelopes" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID of the Envelope", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Changes configuration for a Month. If there is no configuration for the month yet, this endpoint transparently creates a configuration resource.", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Update MonthConfig", + "parameters": [ + { + "type": "string", + "description": "ID of the Envelope", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "path", + "required": true + }, + { + "description": "MonthConfig", + "name": "monthConfig", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.MonthConfigEditable" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + } + } + } + }, + "/v4/goals": { + "get": { + "description": "Returns a list of goals", + "produces": [ + "application/json" + ], + "tags": [ + "Goals" + ], + "summary": "Get goals", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Search for this text in name and note", + "name": "search", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the goal archived?", + "name": "archived", + "in": "query" + }, + { + "type": "string", + "description": "Filter by envelope ID", + "name": "envelope", + "in": "query" + }, + { + "type": "string", + "description": "Month of the goal. Ignores exact time, matches on the month of the RFC3339 timestamp provided.", + "name": "month", + "in": "query" + }, + { + "type": "string", + "description": "Goals for this and later months. Ignores exact time, matches on the month of the RFC3339 timestamp provided.", + "name": "fromMonth", + "in": "query" + }, + { + "type": "string", + "description": "Goals for this and earlier months. Ignores exact time, matches on the month of the RFC3339 timestamp provided.", + "name": "untilMonth", + "in": "query" + }, + { + "type": "string", + "description": "Filter by amount", + "name": "amount", + "in": "query" + }, + { + "type": "string", + "description": "Amount less than or equal to this", + "name": "amountLessOrEqual", + "in": "query" + }, + { + "type": "string", + "description": "Amount more than or equal to this", + "name": "amountMoreOrEqual", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first goal returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of goal to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.GoalListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.GoalListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.GoalListResponse" + } + } + } + }, + "post": { + "description": "Creates new goals", + "produces": [ + "application/json" + ], + "tags": [ + "Goals" + ], + "summary": "Create goals", + "parameters": [ + { + "description": "Goals", + "name": "goals", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.GoalEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.GoalCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.GoalCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.GoalCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.GoalCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Goals" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/goals/{id}": { + "get": { + "description": "Returns a specific goal", + "produces": [ + "application/json" + ], + "tags": [ + "Goals" + ], + "summary": "Get goal", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + } + } + }, + "delete": { + "description": "Deletes a goal", + "tags": [ + "Goals" + ], + "summary": "Delete goal", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Goals" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Updates an existing goal. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Goals" + ], + "summary": "Update goal", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Goal", + "name": "goal", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.GoalEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + } + } + } + }, + "/v4/import": { + "get": { + "description": "Returns general information about the v4 API", + "tags": [ + "Import" + ], + "summary": "Import API overview", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.ImportResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs.", + "tags": [ + "Import" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/import/ynab-import-preview": { + "post": { + "description": "Returns a preview of transactions to be imported after parsing a YNAB Import format csv file", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Import" + ], + "summary": "Transaction Import Preview", + "parameters": [ + { + "type": "file", + "description": "File to import", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "ID of the account to import transactions for", + "name": "accountId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.ImportPreviewList" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.ImportPreviewList" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.ImportPreviewList" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.ImportPreviewList" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Import" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/import/ynab4": { + "post": { + "description": "Imports budgets from YNAB 4", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Import" + ], + "summary": "Import YNAB 4 budget", + "parameters": [ + { + "type": "file", + "description": "File to import", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Name of the Budget to create", + "name": "budgetName", + "in": "query" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Import" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/match-rules": { + "get": { + "description": "Returns a list of matchRules", + "produces": [ + "application/json" + ], + "tags": [ + "MatchRules" + ], + "summary": "Get matchRules", + "parameters": [ + { + "type": "integer", + "description": "Filter by priority", + "name": "priority", + "in": "query" + }, + { + "type": "string", + "description": "Filter by match", + "name": "match", + "in": "query" + }, + { + "type": "string", + "description": "Filter by account ID", + "name": "account", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Match Rule returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Match Rules to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.MatchRuleListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MatchRuleListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MatchRuleListResponse" + } + } + } + }, + "post": { + "description": "Creates matchRules from the list of submitted matchRule data. The response code is the highest response code number that a single matchRule creation would have caused. If it is not equal to 201, at least one matchRule has an error.", + "produces": [ + "application/json" + ], + "tags": [ + "MatchRules" + ], + "summary": "Create matchRules", + "parameters": [ + { + "description": "MatchRules", + "name": "matchRules", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.MatchRuleEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.MatchRuleCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MatchRuleCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MatchRuleCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MatchRuleCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "MatchRules" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/match-rules/{id}": { + "get": { + "description": "Returns a specific matchRule", + "produces": [ + "application/json" + ], + "tags": [ + "MatchRules" + ], + "summary": "Get matchRule", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + } + } + }, + "delete": { + "description": "Deletes an matchRule", + "tags": [ + "MatchRules" + ], + "summary": "Delete matchRule", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "MatchRules" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Update a matchRule. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "MatchRules" + ], + "summary": "Update matchRule", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "MatchRule", + "name": "matchRule", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.MatchRuleEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + } + } + } + }, + "/v4/months": { + "get": { + "description": "Returns data about a specific month.", + "produces": [ + "application/json" + ], + "tags": [ + "Months" + ], + "summary": "Get data about a month", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "budget", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.MonthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MonthResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MonthResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MonthResponse" + } + } + } + }, + "post": { + "description": "Sets allocations for a month for all envelopes that do not have an allocation yet", + "tags": [ + "Months" + ], + "summary": "Set allocations for a month", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "budget", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "query", + "required": true + }, + { + "description": "Budget", + "name": "mode", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.BudgetAllocationMode" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "delete": { + "description": "Deletes all allocation for the specified month", + "tags": [ + "Months" + ], + "summary": "Delete allocations for a month", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "budget", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs.", + "tags": [ + "Months" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/transactions": { + "get": { + "description": "Returns a list of transactions", + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Get transactions", + "parameters": [ + { + "type": "string", + "description": "Date of the transaction. Ignores exact time, matches on the day of the RFC3339 timestamp provided.", + "name": "date", + "in": "query" + }, + { + "type": "string", + "description": "Transactions at and after this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided.", + "name": "fromDate", + "in": "query" + }, + { + "type": "string", + "description": "Transactions before and at this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided.", + "name": "untilDate", + "in": "query" + }, + { + "type": "string", + "description": "Filter by amount", + "name": "amount", + "in": "query" + }, + { + "type": "string", + "description": "Amount less than or equal to this", + "name": "amountLessOrEqual", + "in": "query" + }, + { + "type": "string", + "description": "Amount more than or equal to this", + "name": "amountMoreOrEqual", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Filter by budget ID", + "name": "budget", + "in": "query" + }, + { + "type": "string", + "description": "Filter by ID of associated account, regardeless of source or destination", + "name": "account", + "in": "query" + }, + { + "type": "string", + "description": "Filter by source account ID", + "name": "source", + "in": "query" + }, + { + "type": "string", + "description": "Filter by destination account ID", + "name": "destination", + "in": "query" + }, + { + "type": "string", + "description": "Filter by envelope ID", + "name": "envelope", + "in": "query" + }, + { + "type": "boolean", + "description": "Reconcilication state in source account", + "name": "reconciledSource", + "in": "query" + }, + { + "type": "boolean", + "description": "Reconcilication state in destination account", + "name": "reconciledDestination", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Transaction returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Transactions to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.TransactionListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.TransactionListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.TransactionListResponse" + } + } + } + }, + "post": { + "description": "Creates transactions from the list of submitted transaction data. The response code is the highest response code number that a single transaction creation would have caused. If it is not equal to 201, at least one transaction has an error.", + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Create transactions", + "parameters": [ + { + "description": "Transactions", + "name": "transactions", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.TransactionEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.TransactionCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.TransactionCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.TransactionCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.TransactionCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Transactions" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/transactions/{id}": { + "get": { + "description": "Returns a specific transaction", + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Get transaction", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + } + } + }, + "delete": { + "description": "Deletes a transaction", + "tags": [ + "Transactions" + ], + "summary": "Delete transaction", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Transactions" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Updates an existing transaction. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Update transaction", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Transaction", + "name": "transaction", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.TransactionEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + } + } + } + }, + "/version": { + "get": { + "description": "Returns the software version of the API", + "tags": [ + "General" + ], + "summary": "API version", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/version.Response" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "General" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + } + }, + "definitions": { + "httperrors.HTTPError": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "An ID specified in the query string was not a valid UUID" + } + } + }, + "models.Account": { + "type": "object", + "properties": { + "archived": { + "type": "boolean" + }, + "budget": { + "$ref": "#/definitions/models.Budget" + }, + "budgetID": { + "type": "string" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "external": { + "type": "boolean" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "importHash": { + "description": "A SHA256 hash of a unique combination of values to use in duplicate detection for imports", + "type": "string" + }, + "initialBalance": { + "type": "number" + }, + "initialBalanceDate": { + "type": "string" + }, + "name": { + "type": "string" + }, + "note": { + "type": "string" + }, + "onBudget": { + "type": "boolean" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "models.Budget": { + "type": "object", + "properties": { + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "currency": { + "type": "string" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "name": { + "type": "string" + }, + "note": { + "type": "string" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "models.Category": { + "type": "object", + "properties": { + "archived": { + "type": "boolean" + }, + "budget": { + "$ref": "#/definitions/models.Budget" + }, + "budgetID": { + "type": "string" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "name": { + "type": "string" + }, + "note": { + "type": "string" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "models.Envelope": { + "type": "object", + "properties": { + "archived": { + "type": "boolean" + }, + "category": { + "$ref": "#/definitions/models.Category" + }, + "categoryID": { + "type": "string" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "name": { + "type": "string" + }, + "note": { + "type": "string" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "models.Transaction": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "availableFrom": { + "description": "Only used for income transactions. Defaults to the transaction date.", + "type": "string" + }, + "budget": { + "$ref": "#/definitions/models.Budget" + }, + "budgetID": { + "type": "string" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "date": { + "description": "Time of day is currently only used for sorting", + "type": "string" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "destinationAccount": { + "$ref": "#/definitions/models.Account" + }, + "destinationAccountID": { + "type": "string" + }, + "envelope": { + "$ref": "#/definitions/models.Envelope" + }, + "envelopeID": { + "type": "string" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection when importing transactions", + "type": "string" + }, + "note": { + "type": "string" + }, + "reconciledDestination": { + "description": "Is the transaction reconciled in the destination account?", + "type": "boolean" + }, + "reconciledSource": { + "description": "Is the transaction reconciled in the source account?", + "type": "boolean" + }, + "sourceAccount": { + "$ref": "#/definitions/models.Account" + }, + "sourceAccountID": { + "type": "string" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "root.Links": { + "type": "object", + "properties": { + "docs": { + "description": "Swagger API documentation", + "type": "string", + "example": "https://example.com/api/docs/index.html" + }, + "healthz": { + "description": "Healthz endpoint", + "type": "string", + "example": "https://example.com/api/healtzh" + }, + "metrics": { + "description": "Endpoint returning Prometheus metrics", + "type": "string", + "example": "https://example.com/api/metrics" + }, + "v3": { + "description": "List endpoint for all v3 endpoints", + "type": "string", + "example": "https://example.com/api/v3" + }, + "version": { + "description": "Endpoint returning the version of the backend", + "type": "string", + "example": "https://example.com/api/version" + } + } + }, + "root.Response": { + "type": "object", + "properties": { + "links": { + "$ref": "#/definitions/root.Links" + } + } + }, + "v3.Account": { + "type": "object", + "properties": { + "archived": { + "description": "Is the account archived?", + "type": "boolean", + "default": false, + "example": true + }, + "balance": { + "description": "These fields are computed", + "type": "number", + "example": 2735.17 + }, + "budgetId": { + "description": "ID of the budget this account belongs to", + "type": "string", + "example": "550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "external": { + "description": "Does the account belong to the budget owner or not?", + "type": "boolean", + "default": false, + "example": false + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection for imports", + "type": "string", + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" + }, + "initialBalance": { + "description": "Balance of the account before any transactions were recorded", + "type": "number", + "default": 0, + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 173.12 + }, + "initialBalanceDate": { + "description": "Date of the initial balance", + "type": "string", + "example": "2017-05-12T00:00:00Z" + }, + "links": { + "$ref": "#/definitions/v3.AccountLinks" + }, + "name": { + "description": "Name of the account", + "type": "string", + "example": "Cash" + }, + "note": { + "description": "A longer description for the account", + "type": "string", + "example": "Money in my wallet" + }, + "onBudget": { + "description": "Does the account factor into the available budget? Always false when external: true", + "type": "boolean", + "default": false, + "example": true + }, + "recentEnvelopes": { + "description": "Envelopes recently used with this account", + "type": "array", + "items": { + "type": "string" + } + }, + "reconciledBalance": { + "description": "Balance of the account, including all reconciled transactions referencing it", + "type": "number", + "example": 2539.57 + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.AccountCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of created Accounts", + "type": "array", + "items": { + "$ref": "#/definitions/v3.AccountResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.AccountEditable": { + "type": "object", + "properties": { + "archived": { + "description": "Is the account archived?", + "type": "boolean", + "default": false, + "example": true + }, + "budgetId": { + "description": "ID of the budget this account belongs to", + "type": "string", + "example": "550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "external": { + "description": "Does the account belong to the budget owner or not?", + "type": "boolean", + "default": false, + "example": false + }, + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection for imports", + "type": "string", + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" + }, + "initialBalance": { + "description": "Balance of the account before any transactions were recorded", + "type": "number", + "default": 0, + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 173.12 + }, + "initialBalanceDate": { + "description": "Date of the initial balance", + "type": "string", + "example": "2017-05-12T00:00:00Z" + }, + "name": { + "description": "Name of the account", + "type": "string", + "example": "Cash" + }, + "note": { + "description": "A longer description for the account", + "type": "string", + "example": "Money in my wallet" + }, + "onBudget": { + "description": "Does the account factor into the available budget? Always false when external: true", + "type": "boolean", + "default": false, + "example": true + } + } + }, + "v3.AccountLinks": { + "type": "object", + "properties": { + "self": { + "description": "The account itself", + "type": "string", + "example": "https://example.com/api/v3/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" + }, + "transactions": { + "description": "Transactions referencing the account", + "type": "string", + "example": "https://example.com/api/v3/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" + } + } + }, + "v3.AccountListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of accounts", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Account" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.AccountResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the account", + "allOf": [ + { + "$ref": "#/definitions/v3.Account" + } + ] + }, + "error": { + "description": "The error, if any occurred for this transaction", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.AllocationMode": { + "type": "string", + "enum": [ + "ALLOCATE_LAST_MONTH_BUDGET", + "ALLOCATE_LAST_MONTH_SPEND" + ], + "x-enum-varnames": [ + "AllocateLastMonthBudget", + "AllocateLastMonthSpend" + ] + }, + "v3.Budget": { + "type": "object", + "properties": { + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "currency": { + "description": "The currency for the budget", + "type": "string", + "example": "€" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.BudgetLinks" + }, + "name": { + "description": "Name of the budget", + "type": "string", + "example": "Morre's Budget" + }, + "note": { + "description": "A longer description of the budget", + "type": "string", + "example": "My personal expenses" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.BudgetAllocationMode": { + "type": "object", + "properties": { + "mode": { + "description": "Mode to allocate budget with", + "allOf": [ + { + "$ref": "#/definitions/v3.AllocationMode" + } + ], + "example": "ALLOCATE_LAST_MONTH_SPEND" + } + } + }, + "v3.BudgetCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of created Budgets", + "type": "array", + "items": { + "$ref": "#/definitions/v3.BudgetResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.BudgetEditable": { + "type": "object", + "properties": { + "currency": { + "description": "The currency for the budget", + "type": "string", + "example": "€" + }, + "name": { + "description": "Name of the budget", + "type": "string", + "example": "Morre's Budget" + }, + "note": { + "description": "A longer description of the budget", + "type": "string", + "example": "My personal expenses" + } + } + }, + "v3.BudgetLinks": { + "type": "object", + "properties": { + "accounts": { + "description": "Accounts for this budget", + "type": "string", + "example": "https://example.com/api/v3/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "categories": { + "description": "Categories for this budget", + "type": "string", + "example": "https://example.com/api/v3/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "envelopes": { + "description": "Envelopes for this budget", + "type": "string", + "example": "https://example.com/api/v3/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "month": { + "description": "This uses 'YYYY-MM' for clients to replace with the actual year and month.", + "type": "string", + "example": "https://example.com/api/v3/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf\u0026month=YYYY-MM" + }, + "self": { + "description": "The budget itself", + "type": "string", + "example": "https://example.com/api/v3/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "transactions": { + "description": "Transactions for this budget", + "type": "string", + "example": "https://example.com/api/v3/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + } + } + }, + "v3.BudgetListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of budgets", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Budget" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.BudgetResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the budget", + "allOf": [ + { + "$ref": "#/definitions/v3.Budget" + } + ] + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.Category": { + "type": "object", + "properties": { + "archived": { + "description": "Is the category archived?", + "type": "boolean", + "default": false, + "example": true + }, + "budgetId": { + "description": "ID of the budget the category belongs to", + "type": "string", + "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "envelopes": { + "description": "These fields are computed", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Envelope" + } + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.CategoryLinks" + }, + "name": { + "description": "Name of the category", + "type": "string", + "example": "Saving" + }, + "note": { + "description": "Notes about the category", + "type": "string", + "example": "All envelopes for long-term saving" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.CategoryCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of the created Categories or their respective error", + "type": "array", + "items": { + "$ref": "#/definitions/v3.CategoryResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.CategoryEditable": { + "type": "object", + "properties": { + "archived": { + "description": "Is the category archived?", + "type": "boolean", + "default": false, + "example": true + }, + "budgetId": { + "description": "ID of the budget the category belongs to", + "type": "string", + "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" + }, + "name": { + "description": "Name of the category", + "type": "string", + "example": "Saving" + }, + "note": { + "description": "Notes about the category", + "type": "string", + "example": "All envelopes for long-term saving" + } + } + }, + "v3.CategoryEnvelopes": { + "type": "object", + "properties": { + "allocation": { + "description": "Sum of allocations for the envelopes", + "type": "number", + "example": 90 + }, + "archived": { + "description": "Is the category archived?", + "type": "boolean", + "default": false, + "example": true + }, + "balance": { + "description": "Sum of the balances of the envelopes", + "type": "number", + "example": -10.13 + }, + "budgetId": { + "description": "ID of the budget the category belongs to", + "type": "string", + "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "envelopes": { + "description": "Slice of all envelopes", + "type": "array", + "items": { + "$ref": "#/definitions/v3.EnvelopeMonth" + } + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.CategoryLinks" + }, + "name": { + "description": "Name of the category", + "type": "string", + "example": "Saving" + }, + "note": { + "description": "Notes about the category", + "type": "string", + "example": "All envelopes for long-term saving" + }, + "spent": { + "description": "Sum spent for all envelopes", + "type": "number", + "example": 100.13 + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.CategoryLinks": { + "type": "object", + "properties": { + "envelopes": { + "description": "Envelopes for this category", + "type": "string", + "example": "https://example.com/api/v3/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f" + }, + "self": { + "description": "The category itself", + "type": "string", + "example": "https://example.com/api/v3/categories/3b1ea324-d438-4419-882a-2fc91d71772f" + } + } + }, + "v3.CategoryListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of Categories", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Category" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.CategoryResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the Category", + "allOf": [ + { + "$ref": "#/definitions/v3.Category" + } + ] + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.Envelope": { + "type": "object", + "properties": { + "archived": { + "description": "Is the envelope archived?", + "type": "boolean", + "default": false, + "example": true + }, + "categoryId": { + "description": "ID of the category the envelope belongs to", + "type": "string", + "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "description": "Links to related resources", + "allOf": [ + { + "$ref": "#/definitions/v3.EnvelopeLinks" + } + ] + }, + "name": { + "description": "Name of the envelope", + "type": "string", + "example": "Groceries" + }, + "note": { + "description": "Notes about the envelope", + "type": "string", + "example": "For stuff bought at supermarkets and drugstores" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.EnvelopeCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the Envelope", + "type": "array", + "items": { + "$ref": "#/definitions/v3.EnvelopeResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.EnvelopeEditable": { + "type": "object", + "properties": { + "archived": { + "description": "Is the envelope archived?", + "type": "boolean", + "default": false, + "example": true + }, + "categoryId": { + "description": "ID of the category the envelope belongs to", + "type": "string", + "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" + }, + "name": { + "description": "Name of the envelope", + "type": "string", + "example": "Groceries" + }, + "note": { + "description": "Notes about the envelope", + "type": "string", + "example": "For stuff bought at supermarkets and drugstores" + } + } + }, + "v3.EnvelopeLinks": { + "type": "object", + "properties": { + "month": { + "description": "The MonthConfig for the envelope", + "type": "string", + "example": "https://example.com/api/v3/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM" + }, + "self": { + "description": "The envelope itself", + "type": "string", + "example": "https://example.com/api/v3/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166" + }, + "transactions": { + "description": "The envelope's transactions", + "type": "string", + "example": "https://example.com/api/v3/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166" + } + } + }, + "v3.EnvelopeListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of Envelopes", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Envelope" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.EnvelopeMonth": { + "type": "object", + "properties": { + "allocation": { + "description": "The amount of money allocated", + "type": "number", + "example": 85.44 + }, + "archived": { + "description": "Is the envelope archived?", + "type": "boolean", + "default": false, + "example": true + }, + "balance": { + "description": "The balance at the end of the monht", + "type": "number", + "example": 12.32 + }, + "categoryId": { + "description": "ID of the category the envelope belongs to", + "type": "string", + "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.EnvelopeLinks" + }, + "name": { + "description": "Name of the envelope", + "type": "string", + "example": "Groceries" + }, + "note": { + "description": "Notes about the envelope", + "type": "string", + "example": "For stuff bought at supermarkets and drugstores" + }, + "spent": { + "description": "The amount spent over the whole month", + "type": "number", + "example": 73.12 + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.EnvelopeResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the Envelope", + "allOf": [ + { + "$ref": "#/definitions/v3.Envelope" + } + ] + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.Goal": { + "type": "object", + "properties": { + "amount": { + "description": "How much money should be saved for this goal?", + "type": "number", + "default": 0, + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 750 + }, + "archived": { + "description": "If this goal is still in use or not", + "type": "boolean", + "default": false, + "example": true + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "envelopeId": { + "description": "The ID of the envelope this goal is for", + "type": "string", + "example": "f81566d9-af4d-4f13-9830-c62c4b5e4c7e" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.GoalLinks" + }, + "month": { + "description": "The month the goal should be reached", + "type": "string", + "example": "2024-07-01T00:00:00.000000Z" + }, + "name": { + "description": "Name of the goal", + "type": "string", + "example": "New TV" + }, + "note": { + "description": "Note about the goal", + "type": "string", + "example": "We want to replace the old CRT TV soon-ish" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.GoalCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of created resources", + "type": "array", + "items": { + "$ref": "#/definitions/v3.GoalResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.GoalEditable": { + "type": "object", + "properties": { + "amount": { + "description": "How much money should be saved for this goal?", + "type": "number", + "default": 0, + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 750 + }, + "archived": { + "description": "If this goal is still in use or not", + "type": "boolean", + "default": false, + "example": true + }, + "envelopeId": { + "description": "The ID of the envelope this goal is for", + "type": "string", + "example": "f81566d9-af4d-4f13-9830-c62c4b5e4c7e" + }, + "month": { + "description": "The month the goal should be reached", + "type": "string", + "example": "2024-07-01T00:00:00.000000Z" + }, + "name": { + "description": "Name of the goal", + "type": "string", + "example": "New TV" + }, + "note": { + "description": "Note about the goal", + "type": "string", + "example": "We want to replace the old CRT TV soon-ish" + } + } + }, + "v3.GoalLinks": { + "type": "object", + "properties": { + "envelope": { + "description": "The Envelope this goal references", + "type": "string", + "example": "https://example.com/api/v3/envelopes/c1a96ae4-80e3-4827-8ed0-c7656f224fee" + }, + "self": { + "description": "The Goal itself", + "type": "string", + "example": "https://example.com/api/v3/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c" + } + } + }, + "v3.GoalListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of resources", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Goal" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.GoalResponse": { + "type": "object", + "properties": { + "data": { + "description": "The resource", + "allOf": [ + { + "$ref": "#/definitions/v3.Goal" + } + ] + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.ImportLinks": { + "type": "object", + "properties": { + "matchRules": { + "description": "URL of YNAB Import preview endpoint", + "type": "string", + "example": "https://example.com/api/v3/import/ynab-import-preview" + }, + "transactions": { + "description": "URL of YNAB4 import endpoint", + "type": "string", + "example": "https://example.com/api/v3/import/ynab4" + } + } + }, + "v3.ImportPreviewList": { + "type": "object", + "properties": { + "data": { + "description": "List of transaction previews", + "type": "array", + "items": { + "$ref": "#/definitions/v3.TransactionPreview" + } + }, + "error": { + "description": "The error, if any occurred for this Match Rule", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.ImportResponse": { + "type": "object", + "properties": { + "links": { + "description": "Links for the v3 API", + "allOf": [ + { + "$ref": "#/definitions/v3.ImportLinks" + } + ] + } + } + }, + "v3.Links": { + "type": "object", + "properties": { + "accounts": { + "description": "URL of Account collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/accounts" + }, + "budgets": { + "description": "URL of Budget collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/budgets" + }, + "categories": { + "description": "URL of Category collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/categories" + }, + "envelopes": { + "description": "URL of Envelope collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/envelopes" + }, + "goals": { + "description": "URL of goal collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/goals" + }, + "import": { + "description": "URL of import list endpoint", + "type": "string", + "example": "https://example.com/api/v3/import" + }, + "matchRules": { + "description": "URL of Match Rule collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/match-rules" + }, + "months": { + "description": "URL of Month endpoint", + "type": "string", + "example": "https://example.com/api/v3/months" + }, + "transactions": { + "description": "URL of Transaction collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/transactions" + } + } + }, + "v3.MatchRule": { + "type": "object", + "properties": { + "accountId": { + "description": "The account to map matching transactions to", + "type": "string", + "example": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.MatchRuleLinks" + }, + "match": { + "description": "The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive.", + "type": "string", + "example": "Bank*" + }, + "priority": { + "description": "The priority of the match rule", + "type": "integer", + "example": 3 + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.MatchRuleCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of created Match Rules", + "type": "array", + "items": { + "$ref": "#/definitions/v3.MatchRuleResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.MatchRuleEditable": { + "type": "object", + "properties": { + "accountId": { + "description": "The account to map matching transactions to", + "type": "string", + "example": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" + }, + "match": { + "description": "The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive.", + "type": "string", + "example": "Bank*" + }, + "priority": { + "description": "The priority of the match rule", + "type": "integer", + "example": 3 + } + } + }, + "v3.MatchRuleLinks": { + "type": "object", + "properties": { + "self": { + "description": "The match rule itself", + "type": "string", + "example": "https://example.com/api/v3/match-rules/95685c82-53c6-455d-b235-f49960b73b21" + } + } + }, + "v3.MatchRuleListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of Match Rules", + "type": "array", + "items": { + "$ref": "#/definitions/v3.MatchRule" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.MatchRuleResponse": { "type": "object", "properties": { + "data": { + "description": "The Match Rule data, if creation was successful", + "allOf": [ + { + "$ref": "#/definitions/v3.MatchRule" + } + ] + }, "error": { + "description": "The error, if any occurred for this Match Rule", "type": "string", - "example": "An ID specified in the query string was not a valid UUID" + "example": "the specified resource ID is not a valid UUID" } } }, - "models.Account": { + "v3.Month": { "type": "object", "properties": { - "archived": { - "type": "boolean" + "allocation": { + "description": "The sum of all allocations for this month", + "type": "number", + "example": 1200.5 }, - "budget": { - "$ref": "#/definitions/models.Budget" + "available": { + "description": "The amount available to budget", + "type": "number", + "example": 217.34 }, - "budgetID": { - "type": "string" + "balance": { + "description": "The sum of all envelope balances", + "type": "number", + "example": 5231.37 }, - "createdAt": { - "description": "Time the resource was created", - "type": "string", - "example": "2022-04-02T19:28:44.491514Z" + "categories": { + "description": "A list of envelope month calculations grouped by category", + "type": "array", + "items": { + "$ref": "#/definitions/v3.CategoryEnvelopes" + } }, - "deletedAt": { - "description": "Time the resource was marked as deleted", + "id": { + "description": "The ID of the Budget", "type": "string", - "example": "2022-04-22T21:01:05.058161Z" + "example": "1e777d24-3f5b-4c43-8000-04f65f895578" }, - "external": { - "type": "boolean" + "income": { + "description": "The total income for the month (sum of all incoming transactions without an Envelope)", + "type": "number", + "example": 2317.34 }, - "id": { - "description": "UUID for the resource", + "month": { + "description": "The month", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" + "example": "2006-05-01T00:00:00.000000Z" }, - "importHash": { - "description": "A SHA256 hash of a unique combination of values to use in duplicate detection for imports", - "type": "string" + "name": { + "description": "The name of the Budget", + "type": "string", + "example": "Zero budget" }, - "initialBalance": { - "type": "number" + "spent": { + "description": "The amount of money spent in this month", + "type": "number", + "example": 133.7 + } + } + }, + "v3.MonthConfig": { + "type": "object", + "properties": { + "allocation": { + "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "type": "number", + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 22.01 }, - "initialBalanceDate": { + "envelopeID": { + "description": "We do not use the default model here, we use envelope ID and month", "type": "string" }, - "name": { + "envelopeId": { + "description": "ID of the envelope", + "type": "string", + "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" + }, + "links": { + "$ref": "#/definitions/v3.MonthConfigLinks" + }, + "month": { + "description": "We do not use the default model here, we use envelope ID and month", "type": "string" }, "note": { - "type": "string" + "description": "A note for the month config", + "type": "string", + "example": "Added 200€ here because we replaced Tim's expensive vase" + } + } + }, + "v3.MonthConfigEditable": { + "type": "object", + "properties": { + "allocation": { + "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "type": "number", + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 22.01 }, - "onBudget": { - "type": "boolean" + "envelopeId": { + "description": "ID of the envelope", + "type": "string", + "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" }, - "updatedAt": { - "description": "Last time the resource was updated", + "month": { + "description": "The month. This is always set to 00:00 UTC on the first of the month.", "type": "string", - "example": "2022-04-17T20:14:01.048145Z" + "example": "1969-06-01T00:00:00.000000Z" + }, + "note": { + "description": "A note for the month config", + "type": "string", + "example": "Added 200€ here because we replaced Tim's expensive vase" } } }, - "models.Budget": { + "v3.MonthConfigLinks": { "type": "object", "properties": { - "createdAt": { - "description": "Time the resource was created", + "envelope": { + "description": "The Envelope this config belongs to", "type": "string", - "example": "2022-04-02T19:28:44.491514Z" - }, - "currency": { - "type": "string" + "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b" }, - "deletedAt": { - "description": "Time the resource was marked as deleted", + "self": { + "description": "The Month Config itself", "type": "string", - "example": "2022-04-22T21:01:05.058161Z" + "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" + } + } + }, + "v3.MonthConfigResponse": { + "type": "object", + "properties": { + "data": { + "description": "Config for the month", + "allOf": [ + { + "$ref": "#/definitions/v3.MonthConfig" + } + ] }, - "id": { - "description": "UUID for the resource", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.MonthResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the month", + "allOf": [ + { + "$ref": "#/definitions/v3.Month" + } + ] }, - "name": { + "error": { + "description": "The error, if any occurred", "type": "string" + } + } + }, + "v3.Pagination": { + "type": "object", + "properties": { + "count": { + "description": "The amount of records returned in this response", + "type": "integer", + "example": 25 }, - "note": { - "type": "string" + "limit": { + "description": "The maximum amount of resources to return for this request", + "type": "integer", + "example": 25 }, - "updatedAt": { - "description": "Last time the resource was updated", - "type": "string", - "example": "2022-04-17T20:14:01.048145Z" + "offset": { + "description": "The offset for the first record returned", + "type": "integer", + "example": 50 + }, + "total": { + "description": "The total number of resources matching the query", + "type": "integer", + "example": 827 } } }, - "models.Category": { + "v3.Response": { + "type": "object", + "properties": { + "links": { + "description": "Links for the v3 API", + "allOf": [ + { + "$ref": "#/definitions/v3.Links" + } + ] + } + } + }, + "v3.Transaction": { "type": "object", "properties": { - "archived": { - "type": "boolean" + "amount": { + "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "type": "number", + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 14.03 }, - "budget": { - "$ref": "#/definitions/models.Budget" + "availableFrom": { + "description": "The date from which on the transaction amount is available for budgeting. Only used for income transactions. Defaults to the transaction date.", + "type": "string", + "example": "2021-11-17T00:00:00Z" }, - "budgetID": { - "type": "string" + "budgetId": { + "description": "ID of the budget", + "type": "string", + "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" }, "createdAt": { "description": "Time the resource was created", "type": "string", "example": "2022-04-02T19:28:44.491514Z" }, + "date": { + "description": "Date of the transaction. Time is currently only used for sorting", + "type": "string", + "example": "1815-12-10T18:43:00.271152Z" + }, "deletedAt": { "description": "Time the resource was marked as deleted", "type": "string", "example": "2022-04-22T21:01:05.058161Z" }, + "destinationAccountId": { + "description": "ID of the destination account", + "type": "string", + "example": "8e16b456-a719-48ce-9fec-e115cfa7cbcc" + }, + "envelopeId": { + "description": "ID of the envelope", + "type": "string", + "example": "2649c965-7999-4873-ae16-89d5d5fa972e" + }, "id": { "description": "UUID for the resource", "type": "string", "example": "65392deb-5e92-4268-b114-297faad6cdce" }, - "name": { - "type": "string" + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", + "type": "string", + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" + }, + "links": { + "$ref": "#/definitions/v3.TransactionLinks" }, "note": { - "type": "string" + "description": "A note", + "type": "string", + "example": "Lunch" + }, + "reconciledDestination": { + "description": "Is the transaction reconciled in the destination account?", + "type": "boolean", + "default": false, + "example": true + }, + "reconciledSource": { + "description": "Is the transaction reconciled in the source account?", + "type": "boolean", + "default": false, + "example": true + }, + "sourceAccountId": { + "description": "ID of the source account", + "type": "string", + "example": "fd81dc45-a3a2-468e-a6fa-b2618f30aa45" }, "updatedAt": { "description": "Last time the resource was updated", @@ -3100,160 +7760,172 @@ const docTemplate = `{ } } }, - "models.Envelope": { + "v3.TransactionCreateResponse": { "type": "object", "properties": { - "archived": { - "type": "boolean" - }, - "category": { - "$ref": "#/definitions/models.Category" - }, - "categoryID": { - "type": "string" - }, - "createdAt": { - "description": "Time the resource was created", - "type": "string", - "example": "2022-04-02T19:28:44.491514Z" - }, - "deletedAt": { - "description": "Time the resource was marked as deleted", - "type": "string", - "example": "2022-04-22T21:01:05.058161Z" - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "name": { - "type": "string" - }, - "note": { - "type": "string" + "data": { + "description": "List of created Transactions", + "type": "array", + "items": { + "$ref": "#/definitions/v3.TransactionResponse" + } }, - "updatedAt": { - "description": "Last time the resource was updated", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "2022-04-17T20:14:01.048145Z" + "example": "the specified resource ID is not a valid UUID" } } }, - "models.Transaction": { + "v3.TransactionEditable": { "type": "object", "properties": { "amount": { - "type": "number" + "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "type": "number", + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 14.03 }, "availableFrom": { - "description": "Only used for income transactions. Defaults to the transaction date.", - "type": "string" - }, - "budget": { - "$ref": "#/definitions/models.Budget" - }, - "budgetID": { - "type": "string" + "description": "The date from which on the transaction amount is available for budgeting. Only used for income transactions. Defaults to the transaction date.", + "type": "string", + "example": "2021-11-17T00:00:00Z" }, - "createdAt": { - "description": "Time the resource was created", + "budgetId": { + "description": "ID of the budget", "type": "string", - "example": "2022-04-02T19:28:44.491514Z" + "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" }, "date": { - "description": "Time of day is currently only used for sorting", - "type": "string" - }, - "deletedAt": { - "description": "Time the resource was marked as deleted", + "description": "Date of the transaction. Time is currently only used for sorting", "type": "string", - "example": "2022-04-22T21:01:05.058161Z" - }, - "destinationAccount": { - "$ref": "#/definitions/models.Account" - }, - "destinationAccountID": { - "type": "string" - }, - "envelope": { - "$ref": "#/definitions/models.Envelope" + "example": "1815-12-10T18:43:00.271152Z" }, - "envelopeID": { - "type": "string" + "destinationAccountId": { + "description": "ID of the destination account", + "type": "string", + "example": "8e16b456-a719-48ce-9fec-e115cfa7cbcc" }, - "id": { - "description": "UUID for the resource", + "envelopeId": { + "description": "ID of the envelope", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" + "example": "2649c965-7999-4873-ae16-89d5d5fa972e" }, "importHash": { - "description": "The SHA256 hash of a unique combination of values to use in duplicate detection when importing transactions", - "type": "string" + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", + "type": "string", + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" }, "note": { - "type": "string" + "description": "A note", + "type": "string", + "example": "Lunch" }, "reconciledDestination": { "description": "Is the transaction reconciled in the destination account?", - "type": "boolean" + "type": "boolean", + "default": false, + "example": true }, "reconciledSource": { "description": "Is the transaction reconciled in the source account?", - "type": "boolean" - }, - "sourceAccount": { - "$ref": "#/definitions/models.Account" - }, - "sourceAccountID": { - "type": "string" + "type": "boolean", + "default": false, + "example": true }, - "updatedAt": { - "description": "Last time the resource was updated", + "sourceAccountId": { + "description": "ID of the source account", "type": "string", - "example": "2022-04-17T20:14:01.048145Z" + "example": "fd81dc45-a3a2-468e-a6fa-b2618f30aa45" } } }, - "root.Links": { + "v3.TransactionLinks": { "type": "object", "properties": { - "docs": { - "description": "Swagger API documentation", + "self": { + "description": "The transaction itself", "type": "string", - "example": "https://example.com/api/docs/index.html" + "example": "https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" + } + } + }, + "v3.TransactionListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of transactions", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Transaction" + } }, - "healthz": { - "description": "Healthz endpoint", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "https://example.com/api/healtzh" + "example": "the specified resource ID is not a valid UUID" }, - "metrics": { - "description": "Endpoint returning Prometheus metrics", + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.TransactionPreview": { + "type": "object", + "properties": { + "destinationAccountName": { + "description": "Name of the destination account from the CSV file", "type": "string", - "example": "https://example.com/api/metrics" + "example": "Deutsche Bahn" }, - "v3": { - "description": "List endpoint for all v3 endpoints", + "duplicateTransactionIds": { + "description": "IDs of transactions that this transaction duplicates", + "type": "array", + "items": { + "type": "string" + } + }, + "matchRuleId": { + "description": "ID of the match rule that was applied to this transaction preview", "type": "string", - "example": "https://example.com/api/v3" + "example": "042d101d-f1de-4403-9295-59dc0ea58677" }, - "version": { - "description": "Endpoint returning the version of the backend", + "sourceAccountName": { + "description": "Name of the source account from the CSV file", "type": "string", - "example": "https://example.com/api/version" + "example": "Employer" + }, + "transaction": { + "$ref": "#/definitions/models.Transaction" } } }, - "root.Response": { + "v3.TransactionResponse": { "type": "object", "properties": { - "links": { - "$ref": "#/definitions/root.Links" + "data": { + "description": "The Transaction data, if creation was successful", + "allOf": [ + { + "$ref": "#/definitions/v3.Transaction" + } + ] + }, + "error": { + "description": "The error, if any occurred for this transaction", + "type": "string", + "example": "the specified resource ID is not a valid UUID" } } }, - "v3.Account": { + "v4.Account": { "type": "object", "properties": { "archived": { @@ -3262,11 +7934,6 @@ const docTemplate = `{ "default": false, "example": true }, - "balance": { - "description": "These fields are computed", - "type": "number", - "example": 2735.17 - }, "budgetId": { "description": "ID of the budget this account belongs to", "type": "string", @@ -3313,7 +7980,7 @@ const docTemplate = `{ "example": "2017-05-12T00:00:00Z" }, "links": { - "$ref": "#/definitions/v3.AccountLinks" + "$ref": "#/definitions/v4.AccountLinks" }, "name": { "description": "Name of the account", @@ -3331,33 +7998,71 @@ const docTemplate = `{ "default": false, "example": true }, - "recentEnvelopes": { - "description": "Envelopes recently used with this account", - "type": "array", - "items": { - "type": "string" - } + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v4.AccountComputedData": { + "type": "object", + "properties": { + "balance": { + "description": "Balance of the account, including all transactions referencing it", + "type": "number", + "example": 2735.17 + }, + "id": { + "description": "ID of the account", + "type": "string", + "example": "95018a69-758b-46c6-8bab-db70d9614f9d" }, "reconciledBalance": { "description": "Balance of the account, including all reconciled transactions referencing it", "type": "number", "example": 2539.57 + } + } + }, + "v4.AccountComputedDataResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.AccountComputedData" + } }, - "updatedAt": { - "description": "Last time the resource was updated", - "type": "string", - "example": "2022-04-17T20:14:01.048145Z" + "error": { + "type": "string" } } }, - "v3.AccountCreateResponse": { + "v4.AccountComputedRequest": { + "type": "object", + "properties": { + "ids": { + "description": "A list of UUIDs for the accounts", + "type": "array", + "items": { + "type": "string" + } + }, + "time": { + "description": "The time for which the computation is requested", + "type": "string" + } + } + }, + "v4.AccountCreateResponse": { "type": "object", "properties": { "data": { "description": "List of created Accounts", "type": "array", "items": { - "$ref": "#/definitions/v3.AccountResponse" + "$ref": "#/definitions/v4.AccountResponse" } }, "error": { @@ -3367,7 +8072,7 @@ const docTemplate = `{ } } }, - "v3.AccountEditable": { + "v4.AccountEditable": { "type": "object", "properties": { "archived": { @@ -3424,29 +8129,39 @@ const docTemplate = `{ } } }, - "v3.AccountLinks": { + "v4.AccountLinks": { "type": "object", "properties": { + "computedData": { + "description": "Computed data endpoint for accounts", + "type": "string", + "example": "https://example.com/api/v4/accounts/computed" + }, + "recentEnvelopes": { + "description": "Envelopes in recent transactions where this account was the target", + "type": "string", + "example": "https://example.com/api/v4/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2/recent-envelopes" + }, "self": { "description": "The account itself", "type": "string", - "example": "https://example.com/api/v3/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" + "example": "https://example.com/api/v4/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" }, "transactions": { "description": "Transactions referencing the account", "type": "string", - "example": "https://example.com/api/v3/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" + "example": "https://example.com/api/v4/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" } } }, - "v3.AccountListResponse": { + "v4.AccountListResponse": { "type": "object", "properties": { "data": { "description": "List of accounts", "type": "array", "items": { - "$ref": "#/definitions/v3.Account" + "$ref": "#/definitions/v4.Account" } }, "error": { @@ -3458,20 +8173,20 @@ const docTemplate = `{ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.AccountResponse": { + "v4.AccountResponse": { "type": "object", "properties": { "data": { "description": "Data for the account", "allOf": [ { - "$ref": "#/definitions/v3.Account" + "$ref": "#/definitions/v4.Account" } ] }, @@ -3482,7 +8197,7 @@ const docTemplate = `{ } } }, - "v3.AllocationMode": { + "v4.AllocationMode": { "type": "string", "enum": [ "ALLOCATE_LAST_MONTH_BUDGET", @@ -3493,7 +8208,7 @@ const docTemplate = `{ "AllocateLastMonthSpend" ] }, - "v3.Budget": { + "v4.Budget": { "type": "object", "properties": { "createdAt": { @@ -3517,7 +8232,7 @@ const docTemplate = `{ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.BudgetLinks" + "$ref": "#/definitions/v4.BudgetLinks" }, "name": { "description": "Name of the budget", @@ -3536,28 +8251,28 @@ const docTemplate = `{ } } }, - "v3.BudgetAllocationMode": { + "v4.BudgetAllocationMode": { "type": "object", "properties": { "mode": { "description": "Mode to allocate budget with", "allOf": [ { - "$ref": "#/definitions/v3.AllocationMode" + "$ref": "#/definitions/v4.AllocationMode" } ], "example": "ALLOCATE_LAST_MONTH_SPEND" } } }, - "v3.BudgetCreateResponse": { + "v4.BudgetCreateResponse": { "type": "object", "properties": { "data": { "description": "List of created Budgets", "type": "array", "items": { - "$ref": "#/definitions/v3.BudgetResponse" + "$ref": "#/definitions/v4.BudgetResponse" } }, "error": { @@ -3567,7 +8282,7 @@ const docTemplate = `{ } } }, - "v3.BudgetEditable": { + "v4.BudgetEditable": { "type": "object", "properties": { "currency": { @@ -3587,49 +8302,49 @@ const docTemplate = `{ } } }, - "v3.BudgetLinks": { + "v4.BudgetLinks": { "type": "object", "properties": { "accounts": { "description": "Accounts for this budget", "type": "string", - "example": "https://example.com/api/v3/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + "example": "https://example.com/api/v4/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, "categories": { "description": "Categories for this budget", "type": "string", - "example": "https://example.com/api/v3/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + "example": "https://example.com/api/v4/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, "envelopes": { "description": "Envelopes for this budget", "type": "string", - "example": "https://example.com/api/v3/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + "example": "https://example.com/api/v4/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, "month": { "description": "This uses 'YYYY-MM' for clients to replace with the actual year and month.", "type": "string", - "example": "https://example.com/api/v3/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf\u0026month=YYYY-MM" + "example": "https://example.com/api/v4/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf\u0026month=YYYY-MM" }, "self": { "description": "The budget itself", "type": "string", - "example": "https://example.com/api/v3/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf" + "example": "https://example.com/api/v4/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, "transactions": { "description": "Transactions for this budget", "type": "string", - "example": "https://example.com/api/v3/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + "example": "https://example.com/api/v4/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" } } }, - "v3.BudgetListResponse": { + "v4.BudgetListResponse": { "type": "object", "properties": { "data": { "description": "List of budgets", "type": "array", "items": { - "$ref": "#/definitions/v3.Budget" + "$ref": "#/definitions/v4.Budget" } }, "error": { @@ -3641,20 +8356,20 @@ const docTemplate = `{ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.BudgetResponse": { + "v4.BudgetResponse": { "type": "object", "properties": { "data": { "description": "Data for the budget", "allOf": [ { - "$ref": "#/definitions/v3.Budget" + "$ref": "#/definitions/v4.Budget" } ] }, @@ -3665,7 +8380,7 @@ const docTemplate = `{ } } }, - "v3.Category": { + "v4.Category": { "type": "object", "properties": { "archived": { @@ -3693,7 +8408,7 @@ const docTemplate = `{ "description": "These fields are computed", "type": "array", "items": { - "$ref": "#/definitions/v3.Envelope" + "$ref": "#/definitions/v4.Envelope" } }, "id": { @@ -3702,7 +8417,7 @@ const docTemplate = `{ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.CategoryLinks" + "$ref": "#/definitions/v4.CategoryLinks" }, "name": { "description": "Name of the category", @@ -3721,14 +8436,14 @@ const docTemplate = `{ } } }, - "v3.CategoryCreateResponse": { + "v4.CategoryCreateResponse": { "type": "object", "properties": { "data": { "description": "List of the created Categories or their respective error", "type": "array", "items": { - "$ref": "#/definitions/v3.CategoryResponse" + "$ref": "#/definitions/v4.CategoryResponse" } }, "error": { @@ -3738,7 +8453,7 @@ const docTemplate = `{ } } }, - "v3.CategoryEditable": { + "v4.CategoryEditable": { "type": "object", "properties": { "archived": { @@ -3764,7 +8479,7 @@ const docTemplate = `{ } } }, - "v3.CategoryEnvelopes": { + "v4.CategoryEnvelopes": { "type": "object", "properties": { "allocation": { @@ -3802,7 +8517,7 @@ const docTemplate = `{ "description": "Slice of all envelopes", "type": "array", "items": { - "$ref": "#/definitions/v3.EnvelopeMonth" + "$ref": "#/definitions/v4.EnvelopeMonth" } }, "id": { @@ -3811,7 +8526,7 @@ const docTemplate = `{ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.CategoryLinks" + "$ref": "#/definitions/v4.CategoryLinks" }, "name": { "description": "Name of the category", @@ -3835,29 +8550,29 @@ const docTemplate = `{ } } }, - "v3.CategoryLinks": { + "v4.CategoryLinks": { "type": "object", "properties": { "envelopes": { "description": "Envelopes for this category", "type": "string", - "example": "https://example.com/api/v3/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f" + "example": "https://example.com/api/v4/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f" }, "self": { "description": "The category itself", "type": "string", - "example": "https://example.com/api/v3/categories/3b1ea324-d438-4419-882a-2fc91d71772f" + "example": "https://example.com/api/v4/categories/3b1ea324-d438-4419-882a-2fc91d71772f" } } }, - "v3.CategoryListResponse": { + "v4.CategoryListResponse": { "type": "object", "properties": { "data": { "description": "List of Categories", "type": "array", "items": { - "$ref": "#/definitions/v3.Category" + "$ref": "#/definitions/v4.Category" } }, "error": { @@ -3869,20 +8584,20 @@ const docTemplate = `{ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.CategoryResponse": { + "v4.CategoryResponse": { "type": "object", "properties": { "data": { "description": "Data for the Category", "allOf": [ { - "$ref": "#/definitions/v3.Category" + "$ref": "#/definitions/v4.Category" } ] }, @@ -3893,7 +8608,7 @@ const docTemplate = `{ } } }, - "v3.Envelope": { + "v4.Envelope": { "type": "object", "properties": { "archived": { @@ -3926,7 +8641,7 @@ const docTemplate = `{ "description": "Links to related resources", "allOf": [ { - "$ref": "#/definitions/v3.EnvelopeLinks" + "$ref": "#/definitions/v4.EnvelopeLinks" } ] }, @@ -3947,14 +8662,14 @@ const docTemplate = `{ } } }, - "v3.EnvelopeCreateResponse": { + "v4.EnvelopeCreateResponse": { "type": "object", "properties": { "data": { "description": "Data for the Envelope", "type": "array", "items": { - "$ref": "#/definitions/v3.EnvelopeResponse" + "$ref": "#/definitions/v4.EnvelopeResponse" } }, "error": { @@ -3964,7 +8679,7 @@ const docTemplate = `{ } } }, - "v3.EnvelopeEditable": { + "v4.EnvelopeEditable": { "type": "object", "properties": { "archived": { @@ -3990,34 +8705,34 @@ const docTemplate = `{ } } }, - "v3.EnvelopeLinks": { + "v4.EnvelopeLinks": { "type": "object", "properties": { "month": { "description": "The MonthConfig for the envelope", "type": "string", - "example": "https://example.com/api/v3/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM" + "example": "https://example.com/api/v4/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM" }, "self": { "description": "The envelope itself", "type": "string", - "example": "https://example.com/api/v3/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166" + "example": "https://example.com/api/v4/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166" }, "transactions": { "description": "The envelope's transactions", "type": "string", - "example": "https://example.com/api/v3/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166" + "example": "https://example.com/api/v4/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166" } } }, - "v3.EnvelopeListResponse": { + "v4.EnvelopeListResponse": { "type": "object", "properties": { "data": { "description": "List of Envelopes", "type": "array", "items": { - "$ref": "#/definitions/v3.Envelope" + "$ref": "#/definitions/v4.Envelope" } }, "error": { @@ -4029,13 +8744,13 @@ const docTemplate = `{ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.EnvelopeMonth": { + "v4.EnvelopeMonth": { "type": "object", "properties": { "allocation": { @@ -4075,7 +8790,12 @@ const docTemplate = `{ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.EnvelopeLinks" + "description": "Links to related resources", + "allOf": [ + { + "$ref": "#/definitions/v4.EnvelopeLinks" + } + ] }, "name": { "description": "Name of the envelope", @@ -4099,14 +8819,14 @@ const docTemplate = `{ } } }, - "v3.EnvelopeResponse": { + "v4.EnvelopeResponse": { "type": "object", "properties": { "data": { "description": "Data for the Envelope", "allOf": [ { - "$ref": "#/definitions/v3.Envelope" + "$ref": "#/definitions/v4.Envelope" } ] }, @@ -4117,7 +8837,7 @@ const docTemplate = `{ } } }, - "v3.Goal": { + "v4.Goal": { "type": "object", "properties": { "amount": { @@ -4156,7 +8876,7 @@ const docTemplate = `{ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.GoalLinks" + "$ref": "#/definitions/v4.GoalLinks" }, "month": { "description": "The month the goal should be reached", @@ -4180,14 +8900,14 @@ const docTemplate = `{ } } }, - "v3.GoalCreateResponse": { + "v4.GoalCreateResponse": { "type": "object", "properties": { "data": { "description": "List of created resources", "type": "array", "items": { - "$ref": "#/definitions/v3.GoalResponse" + "$ref": "#/definitions/v4.GoalResponse" } }, "error": { @@ -4197,7 +8917,7 @@ const docTemplate = `{ } } }, - "v3.GoalEditable": { + "v4.GoalEditable": { "type": "object", "properties": { "amount": { @@ -4237,29 +8957,29 @@ const docTemplate = `{ } } }, - "v3.GoalLinks": { + "v4.GoalLinks": { "type": "object", "properties": { "envelope": { "description": "The Envelope this goal references", "type": "string", - "example": "https://example.com/api/v3/envelopes/c1a96ae4-80e3-4827-8ed0-c7656f224fee" + "example": "https://example.com/api/v4/envelopes/c1a96ae4-80e3-4827-8ed0-c7656f224fee" }, "self": { "description": "The Goal itself", "type": "string", - "example": "https://example.com/api/v3/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c" + "example": "https://example.com/api/v4/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c" } } }, - "v3.GoalListResponse": { + "v4.GoalListResponse": { "type": "object", "properties": { "data": { "description": "List of resources", "type": "array", "items": { - "$ref": "#/definitions/v3.Goal" + "$ref": "#/definitions/v4.Goal" } }, "error": { @@ -4271,20 +8991,20 @@ const docTemplate = `{ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.GoalResponse": { + "v4.GoalResponse": { "type": "object", "properties": { "data": { "description": "The resource", "allOf": [ { - "$ref": "#/definitions/v3.Goal" + "$ref": "#/definitions/v4.Goal" } ] }, @@ -4295,29 +9015,29 @@ const docTemplate = `{ } } }, - "v3.ImportLinks": { + "v4.ImportLinks": { "type": "object", "properties": { "matchRules": { "description": "URL of YNAB Import preview endpoint", "type": "string", - "example": "https://example.com/api/v3/import/ynab-import-preview" + "example": "https://example.com/api/v4/import/ynab-import-preview" }, "transactions": { "description": "URL of YNAB4 import endpoint", "type": "string", - "example": "https://example.com/api/v3/import/ynab4" + "example": "https://example.com/api/v4/import/ynab4" } } }, - "v3.ImportPreviewList": { + "v4.ImportPreviewList": { "type": "object", "properties": { "data": { "description": "List of transaction previews", "type": "array", "items": { - "$ref": "#/definitions/v3.TransactionPreview" + "$ref": "#/definitions/v4.TransactionPreview" } }, "error": { @@ -4327,70 +9047,70 @@ const docTemplate = `{ } } }, - "v3.ImportResponse": { + "v4.ImportResponse": { "type": "object", "properties": { "links": { - "description": "Links for the v3 API", + "description": "Links for the v4 API", "allOf": [ { - "$ref": "#/definitions/v3.ImportLinks" + "$ref": "#/definitions/v4.ImportLinks" } ] } } }, - "v3.Links": { + "v4.Links": { "type": "object", "properties": { "accounts": { "description": "URL of Account collection endpoint", "type": "string", - "example": "https://example.com/api/v3/accounts" + "example": "https://example.com/api/v4/accounts" }, "budgets": { "description": "URL of Budget collection endpoint", "type": "string", - "example": "https://example.com/api/v3/budgets" + "example": "https://example.com/api/v4/budgets" }, "categories": { "description": "URL of Category collection endpoint", "type": "string", - "example": "https://example.com/api/v3/categories" + "example": "https://example.com/api/v4/categories" }, "envelopes": { "description": "URL of Envelope collection endpoint", "type": "string", - "example": "https://example.com/api/v3/envelopes" + "example": "https://example.com/api/v4/envelopes" }, "goals": { "description": "URL of goal collection endpoint", "type": "string", - "example": "https://example.com/api/v3/goals" + "example": "https://example.com/api/v4/goals" }, "import": { "description": "URL of import list endpoint", "type": "string", - "example": "https://example.com/api/v3/import" + "example": "https://example.com/api/v4/import" }, "matchRules": { "description": "URL of Match Rule collection endpoint", "type": "string", - "example": "https://example.com/api/v3/match-rules" + "example": "https://example.com/api/v4/match-rules" }, "months": { "description": "URL of Month endpoint", "type": "string", - "example": "https://example.com/api/v3/months" + "example": "https://example.com/api/v4/months" }, "transactions": { "description": "URL of Transaction collection endpoint", "type": "string", - "example": "https://example.com/api/v3/transactions" + "example": "https://example.com/api/v4/transactions" } } }, - "v3.MatchRule": { + "v4.MatchRule": { "type": "object", "properties": { "accountId": { @@ -4414,7 +9134,7 @@ const docTemplate = `{ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.MatchRuleLinks" + "$ref": "#/definitions/v4.MatchRuleLinks" }, "match": { "description": "The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive.", @@ -4433,14 +9153,14 @@ const docTemplate = `{ } } }, - "v3.MatchRuleCreateResponse": { + "v4.MatchRuleCreateResponse": { "type": "object", "properties": { "data": { "description": "List of created Match Rules", "type": "array", "items": { - "$ref": "#/definitions/v3.MatchRuleResponse" + "$ref": "#/definitions/v4.MatchRuleResponse" } }, "error": { @@ -4450,7 +9170,7 @@ const docTemplate = `{ } } }, - "v3.MatchRuleEditable": { + "v4.MatchRuleEditable": { "type": "object", "properties": { "accountId": { @@ -4470,24 +9190,24 @@ const docTemplate = `{ } } }, - "v3.MatchRuleLinks": { + "v4.MatchRuleLinks": { "type": "object", "properties": { "self": { "description": "The match rule itself", "type": "string", - "example": "https://example.com/api/v3/match-rules/95685c82-53c6-455d-b235-f49960b73b21" + "example": "https://example.com/api/v4/match-rules/95685c82-53c6-455d-b235-f49960b73b21" } } }, - "v3.MatchRuleListResponse": { + "v4.MatchRuleListResponse": { "type": "object", "properties": { "data": { "description": "List of Match Rules", "type": "array", "items": { - "$ref": "#/definitions/v3.MatchRule" + "$ref": "#/definitions/v4.MatchRule" } }, "error": { @@ -4499,20 +9219,20 @@ const docTemplate = `{ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.MatchRuleResponse": { + "v4.MatchRuleResponse": { "type": "object", "properties": { "data": { "description": "The Match Rule data, if creation was successful", "allOf": [ { - "$ref": "#/definitions/v3.MatchRule" + "$ref": "#/definitions/v4.MatchRule" } ] }, @@ -4523,7 +9243,7 @@ const docTemplate = `{ } } }, - "v3.Month": { + "v4.Month": { "type": "object", "properties": { "allocation": { @@ -4545,7 +9265,7 @@ const docTemplate = `{ "description": "A list of envelope month calculations grouped by category", "type": "array", "items": { - "$ref": "#/definitions/v3.CategoryEnvelopes" + "$ref": "#/definitions/v4.CategoryEnvelopes" } }, "id": { @@ -4575,7 +9295,7 @@ const docTemplate = `{ } } }, - "v3.MonthConfig": { + "v4.MonthConfig": { "type": "object", "properties": { "allocation": { @@ -4596,7 +9316,7 @@ const docTemplate = `{ "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" }, "links": { - "$ref": "#/definitions/v3.MonthConfigLinks" + "$ref": "#/definitions/v4.MonthConfigLinks" }, "month": { "description": "We do not use the default model here, we use envelope ID and month", @@ -4609,7 +9329,7 @@ const docTemplate = `{ } } }, - "v3.MonthConfigEditable": { + "v4.MonthConfigEditable": { "type": "object", "properties": { "allocation": { @@ -4637,29 +9357,29 @@ const docTemplate = `{ } } }, - "v3.MonthConfigLinks": { + "v4.MonthConfigLinks": { "type": "object", "properties": { "envelope": { "description": "The Envelope this config belongs to", "type": "string", - "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b" + "example": "https://example.com/api/v4/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b" }, "self": { "description": "The Month Config itself", "type": "string", - "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" + "example": "https://example.com/api/v4/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" } } }, - "v3.MonthConfigResponse": { + "v4.MonthConfigResponse": { "type": "object", "properties": { "data": { "description": "Config for the month", "allOf": [ { - "$ref": "#/definitions/v3.MonthConfig" + "$ref": "#/definitions/v4.MonthConfig" } ] }, @@ -4670,14 +9390,14 @@ const docTemplate = `{ } } }, - "v3.MonthResponse": { + "v4.MonthResponse": { "type": "object", "properties": { "data": { "description": "Data for the month", "allOf": [ { - "$ref": "#/definitions/v3.Month" + "$ref": "#/definitions/v4.Month" } ] }, @@ -4687,7 +9407,7 @@ const docTemplate = `{ } } }, - "v3.Pagination": { + "v4.Pagination": { "type": "object", "properties": { "count": { @@ -4712,20 +9432,48 @@ const docTemplate = `{ } } }, - "v3.Response": { + "v4.RecentEnvelope": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "v4.RecentEnvelopesResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the account", + "type": "array", + "items": { + "$ref": "#/definitions/v4.RecentEnvelope" + } + }, + "error": { + "description": "The error, if any occurred for this transaction", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v4.Response": { "type": "object", "properties": { "links": { - "description": "Links for the v3 API", + "description": "Links for the v4 API", "allOf": [ { - "$ref": "#/definitions/v3.Links" + "$ref": "#/definitions/v4.Links" } ] } } }, - "v3.Transaction": { + "v4.Transaction": { "type": "object", "properties": { "amount": { @@ -4741,11 +9489,6 @@ const docTemplate = `{ "type": "string", "example": "2021-11-17T00:00:00Z" }, - "budgetId": { - "description": "ID of the budget", - "type": "string", - "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" - }, "createdAt": { "description": "Time the resource was created", "type": "string", @@ -4782,7 +9525,7 @@ const docTemplate = `{ "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" }, "links": { - "$ref": "#/definitions/v3.TransactionLinks" + "$ref": "#/definitions/v4.TransactionLinks" }, "note": { "description": "A note", @@ -4813,14 +9556,14 @@ const docTemplate = `{ } } }, - "v3.TransactionCreateResponse": { + "v4.TransactionCreateResponse": { "type": "object", "properties": { "data": { "description": "List of created Transactions", "type": "array", "items": { - "$ref": "#/definitions/v3.TransactionResponse" + "$ref": "#/definitions/v4.TransactionResponse" } }, "error": { @@ -4830,7 +9573,7 @@ const docTemplate = `{ } } }, - "v3.TransactionEditable": { + "v4.TransactionEditable": { "type": "object", "properties": { "amount": { @@ -4846,11 +9589,6 @@ const docTemplate = `{ "type": "string", "example": "2021-11-17T00:00:00Z" }, - "budgetId": { - "description": "ID of the budget", - "type": "string", - "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" - }, "date": { "description": "Date of the transaction. Time is currently only used for sorting", "type": "string", @@ -4895,24 +9633,24 @@ const docTemplate = `{ } } }, - "v3.TransactionLinks": { + "v4.TransactionLinks": { "type": "object", "properties": { "self": { "description": "The transaction itself", "type": "string", - "example": "https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" + "example": "https://example.com/api/v4/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" } } }, - "v3.TransactionListResponse": { + "v4.TransactionListResponse": { "type": "object", "properties": { "data": { "description": "List of transactions", "type": "array", "items": { - "$ref": "#/definitions/v3.Transaction" + "$ref": "#/definitions/v4.Transaction" } }, "error": { @@ -4924,13 +9662,13 @@ const docTemplate = `{ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.TransactionPreview": { + "v4.TransactionPreview": { "type": "object", "properties": { "destinationAccountName": { @@ -4960,14 +9698,14 @@ const docTemplate = `{ } } }, - "v3.TransactionResponse": { + "v4.TransactionResponse": { "type": "object", "properties": { "data": { "description": "The Transaction data, if creation was successful", "allOf": [ { - "$ref": "#/definitions/v3.Transaction" + "$ref": "#/definitions/v4.Transaction" } ] }, diff --git a/api/swagger.json b/api/swagger.json index 865ef3f8..e17fc1b8 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -2919,18 +2919,50 @@ } } }, - "/version": { + "/v4": { "get": { - "description": "Returns the software version of the API", + "description": "Returns general information about the v4 API", "tags": [ - "General" + "v4" ], - "summary": "API version", + "summary": "v4 API", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/version.Response" + "$ref": "#/definitions/v4.Response" + } + } + } + }, + "delete": { + "description": "Permanently deletes all resources", + "tags": [ + "v4" + ], + "summary": "Delete everything", + "parameters": [ + { + "type": "string", + "description": "Confirmation to delete all resources. Must have the value 'yes-please-delete-everything'", + "name": "confirm", + "in": "query" + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" } } } @@ -2938,7 +2970,157 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "General" + "v4" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/accounts": { + "get": { + "description": "Returns a list of accounts", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "List accounts", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Filter by budget ID", + "name": "budget", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the account on-budget?", + "name": "onBudget", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the account external?", + "name": "external", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the account archived?", + "name": "archived", + "in": "query" + }, + { + "type": "string", + "description": "Search for this text in name and note", + "name": "search", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Account returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Accounts to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.AccountListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.AccountListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.AccountListResponse" + } + } + } + }, + "post": { + "description": "Creates new accounts", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Creates accounts", + "parameters": [ + { + "description": "Accounts", + "name": "accounts", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.AccountEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.AccountCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.AccountCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.AccountCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.AccountCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Accounts" ], "summary": "Allowed HTTP verbs", "responses": { @@ -2947,140 +3129,4618 @@ } } } - } - }, - "definitions": { - "httperrors.HTTPError": { + }, + "/v4/accounts/computed": { + "post": { + "description": "Returns calculated data for the account, e.g. balances", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Get Account data", + "parameters": [ + { + "description": "Time and IDs of requested accounts", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.AccountComputedRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.AccountComputedDataResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.AccountComputedDataResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.AccountComputedDataResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.AccountComputedDataResponse" + } + } + } + } + }, + "/v4/accounts/{id}": { + "get": { + "description": "Returns a specific account", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Get account", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + } + } + }, + "delete": { + "description": "Deletes an account", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Delete account", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Accounts" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Updates an account. Only values to be updated need to be specified.", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Update account", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Account", + "name": "account", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.AccountEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.AccountResponse" + } + } + } + } + }, + "/v4/accounts/{id}/recent-envelopes": { + "get": { + "description": "Returns a list of objects representing recent envelopes", + "produces": [ + "application/json" + ], + "tags": [ + "Accounts" + ], + "summary": "Get recent envelopes", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.RecentEnvelopesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.RecentEnvelopesResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.RecentEnvelopesResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.RecentEnvelopesResponse" + } + } + } + } + }, + "/v4/budgets": { + "get": { + "description": "Returns a list of budgets", + "produces": [ + "application/json" + ], + "tags": [ + "Budgets" + ], + "summary": "List budgets", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Filter by currency", + "name": "currency", + "in": "query" + }, + { + "type": "string", + "description": "Search for this text in name and note", + "name": "search", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Budget returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Budgets to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.BudgetListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.BudgetListResponse" + } + } + } + }, + "post": { + "description": "Creates a new budget", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Budgets" + ], + "summary": "Create budget", + "parameters": [ + { + "description": "Budget", + "name": "budget", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.BudgetEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.BudgetCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.BudgetCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.BudgetCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Budgets" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/budgets/{id}": { + "get": { + "description": "Returns a specific budget", + "produces": [ + "application/json" + ], + "tags": [ + "Budgets" + ], + "summary": "Get budget", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + } + } + }, + "delete": { + "description": "Deletes a budget", + "tags": [ + "Budgets" + ], + "summary": "Delete budget", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Budgets" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Update an existing budget. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Budgets" + ], + "summary": "Update budget", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Budget", + "name": "budget", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.BudgetEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + } + } + } + }, + "/v4/categories": { + "get": { + "description": "Returns a list of categories", + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Get categories", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Filter by budget ID", + "name": "budget", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the category archived?", + "name": "archived", + "in": "query" + }, + { + "type": "string", + "description": "Search for this text in name and note", + "name": "search", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Category returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Categories to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.CategoryListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.CategoryListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.CategoryListResponse" + } + } + } + }, + "post": { + "description": "Creates a new category", + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Create category", + "parameters": [ + { + "description": "Categories", + "name": "categories", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.CategoryEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.CategoryCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.CategoryCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.CategoryCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.CategoryCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Categories" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/categories/{id}": { + "get": { + "description": "Returns a specific category", + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Get category", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + } + } + }, + "delete": { + "description": "Deletes a category", + "tags": [ + "Categories" + ], + "summary": "Delete category", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Categories" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Update an existing category. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Update category", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Category", + "name": "category", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.CategoryEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.CategoryResponse" + } + } + } + } + }, + "/v4/envelopes": { + "get": { + "description": "Returns a list of envelopes", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Get envelopes", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Filter by category ID", + "name": "category", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the envelope archived?", + "name": "archived", + "in": "query" + }, + { + "type": "string", + "description": "Search for this text in name and note", + "name": "search", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Envelope returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Envelopes to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.EnvelopeListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.EnvelopeListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.EnvelopeListResponse" + } + } + } + }, + "post": { + "description": "Creates a new envelope", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Create envelope", + "parameters": [ + { + "description": "Envelopes", + "name": "envelope", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.EnvelopeEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.EnvelopeCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.EnvelopeCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.EnvelopeCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.EnvelopeCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Envelopes" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/envelopes/{id}": { + "get": { + "description": "Returns a specific Envelope", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Get Envelope", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + } + } + }, + "delete": { + "description": "Deletes an envelope", + "tags": [ + "Envelopes" + ], + "summary": "Delete envelope", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Envelopes" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Updates an existing envelope. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Update envelope", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Envelope", + "name": "envelope", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.EnvelopeEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.EnvelopeResponse" + } + } + } + } + }, + "/v4/envelopes/{id}/{month}": { + "get": { + "description": "Returns configuration for a specific month", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Get MonthConfig", + "parameters": [ + { + "type": "string", + "description": "ID of the Envelope", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Envelopes" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID of the Envelope", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Changes configuration for a Month. If there is no configuration for the month yet, this endpoint transparently creates a configuration resource.", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Update MonthConfig", + "parameters": [ + { + "type": "string", + "description": "ID of the Envelope", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "path", + "required": true + }, + { + "description": "MonthConfig", + "name": "monthConfig", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.MonthConfigEditable" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MonthConfigResponse" + } + } + } + } + }, + "/v4/goals": { + "get": { + "description": "Returns a list of goals", + "produces": [ + "application/json" + ], + "tags": [ + "Goals" + ], + "summary": "Get goals", + "parameters": [ + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Search for this text in name and note", + "name": "search", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the goal archived?", + "name": "archived", + "in": "query" + }, + { + "type": "string", + "description": "Filter by envelope ID", + "name": "envelope", + "in": "query" + }, + { + "type": "string", + "description": "Month of the goal. Ignores exact time, matches on the month of the RFC3339 timestamp provided.", + "name": "month", + "in": "query" + }, + { + "type": "string", + "description": "Goals for this and later months. Ignores exact time, matches on the month of the RFC3339 timestamp provided.", + "name": "fromMonth", + "in": "query" + }, + { + "type": "string", + "description": "Goals for this and earlier months. Ignores exact time, matches on the month of the RFC3339 timestamp provided.", + "name": "untilMonth", + "in": "query" + }, + { + "type": "string", + "description": "Filter by amount", + "name": "amount", + "in": "query" + }, + { + "type": "string", + "description": "Amount less than or equal to this", + "name": "amountLessOrEqual", + "in": "query" + }, + { + "type": "string", + "description": "Amount more than or equal to this", + "name": "amountMoreOrEqual", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first goal returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of goal to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.GoalListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.GoalListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.GoalListResponse" + } + } + } + }, + "post": { + "description": "Creates new goals", + "produces": [ + "application/json" + ], + "tags": [ + "Goals" + ], + "summary": "Create goals", + "parameters": [ + { + "description": "Goals", + "name": "goals", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.GoalEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.GoalCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.GoalCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.GoalCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.GoalCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Goals" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/goals/{id}": { + "get": { + "description": "Returns a specific goal", + "produces": [ + "application/json" + ], + "tags": [ + "Goals" + ], + "summary": "Get goal", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + } + } + }, + "delete": { + "description": "Deletes a goal", + "tags": [ + "Goals" + ], + "summary": "Delete goal", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Goals" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Updates an existing goal. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Goals" + ], + "summary": "Update goal", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Goal", + "name": "goal", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.GoalEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.GoalResponse" + } + } + } + } + }, + "/v4/import": { + "get": { + "description": "Returns general information about the v4 API", + "tags": [ + "Import" + ], + "summary": "Import API overview", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.ImportResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs.", + "tags": [ + "Import" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/import/ynab-import-preview": { + "post": { + "description": "Returns a preview of transactions to be imported after parsing a YNAB Import format csv file", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Import" + ], + "summary": "Transaction Import Preview", + "parameters": [ + { + "type": "file", + "description": "File to import", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "ID of the account to import transactions for", + "name": "accountId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.ImportPreviewList" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.ImportPreviewList" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.ImportPreviewList" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.ImportPreviewList" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Import" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/import/ynab4": { + "post": { + "description": "Imports budgets from YNAB 4", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Import" + ], + "summary": "Import YNAB 4 budget", + "parameters": [ + { + "type": "file", + "description": "File to import", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Name of the Budget to create", + "name": "budgetName", + "in": "query" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.BudgetResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Import" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/match-rules": { + "get": { + "description": "Returns a list of matchRules", + "produces": [ + "application/json" + ], + "tags": [ + "MatchRules" + ], + "summary": "Get matchRules", + "parameters": [ + { + "type": "integer", + "description": "Filter by priority", + "name": "priority", + "in": "query" + }, + { + "type": "string", + "description": "Filter by match", + "name": "match", + "in": "query" + }, + { + "type": "string", + "description": "Filter by account ID", + "name": "account", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Match Rule returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Match Rules to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.MatchRuleListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MatchRuleListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MatchRuleListResponse" + } + } + } + }, + "post": { + "description": "Creates matchRules from the list of submitted matchRule data. The response code is the highest response code number that a single matchRule creation would have caused. If it is not equal to 201, at least one matchRule has an error.", + "produces": [ + "application/json" + ], + "tags": [ + "MatchRules" + ], + "summary": "Create matchRules", + "parameters": [ + { + "description": "MatchRules", + "name": "matchRules", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.MatchRuleEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.MatchRuleCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MatchRuleCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MatchRuleCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MatchRuleCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "MatchRules" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/match-rules/{id}": { + "get": { + "description": "Returns a specific matchRule", + "produces": [ + "application/json" + ], + "tags": [ + "MatchRules" + ], + "summary": "Get matchRule", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + } + } + }, + "delete": { + "description": "Deletes an matchRule", + "tags": [ + "MatchRules" + ], + "summary": "Delete matchRule", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "MatchRules" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Update a matchRule. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "MatchRules" + ], + "summary": "Update matchRule", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "MatchRule", + "name": "matchRule", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.MatchRuleEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MatchRuleResponse" + } + } + } + } + }, + "/v4/months": { + "get": { + "description": "Returns data about a specific month.", + "produces": [ + "application/json" + ], + "tags": [ + "Months" + ], + "summary": "Get data about a month", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "budget", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.MonthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.MonthResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.MonthResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.MonthResponse" + } + } + } + }, + "post": { + "description": "Sets allocations for a month for all envelopes that do not have an allocation yet", + "tags": [ + "Months" + ], + "summary": "Set allocations for a month", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "budget", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "query", + "required": true + }, + { + "description": "Budget", + "name": "mode", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.BudgetAllocationMode" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "delete": { + "description": "Deletes all allocation for the specified month", + "tags": [ + "Months" + ], + "summary": "Delete allocations for a month", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "budget", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The month in YYYY-MM format", + "name": "month", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs.", + "tags": [ + "Months" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/transactions": { + "get": { + "description": "Returns a list of transactions", + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Get transactions", + "parameters": [ + { + "type": "string", + "description": "Date of the transaction. Ignores exact time, matches on the day of the RFC3339 timestamp provided.", + "name": "date", + "in": "query" + }, + { + "type": "string", + "description": "Transactions at and after this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided.", + "name": "fromDate", + "in": "query" + }, + { + "type": "string", + "description": "Transactions before and at this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided.", + "name": "untilDate", + "in": "query" + }, + { + "type": "string", + "description": "Filter by amount", + "name": "amount", + "in": "query" + }, + { + "type": "string", + "description": "Amount less than or equal to this", + "name": "amountLessOrEqual", + "in": "query" + }, + { + "type": "string", + "description": "Amount more than or equal to this", + "name": "amountMoreOrEqual", + "in": "query" + }, + { + "type": "string", + "description": "Filter by note", + "name": "note", + "in": "query" + }, + { + "type": "string", + "description": "Filter by budget ID", + "name": "budget", + "in": "query" + }, + { + "type": "string", + "description": "Filter by ID of associated account, regardeless of source or destination", + "name": "account", + "in": "query" + }, + { + "type": "string", + "description": "Filter by source account ID", + "name": "source", + "in": "query" + }, + { + "type": "string", + "description": "Filter by destination account ID", + "name": "destination", + "in": "query" + }, + { + "type": "string", + "description": "Filter by envelope ID", + "name": "envelope", + "in": "query" + }, + { + "type": "boolean", + "description": "Reconcilication state in source account", + "name": "reconciledSource", + "in": "query" + }, + { + "type": "boolean", + "description": "Reconcilication state in destination account", + "name": "reconciledDestination", + "in": "query" + }, + { + "type": "integer", + "description": "The offset of the first Transaction returned. Defaults to 0.", + "name": "offset", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of Transactions to return. Defaults to 50.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.TransactionListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.TransactionListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.TransactionListResponse" + } + } + } + }, + "post": { + "description": "Creates transactions from the list of submitted transaction data. The response code is the highest response code number that a single transaction creation would have caused. If it is not equal to 201, at least one transaction has an error.", + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Create transactions", + "parameters": [ + { + "description": "Transactions", + "name": "transactions", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.TransactionEditable" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/v4.TransactionCreateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.TransactionCreateResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.TransactionCreateResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.TransactionCreateResponse" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Transactions" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v4/transactions/{id}": { + "get": { + "description": "Returns a specific transaction", + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Get transaction", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + } + } + }, + "delete": { + "description": "Deletes a transaction", + "tags": [ + "Transactions" + ], + "summary": "Delete transaction", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "Transactions" + ], + "summary": "Allowed HTTP verbs", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + } + } + }, + "patch": { + "description": "Updates an existing transaction. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Update transaction", + "parameters": [ + { + "type": "string", + "description": "ID formatted as string", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Transaction", + "name": "transaction", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v4.TransactionEditable" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/v4.TransactionResponse" + } + } + } + } + }, + "/version": { + "get": { + "description": "Returns the software version of the API", + "tags": [ + "General" + ], + "summary": "API version", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/version.Response" + } + } + } + }, + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", + "tags": [ + "General" + ], + "summary": "Allowed HTTP verbs", + "responses": { + "204": { + "description": "No Content" + } + } + } + } + }, + "definitions": { + "httperrors.HTTPError": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "An ID specified in the query string was not a valid UUID" + } + } + }, + "models.Account": { + "type": "object", + "properties": { + "archived": { + "type": "boolean" + }, + "budget": { + "$ref": "#/definitions/models.Budget" + }, + "budgetID": { + "type": "string" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "external": { + "type": "boolean" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "importHash": { + "description": "A SHA256 hash of a unique combination of values to use in duplicate detection for imports", + "type": "string" + }, + "initialBalance": { + "type": "number" + }, + "initialBalanceDate": { + "type": "string" + }, + "name": { + "type": "string" + }, + "note": { + "type": "string" + }, + "onBudget": { + "type": "boolean" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "models.Budget": { + "type": "object", + "properties": { + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "currency": { + "type": "string" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "name": { + "type": "string" + }, + "note": { + "type": "string" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "models.Category": { + "type": "object", + "properties": { + "archived": { + "type": "boolean" + }, + "budget": { + "$ref": "#/definitions/models.Budget" + }, + "budgetID": { + "type": "string" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "name": { + "type": "string" + }, + "note": { + "type": "string" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "models.Envelope": { + "type": "object", + "properties": { + "archived": { + "type": "boolean" + }, + "category": { + "$ref": "#/definitions/models.Category" + }, + "categoryID": { + "type": "string" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "name": { + "type": "string" + }, + "note": { + "type": "string" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "models.Transaction": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "availableFrom": { + "description": "Only used for income transactions. Defaults to the transaction date.", + "type": "string" + }, + "budget": { + "$ref": "#/definitions/models.Budget" + }, + "budgetID": { + "type": "string" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "date": { + "description": "Time of day is currently only used for sorting", + "type": "string" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "destinationAccount": { + "$ref": "#/definitions/models.Account" + }, + "destinationAccountID": { + "type": "string" + }, + "envelope": { + "$ref": "#/definitions/models.Envelope" + }, + "envelopeID": { + "type": "string" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection when importing transactions", + "type": "string" + }, + "note": { + "type": "string" + }, + "reconciledDestination": { + "description": "Is the transaction reconciled in the destination account?", + "type": "boolean" + }, + "reconciledSource": { + "description": "Is the transaction reconciled in the source account?", + "type": "boolean" + }, + "sourceAccount": { + "$ref": "#/definitions/models.Account" + }, + "sourceAccountID": { + "type": "string" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "root.Links": { + "type": "object", + "properties": { + "docs": { + "description": "Swagger API documentation", + "type": "string", + "example": "https://example.com/api/docs/index.html" + }, + "healthz": { + "description": "Healthz endpoint", + "type": "string", + "example": "https://example.com/api/healtzh" + }, + "metrics": { + "description": "Endpoint returning Prometheus metrics", + "type": "string", + "example": "https://example.com/api/metrics" + }, + "v3": { + "description": "List endpoint for all v3 endpoints", + "type": "string", + "example": "https://example.com/api/v3" + }, + "version": { + "description": "Endpoint returning the version of the backend", + "type": "string", + "example": "https://example.com/api/version" + } + } + }, + "root.Response": { + "type": "object", + "properties": { + "links": { + "$ref": "#/definitions/root.Links" + } + } + }, + "v3.Account": { + "type": "object", + "properties": { + "archived": { + "description": "Is the account archived?", + "type": "boolean", + "default": false, + "example": true + }, + "balance": { + "description": "These fields are computed", + "type": "number", + "example": 2735.17 + }, + "budgetId": { + "description": "ID of the budget this account belongs to", + "type": "string", + "example": "550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "external": { + "description": "Does the account belong to the budget owner or not?", + "type": "boolean", + "default": false, + "example": false + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection for imports", + "type": "string", + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" + }, + "initialBalance": { + "description": "Balance of the account before any transactions were recorded", + "type": "number", + "default": 0, + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 173.12 + }, + "initialBalanceDate": { + "description": "Date of the initial balance", + "type": "string", + "example": "2017-05-12T00:00:00Z" + }, + "links": { + "$ref": "#/definitions/v3.AccountLinks" + }, + "name": { + "description": "Name of the account", + "type": "string", + "example": "Cash" + }, + "note": { + "description": "A longer description for the account", + "type": "string", + "example": "Money in my wallet" + }, + "onBudget": { + "description": "Does the account factor into the available budget? Always false when external: true", + "type": "boolean", + "default": false, + "example": true + }, + "recentEnvelopes": { + "description": "Envelopes recently used with this account", + "type": "array", + "items": { + "type": "string" + } + }, + "reconciledBalance": { + "description": "Balance of the account, including all reconciled transactions referencing it", + "type": "number", + "example": 2539.57 + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.AccountCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of created Accounts", + "type": "array", + "items": { + "$ref": "#/definitions/v3.AccountResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.AccountEditable": { + "type": "object", + "properties": { + "archived": { + "description": "Is the account archived?", + "type": "boolean", + "default": false, + "example": true + }, + "budgetId": { + "description": "ID of the budget this account belongs to", + "type": "string", + "example": "550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "external": { + "description": "Does the account belong to the budget owner or not?", + "type": "boolean", + "default": false, + "example": false + }, + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection for imports", + "type": "string", + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" + }, + "initialBalance": { + "description": "Balance of the account before any transactions were recorded", + "type": "number", + "default": 0, + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 173.12 + }, + "initialBalanceDate": { + "description": "Date of the initial balance", + "type": "string", + "example": "2017-05-12T00:00:00Z" + }, + "name": { + "description": "Name of the account", + "type": "string", + "example": "Cash" + }, + "note": { + "description": "A longer description for the account", + "type": "string", + "example": "Money in my wallet" + }, + "onBudget": { + "description": "Does the account factor into the available budget? Always false when external: true", + "type": "boolean", + "default": false, + "example": true + } + } + }, + "v3.AccountLinks": { + "type": "object", + "properties": { + "self": { + "description": "The account itself", + "type": "string", + "example": "https://example.com/api/v3/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" + }, + "transactions": { + "description": "Transactions referencing the account", + "type": "string", + "example": "https://example.com/api/v3/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" + } + } + }, + "v3.AccountListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of accounts", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Account" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.AccountResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the account", + "allOf": [ + { + "$ref": "#/definitions/v3.Account" + } + ] + }, + "error": { + "description": "The error, if any occurred for this transaction", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.AllocationMode": { + "type": "string", + "enum": [ + "ALLOCATE_LAST_MONTH_BUDGET", + "ALLOCATE_LAST_MONTH_SPEND" + ], + "x-enum-varnames": [ + "AllocateLastMonthBudget", + "AllocateLastMonthSpend" + ] + }, + "v3.Budget": { + "type": "object", + "properties": { + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "currency": { + "description": "The currency for the budget", + "type": "string", + "example": "€" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.BudgetLinks" + }, + "name": { + "description": "Name of the budget", + "type": "string", + "example": "Morre's Budget" + }, + "note": { + "description": "A longer description of the budget", + "type": "string", + "example": "My personal expenses" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.BudgetAllocationMode": { + "type": "object", + "properties": { + "mode": { + "description": "Mode to allocate budget with", + "allOf": [ + { + "$ref": "#/definitions/v3.AllocationMode" + } + ], + "example": "ALLOCATE_LAST_MONTH_SPEND" + } + } + }, + "v3.BudgetCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of created Budgets", + "type": "array", + "items": { + "$ref": "#/definitions/v3.BudgetResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.BudgetEditable": { + "type": "object", + "properties": { + "currency": { + "description": "The currency for the budget", + "type": "string", + "example": "€" + }, + "name": { + "description": "Name of the budget", + "type": "string", + "example": "Morre's Budget" + }, + "note": { + "description": "A longer description of the budget", + "type": "string", + "example": "My personal expenses" + } + } + }, + "v3.BudgetLinks": { + "type": "object", + "properties": { + "accounts": { + "description": "Accounts for this budget", + "type": "string", + "example": "https://example.com/api/v3/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "categories": { + "description": "Categories for this budget", + "type": "string", + "example": "https://example.com/api/v3/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "envelopes": { + "description": "Envelopes for this budget", + "type": "string", + "example": "https://example.com/api/v3/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "month": { + "description": "This uses 'YYYY-MM' for clients to replace with the actual year and month.", + "type": "string", + "example": "https://example.com/api/v3/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf\u0026month=YYYY-MM" + }, + "self": { + "description": "The budget itself", + "type": "string", + "example": "https://example.com/api/v3/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf" + }, + "transactions": { + "description": "Transactions for this budget", + "type": "string", + "example": "https://example.com/api/v3/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + } + } + }, + "v3.BudgetListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of budgets", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Budget" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.BudgetResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the budget", + "allOf": [ + { + "$ref": "#/definitions/v3.Budget" + } + ] + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.Category": { + "type": "object", + "properties": { + "archived": { + "description": "Is the category archived?", + "type": "boolean", + "default": false, + "example": true + }, + "budgetId": { + "description": "ID of the budget the category belongs to", + "type": "string", + "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "envelopes": { + "description": "These fields are computed", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Envelope" + } + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.CategoryLinks" + }, + "name": { + "description": "Name of the category", + "type": "string", + "example": "Saving" + }, + "note": { + "description": "Notes about the category", + "type": "string", + "example": "All envelopes for long-term saving" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.CategoryCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of the created Categories or their respective error", + "type": "array", + "items": { + "$ref": "#/definitions/v3.CategoryResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.CategoryEditable": { + "type": "object", + "properties": { + "archived": { + "description": "Is the category archived?", + "type": "boolean", + "default": false, + "example": true + }, + "budgetId": { + "description": "ID of the budget the category belongs to", + "type": "string", + "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" + }, + "name": { + "description": "Name of the category", + "type": "string", + "example": "Saving" + }, + "note": { + "description": "Notes about the category", + "type": "string", + "example": "All envelopes for long-term saving" + } + } + }, + "v3.CategoryEnvelopes": { + "type": "object", + "properties": { + "allocation": { + "description": "Sum of allocations for the envelopes", + "type": "number", + "example": 90 + }, + "archived": { + "description": "Is the category archived?", + "type": "boolean", + "default": false, + "example": true + }, + "balance": { + "description": "Sum of the balances of the envelopes", + "type": "number", + "example": -10.13 + }, + "budgetId": { + "description": "ID of the budget the category belongs to", + "type": "string", + "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "envelopes": { + "description": "Slice of all envelopes", + "type": "array", + "items": { + "$ref": "#/definitions/v3.EnvelopeMonth" + } + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.CategoryLinks" + }, + "name": { + "description": "Name of the category", + "type": "string", + "example": "Saving" + }, + "note": { + "description": "Notes about the category", + "type": "string", + "example": "All envelopes for long-term saving" + }, + "spent": { + "description": "Sum spent for all envelopes", + "type": "number", + "example": 100.13 + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.CategoryLinks": { + "type": "object", + "properties": { + "envelopes": { + "description": "Envelopes for this category", + "type": "string", + "example": "https://example.com/api/v3/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f" + }, + "self": { + "description": "The category itself", + "type": "string", + "example": "https://example.com/api/v3/categories/3b1ea324-d438-4419-882a-2fc91d71772f" + } + } + }, + "v3.CategoryListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of Categories", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Category" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.CategoryResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the Category", + "allOf": [ + { + "$ref": "#/definitions/v3.Category" + } + ] + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.Envelope": { + "type": "object", + "properties": { + "archived": { + "description": "Is the envelope archived?", + "type": "boolean", + "default": false, + "example": true + }, + "categoryId": { + "description": "ID of the category the envelope belongs to", + "type": "string", + "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "description": "Links to related resources", + "allOf": [ + { + "$ref": "#/definitions/v3.EnvelopeLinks" + } + ] + }, + "name": { + "description": "Name of the envelope", + "type": "string", + "example": "Groceries" + }, + "note": { + "description": "Notes about the envelope", + "type": "string", + "example": "For stuff bought at supermarkets and drugstores" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.EnvelopeCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the Envelope", + "type": "array", + "items": { + "$ref": "#/definitions/v3.EnvelopeResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.EnvelopeEditable": { + "type": "object", + "properties": { + "archived": { + "description": "Is the envelope archived?", + "type": "boolean", + "default": false, + "example": true + }, + "categoryId": { + "description": "ID of the category the envelope belongs to", + "type": "string", + "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" + }, + "name": { + "description": "Name of the envelope", + "type": "string", + "example": "Groceries" + }, + "note": { + "description": "Notes about the envelope", + "type": "string", + "example": "For stuff bought at supermarkets and drugstores" + } + } + }, + "v3.EnvelopeLinks": { + "type": "object", + "properties": { + "month": { + "description": "The MonthConfig for the envelope", + "type": "string", + "example": "https://example.com/api/v3/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM" + }, + "self": { + "description": "The envelope itself", + "type": "string", + "example": "https://example.com/api/v3/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166" + }, + "transactions": { + "description": "The envelope's transactions", + "type": "string", + "example": "https://example.com/api/v3/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166" + } + } + }, + "v3.EnvelopeListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of Envelopes", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Envelope" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.EnvelopeMonth": { + "type": "object", + "properties": { + "allocation": { + "description": "The amount of money allocated", + "type": "number", + "example": 85.44 + }, + "archived": { + "description": "Is the envelope archived?", + "type": "boolean", + "default": false, + "example": true + }, + "balance": { + "description": "The balance at the end of the monht", + "type": "number", + "example": 12.32 + }, + "categoryId": { + "description": "ID of the category the envelope belongs to", + "type": "string", + "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.EnvelopeLinks" + }, + "name": { + "description": "Name of the envelope", + "type": "string", + "example": "Groceries" + }, + "note": { + "description": "Notes about the envelope", + "type": "string", + "example": "For stuff bought at supermarkets and drugstores" + }, + "spent": { + "description": "The amount spent over the whole month", + "type": "number", + "example": 73.12 + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.EnvelopeResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the Envelope", + "allOf": [ + { + "$ref": "#/definitions/v3.Envelope" + } + ] + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.Goal": { + "type": "object", + "properties": { + "amount": { + "description": "How much money should be saved for this goal?", + "type": "number", + "default": 0, + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 750 + }, + "archived": { + "description": "If this goal is still in use or not", + "type": "boolean", + "default": false, + "example": true + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "envelopeId": { + "description": "The ID of the envelope this goal is for", + "type": "string", + "example": "f81566d9-af4d-4f13-9830-c62c4b5e4c7e" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.GoalLinks" + }, + "month": { + "description": "The month the goal should be reached", + "type": "string", + "example": "2024-07-01T00:00:00.000000Z" + }, + "name": { + "description": "Name of the goal", + "type": "string", + "example": "New TV" + }, + "note": { + "description": "Note about the goal", + "type": "string", + "example": "We want to replace the old CRT TV soon-ish" + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.GoalCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of created resources", + "type": "array", + "items": { + "$ref": "#/definitions/v3.GoalResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.GoalEditable": { + "type": "object", + "properties": { + "amount": { + "description": "How much money should be saved for this goal?", + "type": "number", + "default": 0, + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 750 + }, + "archived": { + "description": "If this goal is still in use or not", + "type": "boolean", + "default": false, + "example": true + }, + "envelopeId": { + "description": "The ID of the envelope this goal is for", + "type": "string", + "example": "f81566d9-af4d-4f13-9830-c62c4b5e4c7e" + }, + "month": { + "description": "The month the goal should be reached", + "type": "string", + "example": "2024-07-01T00:00:00.000000Z" + }, + "name": { + "description": "Name of the goal", + "type": "string", + "example": "New TV" + }, + "note": { + "description": "Note about the goal", + "type": "string", + "example": "We want to replace the old CRT TV soon-ish" + } + } + }, + "v3.GoalLinks": { + "type": "object", + "properties": { + "envelope": { + "description": "The Envelope this goal references", + "type": "string", + "example": "https://example.com/api/v3/envelopes/c1a96ae4-80e3-4827-8ed0-c7656f224fee" + }, + "self": { + "description": "The Goal itself", + "type": "string", + "example": "https://example.com/api/v3/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c" + } + } + }, + "v3.GoalListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of resources", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Goal" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.GoalResponse": { + "type": "object", + "properties": { + "data": { + "description": "The resource", + "allOf": [ + { + "$ref": "#/definitions/v3.Goal" + } + ] + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.ImportLinks": { + "type": "object", + "properties": { + "matchRules": { + "description": "URL of YNAB Import preview endpoint", + "type": "string", + "example": "https://example.com/api/v3/import/ynab-import-preview" + }, + "transactions": { + "description": "URL of YNAB4 import endpoint", + "type": "string", + "example": "https://example.com/api/v3/import/ynab4" + } + } + }, + "v3.ImportPreviewList": { + "type": "object", + "properties": { + "data": { + "description": "List of transaction previews", + "type": "array", + "items": { + "$ref": "#/definitions/v3.TransactionPreview" + } + }, + "error": { + "description": "The error, if any occurred for this Match Rule", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.ImportResponse": { + "type": "object", + "properties": { + "links": { + "description": "Links for the v3 API", + "allOf": [ + { + "$ref": "#/definitions/v3.ImportLinks" + } + ] + } + } + }, + "v3.Links": { + "type": "object", + "properties": { + "accounts": { + "description": "URL of Account collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/accounts" + }, + "budgets": { + "description": "URL of Budget collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/budgets" + }, + "categories": { + "description": "URL of Category collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/categories" + }, + "envelopes": { + "description": "URL of Envelope collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/envelopes" + }, + "goals": { + "description": "URL of goal collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/goals" + }, + "import": { + "description": "URL of import list endpoint", + "type": "string", + "example": "https://example.com/api/v3/import" + }, + "matchRules": { + "description": "URL of Match Rule collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/match-rules" + }, + "months": { + "description": "URL of Month endpoint", + "type": "string", + "example": "https://example.com/api/v3/months" + }, + "transactions": { + "description": "URL of Transaction collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/transactions" + } + } + }, + "v3.MatchRule": { + "type": "object", + "properties": { + "accountId": { + "description": "The account to map matching transactions to", + "type": "string", + "example": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" + }, + "createdAt": { + "description": "Time the resource was created", + "type": "string", + "example": "2022-04-02T19:28:44.491514Z" + }, + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" + }, + "id": { + "description": "UUID for the resource", + "type": "string", + "example": "65392deb-5e92-4268-b114-297faad6cdce" + }, + "links": { + "$ref": "#/definitions/v3.MatchRuleLinks" + }, + "match": { + "description": "The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive.", + "type": "string", + "example": "Bank*" + }, + "priority": { + "description": "The priority of the match rule", + "type": "integer", + "example": 3 + }, + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v3.MatchRuleCreateResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of created Match Rules", + "type": "array", + "items": { + "$ref": "#/definitions/v3.MatchRuleResponse" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.MatchRuleEditable": { + "type": "object", + "properties": { + "accountId": { + "description": "The account to map matching transactions to", + "type": "string", + "example": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" + }, + "match": { + "description": "The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive.", + "type": "string", + "example": "Bank*" + }, + "priority": { + "description": "The priority of the match rule", + "type": "integer", + "example": 3 + } + } + }, + "v3.MatchRuleLinks": { + "type": "object", + "properties": { + "self": { + "description": "The match rule itself", + "type": "string", + "example": "https://example.com/api/v3/match-rules/95685c82-53c6-455d-b235-f49960b73b21" + } + } + }, + "v3.MatchRuleListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of Match Rules", + "type": "array", + "items": { + "$ref": "#/definitions/v3.MatchRule" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + }, + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.MatchRuleResponse": { "type": "object", "properties": { + "data": { + "description": "The Match Rule data, if creation was successful", + "allOf": [ + { + "$ref": "#/definitions/v3.MatchRule" + } + ] + }, "error": { + "description": "The error, if any occurred for this Match Rule", "type": "string", - "example": "An ID specified in the query string was not a valid UUID" + "example": "the specified resource ID is not a valid UUID" } } }, - "models.Account": { + "v3.Month": { "type": "object", "properties": { - "archived": { - "type": "boolean" + "allocation": { + "description": "The sum of all allocations for this month", + "type": "number", + "example": 1200.5 }, - "budget": { - "$ref": "#/definitions/models.Budget" + "available": { + "description": "The amount available to budget", + "type": "number", + "example": 217.34 }, - "budgetID": { - "type": "string" + "balance": { + "description": "The sum of all envelope balances", + "type": "number", + "example": 5231.37 }, - "createdAt": { - "description": "Time the resource was created", - "type": "string", - "example": "2022-04-02T19:28:44.491514Z" + "categories": { + "description": "A list of envelope month calculations grouped by category", + "type": "array", + "items": { + "$ref": "#/definitions/v3.CategoryEnvelopes" + } }, - "deletedAt": { - "description": "Time the resource was marked as deleted", + "id": { + "description": "The ID of the Budget", "type": "string", - "example": "2022-04-22T21:01:05.058161Z" + "example": "1e777d24-3f5b-4c43-8000-04f65f895578" }, - "external": { - "type": "boolean" + "income": { + "description": "The total income for the month (sum of all incoming transactions without an Envelope)", + "type": "number", + "example": 2317.34 }, - "id": { - "description": "UUID for the resource", + "month": { + "description": "The month", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" + "example": "2006-05-01T00:00:00.000000Z" }, - "importHash": { - "description": "A SHA256 hash of a unique combination of values to use in duplicate detection for imports", - "type": "string" + "name": { + "description": "The name of the Budget", + "type": "string", + "example": "Zero budget" }, - "initialBalance": { - "type": "number" + "spent": { + "description": "The amount of money spent in this month", + "type": "number", + "example": 133.7 + } + } + }, + "v3.MonthConfig": { + "type": "object", + "properties": { + "allocation": { + "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "type": "number", + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 22.01 }, - "initialBalanceDate": { + "envelopeID": { + "description": "We do not use the default model here, we use envelope ID and month", "type": "string" }, - "name": { + "envelopeId": { + "description": "ID of the envelope", + "type": "string", + "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" + }, + "links": { + "$ref": "#/definitions/v3.MonthConfigLinks" + }, + "month": { + "description": "We do not use the default model here, we use envelope ID and month", "type": "string" }, "note": { - "type": "string" + "description": "A note for the month config", + "type": "string", + "example": "Added 200€ here because we replaced Tim's expensive vase" + } + } + }, + "v3.MonthConfigEditable": { + "type": "object", + "properties": { + "allocation": { + "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "type": "number", + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 22.01 }, - "onBudget": { - "type": "boolean" + "envelopeId": { + "description": "ID of the envelope", + "type": "string", + "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" }, - "updatedAt": { - "description": "Last time the resource was updated", + "month": { + "description": "The month. This is always set to 00:00 UTC on the first of the month.", "type": "string", - "example": "2022-04-17T20:14:01.048145Z" + "example": "1969-06-01T00:00:00.000000Z" + }, + "note": { + "description": "A note for the month config", + "type": "string", + "example": "Added 200€ here because we replaced Tim's expensive vase" } } }, - "models.Budget": { + "v3.MonthConfigLinks": { "type": "object", "properties": { - "createdAt": { - "description": "Time the resource was created", + "envelope": { + "description": "The Envelope this config belongs to", "type": "string", - "example": "2022-04-02T19:28:44.491514Z" - }, - "currency": { - "type": "string" + "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b" }, - "deletedAt": { - "description": "Time the resource was marked as deleted", + "self": { + "description": "The Month Config itself", "type": "string", - "example": "2022-04-22T21:01:05.058161Z" + "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" + } + } + }, + "v3.MonthConfigResponse": { + "type": "object", + "properties": { + "data": { + "description": "Config for the month", + "allOf": [ + { + "$ref": "#/definitions/v3.MonthConfig" + } + ] }, - "id": { - "description": "UUID for the resource", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v3.MonthResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the month", + "allOf": [ + { + "$ref": "#/definitions/v3.Month" + } + ] }, - "name": { + "error": { + "description": "The error, if any occurred", "type": "string" + } + } + }, + "v3.Pagination": { + "type": "object", + "properties": { + "count": { + "description": "The amount of records returned in this response", + "type": "integer", + "example": 25 }, - "note": { - "type": "string" + "limit": { + "description": "The maximum amount of resources to return for this request", + "type": "integer", + "example": 25 }, - "updatedAt": { - "description": "Last time the resource was updated", - "type": "string", - "example": "2022-04-17T20:14:01.048145Z" + "offset": { + "description": "The offset for the first record returned", + "type": "integer", + "example": 50 + }, + "total": { + "description": "The total number of resources matching the query", + "type": "integer", + "example": 827 } } }, - "models.Category": { + "v3.Response": { + "type": "object", + "properties": { + "links": { + "description": "Links for the v3 API", + "allOf": [ + { + "$ref": "#/definitions/v3.Links" + } + ] + } + } + }, + "v3.Transaction": { "type": "object", "properties": { - "archived": { - "type": "boolean" + "amount": { + "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "type": "number", + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 14.03 }, - "budget": { - "$ref": "#/definitions/models.Budget" + "availableFrom": { + "description": "The date from which on the transaction amount is available for budgeting. Only used for income transactions. Defaults to the transaction date.", + "type": "string", + "example": "2021-11-17T00:00:00Z" }, - "budgetID": { - "type": "string" + "budgetId": { + "description": "ID of the budget", + "type": "string", + "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" }, "createdAt": { "description": "Time the resource was created", "type": "string", "example": "2022-04-02T19:28:44.491514Z" }, + "date": { + "description": "Date of the transaction. Time is currently only used for sorting", + "type": "string", + "example": "1815-12-10T18:43:00.271152Z" + }, "deletedAt": { "description": "Time the resource was marked as deleted", "type": "string", "example": "2022-04-22T21:01:05.058161Z" }, + "destinationAccountId": { + "description": "ID of the destination account", + "type": "string", + "example": "8e16b456-a719-48ce-9fec-e115cfa7cbcc" + }, + "envelopeId": { + "description": "ID of the envelope", + "type": "string", + "example": "2649c965-7999-4873-ae16-89d5d5fa972e" + }, "id": { "description": "UUID for the resource", "type": "string", "example": "65392deb-5e92-4268-b114-297faad6cdce" }, - "name": { - "type": "string" + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", + "type": "string", + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" + }, + "links": { + "$ref": "#/definitions/v3.TransactionLinks" }, "note": { - "type": "string" + "description": "A note", + "type": "string", + "example": "Lunch" + }, + "reconciledDestination": { + "description": "Is the transaction reconciled in the destination account?", + "type": "boolean", + "default": false, + "example": true + }, + "reconciledSource": { + "description": "Is the transaction reconciled in the source account?", + "type": "boolean", + "default": false, + "example": true + }, + "sourceAccountId": { + "description": "ID of the source account", + "type": "string", + "example": "fd81dc45-a3a2-468e-a6fa-b2618f30aa45" }, "updatedAt": { "description": "Last time the resource was updated", @@ -3089,160 +7749,172 @@ } } }, - "models.Envelope": { + "v3.TransactionCreateResponse": { "type": "object", "properties": { - "archived": { - "type": "boolean" - }, - "category": { - "$ref": "#/definitions/models.Category" - }, - "categoryID": { - "type": "string" - }, - "createdAt": { - "description": "Time the resource was created", - "type": "string", - "example": "2022-04-02T19:28:44.491514Z" - }, - "deletedAt": { - "description": "Time the resource was marked as deleted", - "type": "string", - "example": "2022-04-22T21:01:05.058161Z" - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "name": { - "type": "string" - }, - "note": { - "type": "string" + "data": { + "description": "List of created Transactions", + "type": "array", + "items": { + "$ref": "#/definitions/v3.TransactionResponse" + } }, - "updatedAt": { - "description": "Last time the resource was updated", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "2022-04-17T20:14:01.048145Z" + "example": "the specified resource ID is not a valid UUID" } } }, - "models.Transaction": { + "v3.TransactionEditable": { "type": "object", "properties": { "amount": { - "type": "number" + "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "type": "number", + "maximum": 1000000000000, + "minimum": 1e-8, + "multipleOf": 1e-8, + "example": 14.03 }, "availableFrom": { - "description": "Only used for income transactions. Defaults to the transaction date.", - "type": "string" - }, - "budget": { - "$ref": "#/definitions/models.Budget" - }, - "budgetID": { - "type": "string" + "description": "The date from which on the transaction amount is available for budgeting. Only used for income transactions. Defaults to the transaction date.", + "type": "string", + "example": "2021-11-17T00:00:00Z" }, - "createdAt": { - "description": "Time the resource was created", + "budgetId": { + "description": "ID of the budget", "type": "string", - "example": "2022-04-02T19:28:44.491514Z" + "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" }, "date": { - "description": "Time of day is currently only used for sorting", - "type": "string" - }, - "deletedAt": { - "description": "Time the resource was marked as deleted", + "description": "Date of the transaction. Time is currently only used for sorting", "type": "string", - "example": "2022-04-22T21:01:05.058161Z" - }, - "destinationAccount": { - "$ref": "#/definitions/models.Account" - }, - "destinationAccountID": { - "type": "string" - }, - "envelope": { - "$ref": "#/definitions/models.Envelope" + "example": "1815-12-10T18:43:00.271152Z" }, - "envelopeID": { - "type": "string" + "destinationAccountId": { + "description": "ID of the destination account", + "type": "string", + "example": "8e16b456-a719-48ce-9fec-e115cfa7cbcc" }, - "id": { - "description": "UUID for the resource", + "envelopeId": { + "description": "ID of the envelope", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" + "example": "2649c965-7999-4873-ae16-89d5d5fa972e" }, "importHash": { - "description": "The SHA256 hash of a unique combination of values to use in duplicate detection when importing transactions", - "type": "string" + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", + "type": "string", + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" }, "note": { - "type": "string" + "description": "A note", + "type": "string", + "example": "Lunch" }, "reconciledDestination": { "description": "Is the transaction reconciled in the destination account?", - "type": "boolean" + "type": "boolean", + "default": false, + "example": true }, "reconciledSource": { "description": "Is the transaction reconciled in the source account?", - "type": "boolean" - }, - "sourceAccount": { - "$ref": "#/definitions/models.Account" - }, - "sourceAccountID": { - "type": "string" + "type": "boolean", + "default": false, + "example": true }, - "updatedAt": { - "description": "Last time the resource was updated", + "sourceAccountId": { + "description": "ID of the source account", "type": "string", - "example": "2022-04-17T20:14:01.048145Z" + "example": "fd81dc45-a3a2-468e-a6fa-b2618f30aa45" } } }, - "root.Links": { + "v3.TransactionLinks": { "type": "object", "properties": { - "docs": { - "description": "Swagger API documentation", + "self": { + "description": "The transaction itself", "type": "string", - "example": "https://example.com/api/docs/index.html" + "example": "https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" + } + } + }, + "v3.TransactionListResponse": { + "type": "object", + "properties": { + "data": { + "description": "List of transactions", + "type": "array", + "items": { + "$ref": "#/definitions/v3.Transaction" + } }, - "healthz": { - "description": "Healthz endpoint", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "https://example.com/api/healtzh" + "example": "the specified resource ID is not a valid UUID" }, - "metrics": { - "description": "Endpoint returning Prometheus metrics", + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/v3.Pagination" + } + ] + } + } + }, + "v3.TransactionPreview": { + "type": "object", + "properties": { + "destinationAccountName": { + "description": "Name of the destination account from the CSV file", "type": "string", - "example": "https://example.com/api/metrics" + "example": "Deutsche Bahn" }, - "v3": { - "description": "List endpoint for all v3 endpoints", + "duplicateTransactionIds": { + "description": "IDs of transactions that this transaction duplicates", + "type": "array", + "items": { + "type": "string" + } + }, + "matchRuleId": { + "description": "ID of the match rule that was applied to this transaction preview", "type": "string", - "example": "https://example.com/api/v3" + "example": "042d101d-f1de-4403-9295-59dc0ea58677" }, - "version": { - "description": "Endpoint returning the version of the backend", + "sourceAccountName": { + "description": "Name of the source account from the CSV file", "type": "string", - "example": "https://example.com/api/version" + "example": "Employer" + }, + "transaction": { + "$ref": "#/definitions/models.Transaction" } } }, - "root.Response": { + "v3.TransactionResponse": { "type": "object", "properties": { - "links": { - "$ref": "#/definitions/root.Links" + "data": { + "description": "The Transaction data, if creation was successful", + "allOf": [ + { + "$ref": "#/definitions/v3.Transaction" + } + ] + }, + "error": { + "description": "The error, if any occurred for this transaction", + "type": "string", + "example": "the specified resource ID is not a valid UUID" } } }, - "v3.Account": { + "v4.Account": { "type": "object", "properties": { "archived": { @@ -3251,11 +7923,6 @@ "default": false, "example": true }, - "balance": { - "description": "These fields are computed", - "type": "number", - "example": 2735.17 - }, "budgetId": { "description": "ID of the budget this account belongs to", "type": "string", @@ -3302,7 +7969,7 @@ "example": "2017-05-12T00:00:00Z" }, "links": { - "$ref": "#/definitions/v3.AccountLinks" + "$ref": "#/definitions/v4.AccountLinks" }, "name": { "description": "Name of the account", @@ -3320,33 +7987,71 @@ "default": false, "example": true }, - "recentEnvelopes": { - "description": "Envelopes recently used with this account", - "type": "array", - "items": { - "type": "string" - } + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "v4.AccountComputedData": { + "type": "object", + "properties": { + "balance": { + "description": "Balance of the account, including all transactions referencing it", + "type": "number", + "example": 2735.17 + }, + "id": { + "description": "ID of the account", + "type": "string", + "example": "95018a69-758b-46c6-8bab-db70d9614f9d" }, "reconciledBalance": { "description": "Balance of the account, including all reconciled transactions referencing it", "type": "number", "example": 2539.57 + } + } + }, + "v4.AccountComputedDataResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/v4.AccountComputedData" + } }, - "updatedAt": { - "description": "Last time the resource was updated", - "type": "string", - "example": "2022-04-17T20:14:01.048145Z" + "error": { + "type": "string" } } }, - "v3.AccountCreateResponse": { + "v4.AccountComputedRequest": { + "type": "object", + "properties": { + "ids": { + "description": "A list of UUIDs for the accounts", + "type": "array", + "items": { + "type": "string" + } + }, + "time": { + "description": "The time for which the computation is requested", + "type": "string" + } + } + }, + "v4.AccountCreateResponse": { "type": "object", "properties": { "data": { "description": "List of created Accounts", "type": "array", "items": { - "$ref": "#/definitions/v3.AccountResponse" + "$ref": "#/definitions/v4.AccountResponse" } }, "error": { @@ -3356,7 +8061,7 @@ } } }, - "v3.AccountEditable": { + "v4.AccountEditable": { "type": "object", "properties": { "archived": { @@ -3413,29 +8118,39 @@ } } }, - "v3.AccountLinks": { + "v4.AccountLinks": { "type": "object", "properties": { + "computedData": { + "description": "Computed data endpoint for accounts", + "type": "string", + "example": "https://example.com/api/v4/accounts/computed" + }, + "recentEnvelopes": { + "description": "Envelopes in recent transactions where this account was the target", + "type": "string", + "example": "https://example.com/api/v4/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2/recent-envelopes" + }, "self": { "description": "The account itself", "type": "string", - "example": "https://example.com/api/v3/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" + "example": "https://example.com/api/v4/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" }, "transactions": { "description": "Transactions referencing the account", "type": "string", - "example": "https://example.com/api/v3/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" + "example": "https://example.com/api/v4/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" } } }, - "v3.AccountListResponse": { + "v4.AccountListResponse": { "type": "object", "properties": { "data": { "description": "List of accounts", "type": "array", "items": { - "$ref": "#/definitions/v3.Account" + "$ref": "#/definitions/v4.Account" } }, "error": { @@ -3447,20 +8162,20 @@ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.AccountResponse": { + "v4.AccountResponse": { "type": "object", "properties": { "data": { "description": "Data for the account", "allOf": [ { - "$ref": "#/definitions/v3.Account" + "$ref": "#/definitions/v4.Account" } ] }, @@ -3471,7 +8186,7 @@ } } }, - "v3.AllocationMode": { + "v4.AllocationMode": { "type": "string", "enum": [ "ALLOCATE_LAST_MONTH_BUDGET", @@ -3482,7 +8197,7 @@ "AllocateLastMonthSpend" ] }, - "v3.Budget": { + "v4.Budget": { "type": "object", "properties": { "createdAt": { @@ -3506,7 +8221,7 @@ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.BudgetLinks" + "$ref": "#/definitions/v4.BudgetLinks" }, "name": { "description": "Name of the budget", @@ -3525,28 +8240,28 @@ } } }, - "v3.BudgetAllocationMode": { + "v4.BudgetAllocationMode": { "type": "object", "properties": { "mode": { "description": "Mode to allocate budget with", "allOf": [ { - "$ref": "#/definitions/v3.AllocationMode" + "$ref": "#/definitions/v4.AllocationMode" } ], "example": "ALLOCATE_LAST_MONTH_SPEND" } } }, - "v3.BudgetCreateResponse": { + "v4.BudgetCreateResponse": { "type": "object", "properties": { "data": { "description": "List of created Budgets", "type": "array", "items": { - "$ref": "#/definitions/v3.BudgetResponse" + "$ref": "#/definitions/v4.BudgetResponse" } }, "error": { @@ -3556,7 +8271,7 @@ } } }, - "v3.BudgetEditable": { + "v4.BudgetEditable": { "type": "object", "properties": { "currency": { @@ -3576,49 +8291,49 @@ } } }, - "v3.BudgetLinks": { + "v4.BudgetLinks": { "type": "object", "properties": { "accounts": { "description": "Accounts for this budget", "type": "string", - "example": "https://example.com/api/v3/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + "example": "https://example.com/api/v4/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, "categories": { "description": "Categories for this budget", "type": "string", - "example": "https://example.com/api/v3/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + "example": "https://example.com/api/v4/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, "envelopes": { "description": "Envelopes for this budget", "type": "string", - "example": "https://example.com/api/v3/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + "example": "https://example.com/api/v4/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, "month": { "description": "This uses 'YYYY-MM' for clients to replace with the actual year and month.", "type": "string", - "example": "https://example.com/api/v3/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf\u0026month=YYYY-MM" + "example": "https://example.com/api/v4/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf\u0026month=YYYY-MM" }, "self": { "description": "The budget itself", "type": "string", - "example": "https://example.com/api/v3/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf" + "example": "https://example.com/api/v4/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, "transactions": { "description": "Transactions for this budget", "type": "string", - "example": "https://example.com/api/v3/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" + "example": "https://example.com/api/v4/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" } } }, - "v3.BudgetListResponse": { + "v4.BudgetListResponse": { "type": "object", "properties": { "data": { "description": "List of budgets", "type": "array", "items": { - "$ref": "#/definitions/v3.Budget" + "$ref": "#/definitions/v4.Budget" } }, "error": { @@ -3630,20 +8345,20 @@ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.BudgetResponse": { + "v4.BudgetResponse": { "type": "object", "properties": { "data": { "description": "Data for the budget", "allOf": [ { - "$ref": "#/definitions/v3.Budget" + "$ref": "#/definitions/v4.Budget" } ] }, @@ -3654,7 +8369,7 @@ } } }, - "v3.Category": { + "v4.Category": { "type": "object", "properties": { "archived": { @@ -3682,7 +8397,7 @@ "description": "These fields are computed", "type": "array", "items": { - "$ref": "#/definitions/v3.Envelope" + "$ref": "#/definitions/v4.Envelope" } }, "id": { @@ -3691,7 +8406,7 @@ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.CategoryLinks" + "$ref": "#/definitions/v4.CategoryLinks" }, "name": { "description": "Name of the category", @@ -3710,14 +8425,14 @@ } } }, - "v3.CategoryCreateResponse": { + "v4.CategoryCreateResponse": { "type": "object", "properties": { "data": { "description": "List of the created Categories or their respective error", "type": "array", "items": { - "$ref": "#/definitions/v3.CategoryResponse" + "$ref": "#/definitions/v4.CategoryResponse" } }, "error": { @@ -3727,7 +8442,7 @@ } } }, - "v3.CategoryEditable": { + "v4.CategoryEditable": { "type": "object", "properties": { "archived": { @@ -3753,7 +8468,7 @@ } } }, - "v3.CategoryEnvelopes": { + "v4.CategoryEnvelopes": { "type": "object", "properties": { "allocation": { @@ -3791,7 +8506,7 @@ "description": "Slice of all envelopes", "type": "array", "items": { - "$ref": "#/definitions/v3.EnvelopeMonth" + "$ref": "#/definitions/v4.EnvelopeMonth" } }, "id": { @@ -3800,7 +8515,7 @@ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.CategoryLinks" + "$ref": "#/definitions/v4.CategoryLinks" }, "name": { "description": "Name of the category", @@ -3824,29 +8539,29 @@ } } }, - "v3.CategoryLinks": { + "v4.CategoryLinks": { "type": "object", "properties": { "envelopes": { "description": "Envelopes for this category", "type": "string", - "example": "https://example.com/api/v3/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f" + "example": "https://example.com/api/v4/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f" }, "self": { "description": "The category itself", "type": "string", - "example": "https://example.com/api/v3/categories/3b1ea324-d438-4419-882a-2fc91d71772f" + "example": "https://example.com/api/v4/categories/3b1ea324-d438-4419-882a-2fc91d71772f" } } }, - "v3.CategoryListResponse": { + "v4.CategoryListResponse": { "type": "object", "properties": { "data": { "description": "List of Categories", "type": "array", "items": { - "$ref": "#/definitions/v3.Category" + "$ref": "#/definitions/v4.Category" } }, "error": { @@ -3858,20 +8573,20 @@ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.CategoryResponse": { + "v4.CategoryResponse": { "type": "object", "properties": { "data": { "description": "Data for the Category", "allOf": [ { - "$ref": "#/definitions/v3.Category" + "$ref": "#/definitions/v4.Category" } ] }, @@ -3882,7 +8597,7 @@ } } }, - "v3.Envelope": { + "v4.Envelope": { "type": "object", "properties": { "archived": { @@ -3915,7 +8630,7 @@ "description": "Links to related resources", "allOf": [ { - "$ref": "#/definitions/v3.EnvelopeLinks" + "$ref": "#/definitions/v4.EnvelopeLinks" } ] }, @@ -3936,14 +8651,14 @@ } } }, - "v3.EnvelopeCreateResponse": { + "v4.EnvelopeCreateResponse": { "type": "object", "properties": { "data": { "description": "Data for the Envelope", "type": "array", "items": { - "$ref": "#/definitions/v3.EnvelopeResponse" + "$ref": "#/definitions/v4.EnvelopeResponse" } }, "error": { @@ -3953,7 +8668,7 @@ } } }, - "v3.EnvelopeEditable": { + "v4.EnvelopeEditable": { "type": "object", "properties": { "archived": { @@ -3979,34 +8694,34 @@ } } }, - "v3.EnvelopeLinks": { + "v4.EnvelopeLinks": { "type": "object", "properties": { "month": { "description": "The MonthConfig for the envelope", "type": "string", - "example": "https://example.com/api/v3/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM" + "example": "https://example.com/api/v4/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM" }, "self": { "description": "The envelope itself", "type": "string", - "example": "https://example.com/api/v3/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166" + "example": "https://example.com/api/v4/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166" }, "transactions": { "description": "The envelope's transactions", "type": "string", - "example": "https://example.com/api/v3/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166" + "example": "https://example.com/api/v4/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166" } } }, - "v3.EnvelopeListResponse": { + "v4.EnvelopeListResponse": { "type": "object", "properties": { "data": { "description": "List of Envelopes", "type": "array", "items": { - "$ref": "#/definitions/v3.Envelope" + "$ref": "#/definitions/v4.Envelope" } }, "error": { @@ -4018,13 +8733,13 @@ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.EnvelopeMonth": { + "v4.EnvelopeMonth": { "type": "object", "properties": { "allocation": { @@ -4064,7 +8779,12 @@ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.EnvelopeLinks" + "description": "Links to related resources", + "allOf": [ + { + "$ref": "#/definitions/v4.EnvelopeLinks" + } + ] }, "name": { "description": "Name of the envelope", @@ -4088,14 +8808,14 @@ } } }, - "v3.EnvelopeResponse": { + "v4.EnvelopeResponse": { "type": "object", "properties": { "data": { "description": "Data for the Envelope", "allOf": [ { - "$ref": "#/definitions/v3.Envelope" + "$ref": "#/definitions/v4.Envelope" } ] }, @@ -4106,7 +8826,7 @@ } } }, - "v3.Goal": { + "v4.Goal": { "type": "object", "properties": { "amount": { @@ -4145,7 +8865,7 @@ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.GoalLinks" + "$ref": "#/definitions/v4.GoalLinks" }, "month": { "description": "The month the goal should be reached", @@ -4169,14 +8889,14 @@ } } }, - "v3.GoalCreateResponse": { + "v4.GoalCreateResponse": { "type": "object", "properties": { "data": { "description": "List of created resources", "type": "array", "items": { - "$ref": "#/definitions/v3.GoalResponse" + "$ref": "#/definitions/v4.GoalResponse" } }, "error": { @@ -4186,7 +8906,7 @@ } } }, - "v3.GoalEditable": { + "v4.GoalEditable": { "type": "object", "properties": { "amount": { @@ -4226,29 +8946,29 @@ } } }, - "v3.GoalLinks": { + "v4.GoalLinks": { "type": "object", "properties": { "envelope": { "description": "The Envelope this goal references", "type": "string", - "example": "https://example.com/api/v3/envelopes/c1a96ae4-80e3-4827-8ed0-c7656f224fee" + "example": "https://example.com/api/v4/envelopes/c1a96ae4-80e3-4827-8ed0-c7656f224fee" }, "self": { "description": "The Goal itself", "type": "string", - "example": "https://example.com/api/v3/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c" + "example": "https://example.com/api/v4/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c" } } }, - "v3.GoalListResponse": { + "v4.GoalListResponse": { "type": "object", "properties": { "data": { "description": "List of resources", "type": "array", "items": { - "$ref": "#/definitions/v3.Goal" + "$ref": "#/definitions/v4.Goal" } }, "error": { @@ -4260,20 +8980,20 @@ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.GoalResponse": { + "v4.GoalResponse": { "type": "object", "properties": { "data": { "description": "The resource", "allOf": [ { - "$ref": "#/definitions/v3.Goal" + "$ref": "#/definitions/v4.Goal" } ] }, @@ -4284,29 +9004,29 @@ } } }, - "v3.ImportLinks": { + "v4.ImportLinks": { "type": "object", "properties": { "matchRules": { "description": "URL of YNAB Import preview endpoint", "type": "string", - "example": "https://example.com/api/v3/import/ynab-import-preview" + "example": "https://example.com/api/v4/import/ynab-import-preview" }, "transactions": { "description": "URL of YNAB4 import endpoint", "type": "string", - "example": "https://example.com/api/v3/import/ynab4" + "example": "https://example.com/api/v4/import/ynab4" } } }, - "v3.ImportPreviewList": { + "v4.ImportPreviewList": { "type": "object", "properties": { "data": { "description": "List of transaction previews", "type": "array", "items": { - "$ref": "#/definitions/v3.TransactionPreview" + "$ref": "#/definitions/v4.TransactionPreview" } }, "error": { @@ -4316,70 +9036,70 @@ } } }, - "v3.ImportResponse": { + "v4.ImportResponse": { "type": "object", "properties": { "links": { - "description": "Links for the v3 API", + "description": "Links for the v4 API", "allOf": [ { - "$ref": "#/definitions/v3.ImportLinks" + "$ref": "#/definitions/v4.ImportLinks" } ] } } }, - "v3.Links": { + "v4.Links": { "type": "object", "properties": { "accounts": { "description": "URL of Account collection endpoint", "type": "string", - "example": "https://example.com/api/v3/accounts" + "example": "https://example.com/api/v4/accounts" }, "budgets": { "description": "URL of Budget collection endpoint", "type": "string", - "example": "https://example.com/api/v3/budgets" + "example": "https://example.com/api/v4/budgets" }, "categories": { "description": "URL of Category collection endpoint", "type": "string", - "example": "https://example.com/api/v3/categories" + "example": "https://example.com/api/v4/categories" }, "envelopes": { "description": "URL of Envelope collection endpoint", "type": "string", - "example": "https://example.com/api/v3/envelopes" + "example": "https://example.com/api/v4/envelopes" }, "goals": { "description": "URL of goal collection endpoint", "type": "string", - "example": "https://example.com/api/v3/goals" + "example": "https://example.com/api/v4/goals" }, "import": { "description": "URL of import list endpoint", "type": "string", - "example": "https://example.com/api/v3/import" + "example": "https://example.com/api/v4/import" }, "matchRules": { "description": "URL of Match Rule collection endpoint", "type": "string", - "example": "https://example.com/api/v3/match-rules" + "example": "https://example.com/api/v4/match-rules" }, "months": { "description": "URL of Month endpoint", "type": "string", - "example": "https://example.com/api/v3/months" + "example": "https://example.com/api/v4/months" }, "transactions": { "description": "URL of Transaction collection endpoint", "type": "string", - "example": "https://example.com/api/v3/transactions" + "example": "https://example.com/api/v4/transactions" } } }, - "v3.MatchRule": { + "v4.MatchRule": { "type": "object", "properties": { "accountId": { @@ -4403,7 +9123,7 @@ "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { - "$ref": "#/definitions/v3.MatchRuleLinks" + "$ref": "#/definitions/v4.MatchRuleLinks" }, "match": { "description": "The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive.", @@ -4422,14 +9142,14 @@ } } }, - "v3.MatchRuleCreateResponse": { + "v4.MatchRuleCreateResponse": { "type": "object", "properties": { "data": { "description": "List of created Match Rules", "type": "array", "items": { - "$ref": "#/definitions/v3.MatchRuleResponse" + "$ref": "#/definitions/v4.MatchRuleResponse" } }, "error": { @@ -4439,7 +9159,7 @@ } } }, - "v3.MatchRuleEditable": { + "v4.MatchRuleEditable": { "type": "object", "properties": { "accountId": { @@ -4459,24 +9179,24 @@ } } }, - "v3.MatchRuleLinks": { + "v4.MatchRuleLinks": { "type": "object", "properties": { "self": { "description": "The match rule itself", "type": "string", - "example": "https://example.com/api/v3/match-rules/95685c82-53c6-455d-b235-f49960b73b21" + "example": "https://example.com/api/v4/match-rules/95685c82-53c6-455d-b235-f49960b73b21" } } }, - "v3.MatchRuleListResponse": { + "v4.MatchRuleListResponse": { "type": "object", "properties": { "data": { "description": "List of Match Rules", "type": "array", "items": { - "$ref": "#/definitions/v3.MatchRule" + "$ref": "#/definitions/v4.MatchRule" } }, "error": { @@ -4488,20 +9208,20 @@ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.MatchRuleResponse": { + "v4.MatchRuleResponse": { "type": "object", "properties": { "data": { "description": "The Match Rule data, if creation was successful", "allOf": [ { - "$ref": "#/definitions/v3.MatchRule" + "$ref": "#/definitions/v4.MatchRule" } ] }, @@ -4512,7 +9232,7 @@ } } }, - "v3.Month": { + "v4.Month": { "type": "object", "properties": { "allocation": { @@ -4534,7 +9254,7 @@ "description": "A list of envelope month calculations grouped by category", "type": "array", "items": { - "$ref": "#/definitions/v3.CategoryEnvelopes" + "$ref": "#/definitions/v4.CategoryEnvelopes" } }, "id": { @@ -4564,7 +9284,7 @@ } } }, - "v3.MonthConfig": { + "v4.MonthConfig": { "type": "object", "properties": { "allocation": { @@ -4585,7 +9305,7 @@ "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" }, "links": { - "$ref": "#/definitions/v3.MonthConfigLinks" + "$ref": "#/definitions/v4.MonthConfigLinks" }, "month": { "description": "We do not use the default model here, we use envelope ID and month", @@ -4598,7 +9318,7 @@ } } }, - "v3.MonthConfigEditable": { + "v4.MonthConfigEditable": { "type": "object", "properties": { "allocation": { @@ -4626,29 +9346,29 @@ } } }, - "v3.MonthConfigLinks": { + "v4.MonthConfigLinks": { "type": "object", "properties": { "envelope": { "description": "The Envelope this config belongs to", "type": "string", - "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b" + "example": "https://example.com/api/v4/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b" }, "self": { "description": "The Month Config itself", "type": "string", - "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" + "example": "https://example.com/api/v4/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" } } }, - "v3.MonthConfigResponse": { + "v4.MonthConfigResponse": { "type": "object", "properties": { "data": { "description": "Config for the month", "allOf": [ { - "$ref": "#/definitions/v3.MonthConfig" + "$ref": "#/definitions/v4.MonthConfig" } ] }, @@ -4659,14 +9379,14 @@ } } }, - "v3.MonthResponse": { + "v4.MonthResponse": { "type": "object", "properties": { "data": { "description": "Data for the month", "allOf": [ { - "$ref": "#/definitions/v3.Month" + "$ref": "#/definitions/v4.Month" } ] }, @@ -4676,7 +9396,7 @@ } } }, - "v3.Pagination": { + "v4.Pagination": { "type": "object", "properties": { "count": { @@ -4701,20 +9421,48 @@ } } }, - "v3.Response": { + "v4.RecentEnvelope": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "v4.RecentEnvelopesResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data for the account", + "type": "array", + "items": { + "$ref": "#/definitions/v4.RecentEnvelope" + } + }, + "error": { + "description": "The error, if any occurred for this transaction", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "v4.Response": { "type": "object", "properties": { "links": { - "description": "Links for the v3 API", + "description": "Links for the v4 API", "allOf": [ { - "$ref": "#/definitions/v3.Links" + "$ref": "#/definitions/v4.Links" } ] } } }, - "v3.Transaction": { + "v4.Transaction": { "type": "object", "properties": { "amount": { @@ -4730,11 +9478,6 @@ "type": "string", "example": "2021-11-17T00:00:00Z" }, - "budgetId": { - "description": "ID of the budget", - "type": "string", - "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" - }, "createdAt": { "description": "Time the resource was created", "type": "string", @@ -4771,7 +9514,7 @@ "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" }, "links": { - "$ref": "#/definitions/v3.TransactionLinks" + "$ref": "#/definitions/v4.TransactionLinks" }, "note": { "description": "A note", @@ -4802,14 +9545,14 @@ } } }, - "v3.TransactionCreateResponse": { + "v4.TransactionCreateResponse": { "type": "object", "properties": { "data": { "description": "List of created Transactions", "type": "array", "items": { - "$ref": "#/definitions/v3.TransactionResponse" + "$ref": "#/definitions/v4.TransactionResponse" } }, "error": { @@ -4819,7 +9562,7 @@ } } }, - "v3.TransactionEditable": { + "v4.TransactionEditable": { "type": "object", "properties": { "amount": { @@ -4835,11 +9578,6 @@ "type": "string", "example": "2021-11-17T00:00:00Z" }, - "budgetId": { - "description": "ID of the budget", - "type": "string", - "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" - }, "date": { "description": "Date of the transaction. Time is currently only used for sorting", "type": "string", @@ -4884,24 +9622,24 @@ } } }, - "v3.TransactionLinks": { + "v4.TransactionLinks": { "type": "object", "properties": { "self": { "description": "The transaction itself", "type": "string", - "example": "https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" + "example": "https://example.com/api/v4/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" } } }, - "v3.TransactionListResponse": { + "v4.TransactionListResponse": { "type": "object", "properties": { "data": { "description": "List of transactions", "type": "array", "items": { - "$ref": "#/definitions/v3.Transaction" + "$ref": "#/definitions/v4.Transaction" } }, "error": { @@ -4913,13 +9651,13 @@ "description": "Pagination information", "allOf": [ { - "$ref": "#/definitions/v3.Pagination" + "$ref": "#/definitions/v4.Pagination" } ] } } }, - "v3.TransactionPreview": { + "v4.TransactionPreview": { "type": "object", "properties": { "destinationAccountName": { @@ -4949,14 +9687,14 @@ } } }, - "v3.TransactionResponse": { + "v4.TransactionResponse": { "type": "object", "properties": { "data": { "description": "The Transaction data, if creation was successful", "allOf": [ { - "$ref": "#/definitions/v3.Transaction" + "$ref": "#/definitions/v4.Transaction" } ] }, diff --git a/api/swagger.yaml b/api/swagger.yaml index 5d7c5622..9e3a47c7 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -1501,34 +1501,3163 @@ definitions: example: the specified resource ID is not a valid UUID type: string type: object - version.Object: + v4.Account: properties: - version: - description: the running version of the Envelope Zero backend - example: 1.1.0 + archived: + default: false + description: Is the account archived? + example: true + type: boolean + budgetId: + description: ID of the budget this account belongs to + example: 550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + createdAt: + description: Time the resource was created + example: "2022-04-02T19:28:44.491514Z" + type: string + deletedAt: + description: Time the resource was marked as deleted + example: "2022-04-22T21:01:05.058161Z" + type: string + external: + default: false + description: Does the account belong to the budget owner or not? + example: false + type: boolean + id: + description: UUID for the resource + example: 65392deb-5e92-4268-b114-297faad6cdce + type: string + importHash: + description: The SHA256 hash of a unique combination of values to use in duplicate + detection for imports + example: 867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70 + type: string + initialBalance: + default: 0 + description: Balance of the account before any transactions were recorded + example: 173.12 + maximum: 1000000000000 + minimum: 1e-08 + multipleOf: 1e-08 + type: number + initialBalanceDate: + description: Date of the initial balance + example: "2017-05-12T00:00:00Z" + type: string + links: + $ref: '#/definitions/v4.AccountLinks' + name: + description: Name of the account + example: Cash + type: string + note: + description: A longer description for the account + example: Money in my wallet + type: string + onBudget: + default: false + description: 'Does the account factor into the available budget? Always false + when external: true' + example: true + type: boolean + updatedAt: + description: Last time the resource was updated + example: "2022-04-17T20:14:01.048145Z" + type: string + type: object + v4.AccountComputedData: + properties: + balance: + description: Balance of the account, including all transactions referencing + it + example: 2735.17 + type: number + id: + description: ID of the account + example: 95018a69-758b-46c6-8bab-db70d9614f9d + type: string + reconciledBalance: + description: Balance of the account, including all reconciled transactions + referencing it + example: 2539.57 + type: number + type: object + v4.AccountComputedDataResponse: + properties: + data: + items: + $ref: '#/definitions/v4.AccountComputedData' + type: array + error: + type: string + type: object + v4.AccountComputedRequest: + properties: + ids: + description: A list of UUIDs for the accounts + items: + type: string + type: array + time: + description: The time for which the computation is requested + type: string + type: object + v4.AccountCreateResponse: + properties: + data: + description: List of created Accounts + items: + $ref: '#/definitions/v4.AccountResponse' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.AccountEditable: + properties: + archived: + default: false + description: Is the account archived? + example: true + type: boolean + budgetId: + description: ID of the budget this account belongs to + example: 550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + external: + default: false + description: Does the account belong to the budget owner or not? + example: false + type: boolean + importHash: + description: The SHA256 hash of a unique combination of values to use in duplicate + detection for imports + example: 867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70 + type: string + initialBalance: + default: 0 + description: Balance of the account before any transactions were recorded + example: 173.12 + maximum: 1000000000000 + minimum: 1e-08 + multipleOf: 1e-08 + type: number + initialBalanceDate: + description: Date of the initial balance + example: "2017-05-12T00:00:00Z" + type: string + name: + description: Name of the account + example: Cash + type: string + note: + description: A longer description for the account + example: Money in my wallet + type: string + onBudget: + default: false + description: 'Does the account factor into the available budget? Always false + when external: true' + example: true + type: boolean + type: object + v4.AccountLinks: + properties: + computedData: + description: Computed data endpoint for accounts + example: https://example.com/api/v4/accounts/computed + type: string + recentEnvelopes: + description: Envelopes in recent transactions where this account was the target + example: https://example.com/api/v4/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2/recent-envelopes + type: string + self: + description: The account itself + example: https://example.com/api/v4/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2 + type: string + transactions: + description: Transactions referencing the account + example: https://example.com/api/v4/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2 + type: string + type: object + v4.AccountListResponse: + properties: + data: + description: List of accounts + items: + $ref: '#/definitions/v4.Account' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + pagination: + allOf: + - $ref: '#/definitions/v4.Pagination' + description: Pagination information + type: object + v4.AccountResponse: + properties: + data: + allOf: + - $ref: '#/definitions/v4.Account' + description: Data for the account + error: + description: The error, if any occurred for this transaction + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.AllocationMode: + enum: + - ALLOCATE_LAST_MONTH_BUDGET + - ALLOCATE_LAST_MONTH_SPEND + type: string + x-enum-varnames: + - AllocateLastMonthBudget + - AllocateLastMonthSpend + v4.Budget: + properties: + createdAt: + description: Time the resource was created + example: "2022-04-02T19:28:44.491514Z" + type: string + currency: + description: The currency for the budget + example: € + type: string + deletedAt: + description: Time the resource was marked as deleted + example: "2022-04-22T21:01:05.058161Z" + type: string + id: + description: UUID for the resource + example: 65392deb-5e92-4268-b114-297faad6cdce + type: string + links: + $ref: '#/definitions/v4.BudgetLinks' + name: + description: Name of the budget + example: Morre's Budget + type: string + note: + description: A longer description of the budget + example: My personal expenses + type: string + updatedAt: + description: Last time the resource was updated + example: "2022-04-17T20:14:01.048145Z" + type: string + type: object + v4.BudgetAllocationMode: + properties: + mode: + allOf: + - $ref: '#/definitions/v4.AllocationMode' + description: Mode to allocate budget with + example: ALLOCATE_LAST_MONTH_SPEND + type: object + v4.BudgetCreateResponse: + properties: + data: + description: List of created Budgets + items: + $ref: '#/definitions/v4.BudgetResponse' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.BudgetEditable: + properties: + currency: + description: The currency for the budget + example: € + type: string + name: + description: Name of the budget + example: Morre's Budget + type: string + note: + description: A longer description of the budget + example: My personal expenses + type: string + type: object + v4.BudgetLinks: + properties: + accounts: + description: Accounts for this budget + example: https://example.com/api/v4/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + categories: + description: Categories for this budget + example: https://example.com/api/v4/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + envelopes: + description: Envelopes for this budget + example: https://example.com/api/v4/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + month: + description: This uses 'YYYY-MM' for clients to replace with the actual year + and month. + example: https://example.com/api/v4/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf&month=YYYY-MM + type: string + self: + description: The budget itself + example: https://example.com/api/v4/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + transactions: + description: Transactions for this budget + example: https://example.com/api/v4/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + type: object + v4.BudgetListResponse: + properties: + data: + description: List of budgets + items: + $ref: '#/definitions/v4.Budget' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + pagination: + allOf: + - $ref: '#/definitions/v4.Pagination' + description: Pagination information + type: object + v4.BudgetResponse: + properties: + data: + allOf: + - $ref: '#/definitions/v4.Budget' + description: Data for the budget + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.Category: + properties: + archived: + default: false + description: Is the category archived? + example: true + type: boolean + budgetId: + description: ID of the budget the category belongs to + example: 52d967d3-33f4-4b04-9ba7-772e5ab9d0ce + type: string + createdAt: + description: Time the resource was created + example: "2022-04-02T19:28:44.491514Z" + type: string + deletedAt: + description: Time the resource was marked as deleted + example: "2022-04-22T21:01:05.058161Z" + type: string + envelopes: + description: These fields are computed + items: + $ref: '#/definitions/v4.Envelope' + type: array + id: + description: UUID for the resource + example: 65392deb-5e92-4268-b114-297faad6cdce + type: string + links: + $ref: '#/definitions/v4.CategoryLinks' + name: + description: Name of the category + example: Saving + type: string + note: + description: Notes about the category + example: All envelopes for long-term saving + type: string + updatedAt: + description: Last time the resource was updated + example: "2022-04-17T20:14:01.048145Z" + type: string + type: object + v4.CategoryCreateResponse: + properties: + data: + description: List of the created Categories or their respective error + items: + $ref: '#/definitions/v4.CategoryResponse' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.CategoryEditable: + properties: + archived: + default: false + description: Is the category archived? + example: true + type: boolean + budgetId: + description: ID of the budget the category belongs to + example: 52d967d3-33f4-4b04-9ba7-772e5ab9d0ce + type: string + name: + description: Name of the category + example: Saving + type: string + note: + description: Notes about the category + example: All envelopes for long-term saving + type: string + type: object + v4.CategoryEnvelopes: + properties: + allocation: + description: Sum of allocations for the envelopes + example: 90 + type: number + archived: + default: false + description: Is the category archived? + example: true + type: boolean + balance: + description: Sum of the balances of the envelopes + example: -10.13 + type: number + budgetId: + description: ID of the budget the category belongs to + example: 52d967d3-33f4-4b04-9ba7-772e5ab9d0ce + type: string + createdAt: + description: Time the resource was created + example: "2022-04-02T19:28:44.491514Z" + type: string + deletedAt: + description: Time the resource was marked as deleted + example: "2022-04-22T21:01:05.058161Z" + type: string + envelopes: + description: Slice of all envelopes + items: + $ref: '#/definitions/v4.EnvelopeMonth' + type: array + id: + description: UUID for the resource + example: 65392deb-5e92-4268-b114-297faad6cdce + type: string + links: + $ref: '#/definitions/v4.CategoryLinks' + name: + description: Name of the category + example: Saving + type: string + note: + description: Notes about the category + example: All envelopes for long-term saving + type: string + spent: + description: Sum spent for all envelopes + example: 100.13 + type: number + updatedAt: + description: Last time the resource was updated + example: "2022-04-17T20:14:01.048145Z" + type: string + type: object + v4.CategoryLinks: + properties: + envelopes: + description: Envelopes for this category + example: https://example.com/api/v4/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f + type: string + self: + description: The category itself + example: https://example.com/api/v4/categories/3b1ea324-d438-4419-882a-2fc91d71772f + type: string + type: object + v4.CategoryListResponse: + properties: + data: + description: List of Categories + items: + $ref: '#/definitions/v4.Category' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + pagination: + allOf: + - $ref: '#/definitions/v4.Pagination' + description: Pagination information + type: object + v4.CategoryResponse: + properties: + data: + allOf: + - $ref: '#/definitions/v4.Category' + description: Data for the Category + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.Envelope: + properties: + archived: + default: false + description: Is the envelope archived? + example: true + type: boolean + categoryId: + description: ID of the category the envelope belongs to + example: 878c831f-af99-4a71-b3ca-80deb7d793c1 + type: string + createdAt: + description: Time the resource was created + example: "2022-04-02T19:28:44.491514Z" + type: string + deletedAt: + description: Time the resource was marked as deleted + example: "2022-04-22T21:01:05.058161Z" + type: string + id: + description: UUID for the resource + example: 65392deb-5e92-4268-b114-297faad6cdce + type: string + links: + allOf: + - $ref: '#/definitions/v4.EnvelopeLinks' + description: Links to related resources + name: + description: Name of the envelope + example: Groceries + type: string + note: + description: Notes about the envelope + example: For stuff bought at supermarkets and drugstores + type: string + updatedAt: + description: Last time the resource was updated + example: "2022-04-17T20:14:01.048145Z" + type: string + type: object + v4.EnvelopeCreateResponse: + properties: + data: + description: Data for the Envelope + items: + $ref: '#/definitions/v4.EnvelopeResponse' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.EnvelopeEditable: + properties: + archived: + default: false + description: Is the envelope archived? + example: true + type: boolean + categoryId: + description: ID of the category the envelope belongs to + example: 878c831f-af99-4a71-b3ca-80deb7d793c1 + type: string + name: + description: Name of the envelope + example: Groceries + type: string + note: + description: Notes about the envelope + example: For stuff bought at supermarkets and drugstores + type: string + type: object + v4.EnvelopeLinks: + properties: + month: + description: The MonthConfig for the envelope + example: https://example.com/api/v4/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM + type: string + self: + description: The envelope itself + example: https://example.com/api/v4/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166 + type: string + transactions: + description: The envelope's transactions + example: https://example.com/api/v4/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166 + type: string + type: object + v4.EnvelopeListResponse: + properties: + data: + description: List of Envelopes + items: + $ref: '#/definitions/v4.Envelope' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + pagination: + allOf: + - $ref: '#/definitions/v4.Pagination' + description: Pagination information + type: object + v4.EnvelopeMonth: + properties: + allocation: + description: The amount of money allocated + example: 85.44 + type: number + archived: + default: false + description: Is the envelope archived? + example: true + type: boolean + balance: + description: The balance at the end of the monht + example: 12.32 + type: number + categoryId: + description: ID of the category the envelope belongs to + example: 878c831f-af99-4a71-b3ca-80deb7d793c1 + type: string + createdAt: + description: Time the resource was created + example: "2022-04-02T19:28:44.491514Z" + type: string + deletedAt: + description: Time the resource was marked as deleted + example: "2022-04-22T21:01:05.058161Z" + type: string + id: + description: UUID for the resource + example: 65392deb-5e92-4268-b114-297faad6cdce + type: string + links: + allOf: + - $ref: '#/definitions/v4.EnvelopeLinks' + description: Links to related resources + name: + description: Name of the envelope + example: Groceries + type: string + note: + description: Notes about the envelope + example: For stuff bought at supermarkets and drugstores + type: string + spent: + description: The amount spent over the whole month + example: 73.12 + type: number + updatedAt: + description: Last time the resource was updated + example: "2022-04-17T20:14:01.048145Z" + type: string + type: object + v4.EnvelopeResponse: + properties: + data: + allOf: + - $ref: '#/definitions/v4.Envelope' + description: Data for the Envelope + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.Goal: + properties: + amount: + default: 0 + description: How much money should be saved for this goal? + example: 750 + maximum: 1000000000000 + minimum: 1e-08 + multipleOf: 1e-08 + type: number + archived: + default: false + description: If this goal is still in use or not + example: true + type: boolean + createdAt: + description: Time the resource was created + example: "2022-04-02T19:28:44.491514Z" + type: string + deletedAt: + description: Time the resource was marked as deleted + example: "2022-04-22T21:01:05.058161Z" + type: string + envelopeId: + description: The ID of the envelope this goal is for + example: f81566d9-af4d-4f13-9830-c62c4b5e4c7e + type: string + id: + description: UUID for the resource + example: 65392deb-5e92-4268-b114-297faad6cdce + type: string + links: + $ref: '#/definitions/v4.GoalLinks' + month: + description: The month the goal should be reached + example: "2024-07-01T00:00:00.000000Z" + type: string + name: + description: Name of the goal + example: New TV + type: string + note: + description: Note about the goal + example: We want to replace the old CRT TV soon-ish + type: string + updatedAt: + description: Last time the resource was updated + example: "2022-04-17T20:14:01.048145Z" + type: string + type: object + v4.GoalCreateResponse: + properties: + data: + description: List of created resources + items: + $ref: '#/definitions/v4.GoalResponse' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.GoalEditable: + properties: + amount: + default: 0 + description: How much money should be saved for this goal? + example: 750 + maximum: 1000000000000 + minimum: 1e-08 + multipleOf: 1e-08 + type: number + archived: + default: false + description: If this goal is still in use or not + example: true + type: boolean + envelopeId: + description: The ID of the envelope this goal is for + example: f81566d9-af4d-4f13-9830-c62c4b5e4c7e + type: string + month: + description: The month the goal should be reached + example: "2024-07-01T00:00:00.000000Z" + type: string + name: + description: Name of the goal + example: New TV + type: string + note: + description: Note about the goal + example: We want to replace the old CRT TV soon-ish + type: string + type: object + v4.GoalLinks: + properties: + envelope: + description: The Envelope this goal references + example: https://example.com/api/v4/envelopes/c1a96ae4-80e3-4827-8ed0-c7656f224fee + type: string + self: + description: The Goal itself + example: https://example.com/api/v4/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c + type: string + type: object + v4.GoalListResponse: + properties: + data: + description: List of resources + items: + $ref: '#/definitions/v4.Goal' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + pagination: + allOf: + - $ref: '#/definitions/v4.Pagination' + description: Pagination information + type: object + v4.GoalResponse: + properties: + data: + allOf: + - $ref: '#/definitions/v4.Goal' + description: The resource + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.ImportLinks: + properties: + matchRules: + description: URL of YNAB Import preview endpoint + example: https://example.com/api/v4/import/ynab-import-preview + type: string + transactions: + description: URL of YNAB4 import endpoint + example: https://example.com/api/v4/import/ynab4 + type: string + type: object + v4.ImportPreviewList: + properties: + data: + description: List of transaction previews + items: + $ref: '#/definitions/v4.TransactionPreview' + type: array + error: + description: The error, if any occurred for this Match Rule + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.ImportResponse: + properties: + links: + allOf: + - $ref: '#/definitions/v4.ImportLinks' + description: Links for the v4 API + type: object + v4.Links: + properties: + accounts: + description: URL of Account collection endpoint + example: https://example.com/api/v4/accounts + type: string + budgets: + description: URL of Budget collection endpoint + example: https://example.com/api/v4/budgets + type: string + categories: + description: URL of Category collection endpoint + example: https://example.com/api/v4/categories + type: string + envelopes: + description: URL of Envelope collection endpoint + example: https://example.com/api/v4/envelopes + type: string + goals: + description: URL of goal collection endpoint + example: https://example.com/api/v4/goals + type: string + import: + description: URL of import list endpoint + example: https://example.com/api/v4/import + type: string + matchRules: + description: URL of Match Rule collection endpoint + example: https://example.com/api/v4/match-rules + type: string + months: + description: URL of Month endpoint + example: https://example.com/api/v4/months + type: string + transactions: + description: URL of Transaction collection endpoint + example: https://example.com/api/v4/transactions + type: string + type: object + v4.MatchRule: + properties: + accountId: + description: The account to map matching transactions to + example: f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5 + type: string + createdAt: + description: Time the resource was created + example: "2022-04-02T19:28:44.491514Z" + type: string + deletedAt: + description: Time the resource was marked as deleted + example: "2022-04-22T21:01:05.058161Z" + type: string + id: + description: UUID for the resource + example: 65392deb-5e92-4268-b114-297faad6cdce + type: string + links: + $ref: '#/definitions/v4.MatchRuleLinks' + match: + description: The matching applied to the opposite account. This is a glob + pattern. Multiple globs are allowed. Globbing is case sensitive. + example: Bank* + type: string + priority: + description: The priority of the match rule + example: 3 + type: integer + updatedAt: + description: Last time the resource was updated + example: "2022-04-17T20:14:01.048145Z" + type: string + type: object + v4.MatchRuleCreateResponse: + properties: + data: + description: List of created Match Rules + items: + $ref: '#/definitions/v4.MatchRuleResponse' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.MatchRuleEditable: + properties: + accountId: + description: The account to map matching transactions to + example: f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5 + type: string + match: + description: The matching applied to the opposite account. This is a glob + pattern. Multiple globs are allowed. Globbing is case sensitive. + example: Bank* + type: string + priority: + description: The priority of the match rule + example: 3 + type: integer + type: object + v4.MatchRuleLinks: + properties: + self: + description: The match rule itself + example: https://example.com/api/v4/match-rules/95685c82-53c6-455d-b235-f49960b73b21 + type: string + type: object + v4.MatchRuleListResponse: + properties: + data: + description: List of Match Rules + items: + $ref: '#/definitions/v4.MatchRule' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + pagination: + allOf: + - $ref: '#/definitions/v4.Pagination' + description: Pagination information + type: object + v4.MatchRuleResponse: + properties: + data: + allOf: + - $ref: '#/definitions/v4.MatchRule' + description: The Match Rule data, if creation was successful + error: + description: The error, if any occurred for this Match Rule + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.Month: + properties: + allocation: + description: The sum of all allocations for this month + example: 1200.5 + type: number + available: + description: The amount available to budget + example: 217.34 + type: number + balance: + description: The sum of all envelope balances + example: 5231.37 + type: number + categories: + description: A list of envelope month calculations grouped by category + items: + $ref: '#/definitions/v4.CategoryEnvelopes' + type: array + id: + description: The ID of the Budget + example: 1e777d24-3f5b-4c43-8000-04f65f895578 + type: string + income: + description: The total income for the month (sum of all incoming transactions + without an Envelope) + example: 2317.34 + type: number + month: + description: The month + example: "2006-05-01T00:00:00.000000Z" + type: string + name: + description: The name of the Budget + example: Zero budget + type: string + spent: + description: The amount of money spent in this month + example: 133.7 + type: number + type: object + v4.MonthConfig: + properties: + allocation: + description: The maximum value is "999999999999.99999999", swagger unfortunately + rounds this. + example: 22.01 + maximum: 1000000000000 + minimum: 1e-08 + multipleOf: 1e-08 + type: number + envelopeID: + description: We do not use the default model here, we use envelope ID and + month + type: string + envelopeId: + description: ID of the envelope + example: 10b9705d-3356-459e-9d5a-28d42a6c4547 + type: string + links: + $ref: '#/definitions/v4.MonthConfigLinks' + month: + description: We do not use the default model here, we use envelope ID and + month + type: string + note: + description: A note for the month config + example: Added 200€ here because we replaced Tim's expensive vase + type: string + type: object + v4.MonthConfigEditable: + properties: + allocation: + description: The maximum value is "999999999999.99999999", swagger unfortunately + rounds this. + example: 22.01 + maximum: 1000000000000 + minimum: 1e-08 + multipleOf: 1e-08 + type: number + envelopeId: + description: ID of the envelope + example: 10b9705d-3356-459e-9d5a-28d42a6c4547 + type: string + month: + description: The month. This is always set to 00:00 UTC on the first of the + month. + example: "1969-06-01T00:00:00.000000Z" + type: string + note: + description: A note for the month config + example: Added 200€ here because we replaced Tim's expensive vase + type: string + type: object + v4.MonthConfigLinks: + properties: + envelope: + description: The Envelope this config belongs to + example: https://example.com/api/v4/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b + type: string + self: + description: The Month Config itself + example: https://example.com/api/v4/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10 + type: string + type: object + v4.MonthConfigResponse: + properties: + data: + allOf: + - $ref: '#/definitions/v4.MonthConfig' + description: Config for the month + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.MonthResponse: + properties: + data: + allOf: + - $ref: '#/definitions/v4.Month' + description: Data for the month + error: + description: The error, if any occurred + type: string + type: object + v4.Pagination: + properties: + count: + description: The amount of records returned in this response + example: 25 + type: integer + limit: + description: The maximum amount of resources to return for this request + example: 25 + type: integer + offset: + description: The offset for the first record returned + example: 50 + type: integer + total: + description: The total number of resources matching the query + example: 827 + type: integer + type: object + v4.RecentEnvelope: + properties: + id: + type: string + name: + type: string + type: object + v4.RecentEnvelopesResponse: + properties: + data: + description: Data for the account + items: + $ref: '#/definitions/v4.RecentEnvelope' + type: array + error: + description: The error, if any occurred for this transaction + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.Response: + properties: + links: + allOf: + - $ref: '#/definitions/v4.Links' + description: Links for the v4 API + type: object + v4.Transaction: + properties: + amount: + description: The maximum value is "999999999999.99999999", swagger unfortunately + rounds this. + example: 14.03 + maximum: 1000000000000 + minimum: 1e-08 + multipleOf: 1e-08 + type: number + availableFrom: + description: The date from which on the transaction amount is available for + budgeting. Only used for income transactions. Defaults to the transaction + date. + example: "2021-11-17T00:00:00Z" + type: string + createdAt: + description: Time the resource was created + example: "2022-04-02T19:28:44.491514Z" + type: string + date: + description: Date of the transaction. Time is currently only used for sorting + example: "1815-12-10T18:43:00.271152Z" + type: string + deletedAt: + description: Time the resource was marked as deleted + example: "2022-04-22T21:01:05.058161Z" + type: string + destinationAccountId: + description: ID of the destination account + example: 8e16b456-a719-48ce-9fec-e115cfa7cbcc + type: string + envelopeId: + description: ID of the envelope + example: 2649c965-7999-4873-ae16-89d5d5fa972e + type: string + id: + description: UUID for the resource + example: 65392deb-5e92-4268-b114-297faad6cdce + type: string + importHash: + description: The SHA256 hash of a unique combination of values to use in duplicate + detection + example: 867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70 + type: string + links: + $ref: '#/definitions/v4.TransactionLinks' + note: + description: A note + example: Lunch + type: string + reconciledDestination: + default: false + description: Is the transaction reconciled in the destination account? + example: true + type: boolean + reconciledSource: + default: false + description: Is the transaction reconciled in the source account? + example: true + type: boolean + sourceAccountId: + description: ID of the source account + example: fd81dc45-a3a2-468e-a6fa-b2618f30aa45 + type: string + updatedAt: + description: Last time the resource was updated + example: "2022-04-17T20:14:01.048145Z" + type: string + type: object + v4.TransactionCreateResponse: + properties: + data: + description: List of created Transactions + items: + $ref: '#/definitions/v4.TransactionResponse' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + v4.TransactionEditable: + properties: + amount: + description: The maximum value is "999999999999.99999999", swagger unfortunately + rounds this. + example: 14.03 + maximum: 1000000000000 + minimum: 1e-08 + multipleOf: 1e-08 + type: number + availableFrom: + description: The date from which on the transaction amount is available for + budgeting. Only used for income transactions. Defaults to the transaction + date. + example: "2021-11-17T00:00:00Z" + type: string + date: + description: Date of the transaction. Time is currently only used for sorting + example: "1815-12-10T18:43:00.271152Z" + type: string + destinationAccountId: + description: ID of the destination account + example: 8e16b456-a719-48ce-9fec-e115cfa7cbcc + type: string + envelopeId: + description: ID of the envelope + example: 2649c965-7999-4873-ae16-89d5d5fa972e + type: string + importHash: + description: The SHA256 hash of a unique combination of values to use in duplicate + detection + example: 867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70 + type: string + note: + description: A note + example: Lunch + type: string + reconciledDestination: + default: false + description: Is the transaction reconciled in the destination account? + example: true + type: boolean + reconciledSource: + default: false + description: Is the transaction reconciled in the source account? + example: true + type: boolean + sourceAccountId: + description: ID of the source account + example: fd81dc45-a3a2-468e-a6fa-b2618f30aa45 + type: string + type: object + v4.TransactionLinks: + properties: + self: + description: The transaction itself + example: https://example.com/api/v4/transactions/d430d7c3-d14c-4712-9336-ee56965a6673 + type: string + type: object + v4.TransactionListResponse: + properties: + data: + description: List of transactions + items: + $ref: '#/definitions/v4.Transaction' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + pagination: + allOf: + - $ref: '#/definitions/v4.Pagination' + description: Pagination information + type: object + v4.TransactionPreview: + properties: + destinationAccountName: + description: Name of the destination account from the CSV file + example: Deutsche Bahn + type: string + duplicateTransactionIds: + description: IDs of transactions that this transaction duplicates + items: + type: string + type: array + matchRuleId: + description: ID of the match rule that was applied to this transaction preview + example: 042d101d-f1de-4403-9295-59dc0ea58677 + type: string + sourceAccountName: + description: Name of the source account from the CSV file + example: Employer + type: string + transaction: + $ref: '#/definitions/models.Transaction' + type: object + v4.TransactionResponse: + properties: + data: + allOf: + - $ref: '#/definitions/v4.Transaction' + description: The Transaction data, if creation was successful + error: + description: The error, if any occurred for this transaction + example: the specified resource ID is not a valid UUID + type: string + type: object + version.Object: + properties: + version: + description: the running version of the Envelope Zero backend + example: 1.1.0 + type: string + type: object + version.Response: + properties: + data: + allOf: + - $ref: '#/definitions/version.Object' + description: Data object for the version endpoint + type: object +info: + contact: {} +paths: + /: + get: + description: Entrypoint for the API, listing all endpoints + responses: + "200": + description: OK + schema: + $ref: '#/definitions/root.Response' + summary: API root + tags: + - General + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - General + /healthz: + get: + description: Returns the application health and, if not healthy, an error + produces: + - application/json + responses: + "204": + description: No Content + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Get health + tags: + - General + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - General + /v3: + delete: + description: Permanently deletes all resources + parameters: + - description: Confirmation to delete all resources. Must have the value 'yes-please-delete-everything' + in: query + name: confirm + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete everything + tags: + - v3 + get: + description: Returns general information about the v3 API + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.Response' + summary: v3 API + tags: + - v3 + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - v3 + /v3/accounts: + get: + description: Returns a list of accounts + parameters: + - description: Filter by name + in: query + name: name + type: string + - description: Filter by note + in: query + name: note + type: string + - description: Filter by budget ID + in: query + name: budget + type: string + - description: Is the account on-budget? + in: query + name: onBudget + type: boolean + - description: Is the account external? + in: query + name: external + type: boolean + - description: Is the account archived? + in: query + name: archived + type: boolean + - description: Search for this text in name and note + in: query + name: search + type: string + - description: The offset of the first Account returned. Defaults to 0. + in: query + name: offset + type: integer + - description: Maximum number of Accounts to return. Defaults to 50. + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.AccountListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.AccountListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.AccountListResponse' + summary: List accounts + tags: + - Accounts + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - Accounts + post: + description: Creates new accounts + parameters: + - description: Accounts + in: body + name: accounts + required: true + schema: + items: + $ref: '#/definitions/v3.AccountEditable' + type: array + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/v3.AccountCreateResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.AccountCreateResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.AccountCreateResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.AccountCreateResponse' + summary: Creates accounts + tags: + - Accounts + /v3/accounts/{id}: + delete: + description: Deletes an account + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete account + tags: + - Accounts + get: + description: Returns a specific account + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.AccountResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.AccountResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.AccountResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.AccountResponse' + summary: Get account + tags: + - Accounts + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Allowed HTTP verbs + tags: + - Accounts + patch: + description: Updates an account. Only values to be updated need to be specified. + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + - description: Account + in: body + name: account + required: true + schema: + $ref: '#/definitions/v3.AccountEditable' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.AccountResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.AccountResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.AccountResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.AccountResponse' + summary: Update account + tags: + - Accounts + /v3/budgets: + get: + description: Returns a list of budgets + parameters: + - description: Filter by name + in: query + name: name + type: string + - description: Filter by note + in: query + name: note + type: string + - description: Filter by currency + in: query + name: currency + type: string + - description: Search for this text in name and note + in: query + name: search + type: string + - description: The offset of the first Budget returned. Defaults to 0. + in: query + name: offset + type: integer + - description: Maximum number of Budgets to return. Defaults to 50. + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.BudgetListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.BudgetListResponse' + summary: List budgets + tags: + - Budgets + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - Budgets + post: + consumes: + - application/json + description: Creates a new budget + parameters: + - description: Budget + in: body + name: budget + required: true + schema: + items: + $ref: '#/definitions/v3.BudgetEditable' + type: array + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/v3.BudgetCreateResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.BudgetCreateResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.BudgetCreateResponse' + summary: Create budget + tags: + - Budgets + /v3/budgets/{id}: + delete: + description: Deletes a budget + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete budget + tags: + - Budgets + get: + description: Returns a specific budget + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.BudgetResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.BudgetResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.BudgetResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.BudgetResponse' + summary: Get budget + tags: + - Budgets + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Allowed HTTP verbs + tags: + - Budgets + patch: + consumes: + - application/json + description: Update an existing budget. Only values to be updated need to be + specified. + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + - description: Budget + in: body + name: budget + required: true + schema: + $ref: '#/definitions/v3.BudgetEditable' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.BudgetResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.BudgetResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.BudgetResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.BudgetResponse' + summary: Update budget + tags: + - Budgets + /v3/categories: + get: + description: Returns a list of categories + parameters: + - description: Filter by name + in: query + name: name + type: string + - description: Filter by note + in: query + name: note + type: string + - description: Filter by budget ID + in: query + name: budget + type: string + - description: Is the category archived? + in: query + name: archived + type: boolean + - description: Search for this text in name and note + in: query + name: search + type: string + - description: The offset of the first Category returned. Defaults to 0. + in: query + name: offset + type: integer + - description: Maximum number of Categories to return. Defaults to 50. + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.CategoryListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.CategoryListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.CategoryListResponse' + summary: Get categories + tags: + - Categories + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - Categories + post: + description: Creates a new category + parameters: + - description: Categories + in: body + name: categories + required: true + schema: + items: + $ref: '#/definitions/v3.CategoryEditable' + type: array + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/v3.CategoryCreateResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.CategoryCreateResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.CategoryCreateResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.CategoryCreateResponse' + summary: Create category + tags: + - Categories + /v3/categories/{id}: + delete: + description: Deletes a category + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete category + tags: + - Categories + get: + description: Returns a specific category + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.CategoryResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.CategoryResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.CategoryResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.CategoryResponse' + summary: Get category + tags: + - Categories + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Allowed HTTP verbs + tags: + - Categories + patch: + consumes: + - application/json + description: Update an existing category. Only values to be updated need to + be specified. + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + - description: Category + in: body + name: category + required: true + schema: + $ref: '#/definitions/v3.CategoryEditable' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.CategoryResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.CategoryResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.CategoryResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.CategoryResponse' + summary: Update category + tags: + - Categories + /v3/envelopes: + get: + description: Returns a list of envelopes + parameters: + - description: Filter by name + in: query + name: name + type: string + - description: Filter by note + in: query + name: note + type: string + - description: Filter by category ID + in: query + name: category + type: string + - description: Is the envelope archived? + in: query + name: archived + type: boolean + - description: Search for this text in name and note + in: query + name: search + type: string + - description: The offset of the first Envelope returned. Defaults to 0. + in: query + name: offset + type: integer + - description: Maximum number of Envelopes to return. Defaults to 50. + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.EnvelopeListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.EnvelopeListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.EnvelopeListResponse' + summary: Get envelopes + tags: + - Envelopes + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - Envelopes + post: + description: Creates a new envelope + parameters: + - description: Envelopes + in: body + name: envelope + required: true + schema: + items: + $ref: '#/definitions/v3.EnvelopeEditable' + type: array + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/v3.EnvelopeCreateResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.EnvelopeCreateResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.EnvelopeCreateResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.EnvelopeCreateResponse' + summary: Create envelope + tags: + - Envelopes + /v3/envelopes/{id}: + delete: + description: Deletes an envelope + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete envelope + tags: + - Envelopes + get: + description: Returns a specific Envelope + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.EnvelopeResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.EnvelopeResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.EnvelopeResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.EnvelopeResponse' + summary: Get Envelope + tags: + - Envelopes + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Allowed HTTP verbs + tags: + - Envelopes + patch: + consumes: + - application/json + description: Updates an existing envelope. Only values to be updated need to + be specified. + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + - description: Envelope + in: body + name: envelope + required: true + schema: + $ref: '#/definitions/v3.EnvelopeEditable' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.EnvelopeResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.EnvelopeResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.EnvelopeResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.EnvelopeResponse' + summary: Update envelope + tags: + - Envelopes + /v3/envelopes/{id}/{month}: + get: + description: Returns configuration for a specific month + parameters: + - description: ID of the Envelope + in: path + name: id + required: true + type: string + - description: The month in YYYY-MM format + in: path + name: month + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.MonthConfigResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.MonthConfigResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.MonthConfigResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.MonthConfigResponse' + summary: Get MonthConfig + tags: + - Envelopes + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + parameters: + - description: ID of the Envelope + in: path + name: id + required: true + type: string + - description: The month in YYYY-MM format + in: path + name: month + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Allowed HTTP verbs + tags: + - Envelopes + patch: + description: Changes configuration for a Month. If there is no configuration + for the month yet, this endpoint transparently creates a configuration resource. + parameters: + - description: ID of the Envelope + in: path + name: id + required: true + type: string + - description: The month in YYYY-MM format + in: path + name: month + required: true + type: string + - description: MonthConfig + in: body + name: monthConfig + required: true + schema: + $ref: '#/definitions/v3.MonthConfigEditable' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/v3.MonthConfigResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.MonthConfigResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.MonthConfigResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.MonthConfigResponse' + summary: Update MonthConfig + tags: + - Envelopes + /v3/goals: + get: + description: Returns a list of goals + parameters: + - description: Filter by name + in: query + name: name + type: string + - description: Filter by note + in: query + name: note + type: string + - description: Search for this text in name and note + in: query + name: search + type: string + - description: Is the goal archived? + in: query + name: archived + type: boolean + - description: Filter by envelope ID + in: query + name: envelope + type: string + - description: Month of the goal. Ignores exact time, matches on the month of + the RFC3339 timestamp provided. + in: query + name: month + type: string + - description: Goals for this and later months. Ignores exact time, matches + on the month of the RFC3339 timestamp provided. + in: query + name: fromMonth + type: string + - description: Goals for this and earlier months. Ignores exact time, matches + on the month of the RFC3339 timestamp provided. + in: query + name: untilMonth + type: string + - description: Filter by amount + in: query + name: amount + type: string + - description: Amount less than or equal to this + in: query + name: amountLessOrEqual + type: string + - description: Amount more than or equal to this + in: query + name: amountMoreOrEqual + type: string + - description: The offset of the first goal returned. Defaults to 0. + in: query + name: offset + type: integer + - description: Maximum number of goal to return. Defaults to 50. + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.GoalListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.GoalListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.GoalListResponse' + summary: Get goals + tags: + - Goals + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - Goals + post: + description: Creates new goals + parameters: + - description: Goals + in: body + name: goals + required: true + schema: + items: + $ref: '#/definitions/v3.GoalEditable' + type: array + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/v3.GoalCreateResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.GoalCreateResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.GoalCreateResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.GoalCreateResponse' + summary: Create goals + tags: + - Goals + /v3/goals/{id}: + delete: + description: Deletes a goal + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete goal + tags: + - Goals + get: + description: Returns a specific goal + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.GoalResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.GoalResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.GoalResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.GoalResponse' + summary: Get goal + tags: + - Goals + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Allowed HTTP verbs + tags: + - Goals + patch: + consumes: + - application/json + description: Updates an existing goal. Only values to be updated need to be + specified. + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + - description: Goal + in: body + name: goal + required: true + schema: + $ref: '#/definitions/v3.GoalEditable' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.GoalResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.GoalResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.GoalResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.GoalResponse' + summary: Update goal + tags: + - Goals + /v3/import: + get: + description: Returns general information about the v3 API + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.ImportResponse' + summary: Import API overview + tags: + - Import + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs. + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - Import + /v3/import/ynab-import-preview: + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - Import + post: + consumes: + - multipart/form-data + description: Returns a preview of transactions to be imported after parsing + a YNAB Import format csv file + parameters: + - description: File to import + in: formData + name: file + required: true + type: file + - description: ID of the account to import transactions for + in: query + name: accountId + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.ImportPreviewList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.ImportPreviewList' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.ImportPreviewList' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.ImportPreviewList' + summary: Transaction Import Preview + tags: + - Import + /v3/import/ynab4: + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - Import + post: + consumes: + - multipart/form-data + description: Imports budgets from YNAB 4 + parameters: + - description: File to import + in: formData + name: file + required: true + type: file + - description: Name of the Budget to create + in: query + name: budgetName + type: string + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/v3.BudgetResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.BudgetResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.BudgetResponse' + summary: Import YNAB 4 budget + tags: + - Import + /v3/match-rules: + get: + description: Returns a list of matchRules + parameters: + - description: Filter by priority + in: query + name: priority + type: integer + - description: Filter by match + in: query + name: match + type: string + - description: Filter by account ID + in: query + name: account + type: string + - description: The offset of the first Match Rule returned. Defaults to 0. + in: query + name: offset + type: integer + - description: Maximum number of Match Rules to return. Defaults to 50. + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.MatchRuleListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.MatchRuleListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.MatchRuleListResponse' + summary: Get matchRules + tags: + - MatchRules + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - MatchRules + post: + description: Creates matchRules from the list of submitted matchRule data. The + response code is the highest response code number that a single matchRule + creation would have caused. If it is not equal to 201, at least one matchRule + has an error. + parameters: + - description: MatchRules + in: body + name: matchRules + required: true + schema: + items: + $ref: '#/definitions/v3.MatchRuleEditable' + type: array + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/v3.MatchRuleCreateResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.MatchRuleCreateResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.MatchRuleCreateResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.MatchRuleCreateResponse' + summary: Create matchRules + tags: + - MatchRules + /v3/match-rules/{id}: + delete: + description: Deletes an matchRule + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete matchRule + tags: + - MatchRules + get: + description: Returns a specific matchRule + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.MatchRuleResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.MatchRuleResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.MatchRuleResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.MatchRuleResponse' + summary: Get matchRule + tags: + - MatchRules + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Allowed HTTP verbs + tags: + - MatchRules + patch: + consumes: + - application/json + description: Update a matchRule. Only values to be updated need to be specified. + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + - description: MatchRule + in: body + name: matchRule + required: true + schema: + $ref: '#/definitions/v3.MatchRuleEditable' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.MatchRuleResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.MatchRuleResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.MatchRuleResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.MatchRuleResponse' + summary: Update matchRule + tags: + - MatchRules + /v3/months: + delete: + description: Deletes all allocation for the specified month + parameters: + - description: ID formatted as string + in: query + name: budget + required: true + type: string + - description: The month in YYYY-MM format + in: query + name: month + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete allocations for a month + tags: + - Months + get: + description: Returns data about a specific month. + parameters: + - description: ID formatted as string + in: query + name: budget + required: true + type: string + - description: The month in YYYY-MM format + in: query + name: month + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.MonthResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.MonthResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.MonthResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.MonthResponse' + summary: Get data about a month + tags: + - Months + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs. + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - Months + post: + description: Sets allocations for a month for all envelopes that do not have + an allocation yet + parameters: + - description: ID formatted as string + in: query + name: budget + required: true type: string - type: object - version.Response: - properties: - data: - allOf: - - $ref: '#/definitions/version.Object' - description: Data object for the version endpoint - type: object -info: - contact: {} -paths: - /: + - description: The month in YYYY-MM format + in: query + name: month + required: true + type: string + - description: Budget + in: body + name: mode + required: true + schema: + $ref: '#/definitions/v3.BudgetAllocationMode' + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Set allocations for a month + tags: + - Months + /v3/transactions: get: - description: Entrypoint for the API, listing all endpoints + description: Returns a list of transactions + parameters: + - description: Date of the transaction. Ignores exact time, matches on the day + of the RFC3339 timestamp provided. + in: query + name: date + type: string + - description: Transactions at and after this date. Ignores exact time, matches + on the day of the RFC3339 timestamp provided. + in: query + name: fromDate + type: string + - description: Transactions before and at this date. Ignores exact time, matches + on the day of the RFC3339 timestamp provided. + in: query + name: untilDate + type: string + - description: Filter by amount + in: query + name: amount + type: string + - description: Amount less than or equal to this + in: query + name: amountLessOrEqual + type: string + - description: Amount more than or equal to this + in: query + name: amountMoreOrEqual + type: string + - description: Filter by note + in: query + name: note + type: string + - description: Filter by budget ID + in: query + name: budget + type: string + - description: Filter by ID of associated account, regardeless of source or + destination + in: query + name: account + type: string + - description: Filter by source account ID + in: query + name: source + type: string + - description: Filter by destination account ID + in: query + name: destination + type: string + - description: Filter by envelope ID + in: query + name: envelope + type: string + - description: Reconcilication state in source account + in: query + name: reconciledSource + type: boolean + - description: Reconcilication state in destination account + in: query + name: reconciledDestination + type: boolean + - description: The offset of the first Transaction returned. Defaults to 0. + in: query + name: offset + type: integer + - description: Maximum number of Transactions to return. Defaults to 50. + in: query + name: limit + type: integer + produces: + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/root.Response' - summary: API root + $ref: '#/definitions/v3.TransactionListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.TransactionListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.TransactionListResponse' + summary: Get transactions tags: - - General + - Transactions options: description: Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs @@ -1537,32 +4666,167 @@ paths: description: No Content summary: Allowed HTTP verbs tags: - - General - /healthz: + - Transactions + post: + description: Creates transactions from the list of submitted transaction data. + The response code is the highest response code number that a single transaction + creation would have caused. If it is not equal to 201, at least one transaction + has an error. + parameters: + - description: Transactions + in: body + name: transactions + required: true + schema: + items: + $ref: '#/definitions/v3.TransactionEditable' + type: array + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/v3.TransactionCreateResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.TransactionCreateResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.TransactionCreateResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.TransactionCreateResponse' + summary: Create transactions + tags: + - Transactions + /v3/transactions/{id}: + delete: + description: Deletes a transaction + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete transaction + tags: + - Transactions get: - description: Returns the application health and, if not healthy, an error + description: Returns a specific transaction + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v3.TransactionResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.TransactionResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.TransactionResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v3.TransactionResponse' + summary: Get transaction + tags: + - Transactions + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Allowed HTTP verbs + tags: + - Transactions + patch: + consumes: + - application/json + description: Updates an existing transaction. Only values to be updated need + to be specified. + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + - description: Transaction + in: body + name: transaction + required: true + schema: + $ref: '#/definitions/v3.TransactionEditable' produces: - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + $ref: '#/definitions/v3.TransactionResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v3.TransactionResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v3.TransactionResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Get health - tags: - - General - options: - description: Returns an empty response with the HTTP Header "allow" set to the - allowed HTTP verbs - responses: - "204": - description: No Content - summary: Allowed HTTP verbs + $ref: '#/definitions/v3.TransactionResponse' + summary: Update transaction tags: - - General - /v3: + - Transactions + /v4: delete: description: Permanently deletes all resources parameters: @@ -1583,17 +4847,17 @@ paths: $ref: '#/definitions/httperrors.HTTPError' summary: Delete everything tags: - - v3 + - v4 get: - description: Returns general information about the v3 API + description: Returns general information about the v4 API responses: "200": description: OK schema: - $ref: '#/definitions/v3.Response' - summary: v3 API + $ref: '#/definitions/v4.Response' + summary: v4 API tags: - - v3 + - v4 options: description: Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs @@ -1602,8 +4866,8 @@ paths: description: No Content summary: Allowed HTTP verbs tags: - - v3 - /v3/accounts: + - v4 + /v4/accounts: get: description: Returns a list of accounts parameters: @@ -1649,15 +4913,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.AccountListResponse' + $ref: '#/definitions/v4.AccountListResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.AccountListResponse' + $ref: '#/definitions/v4.AccountListResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.AccountListResponse' + $ref: '#/definitions/v4.AccountListResponse' summary: List accounts tags: - Accounts @@ -1679,7 +4943,7 @@ paths: required: true schema: items: - $ref: '#/definitions/v3.AccountEditable' + $ref: '#/definitions/v4.AccountEditable' type: array produces: - application/json @@ -1687,23 +4951,23 @@ paths: "201": description: Created schema: - $ref: '#/definitions/v3.AccountCreateResponse' + $ref: '#/definitions/v4.AccountCreateResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.AccountCreateResponse' + $ref: '#/definitions/v4.AccountCreateResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.AccountCreateResponse' + $ref: '#/definitions/v4.AccountCreateResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.AccountCreateResponse' + $ref: '#/definitions/v4.AccountCreateResponse' summary: Creates accounts tags: - Accounts - /v3/accounts/{id}: + /v4/accounts/{id}: delete: description: Deletes an account parameters: @@ -1746,19 +5010,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.AccountResponse' + $ref: '#/definitions/v4.AccountResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.AccountResponse' + $ref: '#/definitions/v4.AccountResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.AccountResponse' + $ref: '#/definitions/v4.AccountResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.AccountResponse' + $ref: '#/definitions/v4.AccountResponse' summary: Get account tags: - Accounts @@ -1802,30 +5066,93 @@ paths: name: account required: true schema: - $ref: '#/definitions/v3.AccountEditable' + $ref: '#/definitions/v4.AccountEditable' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/v3.AccountResponse' + $ref: '#/definitions/v4.AccountResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.AccountResponse' + $ref: '#/definitions/v4.AccountResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.AccountResponse' + $ref: '#/definitions/v4.AccountResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.AccountResponse' + $ref: '#/definitions/v4.AccountResponse' summary: Update account tags: - Accounts - /v3/budgets: + /v4/accounts/{id}/recent-envelopes: + get: + description: Returns a list of objects representing recent envelopes + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v4.RecentEnvelopesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v4.RecentEnvelopesResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v4.RecentEnvelopesResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v4.RecentEnvelopesResponse' + summary: Get recent envelopes + tags: + - Accounts + /v4/accounts/computed: + post: + description: Returns calculated data for the account, e.g. balances + parameters: + - description: Time and IDs of requested accounts + in: body + name: request + required: true + schema: + $ref: '#/definitions/v4.AccountComputedRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v4.AccountComputedDataResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v4.AccountComputedDataResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v4.AccountComputedDataResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v4.AccountComputedDataResponse' + summary: Get Account data + tags: + - Accounts + /v4/budgets: get: description: Returns a list of budgets parameters: @@ -1859,11 +5186,11 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.BudgetListResponse' + $ref: '#/definitions/v4.BudgetListResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.BudgetListResponse' + $ref: '#/definitions/v4.BudgetListResponse' summary: List budgets tags: - Budgets @@ -1887,7 +5214,7 @@ paths: required: true schema: items: - $ref: '#/definitions/v3.BudgetEditable' + $ref: '#/definitions/v4.BudgetEditable' type: array produces: - application/json @@ -1895,19 +5222,19 @@ paths: "201": description: Created schema: - $ref: '#/definitions/v3.BudgetCreateResponse' + $ref: '#/definitions/v4.BudgetCreateResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.BudgetCreateResponse' + $ref: '#/definitions/v4.BudgetCreateResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.BudgetCreateResponse' + $ref: '#/definitions/v4.BudgetCreateResponse' summary: Create budget tags: - Budgets - /v3/budgets/{id}: + /v4/budgets/{id}: delete: description: Deletes a budget parameters: @@ -1948,19 +5275,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' summary: Get budget tags: - Budgets @@ -2007,30 +5334,30 @@ paths: name: budget required: true schema: - $ref: '#/definitions/v3.BudgetEditable' + $ref: '#/definitions/v4.BudgetEditable' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' summary: Update budget tags: - Budgets - /v3/categories: + /v4/categories: get: description: Returns a list of categories parameters: @@ -2068,15 +5395,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.CategoryListResponse' + $ref: '#/definitions/v4.CategoryListResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.CategoryListResponse' + $ref: '#/definitions/v4.CategoryListResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.CategoryListResponse' + $ref: '#/definitions/v4.CategoryListResponse' summary: Get categories tags: - Categories @@ -2098,7 +5425,7 @@ paths: required: true schema: items: - $ref: '#/definitions/v3.CategoryEditable' + $ref: '#/definitions/v4.CategoryEditable' type: array produces: - application/json @@ -2106,23 +5433,23 @@ paths: "201": description: Created schema: - $ref: '#/definitions/v3.CategoryCreateResponse' + $ref: '#/definitions/v4.CategoryCreateResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.CategoryCreateResponse' + $ref: '#/definitions/v4.CategoryCreateResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.CategoryCreateResponse' + $ref: '#/definitions/v4.CategoryCreateResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.CategoryCreateResponse' + $ref: '#/definitions/v4.CategoryCreateResponse' summary: Create category tags: - Categories - /v3/categories/{id}: + /v4/categories/{id}: delete: description: Deletes a category parameters: @@ -2163,19 +5490,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.CategoryResponse' + $ref: '#/definitions/v4.CategoryResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.CategoryResponse' + $ref: '#/definitions/v4.CategoryResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.CategoryResponse' + $ref: '#/definitions/v4.CategoryResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.CategoryResponse' + $ref: '#/definitions/v4.CategoryResponse' summary: Get category tags: - Categories @@ -2222,30 +5549,30 @@ paths: name: category required: true schema: - $ref: '#/definitions/v3.CategoryEditable' + $ref: '#/definitions/v4.CategoryEditable' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/v3.CategoryResponse' + $ref: '#/definitions/v4.CategoryResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.CategoryResponse' + $ref: '#/definitions/v4.CategoryResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.CategoryResponse' + $ref: '#/definitions/v4.CategoryResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.CategoryResponse' + $ref: '#/definitions/v4.CategoryResponse' summary: Update category tags: - Categories - /v3/envelopes: + /v4/envelopes: get: description: Returns a list of envelopes parameters: @@ -2283,15 +5610,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.EnvelopeListResponse' + $ref: '#/definitions/v4.EnvelopeListResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.EnvelopeListResponse' + $ref: '#/definitions/v4.EnvelopeListResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.EnvelopeListResponse' + $ref: '#/definitions/v4.EnvelopeListResponse' summary: Get envelopes tags: - Envelopes @@ -2313,7 +5640,7 @@ paths: required: true schema: items: - $ref: '#/definitions/v3.EnvelopeEditable' + $ref: '#/definitions/v4.EnvelopeEditable' type: array produces: - application/json @@ -2321,23 +5648,23 @@ paths: "201": description: Created schema: - $ref: '#/definitions/v3.EnvelopeCreateResponse' + $ref: '#/definitions/v4.EnvelopeCreateResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.EnvelopeCreateResponse' + $ref: '#/definitions/v4.EnvelopeCreateResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.EnvelopeCreateResponse' + $ref: '#/definitions/v4.EnvelopeCreateResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.EnvelopeCreateResponse' + $ref: '#/definitions/v4.EnvelopeCreateResponse' summary: Create envelope tags: - Envelopes - /v3/envelopes/{id}: + /v4/envelopes/{id}: delete: description: Deletes an envelope parameters: @@ -2378,19 +5705,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.EnvelopeResponse' + $ref: '#/definitions/v4.EnvelopeResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.EnvelopeResponse' + $ref: '#/definitions/v4.EnvelopeResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.EnvelopeResponse' + $ref: '#/definitions/v4.EnvelopeResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.EnvelopeResponse' + $ref: '#/definitions/v4.EnvelopeResponse' summary: Get Envelope tags: - Envelopes @@ -2437,30 +5764,30 @@ paths: name: envelope required: true schema: - $ref: '#/definitions/v3.EnvelopeEditable' + $ref: '#/definitions/v4.EnvelopeEditable' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/v3.EnvelopeResponse' + $ref: '#/definitions/v4.EnvelopeResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.EnvelopeResponse' + $ref: '#/definitions/v4.EnvelopeResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.EnvelopeResponse' + $ref: '#/definitions/v4.EnvelopeResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.EnvelopeResponse' + $ref: '#/definitions/v4.EnvelopeResponse' summary: Update envelope tags: - Envelopes - /v3/envelopes/{id}/{month}: + /v4/envelopes/{id}/{month}: get: description: Returns configuration for a specific month parameters: @@ -2480,19 +5807,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.MonthConfigResponse' + $ref: '#/definitions/v4.MonthConfigResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.MonthConfigResponse' + $ref: '#/definitions/v4.MonthConfigResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.MonthConfigResponse' + $ref: '#/definitions/v4.MonthConfigResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.MonthConfigResponse' + $ref: '#/definitions/v4.MonthConfigResponse' summary: Get MonthConfig tags: - Envelopes @@ -2539,30 +5866,30 @@ paths: name: monthConfig required: true schema: - $ref: '#/definitions/v3.MonthConfigEditable' + $ref: '#/definitions/v4.MonthConfigEditable' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/v3.MonthConfigResponse' + $ref: '#/definitions/v4.MonthConfigResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.MonthConfigResponse' + $ref: '#/definitions/v4.MonthConfigResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.MonthConfigResponse' + $ref: '#/definitions/v4.MonthConfigResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.MonthConfigResponse' + $ref: '#/definitions/v4.MonthConfigResponse' summary: Update MonthConfig tags: - Envelopes - /v3/goals: + /v4/goals: get: description: Returns a list of goals parameters: @@ -2627,15 +5954,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.GoalListResponse' + $ref: '#/definitions/v4.GoalListResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.GoalListResponse' + $ref: '#/definitions/v4.GoalListResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.GoalListResponse' + $ref: '#/definitions/v4.GoalListResponse' summary: Get goals tags: - Goals @@ -2657,7 +5984,7 @@ paths: required: true schema: items: - $ref: '#/definitions/v3.GoalEditable' + $ref: '#/definitions/v4.GoalEditable' type: array produces: - application/json @@ -2665,23 +5992,23 @@ paths: "201": description: Created schema: - $ref: '#/definitions/v3.GoalCreateResponse' + $ref: '#/definitions/v4.GoalCreateResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.GoalCreateResponse' + $ref: '#/definitions/v4.GoalCreateResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.GoalCreateResponse' + $ref: '#/definitions/v4.GoalCreateResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.GoalCreateResponse' + $ref: '#/definitions/v4.GoalCreateResponse' summary: Create goals tags: - Goals - /v3/goals/{id}: + /v4/goals/{id}: delete: description: Deletes a goal parameters: @@ -2722,19 +6049,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.GoalResponse' + $ref: '#/definitions/v4.GoalResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.GoalResponse' + $ref: '#/definitions/v4.GoalResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.GoalResponse' + $ref: '#/definitions/v4.GoalResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.GoalResponse' + $ref: '#/definitions/v4.GoalResponse' summary: Get goal tags: - Goals @@ -2781,37 +6108,37 @@ paths: name: goal required: true schema: - $ref: '#/definitions/v3.GoalEditable' + $ref: '#/definitions/v4.GoalEditable' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/v3.GoalResponse' + $ref: '#/definitions/v4.GoalResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.GoalResponse' + $ref: '#/definitions/v4.GoalResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.GoalResponse' + $ref: '#/definitions/v4.GoalResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.GoalResponse' + $ref: '#/definitions/v4.GoalResponse' summary: Update goal tags: - Goals - /v3/import: + /v4/import: get: - description: Returns general information about the v3 API + description: Returns general information about the v4 API responses: "200": description: OK schema: - $ref: '#/definitions/v3.ImportResponse' + $ref: '#/definitions/v4.ImportResponse' summary: Import API overview tags: - Import @@ -2824,7 +6151,7 @@ paths: summary: Allowed HTTP verbs tags: - Import - /v3/import/ynab-import-preview: + /v4/import/ynab-import-preview: options: description: Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs @@ -2855,23 +6182,23 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.ImportPreviewList' + $ref: '#/definitions/v4.ImportPreviewList' "400": description: Bad Request schema: - $ref: '#/definitions/v3.ImportPreviewList' + $ref: '#/definitions/v4.ImportPreviewList' "404": description: Not Found schema: - $ref: '#/definitions/v3.ImportPreviewList' + $ref: '#/definitions/v4.ImportPreviewList' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.ImportPreviewList' + $ref: '#/definitions/v4.ImportPreviewList' summary: Transaction Import Preview tags: - Import - /v3/import/ynab4: + /v4/import/ynab4: options: description: Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs @@ -2901,19 +6228,19 @@ paths: "201": description: Created schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.BudgetResponse' + $ref: '#/definitions/v4.BudgetResponse' summary: Import YNAB 4 budget tags: - Import - /v3/match-rules: + /v4/match-rules: get: description: Returns a list of matchRules parameters: @@ -2943,15 +6270,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.MatchRuleListResponse' + $ref: '#/definitions/v4.MatchRuleListResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.MatchRuleListResponse' + $ref: '#/definitions/v4.MatchRuleListResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.MatchRuleListResponse' + $ref: '#/definitions/v4.MatchRuleListResponse' summary: Get matchRules tags: - MatchRules @@ -2976,7 +6303,7 @@ paths: required: true schema: items: - $ref: '#/definitions/v3.MatchRuleEditable' + $ref: '#/definitions/v4.MatchRuleEditable' type: array produces: - application/json @@ -2984,23 +6311,23 @@ paths: "201": description: Created schema: - $ref: '#/definitions/v3.MatchRuleCreateResponse' + $ref: '#/definitions/v4.MatchRuleCreateResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.MatchRuleCreateResponse' + $ref: '#/definitions/v4.MatchRuleCreateResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.MatchRuleCreateResponse' + $ref: '#/definitions/v4.MatchRuleCreateResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.MatchRuleCreateResponse' + $ref: '#/definitions/v4.MatchRuleCreateResponse' summary: Create matchRules tags: - MatchRules - /v3/match-rules/{id}: + /v4/match-rules/{id}: delete: description: Deletes an matchRule parameters: @@ -3041,19 +6368,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.MatchRuleResponse' + $ref: '#/definitions/v4.MatchRuleResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.MatchRuleResponse' + $ref: '#/definitions/v4.MatchRuleResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.MatchRuleResponse' + $ref: '#/definitions/v4.MatchRuleResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.MatchRuleResponse' + $ref: '#/definitions/v4.MatchRuleResponse' summary: Get matchRule tags: - MatchRules @@ -3099,30 +6426,30 @@ paths: name: matchRule required: true schema: - $ref: '#/definitions/v3.MatchRuleEditable' + $ref: '#/definitions/v4.MatchRuleEditable' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/v3.MatchRuleResponse' + $ref: '#/definitions/v4.MatchRuleResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.MatchRuleResponse' + $ref: '#/definitions/v4.MatchRuleResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.MatchRuleResponse' + $ref: '#/definitions/v4.MatchRuleResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.MatchRuleResponse' + $ref: '#/definitions/v4.MatchRuleResponse' summary: Update matchRule tags: - MatchRules - /v3/months: + /v4/months: delete: description: Deletes all allocation for the specified month parameters: @@ -3173,19 +6500,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.MonthResponse' + $ref: '#/definitions/v4.MonthResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.MonthResponse' + $ref: '#/definitions/v4.MonthResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.MonthResponse' + $ref: '#/definitions/v4.MonthResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.MonthResponse' + $ref: '#/definitions/v4.MonthResponse' summary: Get data about a month tags: - Months @@ -3217,7 +6544,7 @@ paths: name: mode required: true schema: - $ref: '#/definitions/v3.BudgetAllocationMode' + $ref: '#/definitions/v4.BudgetAllocationMode' responses: "204": description: No Content @@ -3236,7 +6563,7 @@ paths: summary: Set allocations for a month tags: - Months - /v3/transactions: + /v4/transactions: get: description: Returns a list of transactions parameters: @@ -3314,15 +6641,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.TransactionListResponse' + $ref: '#/definitions/v4.TransactionListResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.TransactionListResponse' + $ref: '#/definitions/v4.TransactionListResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.TransactionListResponse' + $ref: '#/definitions/v4.TransactionListResponse' summary: Get transactions tags: - Transactions @@ -3347,7 +6674,7 @@ paths: required: true schema: items: - $ref: '#/definitions/v3.TransactionEditable' + $ref: '#/definitions/v4.TransactionEditable' type: array produces: - application/json @@ -3355,23 +6682,23 @@ paths: "201": description: Created schema: - $ref: '#/definitions/v3.TransactionCreateResponse' + $ref: '#/definitions/v4.TransactionCreateResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.TransactionCreateResponse' + $ref: '#/definitions/v4.TransactionCreateResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.TransactionCreateResponse' + $ref: '#/definitions/v4.TransactionCreateResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.TransactionCreateResponse' + $ref: '#/definitions/v4.TransactionCreateResponse' summary: Create transactions tags: - Transactions - /v3/transactions/{id}: + /v4/transactions/{id}: delete: description: Deletes a transaction parameters: @@ -3412,19 +6739,19 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v3.TransactionResponse' + $ref: '#/definitions/v4.TransactionResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.TransactionResponse' + $ref: '#/definitions/v4.TransactionResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.TransactionResponse' + $ref: '#/definitions/v4.TransactionResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.TransactionResponse' + $ref: '#/definitions/v4.TransactionResponse' summary: Get transaction tags: - Transactions @@ -3471,26 +6798,26 @@ paths: name: transaction required: true schema: - $ref: '#/definitions/v3.TransactionEditable' + $ref: '#/definitions/v4.TransactionEditable' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/v3.TransactionResponse' + $ref: '#/definitions/v4.TransactionResponse' "400": description: Bad Request schema: - $ref: '#/definitions/v3.TransactionResponse' + $ref: '#/definitions/v4.TransactionResponse' "404": description: Not Found schema: - $ref: '#/definitions/v3.TransactionResponse' + $ref: '#/definitions/v4.TransactionResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/v3.TransactionResponse' + $ref: '#/definitions/v4.TransactionResponse' summary: Update transaction tags: - Transactions diff --git a/pkg/controllers/v4/account.go b/pkg/controllers/v4/account.go new file mode 100644 index 00000000..2bdd0a0d --- /dev/null +++ b/pkg/controllers/v4/account.go @@ -0,0 +1,486 @@ +package v4 + +import ( + "net/http" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "golang.org/x/exp/slices" +) + +// RegisterAccountRoutes registers the routes for accounts with +// the RouterGroup that is passed. +func RegisterAccountRoutes(r *gin.RouterGroup) { + // Root group + { + r.OPTIONS("", OptionsAccountList) + r.GET("", GetAccounts) + r.POST("", CreateAccounts) + } + + // Account with ID + { + r.OPTIONS("/:id", OptionsAccountDetail) + r.GET("/:id", GetAccount) + r.GET("/:id/recent-envelopes", GetAccountRecentEnvelopes) + r.POST("/computed", GetAccountData) // This is a POST endpoints because some clients don't allow GET requests to have bodies + r.PATCH("/:id", UpdateAccount) + r.DELETE("/:id", DeleteAccount) + } +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Accounts +// @Success 204 +// @Router /v4/accounts [options] +func OptionsAccountList(c *gin.Context) { + httputil.OptionsGetPost(c) +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Accounts +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/accounts/{id} [options] +func OptionsAccountDetail(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + _, err = getModelByID[models.Account](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + httputil.OptionsGetPatchDelete(c) +} + +// @Summary Creates accounts +// @Description Creates new accounts +// @Tags Accounts +// @Produce json +// @Success 201 {object} AccountCreateResponse +// @Failure 400 {object} AccountCreateResponse +// @Failure 404 {object} AccountCreateResponse +// @Failure 500 {object} AccountCreateResponse +// @Param accounts body []AccountEditable true "Accounts" +// @Router /v4/accounts [post] +func CreateAccounts(c *gin.Context) { + var editables []AccountEditable + + // Bind data and return error if not possible + err := httputil.BindData(c, &editables) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, AccountCreateResponse{ + Error: &e, + }) + return + } + // The final http status. Will be modified when errors occur + status := http.StatusCreated + r := AccountCreateResponse{} + + for _, editable := range editables { + account := editable.model() + + // Verify that budget exists. If not, append the error + // and move to the next account + _, err := getModelByID[models.Budget](c, editable.BudgetID) + if !err.Nil() { + status = r.appendError(err, status) + continue + } + + dbErr := models.DB.Create(&account).Error + if dbErr != nil { + err := httperrors.GenericDBError[models.Account](account, c, dbErr) + status = r.appendError(err, status) + continue + } + + data := newAccount(c, account) + r.Data = append(r.Data, AccountResponse{Data: &data}) + } + + c.JSON(status, r) +} + +// @Summary List accounts +// @Description Returns a list of accounts +// @Tags Accounts +// @Produce json +// @Success 200 {object} AccountListResponse +// @Failure 400 {object} AccountListResponse +// @Failure 500 {object} AccountListResponse +// @Router /v4/accounts [get] +// @Param name query string false "Filter by name" +// @Param note query string false "Filter by note" +// @Param budget query string false "Filter by budget ID" +// @Param onBudget query bool false "Is the account on-budget?" +// @Param external query bool false "Is the account external?" +// @Param archived query bool false "Is the account archived?" +// @Param search query string false "Search for this text in name and note" +// @Param offset query uint false "The offset of the first Account returned. Defaults to 0." +// @Param limit query int false "Maximum number of Accounts to return. Defaults to 50." +func GetAccounts(c *gin.Context) { + var filter AccountQueryFilter + if err := c.Bind(&filter); err != nil { + httperrors.InvalidQueryString(c) + return + } + + // Get the set parameters in the query string + queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) + + // Convert the QueryFilter to a Create struct + model, err := filter.model() + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountListResponse{ + Error: &s, + }) + return + } + + q := models.DB. + Order("name ASC"). + Where(&model, queryFields...) + + q = stringFilters(models.DB, q, setFields, filter.Name, filter.Note, filter.Search) + + // Set the offset. Does not need checking since the default is 0 + q = q.Offset(int(filter.Offset)) + + // Default to 50 Accounts and set the limit + limit := 50 + if slices.Contains(setFields, "Limit") { + limit = filter.Limit + } + q = q.Limit(limit) + + var accounts []models.Account + err = query(c, q.Find(&accounts)) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountListResponse{ + Error: &s, + }) + return + } + + var count int64 + err = query(c, q.Limit(-1).Offset(-1).Count(&count)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, AccountListResponse{ + Error: &e, + }) + return + } + + // When there are no resources, we want an empty list, not null + // Therefore, we use make to create a slice with zero elements + // which will be marshalled to an empty JSON array + data := make([]Account, 0) + for _, account := range accounts { + data = append(data, newAccount(c, account)) + } + + c.JSON(http.StatusOK, AccountListResponse{ + Data: data, + Pagination: &Pagination{ + Count: len(data), + Total: count, + Offset: filter.Offset, + Limit: limit, + }, + }) +} + +// @Summary Get account +// @Description Returns a specific account +// @Tags Accounts +// @Produce json +// @Success 200 {object} AccountResponse +// @Failure 400 {object} AccountResponse +// @Failure 404 {object} AccountResponse +// @Failure 500 {object} AccountResponse +// @Param id path string true "ID formatted as string" +// @Router /v4/accounts/{id} [get] +func GetAccount(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountResponse{ + Error: &s, + }) + return + } + + account, err := getModelByID[models.Account](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountResponse{ + Error: &s, + }) + return + } + + data := newAccount(c, account) + c.JSON(http.StatusOK, AccountResponse{Data: &data}) +} + +// @Summary Get recent envelopes +// @Description Returns a list of objects representing recent envelopes +// @Tags Accounts +// @Produce json +// @Success 200 {object} RecentEnvelopesResponse +// @Failure 400 {object} RecentEnvelopesResponse +// @Failure 404 {object} RecentEnvelopesResponse +// @Failure 500 {object} RecentEnvelopesResponse +// @Param id path string true "ID formatted as string" +// @Router /v4/accounts/{id}/recent-envelopes [get] +func GetAccountRecentEnvelopes(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, RecentEnvelopesResponse{ + Error: &s, + }) + return + } + + account, err := getModelByID[models.Account](c, id) + if !err.Nil() { + s := err.Error() + + c.JSON(err.Status, RecentEnvelopesResponse{ + Error: &s, + }) + return + } + + var recentEnvelopes []RecentEnvelope + + // Get the Envelope IDs for the 50 latest transactions + latest := models.DB. + Model(&models.Transaction{}). + Joins("LEFT JOIN envelopes ON envelopes.id = transactions.envelope_id AND envelopes.deleted_at IS NULL"). + Select("envelopes.id as e_id, envelopes.name as name, datetime(envelopes.created_at) as created"). + Where(&models.Transaction{ + DestinationAccountID: account.ID, + }). + Order("datetime(transactions.date) DESC"). + Limit(50) + + // Group by frequency + dbErr := models.DB. + Table("(?)", latest). + // Set the nil UUID as ID if the envelope ID is NULL, since count() only counts non-null values + Select("IIF(e_id IS NOT NULL, e_id, NULL) as id, name"). + Group("id"). + Order("count(IIF(e_id IS NOT NULL, e_id, '0')) DESC"). // Order with a different IIF since NULL is ignored for count + Order("created ASC"). + Limit(5). + Find(&recentEnvelopes).Error + if dbErr != nil { + err = httperrors.Parse(c, err) + s := err.Error() + c.JSON(err.Status, RecentEnvelopesResponse{ + Error: &s, + }) + return + } + + c.JSON(http.StatusOK, RecentEnvelopesResponse{Data: recentEnvelopes}) +} + +// @Summary Get Account data +// @Description Returns calculated data for the account, e.g. balances +// @Tags Accounts +// @Produce json +// @Success 200 {object} AccountComputedDataResponse +// @Failure 400 {object} AccountComputedDataResponse +// @Failure 404 {object} AccountComputedDataResponse +// @Failure 500 {object} AccountComputedDataResponse +// @Param request body AccountComputedRequest true "Time and IDs of requested accounts" +// @Router /v4/accounts/computed [post] +func GetAccountData(c *gin.Context) { + var request AccountComputedRequest + + // Bind data and return error if not possible + err := httputil.BindData(c, &request) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, AccountComputedDataResponse{ + Error: &e, + }) + return + } + + data := make([]AccountComputedData, 0) + for _, idString := range request.IDs { + id, err := httputil.UUIDFromString(idString) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountComputedDataResponse{ + Error: &s, + }) + return + } + + account, err := getModelByID[models.Account](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountComputedDataResponse{ + Error: &s, + }) + return + } + + // Balance + balance, dbErr := account.Balance(models.DB, request.Time) + if dbErr != nil { + e := httperrors.Parse(c, dbErr) + s := e.Error() + c.JSON(err.Status, AccountComputedDataResponse{ + Error: &s, + }) + return + } + + // Reconciled Balance + reconciledBalance, dbErr := account.ReconciledBalance(models.DB, request.Time) + if dbErr != nil { + e := httperrors.Parse(c, dbErr) + s := e.Error() + c.JSON(err.Status, AccountComputedDataResponse{ + Error: &s, + }) + return + } + + data = append(data, AccountComputedData{ + ID: id, + Balance: balance, + ReconciledBalance: reconciledBalance, + }) + } + + c.JSON(http.StatusOK, AccountComputedDataResponse{Data: data}) +} + +// @Summary Update account +// @Description Updates an account. Only values to be updated need to be specified. +// @Tags Accounts +// @Produce json +// @Success 200 {object} AccountResponse +// @Failure 400 {object} AccountResponse +// @Failure 404 {object} AccountResponse +// @Failure 500 {object} AccountResponse +// @Param id path string true "ID formatted as string" +// @Param account body AccountEditable true "Account" +// @Router /v4/accounts/{id} [patch] +func UpdateAccount(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountResponse{ + Error: &s, + }) + return + } + + account, err := getModelByID[models.Account](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountResponse{ + Error: &s, + }) + return + } + + updateFields, err := httputil.GetBodyFields(c, AccountEditable{}) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountResponse{ + Error: &s, + }) + return + } + + var data AccountEditable + err = httputil.BindData(c, &data) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountResponse{ + Error: &s, + }) + return + } + + err = query(c, models.DB.Model(&account).Select("", updateFields...).Updates(data.model())) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, AccountResponse{ + Error: &s, + }) + return + } + + apiResource := newAccount(c, account) + c.JSON(http.StatusOK, AccountResponse{Data: &apiResource}) +} + +// @Summary Delete account +// @Description Deletes an account +// @Tags Accounts +// @Produce json +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/accounts/{id} [delete] +func DeleteAccount(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + account, err := getModelByID[models.Account](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + err = query(c, models.DB.Delete(&account)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusNoContent, nil) +} diff --git a/pkg/controllers/v4/account_test.go b/pkg/controllers/v4/account_test.go new file mode 100644 index 00000000..e38f8f5b --- /dev/null +++ b/pkg/controllers/v4/account_test.go @@ -0,0 +1,674 @@ +package v4_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/test" + "github.com/google/uuid" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" +) + +func createTestAccount(t *testing.T, account v4.AccountEditable, expectedStatus ...int) v4.AccountResponse { + if account.BudgetID == uuid.Nil { + account.BudgetID = createTestBudget(t, v4.BudgetEditable{Name: "Testing budget"}).Data.ID + } + + body := []v4.AccountEditable{ + account, + } + + // Default to 201 Created as expected status + if len(expectedStatus) == 0 { + expectedStatus = append(expectedStatus, http.StatusCreated) + } + + r := test.Request(t, http.MethodPost, "http://example.com/v4/accounts", body) + test.AssertHTTPStatus(t, &r, expectedStatus...) + + var a v4.AccountCreateResponse + test.DecodeResponse(t, &r, &a) + + if r.Code == http.StatusCreated { + return a.Data[0] + } + + return v4.AccountResponse{} +} + +// TestAccountsDBClosed verifies that errors are processed correctly when +// the database is closed. +func (suite *TestSuiteStandard) TestAccountsDBClosed() { + b := createTestBudget(suite.T(), v4.BudgetEditable{}) + + tests := []struct { + name string // Name of the test + test func(t *testing.T) // Code to run + }{ + { + "Creation fails", + func(t *testing.T) { + createTestAccount(t, v4.AccountEditable{BudgetID: b.Data.ID}, http.StatusInternalServerError) + }, + }, + { + "GET fails", + func(t *testing.T) { + recorder := test.Request(t, http.MethodGet, "http://example.com/v4/accounts", "") + test.AssertHTTPStatus(t, &recorder, http.StatusInternalServerError) + assert.Contains(t, test.DecodeError(t, recorder.Body.Bytes()), "there is a problem with the database connection") + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + suite.CloseDB() + + tt.test(t) + }) + } +} + +// TestAccountOptions verifies that OPTIONS requests are handled correctly. +func (suite *TestSuiteStandard) TestAccountsOptions() { + tests := []struct { + name string + id string // path at the Accounts endpoint to test + status int // Expected HTTP status code + }{ + {"No account with this ID", uuid.New().String(), http.StatusNotFound}, + {"Not a valid UUID", "NotParseableAsUUID", http.StatusBadRequest}, + {"Account exists", createTestAccount(suite.T(), v4.AccountEditable{}).Data.ID.String(), http.StatusNoContent}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("%s/%s", "http://example.com/v4/accounts", tt.id) + r := test.Request(t, http.MethodOptions, path, "") + test.AssertHTTPStatus(t, &r, tt.status) + + if tt.status == http.StatusNoContent { + assert.Equal(t, "OPTIONS, GET, PATCH, DELETE", r.Header().Get("allow")) + } + }) + } +} + +// TestAccountGetSingle verifies that requests for the resource endpoints are +// handled correctly. +func (suite *TestSuiteStandard) TestAccountsGetSingle() { + a := createTestAccount(suite.T(), v4.AccountEditable{}) + + tests := []struct { + name string + id string + status int + method string + }{ + {"GET Existing account", a.Data.ID.String(), http.StatusOK, http.MethodGet}, + {"GET No account with this ID", uuid.New().String(), http.StatusNotFound, http.MethodGet}, + {"GET Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodGet}, + {"GET Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodGet}, + {"GET Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodGet}, + {"PATCH Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodPatch}, + {"PATCH Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodPatch}, + {"PATCH Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodPatch}, + {"DELETE Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodDelete}, + {"DELETE Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodDelete}, + {"DELETE Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodDelete}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, tt.method, fmt.Sprintf("http://example.com/v4/accounts/%s", tt.id), "") + + var account v4.AccountResponse + test.DecodeResponse(t, &r, &account) + test.AssertHTTPStatus(t, &r, tt.status) + }) + } +} + +func (suite *TestSuiteStandard) TestAccountsGetFilter() { + b1 := createTestBudget(suite.T(), v4.BudgetEditable{}) + b2 := createTestBudget(suite.T(), v4.BudgetEditable{}) + + _ = createTestAccount(suite.T(), v4.AccountEditable{ + Name: "Exact Account Match", + Note: "This is a specific note", + BudgetID: b1.Data.ID, + OnBudget: true, + External: false, + }) + + _ = createTestAccount(suite.T(), v4.AccountEditable{ + Name: "External Account Filter", + Note: "This is a specific note", + BudgetID: b2.Data.ID, + OnBudget: true, + External: true, + }) + + _ = createTestAccount(suite.T(), v4.AccountEditable{ + Name: "External Account Filter", + Note: "A different note", + BudgetID: b1.Data.ID, + OnBudget: false, + External: true, + Archived: true, + }) + + _ = createTestAccount(suite.T(), v4.AccountEditable{ + Name: "", + Note: "specific note", + BudgetID: b1.Data.ID, + }) + + _ = createTestAccount(suite.T(), v4.AccountEditable{ + Name: "Name only", + Note: "", + BudgetID: b1.Data.ID, + Archived: true, + }) + + tests := []struct { + name string + query string + len int + checkFunc func(t *testing.T, accounts []v4.Account) + }{ + {"Name single", "name=Exact Account Match", 1, nil}, + {"Name multiple", "name=External Account Filter", 2, nil}, + {"Fuzzy name", "name=Account", 3, nil}, + {"Note", "note=A different note", 1, nil}, + {"Fuzzy Note", "note=note", 4, nil}, + {"Empty name with note", "name=¬e=specific", 1, nil}, + {"Empty note with name", "note=&name=Name", 1, nil}, + {"Empty note and name", "note=&name=&onBudget=false", 0, nil}, + {"Budget", fmt.Sprintf("budget=%s", b1.Data.ID), 4, nil}, + {"On budget", "onBudget=true", 1, nil}, + {"Off budget", "onBudget=false", 4, nil}, + {"External", "external=true", 2, nil}, + {"Internal", "external=false", 3, nil}, + {"Not Archived", "archived=false", 3, func(t *testing.T, accounts []v4.Account) { + for _, a := range accounts { + assert.False(t, a.Archived) + } + }}, + {"Archived", "archived=true", 2, func(t *testing.T, accounts []v4.Account) { + for _, a := range accounts { + assert.True(t, a.Archived) + } + }}, + {"Search for 'na", "search=na", 3, nil}, + {"Search for 'fi", "search=fi", 4, nil}, + {"Offset 2", "offset=2", 3, nil}, + {"Offset 2, limit 2", "offset=2&limit=2", 2, nil}, + {"Limit 4", "limit=4", 4, nil}, + {"Limit 0", "limit=0", 0, nil}, + {"Limit -1", "limit=-1", 5, nil}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var re v4.AccountListResponse + r := test.Request(t, http.MethodGet, fmt.Sprintf("/v4/accounts?%s", tt.query), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + test.DecodeResponse(t, &r, &re) + + var accountNames []string + for _, d := range re.Data { + accountNames = append(accountNames, d.Name) + } + assert.Equal(t, tt.len, len(re.Data), "Existing accounts: %#v, Request-ID: %s", strings.Join(accountNames, ", "), r.Header().Get("x-request-id")) + + // Run the custom checks + if tt.checkFunc != nil { + tt.checkFunc(t, re.Data) + } + }) + } +} + +func (suite *TestSuiteStandard) TestAccountsGetMonth() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + + initialBalanceDate := time.Date(2023, 9, 1, 0, 0, 0, 0, time.UTC) + + sourceAccount := createTestAccount(suite.T(), v4.AccountEditable{ + Name: "Source Account", + BudgetID: budget.Data.ID, + OnBudget: true, + External: false, + InitialBalance: decimal.NewFromFloat(50), + InitialBalanceDate: &initialBalanceDate, + }) + + destinationAccount := createTestAccount(suite.T(), v4.AccountEditable{ + Name: "Destination Account", + BudgetID: budget.Data.ID, + External: true, + }) + + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{}) + envelopeID := &envelope.Data.ID + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2023, 10, 15, 0, 0, 0, 0, time.UTC), + Amount: decimal.NewFromFloat(10), + EnvelopeID: envelopeID, + SourceAccountID: sourceAccount.Data.ID, + DestinationAccountID: destinationAccount.Data.ID, + ReconciledSource: true, + }) + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2023, 11, 15, 0, 0, 0, 0, time.UTC), + Amount: decimal.NewFromFloat(10), + EnvelopeID: envelopeID, + SourceAccountID: sourceAccount.Data.ID, + DestinationAccountID: destinationAccount.Data.ID, + ReconciledSource: false, + }) + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC), + Amount: decimal.NewFromFloat(10), + EnvelopeID: envelopeID, + SourceAccountID: sourceAccount.Data.ID, + DestinationAccountID: destinationAccount.Data.ID, + ReconciledSource: true, + ReconciledDestination: true, + }) + + // All tests request the source account + tests := []struct { + name string + time time.Time + sourceBalance float64 + sourceReconciledBalance float64 + destinationBalance float64 + destinationReconciledBalance float64 + }{ + {"Before Initial Balance", time.Date(2023, 8, 15, 0, 0, 0, 0, time.UTC), 0, 0, 0, 0}, + {"Only Initial Balance", time.Date(2023, 9, 15, 0, 0, 0, 0, time.UTC), 50, 50, 0, 0}, + {"After first transaction", time.Date(2023, 10, 20, 0, 0, 0, 0, time.UTC), 40, 40, 10, 0}, + {"After second transaction", time.Date(2023, 11, 20, 0, 0, 0, 0, time.UTC), 30, 40, 20, 0}, + {"After third transaction", time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), 20, 30, 30, 0}, // destinationReconciledBalance is 0 since external accounts cannot be reconciled + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + recorder := test.Request(t, http.MethodPost, "/v4/accounts/computed", map[string]any{ + "time": tt.time.Format(time.RFC3339), + "ids": []string{sourceAccount.Data.ID.String(), destinationAccount.Data.ID.String()}, + }, + ) + test.AssertHTTPStatus(t, &recorder, http.StatusOK) + + var response v4.AccountComputedDataResponse + test.DecodeResponse(t, &recorder, &response) + + assert.True(t, response.Data[0].Balance.Equal(decimal.NewFromFloat(tt.sourceBalance)), "Source Balance is not correct, expected %f, got %s", tt.sourceBalance, response.Data[0].Balance) + assert.True(t, response.Data[0].ReconciledBalance.Equal(decimal.NewFromFloat(tt.sourceReconciledBalance)), "Source Reconciled Balance is not correct, expected %f, got %s", tt.sourceReconciledBalance, response.Data[0].ReconciledBalance) + + assert.True(t, response.Data[1].Balance.Equal(decimal.NewFromFloat(tt.destinationBalance)), "Destination Balance is not correct, expected %f, got %s", tt.destinationBalance, response.Data[1].Balance) + assert.True(t, response.Data[1].ReconciledBalance.Equal(decimal.NewFromFloat(tt.destinationReconciledBalance)), "Destination Reconciled Balance is not correct, expected %f, got %s", tt.destinationReconciledBalance, response.Data[1].ReconciledBalance) + }) + } +} + +func (suite *TestSuiteStandard) TestAccountsCreateFails() { + // Test account for uniqueness + a := createTestAccount(suite.T(), v4.AccountEditable{ + Name: "Unique Account Name for Budget", + }) + + tests := []struct { + name string + body any + status int // expected HTTP status + testFunc func(t *testing.T, a v4.AccountCreateResponse) // tests to perform against the updated account resource + }{ + {"Broken Body", `[{ "note": 2 }]`, http.StatusBadRequest, func(t *testing.T, a v4.AccountCreateResponse) { + assert.Equal(t, "json: cannot unmarshal number into Go struct field AccountEditable.note of type string", *a.Error) + }}, + { + "No body", "", http.StatusBadRequest, + func(t *testing.T, a v4.AccountCreateResponse) { + assert.Equal(t, "the request body must not be empty", *a.Error) + }, + }, + { + "No Budget", + `[{ "note": "Some text" }]`, + http.StatusBadRequest, + func(t *testing.T, a v4.AccountCreateResponse) { + assert.Equal(t, "no Budget ID specified", *a.Data[0].Error) + }, + }, + { + "Non-existing Budget", + `[{ "budgetId": "ea85ad1a-3679-4ced-b83b-89566c12ece9" }]`, + http.StatusNotFound, + func(t *testing.T, a v4.AccountCreateResponse) { + assert.Equal(t, "there is no Budget with this ID", *a.Data[0].Error) + }, + }, + { + "Duplicate name for budget", + []v4.AccountEditable{ + { + Name: a.Data.Name, + BudgetID: a.Data.BudgetID, + }, + }, + http.StatusBadRequest, + func(t *testing.T, a v4.AccountCreateResponse) { + assert.Equal(t, "the account name must be unique for the budget", *a.Data[0].Error) + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPost, "http://example.com/v4/accounts", tt.body) + test.AssertHTTPStatus(t, &r, tt.status) + + var a v4.AccountCreateResponse + test.DecodeResponse(t, &r, &a) + + if tt.testFunc != nil { + tt.testFunc(t, a) + } + }) + } +} + +// Verify that updating accounts works as desired +func (suite *TestSuiteStandard) TestAccountsUpdate() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + account := createTestAccount(suite.T(), v4.AccountEditable{Name: "Original name", BudgetID: budget.Data.ID}) + + tests := []struct { + name string // name of the test + account map[string]any // the updates to perform. This is not a struct because that would set all fields on the request + testFunc func(t *testing.T, a v4.AccountResponse) // tests to perform against the updated account resource + }{ + { + "Name, On Budget, Note", + map[string]any{ + "name": "Another name", + "onBudget": true, + "note": "New note!", + }, + func(t *testing.T, a v4.AccountResponse) { + assert.True(t, a.Data.OnBudget) + assert.Equal(t, "New note!", a.Data.Note) + assert.Equal(t, "Another name", a.Data.Name) + }, + }, + { + "Archived, External", + map[string]any{ + "archived": true, + "external": true, + }, + func(t *testing.T, a v4.AccountResponse) { + assert.True(t, a.Data.Archived) + assert.True(t, a.Data.External) + }, + }, + { + "Initial Balance", + map[string]any{ + "initialBalance": "203.21", + }, + func(t *testing.T, a v4.AccountResponse) { + assert.True(t, a.Data.InitialBalance.Equal(decimal.NewFromFloat(203.21))) + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPatch, account.Data.Links.Self, tt.account) + test.AssertHTTPStatus(t, &r, http.StatusOK) + + var a v4.AccountResponse + test.DecodeResponse(t, &r, &a) + + if tt.testFunc != nil { + tt.testFunc(t, a) + } + }) + } +} + +func (suite *TestSuiteStandard) TestAccountsUpdateFails() { + tests := []struct { + name string + id string + body any + status int // expected response status + }{ + {"Invalid type", "", `{"name": 2}`, http.StatusBadRequest}, + {"Broken JSON", "", `{ "name": 2" }`, http.StatusBadRequest}, + {"Non-existing account", uuid.New().String(), `{"name": 2}`, http.StatusNotFound}, + {"Set budget to uuid.Nil", "", `{ "budgetId": "00000000-0000-0000-0000-000000000000" }`, http.StatusBadRequest}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var recorder httptest.ResponseRecorder + + if tt.id == "" { + account := createTestAccount(suite.T(), v4.AccountEditable{ + Name: "New Budget", + Note: "More tests something something", + }) + + tt.id = account.Data.ID.String() + } + + // Update Account + recorder = test.Request(t, http.MethodPatch, fmt.Sprintf("http://example.com/v4/accounts/%s", tt.id), tt.body) + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestAccountsDelete verifies all cases for Account deletions. +func (suite *TestSuiteStandard) TestAccountsDelete() { + tests := []struct { + name string + id string + status int // expected response status + }{ + {"Success", "", http.StatusNoContent}, + {"Non-existing Account", uuid.New().String(), http.StatusNotFound}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var recorder httptest.ResponseRecorder + + if tt.id == "" { + // Create test Account + a := createTestAccount(t, v4.AccountEditable{}) + tt.id = a.Data.ID.String() + } + + // Delete Account + recorder = test.Request(t, http.MethodDelete, fmt.Sprintf("http://example.com/v4/accounts/%s", tt.id), "") + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestAccountsGetSorted verifies that Accounts are sorted by name. +func (suite *TestSuiteStandard) TestAccountsGetSorted() { + a1 := createTestAccount(suite.T(), v4.AccountEditable{ + Name: "Alphabetically first", + }) + + a2 := createTestAccount(suite.T(), v4.AccountEditable{ + Name: "Second in creation, third in list", + }) + + a3 := createTestAccount(suite.T(), v4.AccountEditable{ + Name: "First is alphabetically second", + }) + + a4 := createTestAccount(suite.T(), v4.AccountEditable{ + Name: "Zulu is the last one", + }) + + r := test.Request(suite.T(), http.MethodGet, "http://example.com/v4/accounts", "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + + var accounts v4.AccountListResponse + test.DecodeResponse(suite.T(), &r, &accounts) + + if !assert.Len(suite.T(), accounts.Data, 4) { + assert.FailNow(suite.T(), "Account list has wrong length") + } + + assert.Equal(suite.T(), a1.Data.Name, accounts.Data[0].Name) + assert.Equal(suite.T(), a2.Data.Name, accounts.Data[2].Name) + assert.Equal(suite.T(), a3.Data.Name, accounts.Data[1].Name) + assert.Equal(suite.T(), a4.Data.Name, accounts.Data[3].Name) +} + +func (suite *TestSuiteStandard) TestAccountsPagination() { + for i := 0; i < 10; i++ { + createTestAccount(suite.T(), v4.AccountEditable{Name: fmt.Sprint(i)}) + } + + tests := []struct { + name string + offset uint + limit int + expectedCount int + expectedTotal int64 + }{ + {"All", 0, -1, 10, 10}, + {"First 5", 0, 5, 5, 10}, + {"Last 5", 5, -1, 5, 10}, + {"Offset 3", 3, -1, 7, 10}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v4/accounts?offset=%d&limit=%d", tt.offset, tt.limit), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + + var accounts v4.AccountListResponse + test.DecodeResponse(t, &r, &accounts) + + assert.Equal(suite.T(), tt.offset, accounts.Pagination.Offset) + assert.Equal(suite.T(), tt.limit, accounts.Pagination.Limit) + assert.Equal(suite.T(), tt.expectedCount, accounts.Pagination.Count) + assert.Equal(suite.T(), tt.expectedTotal, accounts.Pagination.Total) + }) + } +} + +func (suite *TestSuiteStandard) TestAccountRecentEnvelopes() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + + account := createTestAccount(suite.T(), v4.AccountEditable{ + BudgetID: budget.Data.ID, + Name: "Internal Account", + OnBudget: true, + External: false, + InitialBalance: decimal.NewFromFloat(170), + }) + + externalAccount := createTestAccount(suite.T(), v4.AccountEditable{ + BudgetID: budget.Data.ID, + Name: "External Account", + External: true, + }) + + category := createTestCategory(suite.T(), v4.CategoryEditable{ + BudgetID: budget.Data.ID, + }) + + envelopeIDs := []*uuid.UUID{} + for i := 0; i < 3; i++ { + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{ + CategoryID: category.Data.ID, + Name: strconv.Itoa(i), + }) + + envelopeIDs = append(envelopeIDs, &envelope.Data.ID) + + // Sleep for 10 milliseconds because we only save timestamps with 1 millisecond accuracy + // This is needed because the test runs so fast that all envelopes are sometimes created + // within the same millisecond, making the result non-deterministic + time.Sleep(1 * time.Millisecond) + } + + // Create 15 transactions: + // * 2 for the first envelope + // * 2 for the second envelope + // * 11 for the last envelope + for i := 0; i < 15; i++ { + eIndex := i + if i > 5 { + eIndex = 2 + } + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + EnvelopeID: envelopeIDs[eIndex%3], + SourceAccountID: externalAccount.Data.ID, + DestinationAccountID: account.Data.ID, + Amount: decimal.NewFromFloat(17.45), + }) + } + + // Create three income transactions + // + // This is a regression test for income always showing at the last + // position in the recent envelopes (before the LIMIT) since count(id) for + // income was always 0. This is due to the envelope ID for income being NULL + // and count() not counting NULL values. + // + // Creating three income transactions puts "income" as the second most common + // envelope, verifying the fix + for i := 0; i < 3; i++ { + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + EnvelopeID: nil, + SourceAccountID: externalAccount.Data.ID, + DestinationAccountID: account.Data.ID, + Amount: decimal.NewFromFloat(1337.42), + }) + } + + r := test.Request(suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v4/accounts/%s/recent-envelopes", account.Data.ID), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + + var recentEnvelopeResponse v4.RecentEnvelopesResponse + test.DecodeResponse(suite.T(), &r, &recentEnvelopeResponse) + + data := recentEnvelopeResponse.Data + suite.Require().Len(data, 4, "The number of envelopes in recentEnvelopes is not correct, expected 4, got %d", len(data)) + + // The last envelope needs to be the first in the sort since it + // has been the most common one + suite.Assert().Equal(envelopeIDs[2], data[0].ID) + + // Income is the second one since it appears three times + var nilUUIDPointer *uuid.UUID + suite.Assert().Equal(nilUUIDPointer, data[1].ID) + + // Order for envelopes with the same frequency is undefined +} diff --git a/pkg/controllers/v4/account_types.go b/pkg/controllers/v4/account_types.go new file mode 100644 index 00000000..0f15f762 --- /dev/null +++ b/pkg/controllers/v4/account_types.go @@ -0,0 +1,159 @@ +package v4 + +import ( + "fmt" + "time" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +type AccountEditable struct { + Name string `json:"name" example:"Cash" default:""` // Name of the account + Note string `json:"note" example:"Money in my wallet" default:""` // A longer description for the account + BudgetID uuid.UUID `json:"budgetId" example:"550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // ID of the budget this account belongs to + OnBudget bool `json:"onBudget" example:"true" default:"false"` // Does the account factor into the available budget? Always false when external: true + External bool `json:"external" example:"false" default:"false"` // Does the account belong to the budget owner or not? + InitialBalance decimal.Decimal `json:"initialBalance" example:"173.12" default:"0" minimum:"0.00000001" maximum:"999999999999.99999999" multipleOf:"0.00000001"` // Balance of the account before any transactions were recorded + InitialBalanceDate *time.Time `json:"initialBalanceDate" example:"2017-05-12T00:00:00Z"` // Date of the initial balance + Archived bool `json:"archived" example:"true" default:"false"` // Is the account archived? + ImportHash string `json:"importHash" example:"867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" default:""` // The SHA256 hash of a unique combination of values to use in duplicate detection for imports +} + +// model returns the database resource for the editable fields +func (editable AccountEditable) model() models.Account { + return models.Account{ + Name: editable.Name, + Note: editable.Note, + BudgetID: editable.BudgetID, + OnBudget: editable.OnBudget, + External: editable.External, + InitialBalance: editable.InitialBalance, + InitialBalanceDate: editable.InitialBalanceDate, + Archived: editable.Archived, + ImportHash: editable.ImportHash, + } +} + +type AccountLinks struct { + Self string `json:"self" example:"https://example.com/api/v4/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2"` // The account itself + RecentEnvelopes string `json:"recentEnvelopes" example:"https://example.com/api/v4/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2/recent-envelopes"` // Envelopes in recent transactions where this account was the target + ComputedData string `json:"computedData" example:"https://example.com/api/v4/accounts/computed"` // Computed data endpoint for accounts + Transactions string `json:"transactions" example:"https://example.com/api/v4/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2"` // Transactions referencing the account +} + +// Account is the API v4 representation of an Account in EZ. +type Account struct { + models.DefaultModel + AccountEditable + Links AccountLinks `json:"links"` +} + +func newAccount(c *gin.Context, model models.Account) Account { + url := c.GetString(string(models.DBContextURL)) + + return Account{ + DefaultModel: model.DefaultModel, + AccountEditable: AccountEditable{ + Name: model.Name, + Note: model.Note, + BudgetID: model.BudgetID, + OnBudget: model.OnBudget, + External: model.External, + InitialBalance: model.InitialBalance, + InitialBalanceDate: model.InitialBalanceDate, + Archived: model.Archived, + ImportHash: model.ImportHash, + }, + Links: AccountLinks{ + Self: fmt.Sprintf("%s/v4/accounts/%s", url, model.ID), + RecentEnvelopes: fmt.Sprintf("%s/v4/accounts/%s/recent-envelopes", url, model.ID), + ComputedData: fmt.Sprintf("%s/v4/accounts/computed", url), + Transactions: fmt.Sprintf("%s/v4/transactions?account=%s", url, model.ID), + }, + } +} + +type AccountListResponse struct { + Data []Account `json:"data"` // List of accounts + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Pagination *Pagination `json:"pagination"` // Pagination information +} + +type AccountCreateResponse struct { + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Data []AccountResponse `json:"data"` // List of created Accounts +} + +func (a *AccountCreateResponse) appendError(err httperrors.Error, status int) int { + s := err.Error() + a.Data = append(a.Data, AccountResponse{Error: &s}) + + // The final status code is the highest HTTP status code number + if err.Status > status { + status = err.Status + } + + return status +} + +type AccountResponse struct { + Data *Account `json:"data"` // Data for the account + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred for this transaction +} + +type AccountQueryFilter struct { + Name string `form:"name" filterField:"false"` // Fuzzy filter for the account name + Note string `form:"note" filterField:"false"` // Fuzzy filter for the note + BudgetID string `form:"budget"` // By budget ID + OnBudget bool `form:"onBudget"` // Is the account on-budget? + External bool `form:"external"` // Is the account external? + Archived bool `form:"archived"` // Is the account archived? + Search string `form:"search" filterField:"false"` // By string in name or note + Offset uint `form:"offset" filterField:"false"` // The offset of the first Account returned. Defaults to 0. + Limit int `form:"limit" filterField:"false"` // Maximum number of Accounts to return. Defaults to 50. +} + +func (f AccountQueryFilter) model() (models.Account, httperrors.Error) { + budgetID, err := httputil.UUIDFromString(f.BudgetID) + if !err.Nil() { + return models.Account{}, err + } + + return models.Account{ + BudgetID: budgetID, + OnBudget: f.OnBudget, + External: f.External, + Archived: f.Archived, + }, httperrors.Error{} +} + +type RecentEnvelopesResponse struct { + Data []RecentEnvelope `json:"data"` // Data for the account + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred for this transaction +} + +type RecentEnvelope struct { + Name string `json:"name"` + ID *uuid.UUID `json:"id"` +} + +type AccountComputedRequest struct { + Time time.Time `form:"time"` // The time for which the computation is requested + IDs []string `form:"ids"` // A list of UUIDs for the accounts +} + +type AccountComputedData struct { + ID uuid.UUID `json:"id" example:"95018a69-758b-46c6-8bab-db70d9614f9d"` // ID of the account + Balance decimal.Decimal `json:"balance" example:"2735.17"` // Balance of the account, including all transactions referencing it + ReconciledBalance decimal.Decimal `json:"reconciledBalance" example:"2539.57"` // Balance of the account, including all reconciled transactions referencing it +} + +type AccountComputedDataResponse struct { + Data []AccountComputedData `json:"data"` + Error *string `json:"error"` +} diff --git a/pkg/controllers/v4/budget.go b/pkg/controllers/v4/budget.go new file mode 100644 index 00000000..e0b0ab46 --- /dev/null +++ b/pkg/controllers/v4/budget.go @@ -0,0 +1,321 @@ +package v4 + +import ( + "net/http" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "golang.org/x/exp/slices" +) + +// RegisterBudgetRoutes registers the routes for Budgets with +// the RouterGroup that is passed. +func RegisterBudgetRoutes(r *gin.RouterGroup) { + // Root group + { + r.OPTIONS("", OptionsBudgetList) + r.GET("", GetBudgets) + r.POST("", CreateBudgets) + } + + // Budget with ID + { + r.OPTIONS("/:id", OptionsBudgetDetail) + r.GET("/:id", GetBudget) + r.PATCH("/:id", UpdateBudget) + r.DELETE("/:id", DeleteBudget) + } +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Budgets +// @Success 204 +// @Router /v4/budgets [options] +func OptionsBudgetList(c *gin.Context) { + httputil.OptionsGetPost(c) +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Budgets +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/budgets/{id} [options] +func OptionsBudgetDetail(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + _, err = getModelByID[models.Budget](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + httputil.OptionsGetPatchDelete(c) +} + +// @Summary Create budget +// @Description Creates a new budget +// @Tags Budgets +// @Accept json +// @Produce json +// @Success 201 {object} BudgetCreateResponse +// @Failure 400 {object} BudgetCreateResponse +// @Failure 500 {object} BudgetCreateResponse +// @Param budget body []BudgetEditable true "Budget" +// @Router /v4/budgets [post] +func CreateBudgets(c *gin.Context) { + var budgets []BudgetEditable + + // Bind data and return error if not possible + err := httputil.BindData(c, &budgets) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, BudgetCreateResponse{ + Error: &e, + }) + return + } + + // The final http status. Will be modified when errors occur + status := http.StatusCreated + r := BudgetCreateResponse{} + + for _, editable := range budgets { + budget := editable.model() + + dbErr := models.DB.Create(&budget).Error + if dbErr != nil { + err := httperrors.GenericDBError[models.Budget](budget, c, dbErr) + status = r.appendError(err, status) + continue + } + + data := newBudget(c, budget) + r.Data = append(r.Data, BudgetResponse{Data: &data}) + } + + c.JSON(status, r) +} + +// @Summary List budgets +// @Description Returns a list of budgets +// @Tags Budgets +// @Produce json +// @Success 200 {object} BudgetListResponse +// @Failure 500 {object} BudgetListResponse +// @Router /v4/budgets [get] +// @Param name query string false "Filter by name" +// @Param note query string false "Filter by note" +// @Param currency query string false "Filter by currency" +// @Param search query string false "Search for this text in name and note" +// @Param offset query uint false "The offset of the first Budget returned. Defaults to 0." +// @Param limit query int false "Maximum number of Budgets to return. Defaults to 50." +func GetBudgets(c *gin.Context) { + var filter BudgetQueryFilter + + // Every parameter is bound into a string, so this will always succeed + _ = c.Bind(&filter) + + // Get the fields that we're filtering for + queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) + + var budgets []models.Budget + + // Always sort by name + q := models.DB. + Order("name ASC"). + Where(filter.model(), queryFields...) + + q = stringFilters(models.DB, q, setFields, filter.Name, filter.Note, filter.Search) + + // Set the offset. Does not need checking since the default is 0 + q = q.Offset(int(filter.Offset)) + + // Default to all Budgets and set the limit + limit := 50 + if slices.Contains(setFields, "Limit") { + limit = filter.Limit + } + q = q.Limit(limit) + + err := query(c, q.Find(&budgets)) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetListResponse{ + Error: &s, + }) + return + } + + var count int64 + err = query(c, q.Limit(-1).Offset(-1).Count(&count)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, BudgetListResponse{ + Error: &e, + }) + return + } + + apiResources := make([]Budget, 0) + for _, budget := range budgets { + apiResources = append(apiResources, newBudget(c, budget)) + } + + c.JSON(http.StatusOK, BudgetListResponse{ + Data: apiResources, + Pagination: &Pagination{ + Count: len(apiResources), + Total: count, + Offset: filter.Offset, + Limit: limit, + }, + }) +} + +// @Summary Get budget +// @Description Returns a specific budget +// @Tags Budgets +// @Produce json +// @Success 200 {object} BudgetResponse +// @Failure 400 {object} BudgetResponse +// @Failure 404 {object} BudgetResponse +// @Failure 500 {object} BudgetResponse +// @Param id path string true "ID formatted as string" +// @Router /v4/budgets/{id} [get] +func GetBudget(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponse{ + Error: &s, + }) + return + } + + m, err := getModelByID[models.Budget](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponse{ + Error: &s, + }) + return + } + + apiResource := newBudget(c, m) + c.JSON(http.StatusOK, BudgetResponse{Data: &apiResource}) +} + +// @Summary Update budget +// @Description Update an existing budget. Only values to be updated need to be specified. +// @Tags Budgets +// @Accept json +// @Produce json +// @Success 200 {object} BudgetResponse +// @Failure 400 {object} BudgetResponse +// @Failure 404 {object} BudgetResponse +// @Failure 500 {object} BudgetResponse +// @Param id path string true "ID formatted as string" +// @Param budget body BudgetEditable true "Budget" +// @Router /v4/budgets/{id} [patch] +func UpdateBudget(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponse{ + Error: &s, + }) + return + } + + budget, err := getModelByID[models.Budget](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponse{ + Error: &s, + }) + return + } + + updateFields, err := httputil.GetBodyFields(c, BudgetEditable{}) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponse{ + Error: &s, + }) + return + } + + var data BudgetEditable + err = httputil.BindData(c, &data) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponse{ + Error: &s, + }) + return + } + + err = query(c, models.DB.Model(&budget).Select("", updateFields...).Updates(data)) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponse{ + Error: &s, + }) + return + } + + apiResource := newBudget(c, budget) + c.JSON(http.StatusOK, BudgetResponse{Data: &apiResource}) +} + +// @Summary Delete budget +// @Description Deletes a budget +// @Tags Budgets +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/budgets/{id} [delete] +func DeleteBudget(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + budget, err := getModelByID[models.Budget](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + err = query(c, models.DB.Delete(&budget)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusNoContent, nil) +} diff --git a/pkg/controllers/v4/budget_test.go b/pkg/controllers/v4/budget_test.go new file mode 100644 index 00000000..3680cf79 --- /dev/null +++ b/pkg/controllers/v4/budget_test.go @@ -0,0 +1,354 @@ +package v4_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/test" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func createTestBudget(t *testing.T, c v4.BudgetEditable, expectedStatus ...int) v4.BudgetResponse { + // Default to 201 Created as expected status + if len(expectedStatus) == 0 { + expectedStatus = append(expectedStatus, http.StatusCreated) + } + + body := []v4.BudgetEditable{ + c, + } + + r := test.Request(t, http.MethodPost, "http://example.com/v4/budgets", body) + test.AssertHTTPStatus(t, &r, expectedStatus...) + + var a v4.BudgetCreateResponse + test.DecodeResponse(t, &r, &a) + + if r.Code == http.StatusCreated { + return a.Data[0] + } + + return v4.BudgetResponse{} +} + +// TestBudgetsDBClosed verifies that errors are processed correctly when +// the database is closed. +func (suite *TestSuiteStandard) TestBudgetsDBClosed() { + tests := []struct { + name string // Name of the test + test func(t *testing.T) // Code to run + }{ + { + "Creation fails", + func(t *testing.T) { + createTestBudget(t, v4.BudgetEditable{}, http.StatusInternalServerError) + }, + }, + { + "GET fails", + func(t *testing.T) { + recorder := test.Request(t, http.MethodGet, "http://example.com/v4/budgets", "") + test.AssertHTTPStatus(t, &recorder, http.StatusInternalServerError) + assert.Contains(t, test.DecodeError(t, recorder.Body.Bytes()), "there is a problem with the database connection") + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + suite.CloseDB() + + tt.test(t) + }) + } +} + +// TestBudgetOptions verifies that OPTIONS requests are handled correctly. +func (suite *TestSuiteStandard) TestBudgetOptions() { + tests := []struct { + name string + id string // path at the /v4/budgets endpoint to test + status int // Expected HTTP status code + }{ + {"No budget with this ID", uuid.New().String(), http.StatusNotFound}, + {"Not a valid UUID", "NotParseableAsUUID", http.StatusBadRequest}, + {"Budget exists", createTestBudget(suite.T(), v4.BudgetEditable{}).Data.ID.String(), http.StatusNoContent}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("%s/%s", "http://example.com/v4/budgets", tt.id) + r := test.Request(t, http.MethodOptions, path, "") + test.AssertHTTPStatus(t, &r, tt.status) + + if tt.status == http.StatusNoContent { + assert.Equal(t, "OPTIONS, GET, PATCH, DELETE", r.Header().Get("allow")) + } + }) + } +} + +// TestBudgetsGetSingle verifies that requests for the resource endpoints are +// handled correctly. +func (suite *TestSuiteStandard) TestBudgetsGetSingle() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + + tests := []struct { + name string + id string + status int + method string + }{ + {"GET Existing budget", budget.Data.ID.String(), http.StatusOK, http.MethodGet}, + {"GET ID nil", uuid.Nil.String(), http.StatusBadRequest, http.MethodGet}, + {"GET No budget with this ID", uuid.New().String(), http.StatusNotFound, http.MethodGet}, + {"GET Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodGet}, + {"GET Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodGet}, + {"GET Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodGet}, + {"PATCH Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodPatch}, + {"PATCH Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodPatch}, + {"PATCH Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodPatch}, + {"DELETE Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodDelete}, + {"DELETE Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodDelete}, + {"DELETE Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodDelete}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, tt.method, fmt.Sprintf("http://example.com/v4/budgets/%s", tt.id), "") + + var budget v4.BudgetResponse + test.DecodeResponse(t, &r, &budget) + test.AssertHTTPStatus(t, &r, tt.status) + }) + } +} + +func (suite *TestSuiteStandard) TestBudgetsGetFilter() { + _ = createTestBudget(suite.T(), v4.BudgetEditable{ + Name: "Exact String Match", + Note: "This is a specific note", + Currency: "", + }) + + _ = createTestBudget(suite.T(), v4.BudgetEditable{ + Name: "", + Note: "This is a specific note", + Currency: "$", + }) + + _ = createTestBudget(suite.T(), v4.BudgetEditable{ + Name: "Another String", + Note: "A different note", + Currency: "€", + }) + + tests := []struct { + name string + query string + len int + }{ + {"Currency: €", "currency=€", 1}, + {"Currency: $", "currency=$", 1}, + {"Currency & Name", "currency=€&name=Another String", 1}, + {"Note", "note=This is a specific note", 2}, + {"Name", "name=Exact String Match", 1}, + {"Empty Name with Note", "name=¬e=This is a specific note", 1}, + {"No currency", "currency=", 1}, + {"No name", "name=", 1}, + {"Search for 'stRing'", "search=stRing", 2}, + {"Search for 'Note'", "search=Note", 3}, + {"Offset", "offset=1", 2}, + {"Limit", "limit=1", 1}, + } + + var re v4.BudgetListResponse + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodGet, fmt.Sprintf("http://example.com/v4/budgets?%s", tt.query), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + test.DecodeResponse(t, &r, &re) + assert.Equal(t, tt.len, len(re.Data)) + }) + } +} + +func (suite *TestSuiteStandard) TestBudgetsCreateFails() { + tests := []struct { + name string + body string + testFunc func(t *testing.T, b v4.BudgetCreateResponse) // tests to perform against the updated budget resource + }{ + {"Broken Body", `{ "note": 2 }`, func(t *testing.T, b v4.BudgetCreateResponse) { + assert.Equal(t, "json: cannot unmarshal object into Go value of type []v4.BudgetEditable", *b.Error) + }}, + {"No body", "", func(t *testing.T, b v4.BudgetCreateResponse) { + assert.Equal(t, "the request body must not be empty", *b.Error) + }}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPost, "http://example.com/v4/budgets", tt.body) + test.AssertHTTPStatus(t, &r, http.StatusBadRequest) + + var b v4.BudgetCreateResponse + test.DecodeResponse(t, &r, &b) + + if tt.testFunc != nil { + tt.testFunc(t, b) + } + }) + } +} + +func (suite *TestSuiteStandard) TestBudgetsUpdate() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{ + Name: "New Budget", + Note: "More tests something something", + }) + + recorder := test.Request(suite.T(), http.MethodPatch, budget.Data.Links.Self, map[string]any{ + "name": "Updated new budget", + "note": "", + }) + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + + var updatedBudget v4.BudgetResponse + test.DecodeResponse(suite.T(), &recorder, &updatedBudget) + + assert.Equal(suite.T(), "", updatedBudget.Data.Note) + assert.Equal(suite.T(), "Updated new budget", updatedBudget.Data.Name) +} + +func (suite *TestSuiteStandard) TestBudgetsUpdateFails() { + tests := []struct { + name string + id string + body string + status int // expected response status + }{ + {"Invalid type", "", `{"name": 2}`, http.StatusBadRequest}, + {"Non-existing budget", uuid.New().String(), `{"name": 2}`, http.StatusNotFound}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var recorder httptest.ResponseRecorder + + if tt.id == "" { + // Create test budget + budget := createTestBudget(suite.T(), v4.BudgetEditable{ + Name: "New Budget", + Note: "More tests something something", + }) + + tt.id = budget.Data.ID.String() + } + + // Update budget + recorder = test.Request(t, http.MethodPatch, fmt.Sprintf("http://example.com/v4/budgets/%s", tt.id), tt.body) + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestBudgetsDelete verifies all cases for budget deletions. +func (suite *TestSuiteStandard) TestBudgetsDelete() { + tests := []struct { + name string + id string + status int // expected response status + }{ + {"Success", "", http.StatusNoContent}, + {"Non-existing budget", uuid.New().String(), http.StatusNotFound}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var recorder httptest.ResponseRecorder + + if tt.id == "" { + // Create test budget + b := createTestBudget(t, v4.BudgetEditable{}) + tt.id = b.Data.ID.String() + } + + // Update budget + recorder = test.Request(t, http.MethodDelete, fmt.Sprintf("http://example.com/v4/budgets/%s", tt.id), "") + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestBudgetsGetSorted verifies that budgets are sorted by name. +func (suite *TestSuiteStandard) TestBudgetsGetSorted() { + b1 := createTestBudget(suite.T(), v4.BudgetEditable{ + Name: "Alphabetically first", + }) + + b2 := createTestBudget(suite.T(), v4.BudgetEditable{ + Name: "Second in creation, third in list", + }) + + b3 := createTestBudget(suite.T(), v4.BudgetEditable{ + Name: "First is alphabetically second", + }) + + b4 := createTestBudget(suite.T(), v4.BudgetEditable{ + Name: "Zulu is the last one", + }) + + r := test.Request(suite.T(), http.MethodGet, "http://example.com/v4/budgets", "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + + var budgets v4.BudgetListResponse + test.DecodeResponse(suite.T(), &r, &budgets) + + if !assert.Len(suite.T(), budgets.Data, 4) { + assert.FailNow(suite.T(), "Budgets list has wrong length") + } + + assert.Equal(suite.T(), b1.Data.Name, budgets.Data[0].Name) + assert.Equal(suite.T(), b2.Data.Name, budgets.Data[2].Name) + assert.Equal(suite.T(), b3.Data.Name, budgets.Data[1].Name) + assert.Equal(suite.T(), b4.Data.Name, budgets.Data[3].Name) +} + +func (suite *TestSuiteStandard) TestBudgetsPagination() { + for i := 0; i < 10; i++ { + createTestBudget(suite.T(), v4.BudgetEditable{Name: fmt.Sprint(i)}) + } + + tests := []struct { + name string + offset uint + limit int + expectedCount int + expectedTotal int64 + }{ + {"All", 0, -1, 10, 10}, + {"First 5", 0, 5, 5, 10}, + {"Last 5", 5, -1, 5, 10}, + {"Offset 3", 3, -1, 7, 10}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v4/budgets?offset=%d&limit=%d", tt.offset, tt.limit), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + + var budgets v4.BudgetListResponse + test.DecodeResponse(t, &r, &budgets) + + assert.Equal(suite.T(), tt.offset, budgets.Pagination.Offset) + assert.Equal(suite.T(), tt.limit, budgets.Pagination.Limit) + assert.Equal(suite.T(), tt.expectedCount, budgets.Pagination.Count) + assert.Equal(suite.T(), tt.expectedTotal, budgets.Pagination.Total) + }) + } +} diff --git a/pkg/controllers/v4/budget_types.go b/pkg/controllers/v4/budget_types.go new file mode 100644 index 00000000..763378ca --- /dev/null +++ b/pkg/controllers/v4/budget_types.go @@ -0,0 +1,104 @@ +package v4 + +import ( + "fmt" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" +) + +type BudgetEditable struct { + Name string `json:"name" example:"Morre's Budget" default:""` // Name of the budget + Note string `json:"note" example:"My personal expenses" default:""` // A longer description of the budget + Currency string `json:"currency" example:"€" default:""` // The currency for the budget +} + +func (editable BudgetEditable) model() models.Budget { + return models.Budget{ + Name: editable.Name, + Note: editable.Note, + Currency: editable.Currency, + } +} + +type BudgetLinks struct { + Self string `json:"self" example:"https://example.com/api/v4/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // The budget itself + Accounts string `json:"accounts" example:"https://example.com/api/v4/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Accounts for this budget + Categories string `json:"categories" example:"https://example.com/api/v4/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Categories for this budget + Envelopes string `json:"envelopes" example:"https://example.com/api/v4/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Envelopes for this budget + Transactions string `json:"transactions" example:"https://example.com/api/v4/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Transactions for this budget + Month string `json:"month" example:"https://example.com/api/v4/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf&month=YYYY-MM"` // This uses 'YYYY-MM' for clients to replace with the actual year and month. +} + +// Budget is the API v4 representation of a Budget. +type Budget struct { + models.DefaultModel + BudgetEditable + Links BudgetLinks `json:"links"` +} + +func newBudget(c *gin.Context, model models.Budget) Budget { + url := c.GetString(string(models.DBContextURL)) + + return Budget{ + DefaultModel: model.DefaultModel, + BudgetEditable: BudgetEditable{ + Name: model.Name, + Note: model.Note, + Currency: model.Currency, + }, + Links: BudgetLinks{ + Self: fmt.Sprintf("%s/v4/budgets/%s", url, model.ID), + Accounts: fmt.Sprintf("%s/v4/accounts?budget=%s", url, model.ID), + Categories: fmt.Sprintf("%s/v4/categories?budget=%s", url, model.ID), + Envelopes: fmt.Sprintf("%s/v4/envelopes?budget=%s", url, model.ID), + Transactions: fmt.Sprintf("%s/v4/transactions?budget=%s", url, model.ID), + Month: fmt.Sprintf("%s/v4/months?budget=%s&month=YYYY-MM", url, model.ID), + }, + } +} + +type BudgetListResponse struct { + Data []Budget `json:"data"` // List of budgets + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Pagination *Pagination `json:"pagination"` // Pagination information +} + +type BudgetCreateResponse struct { + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Data []BudgetResponse `json:"data"` // List of created Budgets +} + +func (b *BudgetCreateResponse) appendError(err httperrors.Error, status int) int { + s := err.Error() + b.Data = append(b.Data, BudgetResponse{Error: &s}) + + // The final status code is the highest HTTP status code number + if err.Status > status { + status = err.Status + } + + return status +} + +type BudgetResponse struct { + Data *Budget `json:"data"` // Data for the budget + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred +} + +type BudgetQueryFilter struct { + Name string `form:"name" filterField:"false"` // By name + Note string `form:"note" filterField:"false"` // By note + Currency string `form:"currency"` // By currency + Search string `form:"search" filterField:"false"` // By string in name or note + Offset uint `form:"offset" filterField:"false"` // The offset of the first Budget returned. Defaults to 0. + Limit int `form:"limit" filterField:"false"` // Maximum number of Budgets to return. Defaults to 50. +} + +func (f BudgetQueryFilter) model() models.Budget { + // Does not return string fields since they are filtered by the controller + return models.Budget{ + Currency: f.Currency, + } +} diff --git a/pkg/controllers/v4/category.go b/pkg/controllers/v4/category.go new file mode 100644 index 00000000..c494b1d4 --- /dev/null +++ b/pkg/controllers/v4/category.go @@ -0,0 +1,367 @@ +package v4 + +import ( + "net/http" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "golang.org/x/exp/slices" +) + +// RegisterCategoryRoutes registers the routes for categories with +// the RouterGroup that is passed. +func RegisterCategoryRoutes(r *gin.RouterGroup) { + // Root group + { + r.OPTIONS("", OptionsCategoryList) + r.GET("", GetCategories) + r.POST("", CreateCategories) + } + + // Category with ID + { + r.OPTIONS("/:id", OptionsCategoryDetail) + r.GET("/:id", GetCategory) + r.PATCH("/:id", UpdateCategory) + r.DELETE("/:id", DeleteCategory) + } +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Categories +// @Success 204 +// @Router /v4/categories [options] +func OptionsCategoryList(c *gin.Context) { + httputil.OptionsGetPost(c) +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Categories +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/categories/{id} [options] +func OptionsCategoryDetail(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + _, err = getModelByID[models.Category](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + httputil.OptionsGetPatchDelete(c) +} + +// @Summary Create category +// @Description Creates a new category +// @Tags Categories +// @Produce json +// @Success 201 {object} CategoryCreateResponse +// @Failure 400 {object} CategoryCreateResponse +// @Failure 404 {object} CategoryCreateResponse +// @Failure 500 {object} CategoryCreateResponse +// @Param categories body []CategoryEditable true "Categories" +// @Router /v4/categories [post] +func CreateCategories(c *gin.Context) { + var editables []CategoryEditable + + // Bind data and return error if not possible + err := httputil.BindData(c, &editables) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, CategoryCreateResponse{ + Error: &e, + }) + return + } + + // The final http status. Will be modified when errors occur + status := http.StatusCreated + r := CategoryCreateResponse{} + + for _, editable := range editables { + category := editable.model() + + // Verify that the budget exists. If not, append the error + // and move to the next one. + _, err := getModelByID[models.Budget](c, editable.BudgetID) + if !err.Nil() { + status = r.appendError(err, status) + continue + } + + dbErr := models.DB.Create(&category).Error + if dbErr != nil { + err := httperrors.Parse(c, dbErr) + status = r.appendError(err, status) + continue + } + + data, err := newCategory(c, models.DB, category) + if !err.Nil() { + status = r.appendError(err, status) + continue + } + r.Data = append(r.Data, CategoryResponse{Data: &data}) + } + + c.JSON(status, r) +} + +// @Summary Get categories +// @Description Returns a list of categories +// @Tags Categories +// @Produce json +// @Success 200 {object} CategoryListResponse +// @Failure 400 {object} CategoryListResponse +// @Failure 500 {object} CategoryListResponse +// @Router /v4/categories [get] +// @Param name query string false "Filter by name" +// @Param note query string false "Filter by note" +// @Param budget query string false "Filter by budget ID" +// @Param archived query bool false "Is the category archived?" +// @Param search query string false "Search for this text in name and note" +// @Param offset query uint false "The offset of the first Category returned. Defaults to 0." +// @Param limit query int false "Maximum number of Categories to return. Defaults to 50." +func GetCategories(c *gin.Context) { + var filter CategoryQueryFilter + + // Every parameter is bound into a string, so this will always succeed + _ = c.Bind(&filter) + + // Get the fields that we are filtering for + queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) + + // Convert the QueryFilter to a Create struct + filterModel, err := filter.model() + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryListResponse{ + Error: &s, + }) + return + } + + q := models.DB. + Order("name ASC"). + Where(&filterModel, queryFields...) + + q = stringFilters(models.DB, q, setFields, filter.Name, filter.Note, filter.Search) + + // Set the offset. Does not need checking since the default is 0 + q = q.Offset(int(filter.Offset)) + + // Default to 50 Accounts and set the limit + limit := 50 + if slices.Contains(setFields, "Limit") { + limit = filter.Limit + } + q = q.Limit(limit) + + var categories []models.Category + err = query(c, q.Find(&categories)) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryListResponse{ + Error: &s, + }) + return + } + + var count int64 + err = query(c, q.Limit(-1).Offset(-1).Count(&count)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, CategoryListResponse{ + Error: &e, + }) + return + } + + data := make([]Category, 0) + for _, category := range categories { + apiResource, err := newCategory(c, models.DB, category) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryListResponse{ + Error: &s, + }) + return + } + data = append(data, apiResource) + } + + c.JSON(http.StatusOK, CategoryListResponse{ + Data: data, + Pagination: &Pagination{ + Count: len(data), + Total: count, + Offset: filter.Offset, + Limit: limit, + }, + }) +} + +// @Summary Get category +// @Description Returns a specific category +// @Tags Categories +// @Produce json +// @Success 200 {object} CategoryResponse +// @Failure 400 {object} CategoryResponse +// @Failure 404 {object} CategoryResponse +// @Failure 500 {object} CategoryResponse +// @Param id path string true "ID formatted as string" +// @Router /v4/categories/{id} [get] +func GetCategory(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryResponse{ + Error: &s, + }) + return + } + + category, err := getModelByID[models.Category](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryResponse{ + Error: &s, + }) + return + } + + data, err := newCategory(c, models.DB, category) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryResponse{ + Error: &s, + }) + return + } + + c.JSON(http.StatusOK, CategoryResponse{Data: &data}) +} + +// @Summary Update category +// @Description Update an existing category. Only values to be updated need to be specified. +// @Tags Categories +// @Accept json +// @Produce json +// @Success 200 {object} CategoryResponse +// @Failure 400 {object} CategoryResponse +// @Failure 404 {object} CategoryResponse +// @Failure 500 {object} CategoryResponse +// @Param id path string true "ID formatted as string" +// @Param category body CategoryEditable true "Category" +// @Router /v4/categories/{id} [patch] +func UpdateCategory(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryResponse{ + Error: &s, + }) + return + } + + category, err := getModelByID[models.Category](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryResponse{ + Error: &s, + }) + return + } + + updateFields, err := httputil.GetBodyFields(c, CategoryEditable{}) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryResponse{ + Error: &s, + }) + return + } + + var data CategoryEditable + err = httputil.BindData(c, &data) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryResponse{ + Error: &s, + }) + return + } + + err = query(c, models.DB.Model(&category).Select("", updateFields...).Updates(data.model())) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryResponse{ + Error: &s, + }) + return + } + + r, err := newCategory(c, models.DB, category) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, CategoryResponse{ + Error: &s, + }) + return + } + + c.JSON(http.StatusOK, CategoryResponse{Data: &r}) +} + +// @Summary Delete category +// @Description Deletes a category +// @Tags Categories +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/categories/{id} [delete] +func DeleteCategory(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + category, err := getModelByID[models.Category](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + err = query(c, models.DB.Delete(&category)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusNoContent, nil) +} diff --git a/pkg/controllers/v4/category_test.go b/pkg/controllers/v4/category_test.go new file mode 100644 index 00000000..402cf58d --- /dev/null +++ b/pkg/controllers/v4/category_test.go @@ -0,0 +1,451 @@ +package v4_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/test" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func createTestCategory(t *testing.T, c v4.CategoryEditable, expectedStatus ...int) v4.CategoryResponse { + if c.BudgetID == uuid.Nil { + c.BudgetID = createTestBudget(t, v4.BudgetEditable{Name: "Testing budget"}).Data.ID + } + + if c.Name == "" { + c.Name = uuid.NewString() + } + + // Default to 200 OK as expected status + if len(expectedStatus) == 0 { + expectedStatus = append(expectedStatus, http.StatusCreated) + } + + body := []v4.CategoryEditable{c} + + r := test.Request(t, http.MethodPost, "http://example.com/v4/categories", body) + test.AssertHTTPStatus(t, &r, expectedStatus...) + + var category v4.CategoryCreateResponse + test.DecodeResponse(t, &r, &category) + + if r.Code == http.StatusCreated { + return category.Data[0] + } + + return v4.CategoryResponse{} +} + +// TestCategoriesDBClosed verifies that errors are processed correctly when +// the database is closed. +func (suite *TestSuiteStandard) TestCategoriesDBClosed() { + b := createTestBudget(suite.T(), v4.BudgetEditable{}) + + tests := []struct { + name string // Name of the test + test func(t *testing.T) // Code to run + }{ + { + "Creation fails", + func(t *testing.T) { + createTestCategory(t, v4.CategoryEditable{BudgetID: b.Data.ID}, http.StatusInternalServerError) + }, + }, + { + "GET fails", + func(t *testing.T) { + recorder := test.Request(t, http.MethodGet, "http://example.com/v4/categories", "") + test.AssertHTTPStatus(t, &recorder, http.StatusInternalServerError) + assert.Contains(t, test.DecodeError(t, recorder.Body.Bytes()), "there is a problem with the database connection") + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + suite.CloseDB() + + tt.test(t) + }) + } +} + +// TestCategoriesOptions verifies that OPTIONS requests are handled correctly. +func (suite *TestSuiteStandard) TestCategoriesOptions() { + tests := []struct { + name string + id string // path at the Accounts endpoint to test + status int // Expected HTTP status code + }{ + {"No Category with this ID", uuid.New().String(), http.StatusNotFound}, + {"Not a valid UUID", "NotParseableAsUUID", http.StatusBadRequest}, + {"Category exists", createTestCategory(suite.T(), v4.CategoryEditable{}).Data.ID.String(), http.StatusNoContent}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("%s/%s", "http://example.com/v4/categories", tt.id) + r := test.Request(t, http.MethodOptions, path, "") + test.AssertHTTPStatus(t, &r, tt.status) + + if tt.status == http.StatusNoContent { + assert.Equal(t, "OPTIONS, GET, PATCH, DELETE", r.Header().Get("allow")) + } + }) + } +} + +// TestCategoriesGetSingle verifies that requests for the resource endpoints are +// handled correctly. +func (suite *TestSuiteStandard) TestCategoriesGetSingle() { + c := createTestCategory(suite.T(), v4.CategoryEditable{}) + + tests := []struct { + name string + id string + status int + method string + }{ + {"GET Existing Category", c.Data.ID.String(), http.StatusOK, http.MethodGet}, + {"GET ID nil", uuid.Nil.String(), http.StatusBadRequest, http.MethodGet}, + {"GET No Category with this ID", uuid.New().String(), http.StatusNotFound, http.MethodGet}, + {"GET Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodGet}, + {"GET Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodGet}, + {"GET Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodGet}, + {"PATCH Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodPatch}, + {"PATCH Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodPatch}, + {"PATCH Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodPatch}, + {"DELETE Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodDelete}, + {"DELETE Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodDelete}, + {"DELETE Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodDelete}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, tt.method, fmt.Sprintf("http://example.com/v4/categories/%s", tt.id), "") + + var category v4.CategoryResponse + test.DecodeResponse(t, &r, &category) + test.AssertHTTPStatus(t, &r, tt.status) + }) + } +} + +func (suite *TestSuiteStandard) TestCategoriesGetFilter() { + b1 := createTestBudget(suite.T(), v4.BudgetEditable{}) + b2 := createTestBudget(suite.T(), v4.BudgetEditable{}) + + _ = createTestCategory(suite.T(), v4.CategoryEditable{ + Name: "Category Name", + Note: "A note for this category", + BudgetID: b1.Data.ID, + Archived: true, + }) + + _ = createTestCategory(suite.T(), v4.CategoryEditable{ + Name: "Groceries", + Note: "For Groceries", + BudgetID: b2.Data.ID, + }) + + _ = createTestCategory(suite.T(), v4.CategoryEditable{ + Name: "Daily stuff", + Note: "Groceries, Drug Store, …", + BudgetID: b2.Data.ID, + }) + + tests := []struct { + name string + query string + len int + checkFunc func(t *testing.T, accounts []v4.Category) + }{ + {"Budget 1", fmt.Sprintf("budget=%s", b1.Data.ID), 1, nil}, + {"Budget Not Existing", "budget=c9e4ee7a-e702-4f92-b168-11a95b22c7aa", 0, nil}, + {"Empty Note", "note=", 0, nil}, + {"Empty Name", "name=", 0, nil}, + {"Name & Note", "name=Category Name¬e=A note for this category", 1, nil}, + {"Fuzzy name, no note", "name=Category¬e=", 0, nil}, + {"Fuzzy name", "name=t", 2, nil}, + {"Fuzzy note, no name", "name=¬e=Groceries", 0, nil}, + {"Fuzzy note", "note=Groceries", 2, nil}, + {"Not archived", "archived=false", 2, func(t *testing.T, categories []v4.Category) { + for _, c := range categories { + assert.False(t, c.Archived) + } + }}, + {"Archived", "archived=true", 1, func(t *testing.T, categories []v4.Category) { + for _, c := range categories { + assert.True(t, c.Archived) + } + }}, + {"Search for 'groceries'", "search=groceries", 2, nil}, + {"Search for 'FOR'", "search=FOR", 2, nil}, + {"Offset 2", "offset=2", 1, nil}, + {"Offset 0, limit 2", "offset=0&limit=2", 2, nil}, + {"Limit 4", "limit=4", 3, nil}, + {"Limit 0", "limit=0", 0, nil}, + {"Limit -1", "limit=-1", 3, nil}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var re v4.CategoryListResponse + r := test.Request(t, http.MethodGet, fmt.Sprintf("/v4/categories?%s", tt.query), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + test.DecodeResponse(t, &r, &re) + + assert.Equal(t, tt.len, len(re.Data), "Request ID: %s", r.Result().Header.Get("x-request-id")) + }) + } +} + +func (suite *TestSuiteStandard) TestCategoriesCreateFails() { + // Test category for uniqueness + c := createTestCategory(suite.T(), v4.CategoryEditable{ + Name: "Unique Category Name for Budget", + }) + + tests := []struct { + name string + body any + status int // expected HTTP status + testFunc func(t *testing.T, c v4.CategoryCreateResponse) // tests to perform against the updated category resource + }{ + { + "Broken Body", `[{ "note": 2 }]`, http.StatusBadRequest, + func(t *testing.T, c v4.CategoryCreateResponse) { + assert.Equal(t, "json: cannot unmarshal number into Go struct field CategoryEditable.note of type string", *c.Error) + }, + }, + { + "No body", "", http.StatusBadRequest, + func(t *testing.T, c v4.CategoryCreateResponse) { + assert.Equal(t, "the request body must not be empty", *c.Error) + }, + }, + { + "No Budget", + `[{ "note": "Some text" }]`, + http.StatusBadRequest, + func(t *testing.T, c v4.CategoryCreateResponse) { + assert.Equal(t, "no Budget ID specified", *c.Data[0].Error) + }, + }, + { + "Non-existing Budget", + `[{ "budgetId": "ea85ad1a-3679-4ced-b83b-89566c12ece9" }]`, + http.StatusNotFound, + func(t *testing.T, c v4.CategoryCreateResponse) { + assert.Equal(t, "there is no Budget with this ID", *c.Data[0].Error) + }, + }, + { + "Duplicate name in Budget", + []v4.CategoryEditable{ + { + BudgetID: c.Data.BudgetID, + Name: c.Data.Name, + }, + }, + http.StatusBadRequest, + func(t *testing.T, c v4.CategoryCreateResponse) { + assert.Equal(t, "the category name must be unique for the budget", *c.Data[0].Error) + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPost, "http://example.com/v4/categories", tt.body) + test.AssertHTTPStatus(t, &r, tt.status) + + var c v4.CategoryCreateResponse + test.DecodeResponse(t, &r, &c) + + if tt.testFunc != nil { + tt.testFunc(t, c) + } + }) + } +} + +// Verify that updating categories works as desired +func (suite *TestSuiteStandard) TestCategoriesUpdate() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + category := createTestCategory(suite.T(), v4.CategoryEditable{Name: "Name of the category", BudgetID: budget.Data.ID}) + + tests := []struct { + name string // name of the test + category map[string]any // the updates to perform. This is not a struct because that would set all fields on the request + testFunc func(t *testing.T, a v4.CategoryResponse) // tests to perform against the updated category resource + }{ + { + "Name, Note", + map[string]any{ + "name": "Another name", + "note": "New note!", + }, + func(t *testing.T, a v4.CategoryResponse) { + assert.Equal(t, "New note!", a.Data.Note) + assert.Equal(t, "Another name", a.Data.Name) + }, + }, + { + "Archived", + map[string]any{ + "archived": true, + }, + func(t *testing.T, a v4.CategoryResponse) { + assert.True(t, a.Data.Archived) + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPatch, category.Data.Links.Self, tt.category) + test.AssertHTTPStatus(t, &r, http.StatusOK) + + var c v4.CategoryResponse + test.DecodeResponse(t, &r, &c) + + if tt.testFunc != nil { + tt.testFunc(t, c) + } + }) + } +} + +func (suite *TestSuiteStandard) TestCategoriesUpdateFails() { + tests := []struct { + name string + id string + body any + status int // expected response status + }{ + {"Invalid type", "", `{"name": 2}`, http.StatusBadRequest}, + {"Broken JSON", "", `{ "name": 2" }`, http.StatusBadRequest}, + {"Non-existing Category", uuid.New().String(), `{"name": 2}`, http.StatusNotFound}, + {"Set Budget to uuid.Nil", "", v4.CategoryEditable{}, http.StatusBadRequest}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var recorder httptest.ResponseRecorder + + if tt.id == "" { + envelope := createTestCategory(suite.T(), v4.CategoryEditable{ + Name: "New Envelope", + Note: "Auto-created for test", + }) + + tt.id = envelope.Data.ID.String() + } + + recorder = test.Request(t, http.MethodPatch, fmt.Sprintf("http://example.com/v4/categories/%s", tt.id), tt.body) + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestCategoriesDelete verifies all cases for Account deletions. +func (suite *TestSuiteStandard) TestCategoriesDelete() { + tests := []struct { + name string + id string + status int // expected response status + }{ + {"Success", "", http.StatusNoContent}, + {"Non-existing Category", uuid.New().String(), http.StatusNotFound}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var recorder httptest.ResponseRecorder + + if tt.id == "" { + // Create test Account + e := createTestCategory(t, v4.CategoryEditable{}) + tt.id = e.Data.ID.String() + } + + // Delete Account + recorder = test.Request(t, http.MethodDelete, fmt.Sprintf("http://example.com/v4/categories/%s", tt.id), "") + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestCategoriesGetSorted verifies that Accounts are sorted by name. +func (suite *TestSuiteStandard) TestCategoriesGetSorted() { + c1 := createTestCategory(suite.T(), v4.CategoryEditable{ + Name: "Alphabetically first", + }) + + c2 := createTestCategory(suite.T(), v4.CategoryEditable{ + Name: "Second in creation, third in list", + }) + + c3 := createTestCategory(suite.T(), v4.CategoryEditable{ + Name: "First is alphabetically second", + }) + + c4 := createTestCategory(suite.T(), v4.CategoryEditable{ + Name: "Zulu is the last one", + }) + + r := test.Request(suite.T(), http.MethodGet, "http://example.com/v4/categories", "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + + var categories v4.CategoryListResponse + test.DecodeResponse(suite.T(), &r, &categories) + + if !assert.Len(suite.T(), categories.Data, 4) { + assert.FailNow(suite.T(), "Category list has wrong length") + } + + assert.Equal(suite.T(), c1.Data.Name, categories.Data[0].Name) + assert.Equal(suite.T(), c2.Data.Name, categories.Data[2].Name) + assert.Equal(suite.T(), c3.Data.Name, categories.Data[1].Name) + assert.Equal(suite.T(), c4.Data.Name, categories.Data[3].Name) +} + +func (suite *TestSuiteStandard) TestCategoriesPagination() { + for i := 0; i < 10; i++ { + createTestCategory(suite.T(), v4.CategoryEditable{Name: fmt.Sprint(i)}) + } + + tests := []struct { + name string + offset uint + limit int + expectedCount int + expectedTotal int64 + }{ + {"All", 0, -1, 10, 10}, + {"First 5", 0, 5, 5, 10}, + {"Last 5", 5, -1, 5, 10}, + {"Offset 3", 3, -1, 7, 10}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v4/categories?offset=%d&limit=%d", tt.offset, tt.limit), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + + var categories v4.CategoryListResponse + test.DecodeResponse(t, &r, &categories) + + assert.Equal(suite.T(), tt.offset, categories.Pagination.Offset) + assert.Equal(suite.T(), tt.limit, categories.Pagination.Limit) + assert.Equal(suite.T(), tt.expectedCount, categories.Pagination.Count) + assert.Equal(suite.T(), tt.expectedTotal, categories.Pagination.Total) + }) + } +} diff --git a/pkg/controllers/v4/category_types.go b/pkg/controllers/v4/category_types.go new file mode 100644 index 00000000..98d9ac6a --- /dev/null +++ b/pkg/controllers/v4/category_types.go @@ -0,0 +1,123 @@ +package v4 + +import ( + "fmt" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// CategoryEditable represents all user configurable parameters +type CategoryEditable struct { + Name string `json:"name" example:"Saving" default:""` // Name of the category + BudgetID uuid.UUID `json:"budgetId" example:"52d967d3-33f4-4b04-9ba7-772e5ab9d0ce"` // ID of the budget the category belongs to + Note string `json:"note" example:"All envelopes for long-term saving" default:""` // Notes about the category + Archived bool `json:"archived" example:"true" default:"false"` // Is the category archived? +} + +func (editable CategoryEditable) model() models.Category { + return models.Category{ + BudgetID: editable.BudgetID, + Name: editable.Name, + Note: editable.Note, + Archived: editable.Archived, + } +} + +type CategoryLinks struct { + Self string `json:"self" example:"https://example.com/api/v4/categories/3b1ea324-d438-4419-882a-2fc91d71772f"` // The category itself + Envelopes string `json:"envelopes" example:"https://example.com/api/v4/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f"` // Envelopes for this category +} + +type Category struct { + models.DefaultModel + CategoryEditable + Links CategoryLinks `json:"links"` + + // These fields are computed + Envelopes []Envelope `json:"envelopes"` // Envelopes for the category +} + +func newCategory(c *gin.Context, db *gorm.DB, model models.Category) (Category, httperrors.Error) { + url := c.GetString(string(models.DBContextURL)) + + category := Category{ + DefaultModel: model.DefaultModel, + CategoryEditable: CategoryEditable{ + BudgetID: model.BudgetID, + Name: model.Name, + Note: model.Note, + Archived: model.Archived, + }, + Links: CategoryLinks{ + Self: fmt.Sprintf("%s/v4/categories/%s", url, model.ID), + Envelopes: fmt.Sprintf("%s/v4/envelopes?category=%s", url, model.ID), + }, + } + + envelopes, err := model.Envelopes(db) + if err != nil { + e := httperrors.Parse(c, err) + return Category{}, e + } + + for _, envelope := range envelopes { + category.Envelopes = append(category.Envelopes, newEnvelope(c, envelope)) + } + + return category, httperrors.Error{} +} + +type CategoryListResponse struct { + Data []Category `json:"data"` // List of Categories + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Pagination *Pagination `json:"pagination"` // Pagination information +} + +type CategoryCreateResponse struct { + Data []CategoryResponse `json:"data"` // List of the created Categories or their respective error + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred +} + +func (c *CategoryCreateResponse) appendError(err httperrors.Error, status int) int { + s := err.Error() + c.Data = append(c.Data, CategoryResponse{Error: &s}) + + // The final status code is the highest HTTP status code number + if err.Status > status { + status = err.Status + } + + return status +} + +type CategoryResponse struct { + Data *Category `json:"data"` // Data for the Category + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred +} + +type CategoryQueryFilter struct { + BudgetID string `form:"budget"` // By ID of the Budget + Name string `form:"name" filterField:"false"` // By name + Note string `form:"note" filterField:"false"` // By note + Archived bool `form:"archived"` // Is the Category archived? + Search string `form:"search" filterField:"false"` // By string in name or note + Offset uint `form:"offset" filterField:"false"` // The offset of the first Category returned. Defaults to 0. + Limit int `form:"limit" filterField:"false"` // Maximum number of Categories to return. Defaults to 50. +} + +func (f CategoryQueryFilter) model() (models.Category, httperrors.Error) { + budgetID, err := httputil.UUIDFromString(f.BudgetID) + if !err.Nil() { + return models.Category{}, httperrors.Error{} + } + + return models.Category{ + BudgetID: budgetID, + Archived: f.Archived, + }, httperrors.Error{} +} diff --git a/pkg/controllers/v4/cleanup.go b/pkg/controllers/v4/cleanup.go new file mode 100644 index 00000000..0a3cd6a5 --- /dev/null +++ b/pkg/controllers/v4/cleanup.go @@ -0,0 +1,60 @@ +package v4 + +import ( + "net/http" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" +) + +// @Summary Delete everything +// @Description Permanently deletes all resources +// @Tags v4 +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param confirm query string false "Confirmation to delete all resources. Must have the value 'yes-please-delete-everything'" +// @Router /v4 [delete] +func Cleanup(c *gin.Context) { + var params struct { + Confirm string `form:"confirm"` + } + + err := c.Bind(¶ms) + if err != nil || params.Confirm != "yes-please-delete-everything" { + c.JSON(http.StatusBadRequest, httperrors.HTTPError{ + Error: httperrors.ErrCleanupConfirmation.Error(), + }) + return + } + + // The order is important here since there are foreign keys to consider! + resources := []models.Model{ + models.MatchRule{}, + models.Goal{}, + models.Transaction{}, + models.MonthConfig{}, + models.Envelope{}, + models.Category{}, + models.Account{}, + models.Budget{}, + } + + // Use a transaction so that we can roll back if errors happen + tx := models.DB.Begin() + + for _, model := range resources { + err := tx.Unscoped().Where("true").Delete(&model).Error + if err != nil { + c.JSON(http.StatusInternalServerError, httperrors.HTTPError{ + Error: err.Error(), + }) + tx.Rollback() + return + } + } + + tx.Commit() + c.JSON(http.StatusNoContent, nil) +} diff --git a/pkg/controllers/v4/cleanup_test.go b/pkg/controllers/v4/cleanup_test.go new file mode 100644 index 00000000..354f0863 --- /dev/null +++ b/pkg/controllers/v4/cleanup_test.go @@ -0,0 +1,77 @@ +package v4_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/envelope-zero/backend/v4/internal/types" + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/test" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" +) + +func (suite *TestSuiteStandard) TestCleanup() { + _ = createTestBudget(suite.T(), v4.BudgetEditable{}) + account := createTestAccount(suite.T(), v4.AccountEditable{Name: "TestCleanup"}) + _ = createTestCategory(suite.T(), v4.CategoryEditable{}) + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{}) + _ = createTestTransaction(suite.T(), v4.TransactionEditable{Amount: decimal.NewFromFloat(17.32)}) + _ = patchTestMonthConfig(suite.T(), envelope.Data.ID, types.NewMonth(time.Now().Year(), time.Now().Month()), v4.MonthConfigEditable{}) + _ = createTestMatchRule(suite.T(), v4.MatchRuleEditable{AccountID: account.Data.ID, Match: "Delete me"}) + + tests := []string{ + "http://example.com/v4/accounts", + "http://example.com/v4/budgets", + "http://example.com/v4/categories", + "http://example.com/v4/envelopes", + "http://example.com/v4/goals", + "http://example.com/v4/match-rules", + "http://example.com/v4/transactions", + } + + // Delete + recorder := test.Request(suite.T(), http.MethodDelete, "http://example.com/v4?confirm=yes-please-delete-everything", "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) + + // Verify + for _, tt := range tests { + suite.T().Run(tt, func(t *testing.T) { + recorder := test.Request(suite.T(), http.MethodGet, tt, "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + + var response struct { + Data []any `json:"data"` + } + + test.DecodeResponse(t, &recorder, &response) + assert.Len(t, response.Data, 0, "There are resources left for type %s", tt) + }) + } +} + +func (suite *TestSuiteStandard) TestCleanupFails() { + tests := []struct { + name string + path string + }{ + {"Invalid path", "confirm=2"}, + {"Confirmation wrong", "confirm=invalid-confirmation"}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + recorder := test.Request(t, http.MethodDelete, fmt.Sprintf("http://example.com/v4?%s", tt.path), "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) + }) + } +} + +func (suite *TestSuiteStandard) TestCleanupDBError() { + suite.CloseDB() + + recorder := test.Request(suite.T(), http.MethodDelete, "http://example.com/v4?confirm=yes-please-delete-everything", "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusInternalServerError) +} diff --git a/pkg/controllers/v4/database.go b/pkg/controllers/v4/database.go new file mode 100644 index 00000000..84b154d6 --- /dev/null +++ b/pkg/controllers/v4/database.go @@ -0,0 +1,18 @@ +package v4 + +import ( + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// query executes a query. If an error ocurrs, an appropriate user facing +// error message and status code is returned in an httperrors.Error struct. +func query(c *gin.Context, tx *gorm.DB) httperrors.Error { + err := tx.Error + if err != nil { + return httperrors.Parse(c, err) + } + + return httperrors.Error{} +} diff --git a/pkg/controllers/v4/envelope.go b/pkg/controllers/v4/envelope.go new file mode 100644 index 00000000..f00ba643 --- /dev/null +++ b/pkg/controllers/v4/envelope.go @@ -0,0 +1,339 @@ +package v4 + +import ( + "net/http" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "golang.org/x/exp/slices" +) + +// RegisterEnvelopeRoutes registers the routes for envelopes with +// the RouterGroup that is passed. +func RegisterEnvelopeRoutes(r *gin.RouterGroup) { + // Root group + { + r.OPTIONS("", OptionsEnvelopeList) + r.GET("", GetEnvelopes) + r.POST("", CreateEnvelopes) + } + + // Envelope with ID + { + r.OPTIONS("/:id", OptionsEnvelopeDetail) + r.GET("/:id", GetEnvelope) + r.PATCH("/:id", UpdateEnvelope) + r.DELETE("/:id", DeleteEnvelope) + } +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Envelopes +// @Success 204 +// @Router /v4/envelopes [options] +func OptionsEnvelopeList(c *gin.Context) { + httputil.OptionsGetPost(c) +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Envelopes +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/envelopes/{id} [options] +func OptionsEnvelopeDetail(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + _, err = getModelByID[models.Envelope](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + httputil.OptionsGetPatchDelete(c) +} + +// @Summary Create envelope +// @Description Creates a new envelope +// @Tags Envelopes +// @Produce json +// @Success 201 {object} EnvelopeCreateResponse +// @Failure 400 {object} EnvelopeCreateResponse +// @Failure 404 {object} EnvelopeCreateResponse +// @Failure 500 {object} EnvelopeCreateResponse +// @Param envelope body []v4.EnvelopeEditable true "Envelopes" +// @Router /v4/envelopes [post] +func CreateEnvelopes(c *gin.Context) { + var envelopes []EnvelopeEditable + + // Bind data and return error if not possible + err := httputil.BindData(c, &envelopes) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, EnvelopeCreateResponse{ + Error: &e, + }) + return + } + + // The final http status. Will be modified when errors occur + status := http.StatusCreated + r := EnvelopeCreateResponse{} + + for _, editable := range envelopes { + envelope := editable.model() + + // Verify that the category exists. If not, append the error + // and move to the next envelope + _, err := getModelByID[models.Category](c, editable.CategoryID) + if !err.Nil() { + status = r.appendError(err, status) + continue + } + + dbErr := models.DB.Create(&envelope).Error + if dbErr != nil { + err := httperrors.Parse(c, dbErr) + status = r.appendError(err, status) + continue + } + + data := newEnvelope(c, envelope) + r.Data = append(r.Data, EnvelopeResponse{Data: &data}) + } + + c.JSON(status, r) +} + +// @Summary Get envelopes +// @Description Returns a list of envelopes +// @Tags Envelopes +// @Produce json +// @Success 200 {object} EnvelopeListResponse +// @Failure 400 {object} EnvelopeListResponse +// @Failure 500 {object} EnvelopeListResponse +// @Router /v4/envelopes [get] +// @Param name query string false "Filter by name" +// @Param note query string false "Filter by note" +// @Param category query string false "Filter by category ID" +// @Param archived query bool false "Is the envelope archived?" +// @Param search query string false "Search for this text in name and note" +// @Param offset query uint false "The offset of the first Envelope returned. Defaults to 0." +// @Param limit query int false "Maximum number of Envelopes to return. Defaults to 50." +func GetEnvelopes(c *gin.Context) { + var filter EnvelopeQueryFilter + + // The filters contain only strings, so this will always succeed + _ = c.Bind(&filter) + + queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) + + // Convert the QueryFilter to a Create struct + model, err := filter.model() + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, EnvelopeListResponse{ + Error: &s, + }) + return + } + + q := models.DB. + Order("name ASC"). + Where(&model, queryFields...) + + q = stringFilters(models.DB, q, setFields, filter.Name, filter.Note, filter.Search) + + // Set the offset. Does not need checking since the default is 0 + q = q.Offset(int(filter.Offset)) + + // Default to 50 Accounts and set the limit + limit := 50 + if slices.Contains(setFields, "Limit") { + limit = filter.Limit + } + q = q.Limit(limit) + + var envelopes []models.Envelope + err = query(c, q.Find(&envelopes)) + + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, EnvelopeListResponse{ + Error: &s, + }) + return + } + + var count int64 + err = query(c, q.Limit(-1).Offset(-1).Count(&count)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, EnvelopeListResponse{ + Error: &e, + }) + return + } + + data := make([]Envelope, 0, len(envelopes)) + for _, envelope := range envelopes { + data = append(data, newEnvelope(c, envelope)) + } + + c.JSON(http.StatusOK, EnvelopeListResponse{ + Data: data, + Pagination: &Pagination{ + Count: len(data), + Total: count, + Offset: filter.Offset, + Limit: limit, + }, + }) +} + +// @Summary Get Envelope +// @Description Returns a specific Envelope +// @Tags Envelopes +// @Produce json +// @Success 200 {object} EnvelopeResponse +// @Failure 400 {object} EnvelopeResponse +// @Failure 404 {object} EnvelopeResponse +// @Failure 500 {object} EnvelopeResponse +// @Param id path string true "ID formatted as string" +// @Router /v4/envelopes/{id} [get] +func GetEnvelope(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, EnvelopeResponse{ + Error: &s, + }) + return + } + + model, err := getModelByID[models.Envelope](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, EnvelopeResponse{ + Error: &s, + }) + return + } + + data := newEnvelope(c, model) + c.JSON(http.StatusOK, EnvelopeResponse{Data: &data}) +} + +// @Summary Update envelope +// @Description Updates an existing envelope. Only values to be updated need to be specified. +// @Tags Envelopes +// @Accept json +// @Produce json +// @Success 200 {object} EnvelopeResponse +// @Failure 400 {object} EnvelopeResponse +// @Failure 404 {object} EnvelopeResponse +// @Failure 500 {object} EnvelopeResponse +// @Param id path string true "ID formatted as string" +// @Param envelope body v4.EnvelopeEditable true "Envelope" +// @Router /v4/envelopes/{id} [patch] +func UpdateEnvelope(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, EnvelopeResponse{ + Error: &s, + }) + return + } + + envelope, err := getModelByID[models.Envelope](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, EnvelopeResponse{ + Error: &s, + }) + return + } + + updateFields, err := httputil.GetBodyFields(c, EnvelopeEditable{}) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, EnvelopeResponse{ + Error: &s, + }) + return + } + + var data EnvelopeEditable + err = httputil.BindData(c, &data) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, EnvelopeResponse{ + Error: &s, + }) + return + } + + err = query(c, models.DB.Model(&envelope).Select("", updateFields...).Updates(data.model())) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, EnvelopeResponse{ + Error: &s, + }) + return + } + + apiResource := newEnvelope(c, envelope) + c.JSON(http.StatusOK, EnvelopeResponse{Data: &apiResource}) +} + +// @Summary Delete envelope +// @Description Deletes an envelope +// @Tags Envelopes +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/envelopes/{id} [delete] +func DeleteEnvelope(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + envelope, err := getModelByID[models.Envelope](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + err = query(c, models.DB.Delete(&envelope)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusNoContent, nil) +} diff --git a/pkg/controllers/v4/envelope_test.go b/pkg/controllers/v4/envelope_test.go new file mode 100644 index 00000000..c6f952ce --- /dev/null +++ b/pkg/controllers/v4/envelope_test.go @@ -0,0 +1,444 @@ +package v4_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/test" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func createTestEnvelope(t *testing.T, c v4.EnvelopeEditable, expectedStatus ...int) v4.EnvelopeResponse { + if c.CategoryID == uuid.Nil { + c.CategoryID = createTestCategory(t, v4.CategoryEditable{}).Data.ID + } + + if c.Name == "" { + c.Name = uuid.NewString() + } + + // Default to 200 OK as expected status + if len(expectedStatus) == 0 { + expectedStatus = append(expectedStatus, http.StatusCreated) + } + + body := []v4.EnvelopeEditable{c} + + r := test.Request(t, http.MethodPost, "http://example.com/v4/envelopes", body) + test.AssertHTTPStatus(t, &r, expectedStatus...) + + var e v4.EnvelopeCreateResponse + test.DecodeResponse(t, &r, &e) + + if r.Code == http.StatusCreated { + return e.Data[0] + } + + return v4.EnvelopeResponse{} +} + +// TestEnvelopesDBClosed verifies that errors are processed correctly when +// the database is closed. +func (suite *TestSuiteStandard) TestEnvelopesDBClosed() { + b := createTestCategory(suite.T(), v4.CategoryEditable{}) + + tests := []struct { + name string // Name of the test + test func(t *testing.T) // Code to run + }{ + { + "Creation fails", + func(t *testing.T) { + createTestEnvelope(t, v4.EnvelopeEditable{CategoryID: b.Data.ID}, http.StatusInternalServerError) + }, + }, + { + "GET fails", + func(t *testing.T) { + recorder := test.Request(t, http.MethodGet, "http://example.com/v4/envelopes", "") + test.AssertHTTPStatus(t, &recorder, http.StatusInternalServerError) + assert.Contains(t, test.DecodeError(t, recorder.Body.Bytes()), "there is a problem with the database connection") + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + suite.CloseDB() + + tt.test(t) + }) + } +} + +// TestEnvelopesOptions verifies that OPTIONS requests are handled correctly. +func (suite *TestSuiteStandard) TestEnvelopesOptions() { + tests := []struct { + name string + id string // path at the Accounts endpoint to test + status int // Expected HTTP status code + }{ + {"No Envelope with this ID", uuid.New().String(), http.StatusNotFound}, + {"Not a valid UUID", "NotParseableAsUUID", http.StatusBadRequest}, + {"Envelope exists", createTestEnvelope(suite.T(), v4.EnvelopeEditable{}).Data.ID.String(), http.StatusNoContent}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("%s/%s", "http://example.com/v4/envelopes", tt.id) + r := test.Request(t, http.MethodOptions, path, "") + test.AssertHTTPStatus(t, &r, tt.status) + + if tt.status == http.StatusNoContent { + assert.Equal(t, "OPTIONS, GET, PATCH, DELETE", r.Header().Get("allow")) + } + }) + } +} + +// TestEnvelopesGetSingle verifies that requests for the resource endpoints are +// handled correctly. +func (suite *TestSuiteStandard) TestEnvelopesGetSingle() { + e := createTestEnvelope(suite.T(), v4.EnvelopeEditable{}) + + tests := []struct { + name string + id string + status int + method string + }{ + {"GET Existing Envelope", e.Data.ID.String(), http.StatusOK, http.MethodGet}, + {"GET ID nil", uuid.Nil.String(), http.StatusBadRequest, http.MethodGet}, + {"GET No Envelope with this ID", uuid.New().String(), http.StatusNotFound, http.MethodGet}, + {"GET Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodGet}, + {"GET Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodGet}, + {"GET Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodGet}, + {"PATCH Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodPatch}, + {"PATCH Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodPatch}, + {"PATCH Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodPatch}, + {"DELETE Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodDelete}, + {"DELETE Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodDelete}, + {"DELETE Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodDelete}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, tt.method, fmt.Sprintf("http://example.com/v4/envelopes/%s", tt.id), "") + + var envelope v4.EnvelopeResponse + test.DecodeResponse(t, &r, &envelope) + test.AssertHTTPStatus(t, &r, tt.status) + }) + } +} + +func (suite *TestSuiteStandard) TestEnvelopesGetFilter() { + c1 := createTestCategory(suite.T(), v4.CategoryEditable{}) + c2 := createTestCategory(suite.T(), v4.CategoryEditable{}) + + _ = createTestEnvelope(suite.T(), v4.EnvelopeEditable{ + Name: "Groceries", + Note: "For the stuff bought in supermarkets", + CategoryID: c1.Data.ID, + }) + + _ = createTestEnvelope(suite.T(), v4.EnvelopeEditable{ + Name: "Hairdresser", + Note: "Because… Hair!", + CategoryID: c2.Data.ID, + Archived: true, + }) + + _ = createTestEnvelope(suite.T(), v4.EnvelopeEditable{ + Name: "Stamps", + Note: "Because each stamp needs to go on an envelope. Hopefully it's not hairy", + CategoryID: c2.Data.ID, + }) + + tests := []struct { + name string + query string + len int + checkFunc func(t *testing.T, envelopes []v4.Envelope) + }{ + {"Category 2", fmt.Sprintf("category=%s", c2.Data.ID), 2, nil}, + {"Category Not Existing", "category=e0f9ff7a-9f07-463c-bbd2-0d72d09d3cc6", 0, nil}, + {"Empty Note", "note=", 0, nil}, + {"Empty Name", "name=", 0, nil}, + {"Name & Note", "name=Groceries¬e=For the stuff bought in supermarkets", 1, nil}, + {"Fuzzy name", "name=es", 2, nil}, + {"Fuzzy note", "note=Because", 2, nil}, + {"Not archived", "archived=false", 2, func(t *testing.T, envelopes []v4.Envelope) { + for _, e := range envelopes { + assert.False(t, e.Archived) + } + }}, + {"Archived", "archived=true", 1, func(t *testing.T, envelopes []v4.Envelope) { + for _, e := range envelopes { + assert.True(t, e.Archived) + } + }}, + {"Search for 'hair'", "search=hair", 2, nil}, + {"Search for 'st'", "search=st", 2, nil}, + {"Search for 'STUFF'", "search=STUFF", 1, nil}, + {"Offset 2", "offset=2", 1, nil}, + {"Offset 0, limit 2", "offset=0&limit=2", 2, nil}, + {"Limit 4", "limit=4", 3, nil}, + {"Limit 0", "limit=0", 0, nil}, + {"Limit -1", "limit=-1", 3, nil}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var re v4.EnvelopeListResponse + r := test.Request(t, http.MethodGet, fmt.Sprintf("/v4/envelopes?%s", tt.query), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + test.DecodeResponse(t, &r, &re) + + assert.Equal(t, tt.len, len(re.Data), "Request ID: %s", r.Result().Header.Get("x-request-id")) + }) + } +} + +func (suite *TestSuiteStandard) TestEnvelopesCreateFails() { + // Test envelope for uniqueness + e := createTestEnvelope(suite.T(), v4.EnvelopeEditable{ + Name: "Unique Envelope Name for Category", + }) + + tests := []struct { + name string + body any + status int // expected HTTP status + testFunc func(t *testing.T, e v4.EnvelopeCreateResponse) // tests to perform against the updated envelope resource + }{ + { + "Broken Body", `[{ "note": 2 }]`, http.StatusBadRequest, + func(t *testing.T, e v4.EnvelopeCreateResponse) { + assert.Equal(t, "json: cannot unmarshal number into Go struct field EnvelopeEditable.note of type string", *e.Error) + }, + }, + {"No body", "", http.StatusBadRequest, func(t *testing.T, e v4.EnvelopeCreateResponse) { + assert.Equal(t, "the request body must not be empty", *e.Error) + }}, + { + "No Category", + `[{ "note": "Some text" }]`, http.StatusBadRequest, + func(t *testing.T, e v4.EnvelopeCreateResponse) { + assert.Equal(t, "no Category ID specified", *e.Data[0].Error) + }, + }, + { + "Non-existing Category", + `[{ "categoryId": "ea85ad1a-3679-4ced-b83b-89566c12ece9" }]`, http.StatusNotFound, + func(t *testing.T, e v4.EnvelopeCreateResponse) { + assert.Equal(t, "there is no Category with this ID", *e.Data[0].Error) + }, + }, + { + "Duplicate name in Category", + []v4.EnvelopeEditable{ + { + CategoryID: e.Data.CategoryID, + Name: e.Data.Name, + }, + }, + http.StatusBadRequest, + func(t *testing.T, e v4.EnvelopeCreateResponse) { + assert.Equal(t, "the envelope name must be unique for the category", *e.Data[0].Error) + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPost, "http://example.com/v4/envelopes", tt.body) + test.AssertHTTPStatus(t, &r, tt.status) + + var e v4.EnvelopeCreateResponse + test.DecodeResponse(suite.T(), &r, &e) + + if tt.testFunc != nil { + tt.testFunc(t, e) + } + }) + } +} + +// Verify that updating envelopes works as desired +func (suite *TestSuiteStandard) TestEnvelopesUpdate() { + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{}) + + tests := []struct { + name string // name of the test + envelope map[string]any // the updates to perform. This is not a struct because that would set all fields on the request + testFunc func(t *testing.T, e v4.EnvelopeResponse) // tests to perform against the updated envelope resource + }{ + { + "Name, Note", + map[string]any{ + "name": "Another name", + "note": "New note!", + }, + func(t *testing.T, e v4.EnvelopeResponse) { + assert.Equal(t, "New note!", e.Data.Note) + assert.Equal(t, "Another name", e.Data.Name) + }, + }, + { + "Archived", + map[string]any{ + "archived": true, + }, + func(t *testing.T, e v4.EnvelopeResponse) { + assert.True(t, e.Data.Archived) + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPatch, envelope.Data.Links.Self, tt.envelope) + test.AssertHTTPStatus(t, &r, http.StatusOK) + + var e v4.EnvelopeResponse + test.DecodeResponse(t, &r, &e) + + if tt.testFunc != nil { + tt.testFunc(t, e) + } + }) + } +} + +func (suite *TestSuiteStandard) TestEnvelopesUpdateFails() { + tests := []struct { + name string + id string + body any + status int // expected response status + }{ + {"Invalid type", "", `{"name": 2}`, http.StatusBadRequest}, + {"Broken JSON", "", `{ "name": 2" }`, http.StatusBadRequest}, + {"Non-existing Envelope", uuid.New().String(), `{"name": 2}`, http.StatusNotFound}, + {"Set Category to uuid.Nil", "", v4.EnvelopeEditable{}, http.StatusBadRequest}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var recorder httptest.ResponseRecorder + + if tt.id == "" { + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{ + Name: "New Envelope", + Note: "Auto-created for test", + }) + + tt.id = envelope.Data.ID.String() + } + + recorder = test.Request(t, http.MethodPatch, fmt.Sprintf("http://example.com/v4/envelopes/%s", tt.id), tt.body) + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestEnvelopesDelete verifies all cases for Account deletions. +func (suite *TestSuiteStandard) TestEnvelopesDelete() { + tests := []struct { + name string + id string + status int // expected response status + }{ + {"Success", "", http.StatusNoContent}, + {"Non-existing Envelope", uuid.New().String(), http.StatusNotFound}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var recorder httptest.ResponseRecorder + + if tt.id == "" { + // Create test Account + e := createTestEnvelope(t, v4.EnvelopeEditable{}) + tt.id = e.Data.ID.String() + } + + // Delete Account + recorder = test.Request(t, http.MethodDelete, fmt.Sprintf("http://example.com/v4/envelopes/%s", tt.id), "") + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestEnvelopesGetSorted verifies that Accounts are sorted by name. +func (suite *TestSuiteStandard) TestEnvelopesGetSorted() { + e1 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{ + Name: "Alphabetically first", + }) + + e2 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{ + Name: "Second in creation, third in list", + }) + + e3 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{ + Name: "First is alphabetically second", + }) + + e4 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{ + Name: "Zulu is the last one", + }) + + r := test.Request(suite.T(), http.MethodGet, "http://example.com/v4/envelopes", "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + + var envelopes v4.EnvelopeListResponse + test.DecodeResponse(suite.T(), &r, &envelopes) + + if !assert.Len(suite.T(), envelopes.Data, 4) { + assert.FailNow(suite.T(), "Envelope list has wrong length") + } + + assert.Equal(suite.T(), e1.Data.Name, envelopes.Data[0].Name) + assert.Equal(suite.T(), e2.Data.Name, envelopes.Data[2].Name) + assert.Equal(suite.T(), e3.Data.Name, envelopes.Data[1].Name) + assert.Equal(suite.T(), e4.Data.Name, envelopes.Data[3].Name) +} + +func (suite *TestSuiteStandard) TestEnvelopesPagination() { + for i := 0; i < 10; i++ { + createTestEnvelope(suite.T(), v4.EnvelopeEditable{Name: fmt.Sprint(i)}) + } + + tests := []struct { + name string + offset uint + limit int + expectedCount int + expectedTotal int64 + }{ + {"All", 0, -1, 10, 10}, + {"First 5", 0, 5, 5, 10}, + {"Last 5", 5, -1, 5, 10}, + {"Offset 3", 3, -1, 7, 10}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v4/envelopes?offset=%d&limit=%d", tt.offset, tt.limit), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + + var envelopes v4.EnvelopeListResponse + test.DecodeResponse(t, &r, &envelopes) + + assert.Equal(suite.T(), tt.offset, envelopes.Pagination.Offset) + assert.Equal(suite.T(), tt.limit, envelopes.Pagination.Limit) + assert.Equal(suite.T(), tt.expectedCount, envelopes.Pagination.Count) + assert.Equal(suite.T(), tt.expectedTotal, envelopes.Pagination.Total) + }) + } +} diff --git a/pkg/controllers/v4/envelope_types.go b/pkg/controllers/v4/envelope_types.go new file mode 100644 index 00000000..b3d2e775 --- /dev/null +++ b/pkg/controllers/v4/envelope_types.go @@ -0,0 +1,111 @@ +package v4 + +import ( + "fmt" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// EnvelopeEditable represents all user configurable parameters +type EnvelopeEditable struct { + Name string `json:"name" example:"Groceries" default:""` // Name of the envelope + CategoryID uuid.UUID `json:"categoryId" example:"878c831f-af99-4a71-b3ca-80deb7d793c1"` // ID of the category the envelope belongs to + Note string `json:"note" example:"For stuff bought at supermarkets and drugstores" default:""` // Notes about the envelope + Archived bool `json:"archived" example:"true" default:"false"` // Is the envelope archived? +} + +// model transforms the API representation into the model representation +func (e EnvelopeEditable) model() models.Envelope { + return models.Envelope{ + Name: e.Name, + CategoryID: e.CategoryID, + Note: e.Note, + Archived: e.Archived, + } +} + +type EnvelopeLinks struct { + Self string `json:"self" example:"https://example.com/api/v4/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166"` // The envelope itself + Transactions string `json:"transactions" example:"https://example.com/api/v4/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166"` // The envelope's transactions + Month string `json:"month" example:"https://example.com/api/v4/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM"` // The MonthConfig for the envelope +} + +type Envelope struct { + models.DefaultModel + EnvelopeEditable + Links EnvelopeLinks `json:"links"` // Links to related resources +} + +func newEnvelope(c *gin.Context, model models.Envelope) Envelope { + url := c.GetString(string(models.DBContextURL)) + + return Envelope{ + DefaultModel: model.DefaultModel, + EnvelopeEditable: EnvelopeEditable{ + Name: model.Name, + CategoryID: model.CategoryID, + Note: model.Note, + Archived: model.Archived, + }, + Links: EnvelopeLinks{ + Self: fmt.Sprintf("%s/v4/envelopes/%s", url, model.ID), + Transactions: fmt.Sprintf("%s/v4/transactions?envelope=%s", url, model.ID), + Month: fmt.Sprintf("%s/v4/envelopes/%s/YYYY-MM", url, model.ID), + }, + } +} + +type EnvelopeListResponse struct { + Data []Envelope `json:"data"` // List of Envelopes + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Pagination *Pagination `json:"pagination"` // Pagination information +} + +type EnvelopeCreateResponse struct { + Data []EnvelopeResponse `json:"data"` // Data for the Envelope + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred +} + +// appendError appends an EnvelopeResponse with the error and returns the updated HTTP status +func (e *EnvelopeCreateResponse) appendError(err httperrors.Error, status int) int { + s := err.Error() + e.Data = append(e.Data, EnvelopeResponse{Error: &s}) + + // The final status code is the highest HTTP status code number + if err.Status > status { + status = err.Status + } + + return status +} + +type EnvelopeResponse struct { + Data *Envelope `json:"data"` // Data for the Envelope + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred +} + +type EnvelopeQueryFilter struct { + CategoryID string `form:"category"` // By the ID of the category + Name string `form:"name" filterField:"false"` // By name + Note string `form:"note" filterField:"false"` // By the note + Archived bool `form:"archived"` // Is the envelope archived? + Search string `form:"search" filterField:"false"` // By string in name or note + Offset uint `form:"offset" filterField:"false"` // The offset of the first Envelope returned. Defaults to 0. + Limit int `form:"limit" filterField:"false"` // Maximum number of Envelopes to return. Defaults to 50. +} + +func (f EnvelopeQueryFilter) model() (models.Envelope, httperrors.Error) { + categoryID, err := httputil.UUIDFromString(f.CategoryID) + if !err.Nil() { + return models.Envelope{}, err + } + + return models.Envelope{ + CategoryID: categoryID, + Archived: f.Archived, + }, httperrors.Error{} +} diff --git a/pkg/controllers/v4/filters.go b/pkg/controllers/v4/filters.go new file mode 100644 index 00000000..72892379 --- /dev/null +++ b/pkg/controllers/v4/filters.go @@ -0,0 +1,32 @@ +package v4 + +import ( + "fmt" + + "golang.org/x/exp/slices" + "gorm.io/gorm" +) + +func stringFilters(db, query *gorm.DB, setFields []string, name, note, search string) *gorm.DB { + if name != "" { + query = query.Where("name LIKE ?", fmt.Sprintf("%%%s%%", name)) + } else if slices.Contains(setFields, "Name") { + query = query.Where("name = ''") + } + + if note != "" { + query = query.Where("note LIKE ?", fmt.Sprintf("%%%s%%", note)) + } else if slices.Contains(setFields, "Note") { + query = query.Where("note = ''") + } + + if search != "" { + query = query.Where( + db.Where("note LIKE ?", fmt.Sprintf("%%%s%%", search)).Or( + db.Where("name LIKE ?", fmt.Sprintf("%%%s%%", search)), + ), + ) + } + + return query +} diff --git a/pkg/controllers/v4/generics.go b/pkg/controllers/v4/generics.go new file mode 100644 index 00000000..f5eceaff --- /dev/null +++ b/pkg/controllers/v4/generics.go @@ -0,0 +1,29 @@ +package v4 + +import ( + "fmt" + "net/http" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// getModelByID gets a resources of a specified type by its ID. +// +// If the resources does not exist or the ID is the zero UUID, an appropriate error is returned. +// +// For month configs, see getMonthConfigModel +func getModelByID[T models.Model](c *gin.Context, id uuid.UUID) (resource T, err httperrors.Error) { + if id == uuid.Nil { + return resource, httperrors.Error{Err: fmt.Errorf("no %s ID specified", resource.Self()), Status: http.StatusBadRequest} + } + + dbErr := models.DB.First(&resource, "id = ?", id).Error + if dbErr != nil { + return resource, httperrors.GenericDBError(resource, c, dbErr) + } + + return resource, httperrors.Error{} +} diff --git a/pkg/controllers/v4/goal.go b/pkg/controllers/v4/goal.go new file mode 100644 index 00000000..29cc785a --- /dev/null +++ b/pkg/controllers/v4/goal.go @@ -0,0 +1,395 @@ +package v4 + +import ( + "net/http" + + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "golang.org/x/exp/slices" +) + +func RegisterGoalRoutes(r *gin.RouterGroup) { + { + r.OPTIONS("", OptionsGoals) + r.GET("", GetGoals) + r.POST("", CreateGoals) + } + { + r.OPTIONS("/:id", OptionsGoalDetail) + r.GET("/:id", GetGoal) + r.PATCH("/:id", UpdateGoal) + r.DELETE("/:id", DeleteGoal) + } +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Goals +// @Success 204 +// @Router /v4/goals [options] +func OptionsGoals(c *gin.Context) { + httputil.OptionsGetPost(c) +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Goals +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/goals/{id} [options] +func OptionsGoalDetail(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + _, err = getModelByID[models.Goal](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + httputil.OptionsGetPatchDelete(c) +} + +// @Summary Create goals +// @Description Creates new goals +// @Tags Goals +// @Produce json +// @Success 201 {object} GoalCreateResponse +// @Failure 400 {object} GoalCreateResponse +// @Failure 404 {object} GoalCreateResponse +// @Failure 500 {object} GoalCreateResponse +// @Param goals body []GoalEditable true "Goals" +// @Router /v4/goals [post] +func CreateGoals(c *gin.Context) { + var goals []GoalEditable + + // Bind data and return error if not possible + err := httputil.BindData(c, &goals) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, GoalCreateResponse{ + Error: &e, + }) + return + } + + // The final http status. Will be modified when errors occur + status := http.StatusCreated + r := GoalCreateResponse{} + + for _, create := range goals { + goal := create.model() + + // Verify that the envelope exists. If not, append the error and move to the next goal + _, err := getModelByID[models.Envelope](c, create.EnvelopeID) + if !err.Nil() { + status = r.appendError(err, status) + continue + } + + dbErr := models.DB.Create(&goal).Error + if dbErr != nil { + err := httperrors.GenericDBError[models.Goal](goal, c, dbErr) + status = r.appendError(err, status) + continue + } + + // Transform for the API and append + apiResource := newGoal(c, goal) + r.Data = append(r.Data, GoalResponse{Data: &apiResource}) + } + + c.JSON(status, r) +} + +// @Summary Get goals +// @Description Returns a list of goals +// @Tags Goals +// @Produce json +// @Success 200 {object} GoalListResponse +// @Failure 400 {object} GoalListResponse +// @Failure 500 {object} GoalListResponse +// @Router /v4/goals [get] +// @Param name query string false "Filter by name" +// @Param note query string false "Filter by note" +// @Param search query string false "Search for this text in name and note" +// @Param archived query bool false "Is the goal archived?" +// @Param envelope query string false "Filter by envelope ID" +// @Param month query string false "Month of the goal. Ignores exact time, matches on the month of the RFC3339 timestamp provided." +// @Param fromMonth query string false "Goals for this and later months. Ignores exact time, matches on the month of the RFC3339 timestamp provided." +// @Param untilMonth query string false "Goals for this and earlier months. Ignores exact time, matches on the month of the RFC3339 timestamp provided." +// @Param amount query string false "Filter by amount" +// @Param amountLessOrEqual query string false "Amount less than or equal to this" +// @Param amountMoreOrEqual query string false "Amount more than or equal to this" +// @Param offset query uint false "The offset of the first goal returned. Defaults to 0." +// @Param limit query int false "Maximum number of goal to return. Defaults to 50." +func GetGoals(c *gin.Context) { + var filter GoalQueryFilter + + if err := c.Bind(&filter); err != nil { + s := err.Error() + c.JSON(http.StatusBadRequest, GoalListResponse{ + Error: &s, + }) + return + } + + queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) + + where, err := filter.model() + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, GoalListResponse{ + Error: &s, + }) + return + } + + q := models.DB. + Order("date(month) ASC, name ASC"). + Where(&where, queryFields...) + + q = stringFilters(models.DB, q, setFields, filter.Name, filter.Note, filter.Search) + + // Set the offset. Does not need checking since the default is 0 + q = q.Offset(int(filter.Offset)) + + // Default to 50 Accounts and set the limit + limit := 50 + if slices.Contains(setFields, "Limit") { + limit = filter.Limit + } + q = q.Limit(limit) + + if !where.Month.IsZero() { + q = q.Where("goals.month >= date(?)", where.Month).Where("goals.month < date(?)", where.Month.AddDate(0, 1)) + } + + if filter.FromMonth != "" { + fromMonth, e := types.ParseMonth(filter.FromMonth) + if e != nil { + s := e.Error() + c.JSON(http.StatusBadRequest, GoalListResponse{ + Error: &s, + }) + } + q = q.Where("goals.month >= date(?)", fromMonth) + } + + if filter.UntilMonth != "" { + untilMonth, e := types.ParseMonth(filter.UntilMonth) + if e != nil { + s := e.Error() + c.JSON(http.StatusBadRequest, GoalListResponse{ + Error: &s, + }) + } + q = q.Where("goals.month < date(?)", untilMonth.AddDate(0, 1)) + } + + if !filter.AmountLessOrEqual.IsZero() { + q = q.Where("goals.amount <= ?", filter.AmountLessOrEqual) + } + + if !filter.AmountMoreOrEqual.IsZero() { + q = q.Where("goals.amount >= ?", filter.AmountMoreOrEqual) + } + + var goals []models.Goal + err = query(c, q.Find(&goals)) + + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, GoalListResponse{ + Error: &s, + }) + return + } + + var count int64 + err = query(c, q.Limit(-1).Offset(-1).Count(&count)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, GoalListResponse{ + Error: &e, + }) + return + } + + // Transform resources to their API representation + data := make([]Goal, 0, len(goals)) + for _, goal := range goals { + data = append(data, newGoal(c, goal)) + } + + c.JSON(http.StatusOK, GoalListResponse{ + Data: data, + Pagination: &Pagination{ + Count: len(data), + Total: count, + Offset: filter.Offset, + Limit: limit, + }, + }) +} + +// @Summary Get goal +// @Description Returns a specific goal +// @Tags Goals +// @Produce json +// @Success 200 {object} GoalResponse +// @Failure 400 {object} GoalResponse +// @Failure 404 {object} GoalResponse +// @Failure 500 {object} GoalResponse +// @Param id path string true "ID formatted as string" +// @Router /v4/goals/{id} [get] +func GetGoal(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, GoalResponse{ + Error: &e, + }) + return + } + + var goal models.Goal + err = query(c, models.DB.First(&goal, id)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, GoalResponse{ + Error: &e, + }) + return + } + + apiResource := newGoal(c, goal) + c.JSON(http.StatusOK, GoalResponse{Data: &apiResource}) +} + +// @Summary Update goal +// @Description Updates an existing goal. Only values to be updated need to be specified. +// @Tags Goals +// @Accept json +// @Produce json +// @Success 200 {object} GoalResponse +// @Failure 400 {object} GoalResponse +// @Failure 404 {object} GoalResponse +// @Failure 500 {object} GoalResponse +// @Param id path string true "ID formatted as string" +// @Param goal body GoalEditable true "Goal" +// @Router /v4/goals/{id} [patch] +func UpdateGoal(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, GoalResponse{ + Error: &e, + }) + return + } + + goal, err := getModelByID[models.Goal](c, id) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, GoalResponse{ + Error: &e, + }) + return + } + + // Get the fields that are set to be updated + updateFields, err := httputil.GetBodyFields(c, GoalEditable{}) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, GoalResponse{ + Error: &e, + }) + return + } + + // Bind the data for the patch + var data GoalEditable + err = httputil.BindData(c, &data) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, GoalResponse{ + Error: &e, + }) + return + } + + // Check that the referenced envelope exists + if slices.Contains(updateFields, "EnvelopeID") { + _, err = getModelByID[models.Envelope](c, data.EnvelopeID) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, GoalResponse{ + Error: &e, + }) + return + } + } + + err = query(c, models.DB.Model(&goal).Select("", updateFields...).Updates(data.model())) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, GoalResponse{ + Error: &e, + }) + return + } + + apiResource := newGoal(c, goal) + c.JSON(http.StatusOK, GoalResponse{Data: &apiResource}) +} + +// @Summary Delete goal +// @Description Deletes a goal +// @Tags Goals +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/goals/{id} [delete] +func DeleteGoal(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + goal, err := getModelByID[models.Goal](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + err = query(c, models.DB.Delete(&goal)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusNoContent, nil) +} diff --git a/pkg/controllers/v4/goal_test.go b/pkg/controllers/v4/goal_test.go new file mode 100644 index 00000000..9b9bf97a --- /dev/null +++ b/pkg/controllers/v4/goal_test.go @@ -0,0 +1,523 @@ +package v4_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/envelope-zero/backend/v4/internal/types" + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/test" + "github.com/google/uuid" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" +) + +func createTestGoal(t *testing.T, c v4.GoalEditable, expectedStatus ...int) v4.GoalResponse { + // Default to 201 Created as expected status + if len(expectedStatus) == 0 { + expectedStatus = append(expectedStatus, http.StatusCreated) + } + + if c.EnvelopeID == uuid.Nil { + c.EnvelopeID = createTestEnvelope(t, v4.EnvelopeEditable{}).Data.ID + } + + requestBody := []v4.GoalEditable{c} + + recorder := test.Request(t, http.MethodPost, "http://example.com/v4/goals", requestBody) + test.AssertHTTPStatus(t, &recorder, expectedStatus...) + + var response v4.GoalCreateResponse + test.DecodeResponse(t, &recorder, &response) + + return response.Data[0] +} + +// TestGoalsOptions verifies that the HTTP OPTIONS response for //goals/{id} is correct. +func (suite *TestSuiteStandard) TestGoalsOptions() { + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + id string // String to use as ID. Ignored when pathFunc is non-nil + pathFunc func() string // Function returning the path + }{ + { + "Does not exist", + http.StatusNotFound, + uuid.New().String(), + nil, + }, + { + "Invalid UUID", + http.StatusBadRequest, + "NotParseableAsUUID", + nil, + }, + { + "Success", + http.StatusNoContent, + "", + func() string { + return createTestGoal(suite.T(), v4.GoalEditable{Amount: decimal.NewFromFloat(31)}).Data.Links.Self + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var p string + if tt.pathFunc != nil { + p = tt.pathFunc() + } else { + p = fmt.Sprintf("%s/%s", "http://example.com/v4/goals", tt.id) + } + + r := test.Request(t, http.MethodOptions, p, "") + test.AssertHTTPStatus(t, &r, tt.status) + + if tt.status == http.StatusNoContent { + assert.Equal(t, "OPTIONS, GET, PATCH, DELETE", r.Header().Get("allow")) + } + }) + } +} + +// TestGoalsDatabaseError verifies that the endpoints return the appropriate +// error when the database is disconncted. +func (suite *TestSuiteStandard) TestGoalsDatabaseError() { + tests := []struct { + name string // Name of the test + path string // Path to send request to + method string // HTTP method to use + body string // The request body + }{ + {"GET Collection", "", http.MethodGet, ""}, + // Skipping POST Collection here since we need to check the indivdual transactions for that one + {"OPTIONS Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodOptions, ""}, + {"GET Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodGet, ""}, + {"PATCH Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodPatch, ""}, + {"DELETE Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodDelete, ""}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + suite.CloseDB() + + recorder := test.Request(t, tt.method, fmt.Sprintf("http://example.com/v4/goals%s", tt.path), tt.body) + test.AssertHTTPStatus(t, &recorder, http.StatusInternalServerError) + assert.Equal(t, httperrors.ErrDatabaseClosed.Error(), test.DecodeError(t, recorder.Body.Bytes())) + }) + } +} + +// TestGoalsGet verifies that goals can be read from the API and +// that the default sorting is correct. +func (suite *TestSuiteStandard) TestGoalsGet() { + g1 := createTestGoal(suite.T(), v4.GoalEditable{ + Amount: decimal.NewFromFloat(100), + Month: types.NewMonth(2024, 1), + Name: "Irrelevant", + }) + + _ = createTestGoal(suite.T(), v4.GoalEditable{ + Amount: decimal.NewFromFloat(300), + Month: types.NewMonth(2024, 2), + Name: "First", + }) + + g3 := createTestGoal(suite.T(), v4.GoalEditable{ + Amount: decimal.NewFromFloat(50), + Month: types.NewMonth(2024, 2), + Name: "Before g2", + }) + + recorder := test.Request(suite.T(), http.MethodGet, "http://example.com/v4/goals", "") + + var response v4.GoalListResponse + test.DecodeResponse(suite.T(), &recorder, &response) + + assert.Equal(suite.T(), 200, recorder.Code) + assert.Len(suite.T(), response.Data, 3) + + // Verify that the goal with the earlier month is the first in the list + assert.Equal(suite.T(), g1.Data.ID, response.Data[0].ID) + + // Verify that the goal with the alphabetically earlier name is earlier + assert.Equal(suite.T(), g3.Data.ID, response.Data[1].ID) +} + +// TestGoalsGetFilter verifies that filtering goals works as expected. +func (suite *TestSuiteStandard) TestGoalsGetFilter() { + b := createTestBudget(suite.T(), v4.BudgetEditable{}) + + c := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: b.Data.ID}) + + e1 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: c.Data.ID}) + e2 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: c.Data.ID}) + + _ = createTestGoal(suite.T(), v4.GoalEditable{ + Name: "Test Goal", + Note: "So that we can go to X", + EnvelopeID: e1.Data.ID, + Amount: decimal.NewFromFloat(100), + Month: types.NewMonth(2024, 1), + Archived: false, + }) + + _ = createTestGoal(suite.T(), v4.GoalEditable{ + Name: "Goal for something else", + EnvelopeID: e1.Data.ID, + Amount: decimal.NewFromFloat(200), + Month: types.NewMonth(2024, 2), + Archived: true, + }) + + _ = createTestGoal(suite.T(), v4.GoalEditable{ + Name: "testing the filters", + Note: "so that I know they work", + EnvelopeID: e2.Data.ID, + Amount: decimal.NewFromFloat(1000), + Month: types.NewMonth(2024, 1), + Archived: false, + }) + + tests := []struct { + name string + query string + len int + }{ + {"Same month", fmt.Sprintf("month=%s", types.NewMonth(2024, 1)), 2}, + {"After month", fmt.Sprintf("fromMonth=%s", types.NewMonth(2024, 2)), 1}, + {"Before month", fmt.Sprintf("untilMonth=%s", types.NewMonth(2024, 2)), 3}, + {"After all months", fmt.Sprintf("fromMonth=%s", types.NewMonth(2024, 6)), 0}, + {"Before all months", fmt.Sprintf("untilMonth=%s", types.NewMonth(2023, 6)), 0}, + {"Impossible between two months", fmt.Sprintf("fromMonth=%s&untilMonth=%s", types.NewMonth(2024, 11), types.NewMonth(2024, 10)), 0}, + {"Exact Amount", fmt.Sprintf("amount=%s", decimal.NewFromFloat(200).String()), 1}, + {"Note", "note=can", 1}, + {"No note", "note=", 1}, + {"Fuzzy note", "note=so", 2}, + {"Amount less or equal to 99", "amountLessOrEqual=99", 0}, + {"Amount less or equal to 200", "amountLessOrEqual=200", 2}, + {"Amount more or equal to 3", "amountMoreOrEqual=3", 3}, + {"Amount more or equal to 500.813", "amountMoreOrEqual=500.813", 1}, + {"Amount more or equal to 99999", "amountMoreOrEqual=99999", 0}, + {"Amount more or equal to 100 and less than 10", "amountMoreOrEqual=100&amountLessOrEqual=10", 0}, + {"Amount more or equal to 50 and less than 500", "amountMoreOrEqual=50&amountLessOrEqual=500", 2}, + {"Limit positive", "limit=2", 2}, + {"Limit zero", "limit=0", 0}, + {"Limit unset", "limit=-1", 3}, + {"Limit negative", "limit=-123", 3}, + {"Offset zero", "offset=0", 3}, + {"Offset positive", "offset=2", 1}, + {"Offset higher than number", "offset=5", 0}, + {"Limit and Offset", "limit=1&offset=1", 1}, + {"Limit and Fuzzy Note", "limit=1¬e=so", 1}, + {"Offset and Fuzzy Note", "offset=2¬e=they", 0}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var re v4.GoalListResponse + r := test.Request(t, http.MethodGet, fmt.Sprintf("/v4/goals?%s", tt.query), "") + test.AssertHTTPStatus(t, &r, http.StatusOK) + test.DecodeResponse(t, &r, &re) + + assert.Equal(t, tt.len, len(re.Data), "Request ID: %s", r.Result().Header.Get("x-request-id")) + }) + } +} + +// TestGoalsGetInvalidQuery verifies that invalid filtering queries +// return a HTTP Bad Request. +func (suite *TestSuiteStandard) TestGoalsGetInvalidQuery() { + tests := []string{ + "envelope=ThisIsDefinitelyACat!", + "month=A long time ago", + "archived=0.00", + "amount=Seventeen Cents", + "offset=-1", // offset is a uint + "limit=name", // limit is an int + "untilMonth=2023-11-01", // Format is "YYYY-MM" + "fromMonth=Yesterday", + } + + for _, tt := range tests { + suite.T().Run(tt, func(t *testing.T) { + recorder := test.Request(t, http.MethodGet, fmt.Sprintf("http://example.com/v4/goals?%s", tt), "") + test.AssertHTTPStatus(t, &recorder, http.StatusBadRequest) + }) + } +} + +// TestGoalsCreateInvalidBody verifies that creation of goals +// with an unparseable request body returns a HTTP Bad Request. +func (suite *TestSuiteStandard) TestGoalsCreateInvalidBody() { + r := test.Request(suite.T(), http.MethodPost, "http://example.com/v4/goals", `{ Invalid request": Body }`) + test.AssertHTTPStatus(suite.T(), &r, http.StatusBadRequest) + + var response v4.GoalCreateResponse + test.DecodeResponse(suite.T(), &r, &response) + + assert.Equal(suite.T(), httperrors.ErrInvalidBody.Error(), *response.Error) + assert.Nil(suite.T(), response.Data) +} + +// TestGoalsCreate verifies that transaction goal works. +func (suite *TestSuiteStandard) TestGoalsCreate() { + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{Name: "An envelope for this test"}) + + tests := []struct { + name string + goals []v4.GoalEditable + expectedStatus int + expectedError *error // Error expected in the response + expectedErrors []string // Errors expected for the individual transactions + }{ + { + "One success, one fail", + []v4.GoalEditable{ + { + EnvelopeID: uuid.New(), + Amount: decimal.NewFromFloat(17.23), + Note: "v4 non-existing envelope ID", + Name: "One success, one fail", + }, + { + EnvelopeID: envelope.Data.ID, + Amount: decimal.NewFromFloat(57.01), + }, + }, + http.StatusNotFound, + nil, + []string{ + "there is no Envelope with this ID", + "", + }, + }, + { + "Both succeed", + []v4.GoalEditable{ + { + Name: "Both succeed - 1", + EnvelopeID: envelope.Data.ID, + Amount: decimal.NewFromFloat(17.23), + }, + { + Name: "Unique Name for the Envelope", + EnvelopeID: envelope.Data.ID, + Amount: decimal.NewFromFloat(57.01), + }, + }, + http.StatusCreated, + nil, + []string{ + "", + "", + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPost, "http://example.com/v4/goals", tt.goals) + test.AssertHTTPStatus(t, &r, tt.expectedStatus) + + var response v4.GoalCreateResponse + test.DecodeResponse(t, &r, &response) + + for i, goal := range response.Data { + if tt.expectedErrors[i] == "" { + assert.Equal(t, fmt.Sprintf("http://example.com/v4/goals/%s", goal.Data.ID), goal.Data.Links.Self) + } else { + // This needs to be in the else to prevent nil pointer errors since we're dereferencing pointers + assert.Equal(t, tt.expectedErrors[i], *goal.Error) + } + } + }) + } +} + +// TestGoalsGetSingle verifies that a goal can be read from the API via its link +// and that the link is for API v4. +func (suite *TestSuiteStandard) TestGoalsGetSingle() { + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + id string // String to use as ID. Ignored when pathFunc is non-nil + pathFunc func() string // Function returning the path + }{ + { + "Standard transaction", + http.StatusOK, + "", + func() string { + return createTestGoal(suite.T(), v4.GoalEditable{Amount: decimal.NewFromFloat(42)}).Data.Links.Self + }, + }, + { + "Invalid UUID", + http.StatusBadRequest, + "NotParseableAsUUID", + nil, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var p string + if tt.pathFunc != nil { + p = tt.pathFunc() + } else { + p = fmt.Sprintf("%s/%s", "http://example.com/v4/goals", tt.id) + } + + r := test.Request(suite.T(), http.MethodGet, p, "") + test.AssertHTTPStatus(suite.T(), &r, tt.status) + }) + } +} + +// TestGoalsDelete verifies the correct success and error responses +// for DELETE requests. +func (suite *TestSuiteStandard) TestGoalsDelete() { + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + id string // String to use as ID. + }{ + { + "Standard deletion", + http.StatusNoContent, + createTestGoal(suite.T(), v4.GoalEditable{Amount: decimal.NewFromFloat(2100)}).Data.ID.String(), + }, + { + "Does not exist", + http.StatusNotFound, + "4bcb6d09-ced1-41e8-a3fe-bf4f16c5e501", + }, + { + "Null transaction", + http.StatusBadRequest, + "00000000-0000-0000-0000-000000000000", + }, + { + "Invalid UUID", + http.StatusBadRequest, + "Definitely an Invalid ID", + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + p := fmt.Sprintf("%s/%s", "http://example.com/v4/goals", tt.id) + + r := test.Request(t, http.MethodDelete, p, "") + test.AssertHTTPStatus(t, &r, tt.status) + }) + } +} + +// TestGoalsUpdateFail verifies that goal updates fail where they should. +func (suite *TestSuiteStandard) TestGoalsUpdateFail() { + goal := createTestGoal(suite.T(), v4.GoalEditable{Amount: decimal.NewFromFloat(170), Note: "Test note for goal"}) + + tests := []struct { + name string // Name for the test + id string // ID of the Goal to update + status int // Expected HTTP status + body any // Body to send to the PATCH endpoint + }{ + { + "Invalid body", + goal.Data.ID.String(), + http.StatusBadRequest, + `{ "amount": 2" }`, + }, + { + "Invalid type", + goal.Data.ID.String(), + http.StatusBadRequest, + map[string]any{ + "amount": false, + }, + }, + { + "Invalid goal ID", + "Not a valid UUID", + http.StatusBadRequest, + ``, + }, + { + "Invalid envelope ID", + goal.Data.ID.String(), + http.StatusBadRequest, + v4.GoalEditable{}, // Sets the EnvelopeID to uuid.Nil + }, + { + "Negative amount", + goal.Data.ID.String(), + http.StatusBadRequest, + `{ "amount": -58.23 }`, + }, + { + "Non-existing envelope", + goal.Data.ID.String(), + http.StatusNotFound, + `{ "envelopeId": "e6fa8eb5-5f2c-4292-8ef9-02f0c2af1ce4" }`, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + p := fmt.Sprintf("%s/%s", "http://example.com/v4/goals", tt.id) + + r := test.Request(t, http.MethodPatch, p, tt.body) + test.AssertHTTPStatus(t, &r, tt.status) + }) + } +} + +// TestUpdateNonExistingGoal verifies that patching a non-existent transaction returns a 404. +func (suite *TestSuiteStandard) TestUpdateNonExistingGoal() { + recorder := test.Request(suite.T(), http.MethodPatch, "http://example.com/v4/goals/c08c0a04-2a12-4cb9-8b4a-87cf270cdd8d", `{ "note": "2" }`) + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) +} + +// TestGoalsUpdate verifies that transaction updates are successful. +func (suite *TestSuiteStandard) TestGoalsUpdate() { + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{}) + + goal := createTestGoal(suite.T(), v4.GoalEditable{ + Amount: decimal.NewFromFloat(23.14), + Note: "Test note for transaction", + Archived: true, + EnvelopeID: envelope.Data.ID, + }) + + tests := []struct { + name string // Name for the test + body any // Body to send to the PATCH endpoint + }{ + { + "Empty note", + map[string]any{ + "note": "", + }, + }, + { + "Change amount", + map[string]any{ + "amount": decimal.NewFromFloat(130), + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPatch, goal.Data.Links.Self, tt.body) + test.AssertHTTPStatus(t, &r, http.StatusOK) + }) + } +} diff --git a/pkg/controllers/v4/goal_types.go b/pkg/controllers/v4/goal_types.go new file mode 100644 index 00000000..a4df022a --- /dev/null +++ b/pkg/controllers/v4/goal_types.go @@ -0,0 +1,140 @@ +package v4 + +import ( + "fmt" + "net/http" + + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +type GoalEditable struct { + Name string `json:"name" example:"New TV" default:""` // Name of the goal + Note string `json:"note" example:"We want to replace the old CRT TV soon-ish" default:""` // Note about the goal + EnvelopeID uuid.UUID `json:"envelopeId" example:"f81566d9-af4d-4f13-9830-c62c4b5e4c7e"` // The ID of the envelope this goal is for + Amount decimal.Decimal `json:"amount" example:"750" minimum:"0.00000001" maximum:"999999999999.99999999" multipleOf:"0.00000001" default:"0"` // How much money should be saved for this goal? + Month types.Month `json:"month" example:"2024-07-01T00:00:00.000000Z"` // The month the goal should be reached + Archived bool `json:"archived" example:"true" default:"false"` // If this goal is still in use or not +} + +// model returns the database resource for the API representation of the editable fields +func (editable GoalEditable) model() models.Goal { + return models.Goal{ + Name: editable.Name, + Note: editable.Note, + EnvelopeID: editable.EnvelopeID, + Amount: editable.Amount, + Month: editable.Month, + Archived: editable.Archived, + } +} + +type GoalLinks struct { + Self string `json:"self" example:"https://example.com/api/v4/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c"` // The Goal itself + Envelope string `json:"envelope" example:"https://example.com/api/v4/envelopes/c1a96ae4-80e3-4827-8ed0-c7656f224fee"` // The Envelope this goal references +} + +type Goal struct { + models.DefaultModel + GoalEditable + Links GoalLinks `json:"links"` +} + +// newGoal returns the API v4 representation of the resource +func newGoal(c *gin.Context, model models.Goal) Goal { + url := c.GetString(string(models.DBContextURL)) + + return Goal{ + DefaultModel: model.DefaultModel, + GoalEditable: GoalEditable{ + Name: model.Name, + Note: model.Note, + EnvelopeID: model.EnvelopeID, + Amount: model.Amount, + Month: model.Month, + Archived: model.Archived, + }, + Links: GoalLinks{ + Self: fmt.Sprintf("%s/v4/goals/%s", url, model.ID), + Envelope: fmt.Sprintf("%s/v4/envelopes/%s", url, model.EnvelopeID), + }, + } +} + +type GoalListResponse struct { + Data []Goal `json:"data"` // List of resources + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Pagination *Pagination `json:"pagination"` // Pagination information +} + +type GoalCreateResponse struct { + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Data []GoalResponse `json:"data"` // List of created resources +} + +func (t *GoalCreateResponse) appendError(err httperrors.Error, status int) int { + s := err.Error() + t.Data = append(t.Data, GoalResponse{Error: &s}) + + // The final status code is the highest HTTP status code number + if err.Status > status { + status = err.Status + } + + return status +} + +type GoalResponse struct { + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Data *Goal `json:"data"` // The resource +} + +type GoalQueryFilter struct { + Name string `form:"name" filterField:"false"` // By name + Note string `form:"note" filterField:"false"` // By the note + Search string `form:"search" filterField:"false"` // By string in name or note + Archived bool `form:"archived"` // Is the goal archived? + EnvelopeID string `form:"envelope"` // ID of the envelope + Month string `form:"month"` // Exact month + FromMonth string `form:"fromMonth" filterField:"false"` // From this month + UntilMonth string `form:"untilMonth" filterField:"false"` // Until this month + Amount decimal.Decimal `form:"amount"` // Exact amount + AmountLessOrEqual decimal.Decimal `form:"amountLessOrEqual" filterField:"false"` // Amount less than or equal to this + AmountMoreOrEqual decimal.Decimal `form:"amountMoreOrEqual" filterField:"false"` // Amount more than or equal to this + Offset uint `form:"offset" filterField:"false"` // The offset of the first goal returned. Defaults to 0. + Limit int `form:"limit" filterField:"false"` // Maximum number of goals to return. Defaults to 50. +} + +func (f GoalQueryFilter) model() (models.Goal, httperrors.Error) { + envelopeID, err := httputil.UUIDFromString(f.EnvelopeID) + if !err.Nil() { + return models.Goal{}, err + } + + var month types.Month + if f.Month != "" { + m, e := types.ParseMonth(f.Month) + if e != nil { + return models.Goal{}, httperrors.Error{ + Err: e, + Status: http.StatusBadRequest, + } + } + + month = m + } + + // This does not set the string fields since they are + // handled in the controller function + return GoalEditable{ + EnvelopeID: envelopeID, + Amount: f.Amount, + Month: month, + Archived: f.Archived, + }.model(), httperrors.Error{} +} diff --git a/pkg/controllers/v4/import.go b/pkg/controllers/v4/import.go new file mode 100644 index 00000000..a6d59413 --- /dev/null +++ b/pkg/controllers/v4/import.go @@ -0,0 +1,426 @@ +package v4 + +import ( + "errors" + "fmt" + "mime/multipart" + "net/http" + "strings" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/importer" + ynabimport "github.com/envelope-zero/backend/v4/pkg/importer/parser/ynab-import" + "github.com/envelope-zero/backend/v4/pkg/importer/parser/ynab4" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/ryanuber/go-glob" + "gorm.io/gorm" +) + +type ImportQuery struct { + BudgetName string `form:"budgetName" binding:"required"` // Name for the new budget +} + +type ImportPreviewQuery struct { + AccountID string `form:"accountId" binding:"required"` // ID of the account to import the transactions for +} + +// getUploadedFile returns the form file and handles potential errors. +func getUploadedFile(c *gin.Context, suffix string) (multipart.File, httperrors.Error) { + formFile, err := c.FormFile("file") + if formFile == nil { + return nil, httperrors.Error{ + Status: http.StatusBadRequest, + Err: httperrors.ErrNoFilePost, + } + } + + if err != nil { + return nil, httperrors.Parse(c, err) + } + + if !strings.HasSuffix(formFile.Filename, suffix) { + return nil, httperrors.Error{ + Status: http.StatusBadRequest, + Err: fmt.Errorf("this endpoint only supports %s files", suffix), + } + } + + f, err := formFile.Open() + if err != nil { + return nil, httperrors.Parse(c, err) + } + + return f, httperrors.Error{} +} + +// duplicateTransactions finds duplicate transactions by their import hash. For all input resources, +// existing resources with the same import hash are searched. If any exist, their IDs are set in the +// DuplicateTransactionIDs field. +func duplicateTransactions(transaction *importer.TransactionPreview, budgetID uuid.UUID) { + var duplicates []models.Transaction + models.DB. + Preload("SourceAccount"). + Preload("DestinationAccount"). + Where(models.Transaction{ + ImportHash: transaction.Transaction.ImportHash, + }). + Where(models.Transaction{SourceAccount: models.Account{BudgetID: budgetID}}). + Or(models.Transaction{DestinationAccount: models.Account{BudgetID: budgetID}}). + Find(&duplicates) + + // When there are no resources, we want an empty list, not null + // Therefore, we use make to create a slice with zero elements + // which will be marshalled to an empty JSON array + duplicateIDs := make([]uuid.UUID, 0) + for _, duplicate := range duplicates { + if duplicate.SourceAccount.BudgetID == budgetID || duplicate.DestinationAccount.BudgetID == budgetID { + duplicateIDs = append(duplicateIDs, duplicate.ID) + } + } + transaction.DuplicateTransactionIDs = duplicateIDs +} + +// findAccounts sets the source or destination account ID for a TransactionPreview resource +// if there is exactly one account with a matching name. +func findAccounts(transaction *importer.TransactionPreview, budgetID uuid.UUID) error { + // Find the right account name + name := transaction.DestinationAccountName + if transaction.SourceAccountName != "" { + name = transaction.SourceAccountName + } + + var account models.Account + err := models.DB.Where(models.Account{ + Name: name, + BudgetID: budgetID, + Archived: false, + }, + // Account Names are unique, therefore only one can match + "Name", "BudgetID", "Archived").First(&account).Error + + // Abort if no accounts are found, but with no error + // since this is an expected case - there might just + // not be a matching account + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + + // Set source or destination, depending on which one we checked for + if account.ID != uuid.Nil { + if transaction.SourceAccountName != "" { + transaction.Transaction.SourceAccountID = account.ID + } else { + transaction.Transaction.DestinationAccountID = account.ID + } + } + + return nil +} + +// match applies the match rules to a transaction. +func match(transaction *importer.TransactionPreview, rules []models.MatchRule) { + replace := func(name string) (uuid.UUID, uuid.UUID) { + // Iterate over all rules + for _, rule := range rules { + // If the rule matches, return the account ID. Since rules are loaded from + // the database in priority order, we can simply return the first match + if glob.Glob(rule.Match, name) { + return rule.AccountID, rule.ID + } + } + return uuid.Nil, uuid.Nil + } + + if transaction.SourceAccountName != "" { + transaction.Transaction.SourceAccountID, transaction.MatchRuleID = replace(transaction.SourceAccountName) + } + + if transaction.DestinationAccountName != "" { + transaction.Transaction.DestinationAccountID, transaction.MatchRuleID = replace(transaction.DestinationAccountName) + } +} + +// recommendEnvelope sets the first of the recommended envelopes for the opposing account. +func recommendEnvelope(transaction *importer.TransactionPreview, id uuid.UUID) error { + // Load the account + var destinationAccount models.Account + err := models.DB.First(&destinationAccount, models.Account{DefaultModel: models.DefaultModel{ID: id}}).Error + if err != nil { + return err + } + + // Preset the most popular recent envelope + envelopes, err := destinationAccount.RecentEnvelopes(models.DB) + if err != nil { + return err + } + + if len(envelopes) > 0 && envelopes[0] != &uuid.Nil { + transaction.Transaction.EnvelopeID = envelopes[0] + } + + return nil +} + +type ImportPreviewList struct { + Data []TransactionPreview `json:"data"` // List of transaction previews + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred for this Match Rule +} + +// RegisterImportRoutes registers the routes for imports. +func RegisterImportRoutes(r *gin.RouterGroup) { + // Root group + { + r.OPTIONS("", OptionsImport) + r.GET("", GetImport) + + r.OPTIONS("/ynab4", OptionsImportYnab4) + r.POST("/ynab4", ImportYnab4) + + r.OPTIONS("/ynab-import-preview", OptionsImportYnabImportPreview) + r.POST("/ynab-import-preview", ImportYnabImportPreview) + } +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs. +// @Tags Import +// @Success 204 +// @Router /v4/import [options] +func OptionsImport(c *gin.Context) { + httputil.OptionsGet(c) +} + +type ImportResponse struct { + Links ImportLinks `json:"links"` // Links for the v4 API +} + +type ImportLinks struct { + Ynab4 string `json:"transactions" example:"https://example.com/api/v4/import/ynab4"` // URL of YNAB4 import endpoint + YnabImportPreview string `json:"matchRules" example:"https://example.com/api/v4/import/ynab-import-preview"` // URL of YNAB Import preview endpoint +} + +// @Summary Import API overview +// @Description Returns general information about the v4 API +// @Tags Import +// @Success 200 {object} ImportResponse +// @Router /v4/import [get] +func GetImport(c *gin.Context) { + c.JSON(http.StatusOK, ImportResponse{ + Links: ImportLinks{ + Ynab4: c.GetString(string(models.DBContextURL)) + "/v4/import/ynab4", + YnabImportPreview: c.GetString(string(models.DBContextURL)) + "/v4/import/ynab-import-preview", + }, + }) +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Import +// @Success 204 +// @Router /v4/import/ynab4 [options] +func OptionsImportYnab4(c *gin.Context) { + httputil.OptionsPost(c) +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Import +// @Success 204 +// @Router /v4/import/ynab-import-preview [options] +func OptionsImportYnabImportPreview(c *gin.Context) { + httputil.OptionsPost(c) +} + +// @Summary Transaction Import Preview +// @Description Returns a preview of transactions to be imported after parsing a YNAB Import format csv file +// @Tags Import +// @Accept multipart/form-data +// @Produce json +// @Success 200 {object} ImportPreviewList +// @Failure 400 {object} ImportPreviewList +// @Failure 404 {object} ImportPreviewList +// @Failure 500 {object} ImportPreviewList +// @Param file formData file true "File to import" +// @Param accountId query string false "ID of the account to import transactions for" +// @Router /v4/import/ynab-import-preview [post] +func ImportYnabImportPreview(c *gin.Context) { + var query ImportPreviewQuery + err := c.BindQuery(&query) + // When the binding fails, it is always because the accountID is not set + if err != nil { + s := httperrors.ErrAccountIDParameter.Error() + c.JSON(http.StatusBadRequest, ImportPreviewList{ + Error: &s, + }) + return + } + + f, e := getUploadedFile(c, ".csv") + if !e.Nil() { + s := e.Error() + c.JSON(e.Status, ImportPreviewList{ + Error: &s, + }) + return + } + + accountID, e := httputil.UUIDFromString(query.AccountID) + if !e.Nil() { + s := e.Error() + c.JSON(e.Status, ImportPreviewList{ + Error: &s, + }) + return + } + + // Verify that the account exists + account, e := getModelByID[models.Account](c, accountID) + if !e.Nil() { + s := e.Error() + c.JSON(e.Status, ImportPreviewList{ + Error: &s, + }) + return + } + + transactions, err := ynabimport.Parse(f, account) + if err != nil { + // ynabimport.Parse parsing returns a usable error already, no parsing necessary + s := err.Error() + c.JSON(http.StatusBadRequest, ImportPreviewList{ + Error: &s, + }) + return + } + + // Get all match rules for the budget that the import target account is part of + var matchRules []models.MatchRule + err = models.DB. + Joins("JOIN accounts ON accounts.budget_id = ?", account.BudgetID). + Joins("JOIN match_rules rr ON rr.account_id = accounts.id"). + Order("rr.priority asc"). + Find(&matchRules).Error + if err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, ImportPreviewList{ + Error: &s, + }) + return + } + + for i, transaction := range transactions { + if len(matchRules) > 0 { + match(&transaction, matchRules) + } + + // Only find accounts when they are not yet both set + if transaction.Transaction.SourceAccountID == uuid.Nil || transaction.Transaction.DestinationAccountID == uuid.Nil { + err = findAccounts(&transaction, account.BudgetID) + if err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, ImportPreviewList{ + Error: &s, + }) + return + } + } + + duplicateTransactions(&transaction, account.BudgetID) + + // Recommend an envelope + if transaction.Transaction.DestinationAccountID != uuid.Nil { + err = recommendEnvelope(&transaction, transaction.Transaction.DestinationAccountID) + if err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, ImportPreviewList{ + Error: &s, + }) + return + } + } + + transactions[i] = transaction + } + + // We need to transform the responses for v4 + data := make([]TransactionPreview, 0, len(transactions)) + for _, t := range transactions { + data = append(data, newTransactionPreview(t)) + } + + c.JSON(http.StatusOK, ImportPreviewList{Data: data}) +} + +// @Summary Import YNAB 4 budget +// @Description Imports budgets from YNAB 4 +// @Tags Import +// @Accept multipart/form-data +// @Produce json +// @Success 201 {object} BudgetResponse +// @Failure 400 {object} BudgetResponse +// @Failure 500 {object} BudgetResponse +// @Param file formData file true "File to import" +// @Param budgetName query string false "Name of the Budget to create" +// @Router /v4/import/ynab4 [post] +func ImportYnab4(c *gin.Context) { + var query ImportQuery + if err := c.BindQuery(&query); err != nil { + httperrors.New(c, http.StatusBadRequest, "The budgetName parameter must be set") + return + } + + // Verify if the budget does already exist. If yes, return an error + // as we only allow imports to new budgets + var budget models.Budget + err := models.DB.Where(&models.Budget{ + Name: query.BudgetName, + }).First(&budget).Error + + if err == nil { + httperrors.New(c, http.StatusBadRequest, "This budget name is already in use. Imports from YNAB 4 create a new budget, therefore the name needs to be unique.") + return + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, BudgetResponse{ + Error: &s, + }) + return + } + + f, e := getUploadedFile(c, ".yfull") + if !e.Nil() { + s := e.Error() + c.JSON(e.Status, BudgetResponse{ + Error: &s, + }) + return + } + + // Parse the Budget.yfull + resources, err := ynab4.Parse(f) + if err != nil { + httperrors.New(c, http.StatusBadRequest, err.Error()) + return + } + + // Set the budget name explicitly since YNAB 4 files + // do not contain it + resources.Budget.Name = query.BudgetName + + budget, err = importer.Create(models.DB, resources) + if err != nil { + httperrors.Handler(c, err) + return + } + + data := newBudget(c, budget) + c.JSON(http.StatusCreated, BudgetResponse{Data: &data}) +} diff --git a/pkg/controllers/v4/import_test.go b/pkg/controllers/v4/import_test.go new file mode 100644 index 00000000..767319a5 --- /dev/null +++ b/pkg/controllers/v4/import_test.go @@ -0,0 +1,394 @@ +package v4_test + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/envelope-zero/backend/v4/internal/types" + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/envelope-zero/backend/v4/test" + "github.com/google/uuid" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" +) + +func (suite *TestSuiteStandard) parseCSV(t *testing.T, accountID uuid.UUID, file string) v4.ImportPreviewList { + path := fmt.Sprintf("ynab-import-preview?accountId=%s", accountID.String()) + + // Parse the test CSV + body, headers := test.LoadTestFile(t, fmt.Sprintf("importer/ynab-import/%s", file)) + recorder := test.Request(t, http.MethodPost, fmt.Sprintf("http://example.com/v4/import/%s", path), body, headers) + test.AssertHTTPStatus(t, &recorder, http.StatusOK) + + // Decode the response + var response v4.ImportPreviewList + test.DecodeResponse(t, &recorder, &response) + + return response +} + +// TestImportSuccess verifies successful imports for all import types. +func (suite *TestSuiteStandard) TestImportSuccess() { + accountID := createTestAccount(suite.T(), v4.AccountEditable{Name: "TestImport"}).Data.ID.String() + + tests := []struct { + name string + path string + file string + status int + }{ + {"Import whole budget", "ynab4?budgetName=Test Budget", "importer/Budget.yfull", http.StatusCreated}, + {"Preview transaction import", fmt.Sprintf("ynab-import-preview?accountId=%s", accountID), "importer/ynab-import/comdirect-ynap.csv", http.StatusOK}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + // Import one + body, headers := test.LoadTestFile(t, tt.file) + recorder := test.Request(t, http.MethodPost, fmt.Sprintf("http://example.com/v4/import/%s", tt.path), body, headers) + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestImportYnab4BudgetCalculation verifies that the budget calculation is correct +// for an imported budget from YNAB 4. +// +// This in turn tests the budget calculation itself for edge cases that can only happen +// with YNAB 4 budgets, e.g. for overspend handling migration +// +// Resource creation for YNAB 4 imports is tested in pkg/importer/parser/ynab4/parse_test.go -> TestParse, +// but budget calculation is a controller function and therefore is tested here +func (suite *TestSuiteStandard) TestImportYnab4BudgetCalculation() { + body, headers := test.LoadTestFile(suite.T(), "importer/Budget.yfull") + recorder := test.Request(suite.T(), http.MethodPost, "http://example.com/v4/import/ynab4?budgetName=Test Budget", body, headers) + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusCreated) + + var budget v4.BudgetResponse + test.DecodeResponse(suite.T(), &recorder, &budget) + + // In YNAB 4, starting balance counts as income our outflow, in Envelope Zero it does not + // Therefore, the numbers for available, balance, spent and income will differ in some cases + tests := []struct { + month types.Month + available float32 + balance float32 + spent float32 + budgeted float32 + income float32 + }{ + {types.NewMonth(2022, 10), 46.17, -100, -175, 75, 0}, + {types.NewMonth(2022, 11), 906.17, -60, -100, 140, 1000}, + {types.NewMonth(2022, 12), 886.17, -55, -110, 115, 95}, + {types.NewMonth(2023, 1), 576.17, 55, 0, 0, 0}, + {types.NewMonth(2023, 2), 456.17, 175, 0, 0, 0}, + } + + for _, tt := range tests { + suite.T().Run(tt.month.String(), func(t *testing.T) { + // Get the budget caculations for + recorder := test.Request(t, http.MethodGet, strings.Replace(budget.Data.Links.Month, "YYYY-MM", tt.month.String(), 1), "") + test.AssertHTTPStatus(t, &recorder, http.StatusOK) + var month v4.MonthResponse + test.DecodeResponse(t, &recorder, &month) + + assert.True(t, decimal.NewFromFloat32(tt.available).Equal(month.Data.Available), "Available for %s is wrong, should be %s but is %s", tt.month, decimal.NewFromFloat32(tt.available), month.Data.Available) + assert.True(t, decimal.NewFromFloat32(tt.balance).Equal(month.Data.Balance), "Balance for %s is wrong, should be %s but is %s", tt.month, decimal.NewFromFloat32(tt.balance), month.Data.Balance) + assert.True(t, decimal.NewFromFloat32(tt.spent).Equal(month.Data.Spent), "Spent for %s is wrong, should be %s but is %s", tt.month, decimal.NewFromFloat32(tt.spent), month.Data.Spent) + assert.True(t, decimal.NewFromFloat32(tt.income).Equal(month.Data.Income), "Income for %s is wrong, should be %s but is %s", tt.month, decimal.NewFromFloat32(tt.income), month.Data.Income) + }) + } +} + +// TestImportYnab4Fails tests failing imports for the YNAB 4 budget import endpoint. +func (suite *TestSuiteStandard) TestImportYnab4Fails() { + tests := []struct { + name string + budgetName string + expectedError string + status int + file string + preTest func() + }{ + {"No budget name", "", "The budgetName parameter must be set", http.StatusBadRequest, "", func() {}}, + {"No file sent", "same", "you must send a file to this endpoint", http.StatusBadRequest, "", func() {}}, + {"Wrong file name", "same", "this endpoint only supports .yfull files", http.StatusBadRequest, "importer/wrong-name.json", func() {}}, + {"Empty file", "same", "not a valid YNAB4 Budget.yfull file: unexpected end of JSON input", http.StatusBadRequest, "importer/EmptyFile.yfull", func() {}}, + {"Duplicate budget name", "Import Test", "This budget name is already in use", http.StatusBadRequest, "", func() { + _ = createTestBudget(suite.T(), v4.BudgetEditable{Name: "Import Test"}) + }}, + {"Database error. This test must be the last one.", "Nope. DB is closed.", "there is a problem with the database connection", http.StatusInternalServerError, "", func() { + suite.CloseDB() + }}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + tt.preTest() + + path := fmt.Sprintf("http://example.com/v4/import/ynab4?budgetName=%s", tt.budgetName) + + var body *bytes.Buffer + var headers map[string]string + var recorder httptest.ResponseRecorder + if tt.file != "" { + body, headers = test.LoadTestFile(t, tt.file) + recorder = test.Request(t, http.MethodPost, path, body, headers) + } else { + recorder = test.Request(t, http.MethodPost, path, "") + } + + test.AssertHTTPStatus(t, &recorder, tt.status) + assert.Contains(t, test.DecodeError(t, recorder.Body.Bytes()), tt.expectedError) + }) + } +} + +// TestImportYnabImportPreviewFails tests failing requests for the YNAB import format preview endpoint. +func (suite *TestSuiteStandard) TestImportYnabImportPreviewFails() { + accountID := createTestAccount(suite.T(), v4.AccountEditable{Name: "TestImportYnabImportPreviewFails"}).Data.ID.String() + + tests := []struct { + name string + accountID string + status int + expectedError string + file string + }{ + {"No account ID", "", http.StatusBadRequest, "the accountId parameter must be set", ""}, + {"Broken ID", "NotAUUID", http.StatusBadRequest, "the specified resource ID is not a valid UUID", "importer/ynab-import/empty.csv"}, + {"No account with ID", "d2525c4f-2f45-49ba-9c5d-75d6b1c26f56", http.StatusNotFound, "there is no Account with this ID", "importer/ynab-import/empty.csv"}, + {"No file sent", accountID, http.StatusBadRequest, "you must send a file to this endpoint", ""}, + {"Wrong file name", accountID, http.StatusBadRequest, "this endpoint only supports .csv files", "importer/ynab-import/wrong-suffix.json"}, + {"Broken upload", accountID, http.StatusBadRequest, "error in line 4 of the CSV: could not parse time: parsing time \"03.23.2020\" as \"01/02/2006\": cannot parse \".23.2020\" as \"/\"", "importer/ynab-import/error-date.csv"}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("http://example.com/v4/import/ynab-import-preview?accountId=%s", tt.accountID) + + var body *bytes.Buffer + var headers map[string]string + var recorder httptest.ResponseRecorder + if tt.file != "" { + body, headers = test.LoadTestFile(t, tt.file) + recorder = test.Request(t, http.MethodPost, path, body, headers) + } else { + recorder = test.Request(t, http.MethodPost, path, "") + } + + test.AssertHTTPStatus(t, &recorder, tt.status) + assert.Contains(t, test.DecodeError(t, recorder.Body.Bytes()), tt.expectedError) + }) + } +} + +func (suite *TestSuiteStandard) TestImportYnabImportPreviewDuplicateDetection() { + // Create test account + account := createTestAccount(suite.T(), v4.AccountEditable{Name: "TestImportYnabImportPreviewDuplicateDetection"}) + + // Get the import hash of the first transaction and create one with the same import hash + preview := suite.parseCSV(suite.T(), account.Data.ID, "comdirect-ynap.csv") + + transaction := createTestTransaction(suite.T(), v4.TransactionEditable{ + SourceAccountID: account.Data.ID, + ImportHash: preview.Data[0].Transaction.ImportHash, + Amount: decimal.NewFromFloat(1.13), + }) + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + SourceAccountID: createTestAccount(suite.T(), v4.AccountEditable{Note: "This account is in a different Budget, but has the same ImportHash", Name: "TestYnabImportPreviewDuplicateDetection Different Budget"}).Data.ID, + ImportHash: preview.Data[0].Transaction.ImportHash, + Amount: decimal.NewFromFloat(42.23), + }) + + preview = suite.parseCSV(suite.T(), account.Data.ID, "comdirect-ynap.csv") + + suite.Assert().Len(preview.Data[0].DuplicateTransactionIDs, 1, "Duplicate transaction IDs field does not have the correct number of IDs") + suite.Assert().Equal(transaction.Data.ID, preview.Data[0].DuplicateTransactionIDs[0], "Duplicate transaction ID is not ID of the transaction that is duplicated") +} + +func (suite *TestSuiteStandard) TestImportYnabImportPreviewAvailableFrom() { + // Create test account + account := createTestAccount(suite.T(), v4.AccountEditable{Name: "TestImportYnabImportPreviewAvailableFrom"}) + preview := suite.parseCSV(suite.T(), account.Data.ID, "available-from-test.csv") + + dates := []types.Month{ + types.NewMonth(2019, 2), + types.NewMonth(2019, 4), + types.NewMonth(2019, 5), + } + + for i, transaction := range preview.Data { + assert.Equal(suite.T(), dates[i], transaction.Transaction.AvailableFrom) + } +} + +func (suite *TestSuiteStandard) TestImportYnabImportPreviewFindAccounts() { + // Create a budget and two existing accounts to use + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + edeka := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: budget.Data.ID, Name: "Edeka", External: true}) + + // Create an account named "Edeka" in another budget to ensure it is not found. If it were found, the tests for the non-archived + // Edeka account being found would fail since we do not use an account if we find more than one with the same name + _ = createTestAccount(suite.T(), v4.AccountEditable{Name: "Edeka"}) + + // Account we import to + internalAccount := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: budget.Data.ID, Name: "Envelope Zero Account"}) + + // Test envelope and test transaction to the Edeka account with an envelope to test the envelope prefill + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: budget.Data.ID}).Data.ID}) + envelopeID := envelope.Data.ID + _ = createTestTransaction(suite.T(), v4.TransactionEditable{SourceAccountID: internalAccount.Data.ID, DestinationAccountID: edeka.Data.ID, EnvelopeID: &envelopeID, Amount: decimal.NewFromFloat(12.00)}) + + tests := []struct { + name string // Name of the test + sourceAccountIDs []uuid.UUID // The IDs of the source accounts + sourceAccountNames []string // The sourceAccountName attribute after the find has been performed + destinationAccountIDs []uuid.UUID // The IDs of the destination accounts + destinationAccountNames []string // The destinationAccountName attribute after the find has been performed + envelopeIDs []*uuid.UUID // expected IDs of envelopes + }{ + { + "No matching (Some Company) & 1 Matching (Edeka) accounts", + []uuid.UUID{internalAccount.Data.ID, internalAccount.Data.ID, uuid.Nil}, + []string{"", "", "Some Company"}, + []uuid.UUID{edeka.Data.ID, uuid.Nil, internalAccount.Data.ID}, + []string{"Edeka", "Deutsche Bahn", ""}, + []*uuid.UUID{&envelopeID, nil, nil}, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + preview := suite.parseCSV(t, internalAccount.Data.ID, "account-find-test.csv") + + for i, transaction := range preview.Data { + // Add 2 since the loop is 0-indexed but the CSV data begins at row 2 (line 1 is the header row) + line := i + 2 + + assert.Equal(t, tt.sourceAccountNames[i], transaction.SourceAccountName, "sourceAccountName does not match in line %d", line) + assert.Equal(t, tt.destinationAccountNames[i], transaction.DestinationAccountName, "destinationAccountName does not match in line %d", line) + + assert.Equal(t, tt.envelopeIDs[i], transaction.Transaction.EnvelopeID, "proposed envelope ID does not match in line %d", line) + + if tt.sourceAccountIDs[i] != uuid.Nil { + assert.Equal(t, tt.sourceAccountIDs[i], transaction.Transaction.SourceAccountID, "sourceAccountID does not match in line %d", line) + } + + if tt.destinationAccountIDs[i] != uuid.Nil { + assert.Equal(t, tt.destinationAccountIDs[i], transaction.Transaction.DestinationAccountID, "destinationAccountID does not match in line %d", line) + } + } + }) + } +} + +func (suite *TestSuiteStandard) TestImportYnabImportPreviewMatch() { + // Create a budget and two existing accounts to use + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + edeka := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: budget.Data.ID, Name: "Edeka", External: true}) + bahn := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: budget.Data.ID, Name: "Deutsche Bahn", External: true}) + + // Account we import to + internalAccount := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: budget.Data.ID, Name: "Envelope Zero Account"}) + + // Test envelope and test transaction to the Edeka account with an envelope to test the envelope prefill + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: budget.Data.ID}).Data.ID}) + envelopeID := envelope.Data.ID + _ = createTestTransaction(suite.T(), v4.TransactionEditable{SourceAccountID: internalAccount.Data.ID, DestinationAccountID: edeka.Data.ID, EnvelopeID: &envelopeID, Amount: decimal.NewFromFloat(12.00)}) + + tests := []struct { + name string // Name of the test + sourceAccountIDs []uuid.UUID // The IDs of the source accounts + destinationAccountIDs []uuid.UUID // The IDs of the destination accounts + envelopeIDs []*uuid.UUID // expected IDs of envelopes + preTest func(*testing.T) [3]uuid.UUID // Function to execute before running tests + }{ + { + "Rule for Edeka", + []uuid.UUID{internalAccount.Data.ID, internalAccount.Data.ID, uuid.Nil}, + []uuid.UUID{edeka.Data.ID, uuid.Nil, internalAccount.Data.ID}, + []*uuid.UUID{&envelopeID, nil, nil}, + func(t *testing.T) [3]uuid.UUID { + edeka := createTestMatchRule(t, v4.MatchRuleEditable{ + Match: "EDEKA*", + AccountID: edeka.Data.ID, + }) + + return [3]uuid.UUID{edeka.Data.ID} + }, + }, + { + "Rule for Edeka and DB", + []uuid.UUID{internalAccount.Data.ID, internalAccount.Data.ID, uuid.Nil}, + []uuid.UUID{edeka.Data.ID, bahn.Data.ID, internalAccount.Data.ID}, + []*uuid.UUID{&envelopeID, nil, nil}, + func(t *testing.T) [3]uuid.UUID { + edeka := createTestMatchRule(t, v4.MatchRuleEditable{ + Match: "EDEKA*", + AccountID: edeka.Data.ID, + }) + + db := createTestMatchRule(t, v4.MatchRuleEditable{ + Match: "DB Vertrieb GmbH", + AccountID: bahn.Data.ID, + }) + + return [3]uuid.UUID{edeka.Data.ID, db.Data.ID} + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + matchRuleIDs := tt.preTest(t) + preview := suite.parseCSV(t, internalAccount.Data.ID, "match-rule-test.csv") + + for i, transaction := range preview.Data { + line := i + 1 + if tt.sourceAccountIDs[i] != uuid.Nil { + assert.Equal(t, tt.sourceAccountIDs[i], transaction.Transaction.SourceAccountID, "sourceAccountID does not match in line %d", line) + } + + if tt.destinationAccountIDs[i] != uuid.Nil { + assert.Equal(t, tt.destinationAccountIDs[i], transaction.Transaction.DestinationAccountID, "destinationAccountID does not match in line %d", line) + } + + if matchRuleIDs[i] != uuid.Nil { + assert.Equal(t, matchRuleIDs[i], *transaction.MatchRuleID, "Expected match rule has match '%s', actual match rule has match '%s'", matchRuleIDs[i], transaction.MatchRuleID) + } + + assert.Equal(t, tt.envelopeIDs[i], transaction.Transaction.EnvelopeID, "proposed envelope ID does not match in line %d", line) + } + + // Delete match rules + for _, id := range matchRuleIDs { + if id != uuid.Nil { + models.DB.Delete(&models.MatchRule{}, id) + } + } + }) + } +} + +// TestImportGet verifies that the links for the //import path are set correctly. +func (suite *TestSuiteStandard) TestImportGet() { + r := test.Request(suite.T(), http.MethodGet, "http://example.com/v4/import", "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + + var links v4.ImportResponse + test.DecodeResponse(suite.T(), &r, &links) + + assert.Equal(suite.T(), v4.ImportResponse{ + Links: v4.ImportLinks{ + Ynab4: "http://example.com/v4/import/ynab4", + YnabImportPreview: "http://example.com/v4/import/ynab-import-preview", + }, + }, links) +} diff --git a/pkg/controllers/v4/import_types.go b/pkg/controllers/v4/import_types.go new file mode 100644 index 00000000..0a33c8e5 --- /dev/null +++ b/pkg/controllers/v4/import_types.go @@ -0,0 +1,32 @@ +package v4 + +import ( + "github.com/envelope-zero/backend/v4/pkg/importer" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/google/uuid" +) + +// newTransactionPreview transforms a TransactionPreview to the API resource +func newTransactionPreview(t importer.TransactionPreview) TransactionPreview { + id := &t.MatchRuleID + if t.MatchRuleID == uuid.Nil { + id = nil + } + + return TransactionPreview{ + Transaction: t.Transaction, + SourceAccountName: t.SourceAccountName, + DestinationAccountName: t.DestinationAccountName, + DuplicateTransactionIDs: t.DuplicateTransactionIDs, + MatchRuleID: id, + } +} + +// TransactionPreview is used to preview transactions that will be imported to allow for editing. +type TransactionPreview struct { + Transaction models.Transaction `json:"transaction"` + SourceAccountName string `json:"sourceAccountName" example:"Employer"` // Name of the source account from the CSV file + DestinationAccountName string `json:"destinationAccountName" example:"Deutsche Bahn"` // Name of the destination account from the CSV file + DuplicateTransactionIDs []uuid.UUID `json:"duplicateTransactionIds"` // IDs of transactions that this transaction duplicates + MatchRuleID *uuid.UUID `json:"matchRuleId" example:"042d101d-f1de-4403-9295-59dc0ea58677"` // ID of the match rule that was applied to this transaction preview +} diff --git a/pkg/controllers/v4/match_rule.go b/pkg/controllers/v4/match_rule.go new file mode 100644 index 00000000..9d900df3 --- /dev/null +++ b/pkg/controllers/v4/match_rule.go @@ -0,0 +1,355 @@ +package v4 + +import ( + "fmt" + "net/http" + + "golang.org/x/exp/slices" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" +) + +// RegisterMatchRuleRoutes registers the routes for matchRules with +// the RouterGroup that is passed. +func RegisterMatchRuleRoutes(r *gin.RouterGroup) { + // Root group + { + r.OPTIONS("", OptionsMatchRuleList) + r.GET("", GetMatchRules) + r.POST("", CreateMatchRules) + } + + // MatchRule with ID + { + r.OPTIONS("/:id", OptionsMatchRuleDetail) + r.GET("/:id", GetMatchRule) + r.PATCH("/:id", UpdateMatchRule) + r.DELETE("/:id", DeleteMatchRule) + } +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags MatchRules +// @Success 204 +// @Router /v4/match-rules [options] +func OptionsMatchRuleList(c *gin.Context) { + httputil.OptionsGetPost(c) +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags MatchRules +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/match-rules/{id} [options] +func OptionsMatchRuleDetail(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{Error: err.Error()}) + } + + _, err = getModelByID[models.MatchRule](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{Error: err.Error()}) + return + } + + httputil.OptionsGetPatchDelete(c) +} + +// @Summary Create matchRules +// @Description Creates matchRules from the list of submitted matchRule data. The response code is the highest response code number that a single matchRule creation would have caused. If it is not equal to 201, at least one matchRule has an error. +// @Tags MatchRules +// @Produce json +// @Success 201 {object} MatchRuleCreateResponse +// @Failure 400 {object} MatchRuleCreateResponse +// @Failure 404 {object} MatchRuleCreateResponse +// @Failure 500 {object} MatchRuleCreateResponse +// @Param matchRules body []MatchRuleEditable true "MatchRules" +// @Router /v4/match-rules [post] +func CreateMatchRules(c *gin.Context) { + var matchRules []MatchRuleEditable + + err := httputil.BindData(c, &matchRules) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleCreateResponse{ + Error: &e, + }) + return + } + + // The final http status. Will be modified when errors occur + status := http.StatusCreated + r := MatchRuleCreateResponse{} + + for _, editable := range matchRules { + model, err := createMatchRule(c, editable.model()) + + // Append the error or the successfully created transaction to the response list + if !err.Nil() { + status = r.appendError(err, status) + continue + } + + data := newMatchRule(c, model) + r.Data = append(r.Data, MatchRuleResponse{Data: &data}) + } + + c.JSON(status, r) +} + +// @Summary Get matchRules +// @Description Returns a list of matchRules +// @Tags MatchRules +// @Produce json +// @Success 200 {object} MatchRuleListResponse +// @Failure 400 {object} MatchRuleListResponse +// @Failure 500 {object} MatchRuleListResponse +// @Param priority query uint false "Filter by priority" +// @Param match query string false "Filter by match" +// @Param account query string false "Filter by account ID" +// @Param offset query uint false "The offset of the first Match Rule returned. Defaults to 0." +// @Param limit query int false "Maximum number of Match Rules to return. Defaults to 50.". +// @Router /v4/match-rules [get] +func GetMatchRules(c *gin.Context) { + var filter MatchRuleQueryFilter + if err := c.Bind(&filter); err != nil { + s := httperrors.ErrInvalidQueryString.Error() + c.JSON(http.StatusBadRequest, MatchRuleListResponse{ + Error: &s, + }) + return + } + + // Get the parameters set in the query string + queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) + + // Convert the QueryFilter to a Create struct + model, err := filter.model() + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleListResponse{Error: &e}) + return + } + + q := models.DB. + Order("priority ASC, match ASC"). + Where(&model, queryFields...) + + // Filter for match containing the query string or explicitly empty one + if filter.Match != "" { + q = q.Where("match LIKE ?", fmt.Sprintf("%%%s%%", filter.Match)) + } else if slices.Contains(setFields, "Match") { + q = q.Where("match = ''") + } + + // Set the offset. Does not need checking since the default is 0 + q = q.Offset(int(filter.Offset)) + + // Default to 50 Match Rules and set the limit + limit := 50 + if slices.Contains(setFields, "Limit") { + limit = filter.Limit + } + q = q.Limit(limit) + + // Execute the query + var matchRules []models.MatchRule + err = query(c, q.Find(&matchRules)) + + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleListResponse{Error: &e}) + return + } + + var count int64 + err = query(c, q.Limit(-1).Offset(-1).Count(&count)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleListResponse{ + Error: &e, + }) + return + } + + data := make([]MatchRule, 0) + for _, matchRule := range matchRules { + data = append(data, newMatchRule(c, matchRule)) + } + + c.JSON(http.StatusOK, MatchRuleListResponse{ + Data: data, + Pagination: &Pagination{ + Count: len(data), + Total: count, + Offset: filter.Offset, + Limit: limit, + }, + }) +} + +// @Summary Get matchRule +// @Description Returns a specific matchRule +// @Tags MatchRules +// @Produce json +// @Success 200 {object} MatchRuleResponse +// @Failure 400 {object} MatchRuleResponse +// @Failure 404 {object} MatchRuleResponse +// @Failure 500 {object} MatchRuleResponse +// @Param id path string true "ID formatted as string" +// @Router /v4/match-rules/{id} [get] +func GetMatchRule(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleResponse{ + Error: &e, + }) + return + } + + model, err := getModelByID[models.MatchRule](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, MatchRuleResponse{Error: &s}) + return + } + data := newMatchRule(c, model) + + c.JSON(http.StatusOK, MatchRuleResponse{ + Data: &data, + }) +} + +// @Summary Update matchRule +// @Description Update a matchRule. Only values to be updated need to be specified. +// @Tags MatchRules +// @Accept json +// @Produce json +// @Success 200 {object} MatchRuleResponse +// @Failure 400 {object} MatchRuleResponse +// @Failure 404 {object} MatchRuleResponse +// @Failure 500 {object} MatchRuleResponse +// @Param id path string true "ID formatted as string" +// @Param matchRule body MatchRuleEditable true "MatchRule" +// @Router /v4/match-rules/{id} [patch] +func UpdateMatchRule(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleResponse{ + Error: &e, + }) + return + } + + matchRule, err := getModelByID[models.MatchRule](c, id) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleResponse{ + Error: &e, + }) + return + } + + updateFields, err := httputil.GetBodyFields(c, MatchRuleEditable{}) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleResponse{ + Error: &e, + }) + return + } + + var data MatchRuleEditable + err = httputil.BindData(c, &data) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleResponse{ + Error: &e, + }) + return + } + + // Check that the referenced account exists + if slices.Contains(updateFields, "AccountID") { + _, err = getModelByID[models.Account](c, data.AccountID) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleResponse{ + Error: &e, + }) + return + } + } + + err = query(c, models.DB.Model(&matchRule).Select("", updateFields...).Updates(data.model())) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, MatchRuleResponse{ + Error: &e, + }) + return + } + + apiResource := newMatchRule(c, matchRule) + c.JSON(http.StatusOK, MatchRuleResponse{ + Data: &apiResource, + }) +} + +// @Summary Delete matchRule +// @Description Deletes an matchRule +// @Tags MatchRules +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/match-rules/{id} [delete] +func DeleteMatchRule(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{Error: err.Error()}) + return + } + matchRule, err := getModelByID[models.MatchRule](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{Error: err.Error()}) + return + } + + err = query(c, models.DB.Delete(&matchRule)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{Error: err.Error()}) + return + } + + c.JSON(http.StatusNoContent, gin.H{}) +} + +// createMatchRule creates a single matchRule after verifying it is a valid matchRule. +func createMatchRule(c *gin.Context, matchRule models.MatchRule) (models.MatchRule, httperrors.Error) { + // Check that the referenced account exists + _, err := getModelByID[models.Account](c, matchRule.AccountID) + if !err.Nil() { + return matchRule, err + } + + // Create the resource + dbErr := models.DB.Create(&matchRule).Error + if dbErr != nil { + return models.MatchRule{}, httperrors.GenericDBError[models.MatchRule](matchRule, c, dbErr) + } + + return matchRule, httperrors.Error{} +} diff --git a/pkg/controllers/v4/match_rule_test.go b/pkg/controllers/v4/match_rule_test.go new file mode 100644 index 00000000..17f7dcaf --- /dev/null +++ b/pkg/controllers/v4/match_rule_test.go @@ -0,0 +1,590 @@ +package v4_test + +import ( + "fmt" + "net/http" + "testing" + + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/envelope-zero/backend/v4/test" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func createTestMatchRule(t *testing.T, matchRule v4.MatchRuleEditable, expectedStatus ...int) v4.MatchRuleResponse { + // Default to 201 Creted as expected status + if len(expectedStatus) == 0 { + expectedStatus = append(expectedStatus, http.StatusCreated) + } + + rules := []v4.MatchRuleEditable{matchRule} + + r := test.Request(t, http.MethodPost, "http://example.com/v4/match-rules", rules) + test.AssertHTTPStatus(t, &r, expectedStatus...) + + var res v4.MatchRuleCreateResponse + test.DecodeResponse(t, &r, &res) + + return res.Data[0] +} + +func (suite *TestSuiteStandard) TestMatchRuleCreate() { + a := createTestAccount(suite.T(), v4.AccountEditable{Name: "TestMatchRuleCreate"}) + + tests := []struct { + name string + create []models.MatchRule + expectedErrors []string + expectedStatus int + }{ + { + "All successful", + []models.MatchRule{ + { + Priority: 10, + Match: "Some Match*", + AccountID: a.Data.ID, + }, + { + Priority: 10, + Match: "Bank*", + AccountID: a.Data.ID, + }, + }, + []string{ + "", + "", + }, + http.StatusCreated, + }, + { + "Second fails", + []models.MatchRule{ + { + Priority: 10, + Match: "Bank*", + AccountID: a.Data.ID, + }, + { + Priority: 10, + Match: "Bank*", + AccountID: uuid.New(), + }, + }, + []string{ + "", + "there is no Account with this ID", + }, + http.StatusNotFound, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPost, "http://example.com/v4/match-rules", tt.create) + test.AssertHTTPStatus(t, &r, tt.expectedStatus) + + var tr v4.MatchRuleCreateResponse + test.DecodeResponse(t, &r, &tr) + + for i, r := range tr.Data { + if tt.expectedErrors[i] != "" { + assert.Equal(t, tt.expectedErrors[i], *r.Error) + } else { + assert.Equal(t, fmt.Sprintf("http://example.com/v4/match-rules/%s", r.Data.ID), r.Data.Links.Self) + } + } + }) + } +} + +// TestMatchRulesOptions verifies that the HTTP OPTIONS response for //match-rules/{id} is correct. +func (suite *TestSuiteStandard) TestMatchRulesOptions() { + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + id string // String to use as ID. Ignored when pathFunc is non-nil + pathFunc func(t *testing.T) string // Function returning the path + }{ + { + "Does not exist", + http.StatusNotFound, + uuid.New().String(), + nil, + }, + { + "Invalid UUID", + http.StatusBadRequest, + "NotParseableAsUUID", + nil, + }, + { + "Success", + http.StatusNoContent, + "", + func(t *testing.T) string { + return createTestMatchRule(t, v4.MatchRuleEditable{ + AccountID: createTestAccount(t, v4.AccountEditable{}).Data.ID, + Match: "TestMatch*", + }).Data.Links.Self + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var p string + if tt.pathFunc != nil { + p = tt.pathFunc(t) + } else { + p = fmt.Sprintf("%s/%s", "http://example.com/v4/match-rules", tt.id) + } + + r := test.Request(t, http.MethodOptions, p, "") + test.AssertHTTPStatus(t, &r, tt.status) + + if tt.status == http.StatusNoContent { + assert.Equal(t, "OPTIONS, GET, PATCH, DELETE", r.Header().Get("allow")) + } + }) + } +} + +// TestMatchRulesDatabaseError verifies that the endpoints return the appropriate +// error when the database is disconncted. +func (suite *TestSuiteStandard) TestMatchRulesDatabaseError() { + tests := []struct { + name string // Name of the test + path string // Path to send request to + method string // HTTP method to use + body string // The request body + }{ + {"GET Collection", "", http.MethodGet, ""}, + // Skipping POST Collection here since we need to check the indivdual Match Rules for that one + {"OPTIONS Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodOptions, ""}, + {"GET Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodGet, ""}, + {"PATCH Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodPatch, ""}, + {"DELETE Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodDelete, ""}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + suite.CloseDB() + + recorder := test.Request(t, tt.method, fmt.Sprintf("http://example.com/v4/match-rules%s", tt.path), tt.body) + test.AssertHTTPStatus(t, &recorder, http.StatusInternalServerError) + assert.Equal(t, httperrors.ErrDatabaseClosed.Error(), test.DecodeError(t, recorder.Body.Bytes())) + }) + } +} + +// TestMatchRulesGetFilter verifies that filtering Match Rules works as expected. +func (suite *TestSuiteStandard) TestMatchRulesGetFilter() { + b := createTestBudget(suite.T(), v4.BudgetEditable{}) + + a1 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestMatchRulesGetFilter 1"}) + a2 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestMatchRulesGetFilter 2"}) + + _ = createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 1, + Match: "Testing A Match*", + AccountID: a1.Data.ID, + }) + + _ = createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 2, + Match: "*Match the Second Account", + AccountID: a2.Data.ID, + }) + + _ = createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 1, + Match: "Exact match", + AccountID: a2.Data.ID, + }) + + tests := []struct { + name string + query string + len int + }{ + {"Limit over count", "limit=5", 3}, + {"Limit under count", "limit=2", 2}, + {"Offset", "offset=2", 1}, + {"Account ID", fmt.Sprintf("account=%s", a2.Data.ID), 2}, + {"Priority", "priority=1", 2}, + {"Non-existent account", fmt.Sprintf("account=%s", uuid.New().String()), 0}, + {"Match with content", "match=match", 3}, + {"Empty match", "match=", 0}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var re v4.MatchRuleListResponse + r := test.Request(t, http.MethodGet, fmt.Sprintf("/v4/match-rules?%s", tt.query), "") + test.AssertHTTPStatus(t, &r, http.StatusOK) + test.DecodeResponse(t, &r, &re) + + assert.Equal(t, tt.len, len(re.Data), "Request ID: %s", r.Result().Header.Get("x-request-id")) + }) + } +} + +// TestMatchRulesGetFilterErrors verifies that filtering Match Rules returns errors as expected. +func (suite *TestSuiteStandard) TestMatchRulesGetFilterErrors() { + b := createTestBudget(suite.T(), v4.BudgetEditable{}) + + a1 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestMatchRulesGetFilter 1"}) + a2 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestMatchRulesGetFilter 2"}) + + _ = createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 1, + Match: "Testing A Match*", + AccountID: a1.Data.ID, + }) + + _ = createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 2, + Match: "*Match the Second Account", + AccountID: a2.Data.ID, + }) + + _ = createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 1, + Match: "Exact match", + AccountID: a2.Data.ID, + }) + + tests := []struct { + name string + query string + status int + }{ + {"Invalid UUID", "account=MorreWroteThis", http.StatusBadRequest}, + {"Invalid query string", "&test&% 20hello", http.StatusBadRequest}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var re v4.MatchRuleListResponse + r := test.Request(t, http.MethodGet, fmt.Sprintf("/v4/match-rules?%s", tt.query), "") + test.AssertHTTPStatus(t, &r, tt.status) + test.DecodeResponse(t, &r, &re) + }) + } +} + +// TestMatchRulesCreateInvalidBody verifies that creation of Match Rules +// with an unparseable request body returns a HTTP Bad Request. +func (suite *TestSuiteStandard) TestMatchRulesCreateInvalidBody() { + r := test.Request(suite.T(), http.MethodPost, "http://example.com/v4/match-rules", `{ Invalid request": Body }`) + test.AssertHTTPStatus(suite.T(), &r, http.StatusBadRequest) + + var tr v4.MatchRuleCreateResponse + test.DecodeResponse(suite.T(), &r, &tr) + + assert.Equal(suite.T(), httperrors.ErrInvalidBody.Error(), *tr.Error) + assert.Nil(suite.T(), tr.Data) +} + +// TestMatchRulesCreate verifies that transaction creation works. +func (suite *TestSuiteStandard) TestMatchRulesCreate() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + internalAccount := createTestAccount(suite.T(), v4.AccountEditable{External: false, BudgetID: budget.Data.ID, Name: "TestMatchRulesCreate Internal"}) + + tests := []struct { + name string + matchRules []models.MatchRule + expectedStatus int + expectedError *error // Error expected in the response + expectedErrors []string // Errors expected for the individual transactions + }{ + { + "One success, one fail", + []models.MatchRule{ + { + AccountID: internalAccount.Data.ID, + }, + { + AccountID: uuid.New(), + }, + }, + http.StatusNotFound, + nil, + []string{ + "", + "there is no Account with this ID", + }, + }, + { + "Two success", + []models.MatchRule{ + { + AccountID: internalAccount.Data.ID, + Match: "* glob glob glob *", + }, + { + AccountID: internalAccount.Data.ID, + Match: "Test Match 2", + }, + }, + http.StatusCreated, + nil, + []string{ + "", + "", + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPost, "http://example.com/v4/match-rules", tt.matchRules) + test.AssertHTTPStatus(t, &r, tt.expectedStatus) + + var tr v4.MatchRuleCreateResponse + test.DecodeResponse(t, &r, &tr) + + for i, mr := range tr.Data { + if tt.expectedErrors[i] == "" { + assert.Equal(t, fmt.Sprintf("http://example.com/v4/match-rules/%s", mr.Data.ID), mr.Data.Links.Self) + } else { + // This needs to be in the else to prevent nil pointer errors since we're dereferencing pointers + assert.Equal(t, tt.expectedErrors[i], *mr.Error) + } + } + }) + } +} + +// TestMatchRulesGetSingle verifies that a Match Rule can be read from the API via its link +// and that the link is for API v4. +func (suite *TestSuiteStandard) TestMatchRulesGetSingle() { + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + id string // String to use as ID. Ignored when pathFunc is non-nil + pathFunc func(t *testing.T) string // Function returning the path + }{ + { + "Standard transaction", + http.StatusOK, + "", + func(t *testing.T) string { + return createTestMatchRule(t, v4.MatchRuleEditable{AccountID: createTestAccount(t, v4.AccountEditable{}).Data.ID}).Data.Links.Self + }, + }, + { + "Invalid UUID", + http.StatusBadRequest, + "NotParseableAsUUID", + nil, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var p string + if tt.pathFunc != nil { + p = tt.pathFunc(t) + } else { + p = fmt.Sprintf("%s/%s", "http://example.com/v4/match-rules", tt.id) + } + + r := test.Request(suite.T(), http.MethodGet, p, "") + test.AssertHTTPStatus(suite.T(), &r, tt.status) + }) + } +} + +// TestMatchRulesUpdateFail verifies that transaction updates fail where they should. +func (suite *TestSuiteStandard) TestMatchRulesUpdateFail() { + m := createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + AccountID: createTestAccount(suite.T(), v4.AccountEditable{}).Data.ID, + Match: "Some match*", + }) + + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + body any // Body to send to the PATCH endpoint + path string // Path to send the PATCH request to + }{ + { + "Invalid body", + http.StatusBadRequest, + `{ "priority": 2" }`, + m.Data.Links.Self, + }, + { + "Invalid type", + http.StatusBadRequest, + map[string]any{ + "match": false, + }, + m.Data.Links.Self, + }, + { + "Non-existing account", + http.StatusNotFound, + `{ "accountId": "e6fa8eb5-5f2c-4292-8ef9-02f0c2af1ce4" }`, + m.Data.Links.Self, + }, + { + "Invalid path", + http.StatusBadRequest, + "", + "http://example.com/v4/match-rules/NotAUUID", + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPatch, tt.path, tt.body) + test.AssertHTTPStatus(t, &r, tt.status) + }) + } +} + +// TestMatchRulesUpdate verifies that transaction updates are successful. +func (suite *TestSuiteStandard) TestMatchRulesUpdate() { + m := createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + AccountID: createTestAccount(suite.T(), v4.AccountEditable{}).Data.ID, + Match: "Some match*", + }) + + newAccount := createTestAccount(suite.T(), v4.AccountEditable{}) + + tests := []struct { + name string // Name for the test + body any // Body to send to the PATCH endpoint + }{ + { + "Change match", + map[string]string{ + "match": "Some match more exactly*", + }, + }, + { + "Change priority and match", + map[string]any{ + "priority": 1487, + "match": "return 4;", + }, + }, + { + "Change account", + map[string]any{ + "accountId": newAccount.Data.ID, + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPatch, m.Data.Links.Self, tt.body) + test.AssertHTTPStatus(t, &r, http.StatusOK) + }) + } +} + +// TestMatchRulesDelete verifies the correct success and error responses +// for DELETE requests. +func (suite *TestSuiteStandard) TestMatchRulesDelete() { + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + id string // String to use as ID. + }{ + { + "Standard deletion", + http.StatusNoContent, + createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + AccountID: createTestAccount(suite.T(), v4.AccountEditable{}).Data.ID, + Match: "Some match*", + }).Data.ID.String(), + }, + { + "Does not exist", + http.StatusNotFound, + "4bcb6d09-ced1-41e8-a3fe-bf4f16c5e501", + }, + { + "Null UUID", + http.StatusBadRequest, + "00000000-0000-0000-0000-000000000000", + }, + { + "Invalid UUID", + http.StatusBadRequest, + "NotAUUID", + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + p := fmt.Sprintf("%s/%s", "http://example.com/v4/match-rules", tt.id) + + r := test.Request(t, http.MethodDelete, p, "") + test.AssertHTTPStatus(t, &r, tt.status) + }) + } +} + +// TestMatchRulesGetSorted verifies that Match Rules are sorted as expected. +func (suite *TestSuiteStandard) TestMatchRulesGetSorted() { + b := createTestBudget(suite.T(), v4.BudgetEditable{}) + a := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestMatchRulesGetFilter 1"}) + + m1 := createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 1, + Match: "Testing A Match*", + AccountID: a.Data.ID, + }) + + m2 := createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 2, + Match: "*Match the Second Account", + AccountID: a.Data.ID, + }) + + m3 := createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 1, + Match: "Exact match", + AccountID: a.Data.ID, + }) + + m4 := createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 3, + Match: "Coffee Shop*", + AccountID: a.Data.ID, + }) + + m5 := createTestMatchRule(suite.T(), v4.MatchRuleEditable{ + Priority: 3, + Match: "Coffee Shop", + AccountID: a.Data.ID, + }) + + var re v4.MatchRuleListResponse + r := test.Request(suite.T(), http.MethodGet, "/v4/match-rules", "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + test.DecodeResponse(suite.T(), &r, &re) + + // Lowest priority, alphabetically first + assert.Equal(suite.T(), *m3.Data, re.Data[0]) + + // Lowest priority, alphabetically second + assert.Equal(suite.T(), *m1.Data, re.Data[1]) + + // Higher priority + assert.Equal(suite.T(), *m2.Data, re.Data[2]) + + // Highest priority, alphabetically first + assert.Equal(suite.T(), *m5.Data, re.Data[3]) + + // Highest priority, alphabetically second + assert.Equal(suite.T(), *m4.Data, re.Data[4]) +} diff --git a/pkg/controllers/v4/match_rule_types.go b/pkg/controllers/v4/match_rule_types.go new file mode 100644 index 00000000..aafa0882 --- /dev/null +++ b/pkg/controllers/v4/match_rule_types.go @@ -0,0 +1,102 @@ +package v4 + +import ( + "fmt" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type MatchRuleEditable struct { + AccountID uuid.UUID `json:"accountId" example:"f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5"` // The account to map matching transactions to + Priority uint `json:"priority" example:"3"` // The priority of the match rule + Match string `json:"match" example:"Bank*"` // The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive. +} + +func (editable MatchRuleEditable) model() models.MatchRule { + return models.MatchRule{ + AccountID: editable.AccountID, + Priority: editable.Priority, + Match: editable.Match, + } +} + +type MatchRuleListResponse struct { + Data []MatchRule `json:"data"` // List of Match Rules + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Pagination *Pagination `json:"pagination"` // Pagination information +} + +type MatchRuleCreateResponse struct { + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Data []MatchRuleResponse `json:"data"` // List of created Match Rules +} + +func (m *MatchRuleCreateResponse) appendError(err httperrors.Error, status int) int { + s := err.Error() + m.Data = append(m.Data, MatchRuleResponse{Error: &s}) + + // The final status code is the highest HTTP status code number + if err.Status > status { + status = err.Status + } + + return status +} + +type MatchRuleResponse struct { + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred for this Match Rule + Data *MatchRule `json:"data"` // The Match Rule data, if creation was successful +} + +type MatchRuleLinks struct { + Self string `json:"self" example:"https://example.com/api/v4/match-rules/95685c82-53c6-455d-b235-f49960b73b21"` // The match rule itself +} + +// MatchRule is the API representation of a Match Rule. +type MatchRule struct { + models.DefaultModel + MatchRuleEditable + Links MatchRuleLinks `json:"links"` +} + +func newMatchRule(c *gin.Context, model models.MatchRule) MatchRule { + url := c.GetString(string(models.DBContextURL)) + + return MatchRule{ + DefaultModel: model.DefaultModel, + MatchRuleEditable: MatchRuleEditable{ + AccountID: model.AccountID, + Priority: model.Priority, + Match: model.Match, + }, + Links: MatchRuleLinks{ + Self: fmt.Sprintf("%s/v4/match-rules/%s", url, model.ID), + }, + } +} + +// MatchRuleQueryFilter contains the fields that Match Rules can be filtered with. +type MatchRuleQueryFilter struct { + Priority uint `form:"priority"` // By priority + Match string `form:"match" filterField:"false"` // By match + AccountID string `form:"account"` // By ID of the Account they map to + Offset uint `form:"offset" filterField:"false"` // The offset of the first Match Rule returned. Defaults to 0. + Limit int `form:"limit" filterField:"false"` // Maximum number of Match Rules to return. Defaults to 50. +} + +// Parse returns a models.MatchRuleCreate struct that represents the MatchRuleQueryFilter. +func (f MatchRuleQueryFilter) model() (models.MatchRule, httperrors.Error) { + envelopeID, err := httputil.UUIDFromString(f.AccountID) + if !err.Nil() { + return models.MatchRule{}, err + } + + return models.MatchRule{ + Priority: f.Priority, + AccountID: envelopeID, + }, httperrors.Error{} +} diff --git a/pkg/controllers/v4/month.go b/pkg/controllers/v4/month.go new file mode 100644 index 00000000..cf53efb2 --- /dev/null +++ b/pkg/controllers/v4/month.go @@ -0,0 +1,439 @@ +package v4 + +import ( + "fmt" + "net/http" + + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +type MonthResponse struct { + Data *Month `json:"data"` // Data for the month + Error *string `json:"error"` // The error, if any occurred +} + +type Month struct { + ID uuid.UUID `json:"id" example:"1e777d24-3f5b-4c43-8000-04f65f895578"` // The ID of the Budget + Name string `json:"name" example:"Zero budget"` // The name of the Budget + Month types.Month `json:"month" example:"2006-05-01T00:00:00.000000Z"` // The month + Income decimal.Decimal `json:"income" example:"2317.34"` // The total income for the month (sum of all incoming transactions without an Envelope) + Available decimal.Decimal `json:"available" example:"217.34"` // The amount available to budget + Balance decimal.Decimal `json:"balance" example:"5231.37"` // The sum of all envelope balances + Spent decimal.Decimal `json:"spent" example:"133.70"` // The amount of money spent in this month + Allocation decimal.Decimal `json:"allocation" example:"1200.50"` // The sum of all allocations for this month + Categories []CategoryEnvelopes `json:"categories"` // A list of envelope month calculations grouped by category +} + +type CategoryEnvelopes struct { + Category + Envelopes []EnvelopeMonth `json:"envelopes"` // Slice of all envelopes + Balance decimal.Decimal `json:"balance" example:"-10.13"` // Sum of the balances of the envelopes + Allocation decimal.Decimal `json:"allocation" example:"90"` // Sum of allocations for the envelopes + Spent decimal.Decimal `json:"spent" example:"100.13"` // Sum spent for all envelopes +} + +// EnvelopeMonth contains data about an Envelope for a specific month. +type EnvelopeMonth struct { + Envelope + Spent decimal.Decimal `json:"spent" example:"73.12"` // The amount spent over the whole month + Balance decimal.Decimal `json:"balance" example:"12.32"` // The balance at the end of the monht + Allocation decimal.Decimal `json:"allocation" example:"85.44"` // The amount of money allocated +} + +// RegisterMonthRoutes registers the routes for months with +// the RouterGroup that is passed. +func RegisterMonthRoutes(r *gin.RouterGroup) { + { + r.OPTIONS("", OptionsMonth) + r.GET("", GetMonth) + r.POST("", SetAllocations) + r.DELETE("", DeleteAllocations) + } +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs. +// @Tags Months +// @Success 204 +// @Router /v4/months [options] +func OptionsMonth(c *gin.Context) { + httputil.OptionsGetPostDelete(c) +} + +// @Summary Get data about a month +// @Description Returns data about a specific month. +// @Tags Months +// @Produce json +// @Success 200 {object} MonthResponse +// @Failure 400 {object} MonthResponse +// @Failure 404 {object} MonthResponse +// @Failure 500 {object} MonthResponse +// @Param budget query string true "ID formatted as string" +// @Param month query string true "The month in YYYY-MM format" +// @Router /v4/months [get] +func GetMonth(c *gin.Context) { + qMonth, b, e := parseMonthQuery(c) + if !e.Nil() { + s := e.Error() + c.JSON(e.Status, MonthResponse{ + Error: &s, + }) + return + } + + month := qMonth + + result := Month{ + ID: b.ID, + Name: b.Name, + Month: month, + } + + // Add allocated sum to response + allocated, err := b.Allocated(models.DB, result.Month) + if err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, MonthResponse{ + Error: &s, + }) + return + } + result.Allocation = allocated + + // Add income to response + income, err := b.Income(models.DB, result.Month) + if err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, MonthResponse{ + Error: &s, + }) + return + } + result.Income = income + + // Get all categories for the budget + var categories []models.Category + err = models.DB. + Where(&models.Category{BudgetID: b.ID}). + Order("name ASC"). + Find(&categories). + Error + + if err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, MonthResponse{ + Error: &s, + }) + return + } + + result.Categories = make([]CategoryEnvelopes, 0) + result.Balance = decimal.Zero + + // Get envelopes for all categories + for _, category := range categories { + var categoryEnvelopes CategoryEnvelopes + + // Set the basic category values + categoryResource, e := newCategory(c, models.DB, category) + if !e.Nil() { + s := e.Error() + c.JSON(e.Status, MonthResponse{ + Error: &s, + }) + return + } + + categoryEnvelopes.Category = categoryResource + categoryEnvelopes.Envelopes = make([]EnvelopeMonth, 0) + + var envelopes []models.Envelope + + err = models.DB. + Where(&models.Envelope{ + CategoryID: category.ID, + }). + Order("name asc"). + Find(&envelopes). + Error + + if err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, MonthResponse{ + Error: &s, + }) + return + } + + for _, envelope := range envelopes { + envelopeMonth, err := envelopeMonth(c, models.DB, envelope, result.Month) + if err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, MonthResponse{ + Error: &s, + }) + return + } + + // Update the month's summarized data + result.Balance = result.Balance.Add(envelopeMonth.Balance) + result.Spent = result.Spent.Add(envelopeMonth.Spent) + + // Update the category's summarized data + categoryEnvelopes.Balance = categoryEnvelopes.Balance.Add(envelopeMonth.Balance) + categoryEnvelopes.Spent = categoryEnvelopes.Spent.Add(envelopeMonth.Spent) + categoryEnvelopes.Allocation = categoryEnvelopes.Allocation.Add(envelopeMonth.Allocation) + categoryEnvelopes.Envelopes = append(categoryEnvelopes.Envelopes, envelopeMonth) + } + + result.Categories = append(result.Categories, categoryEnvelopes) + } + + // Available amount is the sum of balances of all on-budget accounts, then subtract the sum of all envelope balances + result.Available = result.Balance.Neg() + + // Get all on budget accounts for the budget + var accounts []models.Account + err = models.DB.Where(&models.Account{BudgetID: b.ID, OnBudget: true}).Find(&accounts).Error + if err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, MonthResponse{ + Error: &s, + }) + return + } + + // Add all on-balance accounts to the available sum + for _, a := range accounts { + _, available, err := a.GetBalanceMonth(models.DB, month) + if err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, MonthResponse{ + Error: &s, + }) + return + } + result.Available = result.Available.Add(available) + } + + c.JSON(http.StatusOK, MonthResponse{Data: &result}) +} + +// @Summary Delete allocations for a month +// @Description Deletes all allocation for the specified month +// @Tags Months +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param budget query string true "ID formatted as string" +// @Param month query string true "The month in YYYY-MM format" +// @Router /v4/months [delete] +func DeleteAllocations(c *gin.Context) { + month, budget, err := parseMonthQuery(c) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + var monthConfigs []models.MonthConfig + + err = query(c, models.DB. + Joins("JOIN envelopes ON envelopes.id = month_configs.envelope_id"). + Joins("JOIN categories ON categories.id = envelopes.category_id"). + Joins("JOIN budgets on budgets.id = categories.budget_id"). + Where(models.MonthConfig{Month: month}). + Where("budgets.id = ?", budget.ID). + Where("month_configs.allocation > 0"). + Find(&monthConfigs)) + + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + for _, monthConfig := range monthConfigs { + monthConfig.Allocation = decimal.Zero + err = query(c, models.DB.Updates(&monthConfig)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + } + + c.JSON(http.StatusNoContent, gin.H{}) +} + +// @Summary Set allocations for a month +// @Description Sets allocations for a month for all envelopes that do not have an allocation yet +// @Tags Months +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param budget query string true "ID formatted as string" +// @Param month query string true "The month in YYYY-MM format" +// @Param mode body BudgetAllocationMode true "Budget" +// @Router /v4/months [post] +func SetAllocations(c *gin.Context) { + month, _, err := parseMonthQuery(c) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + // Get the mode to set new allocations in + var data BudgetAllocationMode + err = httputil.BindData(c, &data) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + if data.Mode != AllocateLastMonthBudget && data.Mode != AllocateLastMonthSpend { + httperrors.New(c, http.StatusBadRequest) + c.JSON(http.StatusBadRequest, httperrors.HTTPError{ + Error: fmt.Sprintf("The mode must be %s or %s", AllocateLastMonthBudget, AllocateLastMonthSpend), + }) + return + } + + pastMonth := month.AddDate(0, -1) + queryCurrentMonth := models.DB.Select("*").Table("month_configs").Where("month_configs.envelope_id = envelopes.id AND month_configs.month = ? AND month_configs.allocation != 0", month) + + // Get all envelopes that do not have an allocation for the target month + // but for the month before + var envelopesAmount []struct { + EnvelopeID uuid.UUID `gorm:"column:id"` + Amount decimal.Decimal `gorm:"column:allocation"` + } + + // Get all envelope IDs and allocation amounts where there is no allocation + // for the request month, but one for the last month + err = query(c, models.DB. + Joins("JOIN month_configs ON month_configs.envelope_id = envelopes.id AND envelopes.archived IS FALSE AND month_configs.month = ? AND NOT EXISTS(?)", pastMonth, queryCurrentMonth). + Select("envelopes.id, month_configs.allocation"). + Table("envelopes"). + Find(&envelopesAmount)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + // Create all new allocations + for _, allocation := range envelopesAmount { + // If the mode is the spend of last month, calculate and set it + amount := allocation.Amount + if data.Mode == AllocateLastMonthSpend { + amount = models.Envelope{DefaultModel: models.DefaultModel{ID: allocation.EnvelopeID}}.Spent(models.DB, pastMonth).Neg() + } + + // Find and update the correct MonthConfig. + // If it does not exist, create it + err = query(c, models.DB.Where(models.MonthConfig{ + Month: month, + EnvelopeID: allocation.EnvelopeID, + }).Assign(models.MonthConfig{ + Allocation: amount, + }).FirstOrCreate(&models.MonthConfig{})) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + } + + c.JSON(http.StatusNoContent, gin.H{}) +} + +// envelopeMonth calculates the month specific values for an envelope and returns an EnvelopeMonth with them +func envelopeMonth(c *gin.Context, db *gorm.DB, e models.Envelope, month types.Month) (EnvelopeMonth, error) { + spent := e.Spent(db, month) + + envelopeMonth := EnvelopeMonth{ + Envelope: newEnvelope(c, e), + Spent: spent, + Balance: decimal.NewFromFloat(0), + Allocation: decimal.NewFromFloat(0), + } + + var monthConfig models.MonthConfig + err := db.First(&monthConfig, &models.MonthConfig{ + EnvelopeID: e.ID, + Month: month, + }).Error + + // If an unexpected error occurs, return + if err != nil && err != gorm.ErrRecordNotFound { + return EnvelopeMonth{}, err + } + + envelopeMonth.Balance, err = e.Balance(db, month) + if err != nil { + return EnvelopeMonth{}, err + } + + envelopeMonth.Allocation = monthConfig.Allocation + return envelopeMonth, nil +} + +// parseMonthQuery takes in the context and parses the request +// +// It verifies that the requested budget exists and parses the ID to return +// the budget resource itself. +func parseMonthQuery(c *gin.Context) (types.Month, models.Budget, httperrors.Error) { + var query struct { + QueryMonth + BudgetID string `form:"budget" example:"81b0c9ce-6fd3-4e1e-becc-106055898a2a"` + } + + if err := c.BindQuery(&query); err != nil { + return types.Month{}, models.Budget{}, httperrors.Parse(c, err) + } + + if query.Month.IsZero() { + return types.Month{}, models.Budget{}, httperrors.Error{ + Status: http.StatusBadRequest, + Err: httperrors.ErrMonthNotSetInQuery, + } + } + + budgetID, err := uuid.Parse(query.BudgetID) + if err != nil { + return types.Month{}, models.Budget{}, httperrors.Parse(c, err) + } + + budget, e := getModelByID[models.Budget](c, budgetID) + if !e.Nil() { + return types.Month{}, models.Budget{}, e + } + + return types.MonthOf(query.Month), budget, httperrors.Error{} +} diff --git a/pkg/controllers/v4/month_config.go b/pkg/controllers/v4/month_config.go new file mode 100644 index 00000000..58ea2ee5 --- /dev/null +++ b/pkg/controllers/v4/month_config.go @@ -0,0 +1,221 @@ +package v4 + +import ( + "errors" + "net/http" + + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" +) + +// RegisterMonthConfigRoutes registers the routes for transactions with +// the RouterGroup that is passed. +func RegisterMonthConfigRoutes(r *gin.RouterGroup) { + r.OPTIONS("/:id/:month", OptionsMonthConfigDetail) + r.GET("/:id/:month", GetMonthConfig) + r.PATCH("/:id/:month", UpdateMonthConfig) +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Envelopes +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Param id path string true "ID of the Envelope" +// @Param month path string true "The month in YYYY-MM format" +// @Router /v4/envelopes/{id}/{month} [options] +func OptionsMonthConfigDetail(c *gin.Context) { + _, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + var month URIMonth + if err := c.BindUri(&month); err != nil { + e := httperrors.Parse(c, err) + c.JSON(e.Status, httperrors.HTTPError{ + Error: e.Error(), + }) + return + } + + httputil.OptionsGetPatch(c) +} + +// @Summary Get MonthConfig +// @Description Returns configuration for a specific month +// @Tags Envelopes +// @Produce json +// @Success 200 {object} MonthConfigResponse +// @Failure 400 {object} MonthConfigResponse +// @Failure 404 {object} MonthConfigResponse +// @Failure 500 {object} MonthConfigResponse +// @Param id path string true "ID of the Envelope" +// @Param month path string true "The month in YYYY-MM format" +// @Router /v4/envelopes/{id}/{month} [get] +func GetMonthConfig(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + var month URIMonth + if err := c.BindUri(&month); err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + _, err = getModelByID[models.Envelope](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + mConfig, err := getMonthConfigModel(c, id, types.MonthOf(month.Month)) + var data MonthConfig + if !err.Nil() { + // If there is no MonthConfig in the database, return one with the zero values + if errors.Is(err.Err, httperrors.ErrNoResource) { + data = newMonthConfig(c, models.MonthConfig{ + EnvelopeID: id, + Month: types.MonthOf(month.Month), + }) + c.JSON(http.StatusOK, MonthConfigResponse{Data: &data}) + return + } + + s := err.Error() + c.JSON(err.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + data = newMonthConfig(c, mConfig) + c.JSON(http.StatusOK, MonthConfigResponse{Data: &data}) +} + +// @Summary Update MonthConfig +// @Description Changes configuration for a Month. If there is no configuration for the month yet, this endpoint transparently creates a configuration resource. +// @Tags Envelopes +// @Produce json +// @Success 201 {object} MonthConfigResponse +// @Failure 400 {object} MonthConfigResponse +// @Failure 404 {object} MonthConfigResponse +// @Failure 500 {object} MonthConfigResponse +// @Param id path string true "ID of the Envelope" +// @Param month path string true "The month in YYYY-MM format" +// @Param monthConfig body MonthConfigEditable true "MonthConfig" +// @Router /v4/envelopes/{id}/{month} [patch] +func UpdateMonthConfig(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + var month URIMonth + if err := c.BindUri(&month); err != nil { + e := httperrors.Parse(c, err) + s := e.Error() + c.JSON(e.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + _, err = getModelByID[models.Envelope](c, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + updateFields, err := httputil.GetBodyFields(c, MonthConfigEditable{}) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + var data MonthConfigEditable + err = httputil.BindData(c, &data) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + m, err := getMonthConfigModel(c, id, types.MonthOf(month.Month)) + + // If no Month Config exists yet, create one + if !err.Nil() && errors.Is(err.Err, httperrors.ErrNoResource) { + data.EnvelopeID = id + data.Month = types.Month(month.Month) + + model := data.model() + e := models.DB.Create(&model).Error + + if e != nil { + err = httperrors.Parse(c, err) + s := e.Error() + c.JSON(err.Status, MonthConfigResponse{ + Error: &s, + }) + } + + apiResource := newMonthConfig(c, model) + c.JSON(http.StatusOK, MonthConfigResponse{ + Data: &apiResource, + }) + return + } + + // Handle all other errors + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + // Perform the actual update + err = query(c, models.DB.Model(&m).Select("", updateFields...).Updates(data.model())) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, MonthConfigResponse{ + Error: &s, + }) + return + } + + apiResource := newMonthConfig(c, m) + c.JSON(http.StatusOK, MonthConfigResponse{Data: &apiResource}) +} diff --git a/pkg/controllers/v4/month_config_test.go b/pkg/controllers/v4/month_config_test.go new file mode 100644 index 00000000..cfbdc223 --- /dev/null +++ b/pkg/controllers/v4/month_config_test.go @@ -0,0 +1,152 @@ +package v4_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/envelope-zero/backend/v4/internal/types" + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/envelope-zero/backend/v4/test" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func patchTestMonthConfig(t *testing.T, envelopeID uuid.UUID, month types.Month, c v4.MonthConfigEditable, expectedStatus ...int) v4.MonthConfigResponse { + if envelopeID == uuid.Nil { + envelopeID = createTestEnvelope(t, v4.EnvelopeEditable{Name: "Transaction Test Envelope"}).Data.ID + } + + // Default to 200 OK as expected status + if len(expectedStatus) == 0 { + expectedStatus = append(expectedStatus, http.StatusOK) + } + + path := fmt.Sprintf("http://example.com/v4/envelopes/%s/%s", envelopeID, month.String()) + r := test.Request(t, http.MethodPatch, path, c) + test.AssertHTTPStatus(t, &r, expectedStatus...) + + var mc v4.MonthConfigResponse + test.DecodeResponse(t, &r, &mc) + + return mc +} + +func (suite *TestSuiteStandard) TestMonthConfigsGetSingle() { + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{}) + someMonth := types.NewMonth(2020, 3) + + models.DB.Create(&models.MonthConfig{ + Note: "This is to test GET with existing Month Config", + EnvelopeID: envelope.Data.ID, + Month: someMonth, + }) + + tests := []struct { + name string + envelopeID string + month string + status int + }{ + {"Month Config exists", envelope.Data.ID.String(), someMonth.String(), http.StatusOK}, + {"No Month Config exists", envelope.Data.ID.String(), "0333-11", http.StatusOK}, + {"No envelope", uuid.New().String(), someMonth.String(), http.StatusNotFound}, + {"Invalid UUID", "Not a UUID", someMonth.String(), http.StatusBadRequest}, + {"Invalid month", envelope.Data.ID.String(), "2193-1", http.StatusBadRequest}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("%s/%s/%s", "http://example.com/v4/envelopes", tt.envelopeID, tt.month) + + recorder := test.Request(suite.T(), http.MethodGet, path, "") + test.AssertHTTPStatus(t, &recorder, tt.status) + + if tt.status == http.StatusOK { + var mConfig v4.MonthConfigResponse + test.DecodeResponse(t, &recorder, &mConfig) + + selfLink := fmt.Sprintf("http://example.com/v4/envelopes/%s/%s", tt.envelopeID, tt.month) + assert.Equal(t, selfLink, mConfig.Data.Links.Self, "Request ID %s", recorder.Header().Get("x-request-id")) + + envelopeLink := fmt.Sprintf("http://example.com/v4/envelopes/%s", tt.envelopeID) + assert.Equal(t, envelopeLink, mConfig.Data.Links.Envelope, "Request ID %s", recorder.Header().Get("x-request-id")) + } + }) + } +} + +func (suite *TestSuiteStandard) TestMonthConfigsOptions() { + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{}) + + tests := []struct { + name string + envelope string + month string + status int + errMsg string + }{ + {"Bad Envelope ID", "Definitely-Not-A-UUID", "1984-03", http.StatusBadRequest, "not a valid UUID"}, + {"Invalid Month", envelope.Data.ID.String(), "2000-00", http.StatusBadRequest, "could not parse: parsing time \"2000-00\": month out of range"}, + {"No envelope", uuid.New().String(), "1984-03", http.StatusNoContent, ""}, + {"No MonthConfig", envelope.Data.ID.String(), "1984-03", http.StatusNoContent, ""}, + {"Existing", envelope.Data.ID.String(), "2014-05", http.StatusNoContent, ""}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("%s/%s/%s", "http://example.com/v4/envelopes", tt.envelope, tt.month) + recorder := test.Request(suite.T(), http.MethodOptions, path, "") + test.AssertHTTPStatus(t, &recorder, tt.status) + + if tt.status != http.StatusNoContent { + assert.Contains(t, test.DecodeError(suite.T(), recorder.Body.Bytes()), tt.errMsg) + } + }) + } +} + +func (suite *TestSuiteStandard) TestMonthConfigsUpdate() { + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{}) + month := types.NewMonth(time.Now().Year(), time.Now().Month()) + + recorder := test.Request(suite.T(), http.MethodPatch, fmt.Sprintf("http://example.com/v4/envelopes/%s/%s", envelope.Data.ID, month), v4.MonthConfigEditable{ + Note: "This is the updated note", + }) + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + + var updatedMonthConfig v4.MonthConfigResponse + test.DecodeResponse(suite.T(), &recorder, &updatedMonthConfig) + assert.Equal(suite.T(), "This is the updated note", updatedMonthConfig.Data.Note) +} + +func (suite *TestSuiteStandard) TestMonthConfigsUpdateFails() { + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{}) + month := types.NewMonth(2022, 3) + + tests := []struct { + name string + envelopeID string + month string + body string + status int + }{ + {"Invalid Body", envelope.Data.ID.String(), month.String(), `{"name": "not valid body"`, http.StatusBadRequest}, + {"Invaid UUID", "not a uuid", "2017-04", "", http.StatusBadRequest}, + {"Invalid month", envelope.Data.ID.String(), "September Seventy Seven", "", http.StatusBadRequest}, + {"No envelope", uuid.NewString(), month.String(), "", http.StatusNotFound}, + {"No month config", envelope.Data.ID.String(), "1137-12", `{"note": "This implicitly creates a Month Config"}`, http.StatusOK}, + {"Broken values", envelope.Data.ID.String(), month.String(), `{"note": 2 }`, http.StatusBadRequest}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("%s/%s/%s", "http://example.com/v4/envelopes", tt.envelopeID, tt.month) + + recorder := test.Request(suite.T(), http.MethodPatch, path, tt.body) + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} diff --git a/pkg/controllers/v4/month_config_types.go b/pkg/controllers/v4/month_config_types.go new file mode 100644 index 00000000..85d3afc6 --- /dev/null +++ b/pkg/controllers/v4/month_config_types.go @@ -0,0 +1,100 @@ +package v4 + +import ( + "fmt" + + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +// swagger:enum AllocationMode +type AllocationMode string + +const ( + AllocateLastMonthBudget AllocationMode = "ALLOCATE_LAST_MONTH_BUDGET" + AllocateLastMonthSpend AllocationMode = "ALLOCATE_LAST_MONTH_SPEND" +) + +type BudgetAllocationMode struct { + Mode AllocationMode `json:"mode" example:"ALLOCATE_LAST_MONTH_SPEND"` // Mode to allocate budget with +} + +type MonthConfigEditable struct { + EnvelopeID uuid.UUID `json:"envelopeId" gorm:"primaryKey" example:"10b9705d-3356-459e-9d5a-28d42a6c4547"` // ID of the envelope + Month types.Month `json:"month" gorm:"primaryKey" example:"1969-06-01T00:00:00.000000Z"` // The month. This is always set to 00:00 UTC on the first of the month. + Allocation decimal.Decimal `json:"allocation" gorm:"-" example:"22.01" minimum:"0.00000001" maximum:"999999999999.99999999" multipleOf:"0.00000001"` // The maximum value is "999999999999.99999999", swagger unfortunately rounds this. + Note string `json:"note" example:"Added 200€ here because we replaced Tim's expensive vase" default:""` // A note for the month config +} + +func (editable MonthConfigEditable) model() models.MonthConfig { + return models.MonthConfig{ + EnvelopeID: editable.EnvelopeID, + Month: editable.Month, + Allocation: editable.Allocation, + Note: editable.Note, + } +} + +type MonthConfigLinks struct { + Self string `json:"self" example:"https://example.com/api/v4/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10"` // The Month Config itself + Envelope string `json:"envelope" example:"https://example.com/api/v4/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b"` // The Envelope this config belongs to +} + +type MonthConfig struct { + MonthConfigEditable + EnvelopeID uuid.UUID // We do not use the default model here, we use envelope ID and month + Month types.Month // We do not use the default model here, we use envelope ID and month + Links MonthConfigLinks `json:"links"` +} + +func newMonthConfig(c *gin.Context, model models.MonthConfig) MonthConfig { + url := c.GetString(string(models.DBContextURL)) + + return MonthConfig{ + EnvelopeID: model.EnvelopeID, + Month: model.Month, + MonthConfigEditable: MonthConfigEditable{ + EnvelopeID: model.EnvelopeID, + Month: model.Month, + Allocation: model.Allocation, + Note: model.Note, + }, + Links: MonthConfigLinks{ + Self: fmt.Sprintf("%s/v4/envelopes/%s/%s", url, model.EnvelopeID, model.Month), + Envelope: fmt.Sprintf("%s/v4/envelopes/%s", url, model.EnvelopeID), + }, + } +} + +// getMonthConfigModel returns the month config for a specific envelope and month +// +// It is the month config equivalent for getModelByID +func getMonthConfigModel(c *gin.Context, id uuid.UUID, month types.Month) (models.MonthConfig, httperrors.Error) { + var m models.MonthConfig + + err := query(c, models.DB.First(&m, &models.MonthConfig{ + EnvelopeID: id, + Month: month, + })) + + if !err.Nil() { + return models.MonthConfig{}, err + } + + return m, httperrors.Error{} +} + +type MonthConfigResponse struct { + Data *MonthConfig `json:"data"` // Config for the month + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred +} + +type MonthConfigListResponse struct { + Data []MonthConfig `json:"data"` // List of Month Configs + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Pagination *Pagination `json:"pagination"` // Pagination information +} diff --git a/pkg/controllers/v4/month_test.go b/pkg/controllers/v4/month_test.go new file mode 100644 index 00000000..585850dd --- /dev/null +++ b/pkg/controllers/v4/month_test.go @@ -0,0 +1,529 @@ +package v4_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/envelope-zero/backend/v4/internal/types" + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/test" + "github.com/google/uuid" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" +) + +func (suite *TestSuiteStandard) TestMonthsGet() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + + r := test.Request(suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.Month, "YYYY-MM", "2022-01", -1), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) +} + +func (suite *TestSuiteStandard) TestMonthsGetEnvelopeAllocationLink() { + var month v4.MonthResponse + + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + category := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: budget.Data.ID}) + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: category.Data.ID}) + + _ = patchTestMonthConfig(suite.T(), + + envelope.Data.ID, + types.NewMonth(2022, 1), + v4.MonthConfigEditable{ + Allocation: decimal.NewFromFloat(10), + }) + + r := test.Request(suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.Month, "YYYY-MM", "2022-01", 1), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + test.DecodeResponse(suite.T(), &r, &month) + suite.Assert().NotEmpty(month.Data.Categories[0].Envelopes) + suite.Assert().True(month.Data.Categories[0].Allocation.Equal(decimal.NewFromFloat(10))) +} + +func (suite *TestSuiteStandard) TestMonthsGetNotNil() { + var month v4.MonthResponse + + // Verify that the categories list is empty, not nil + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + + r := test.Request(suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.Month, "YYYY-MM", "2022-01", 1), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + test.DecodeResponse(suite.T(), &r, &month) + if !suite.Assert().NotNil(month.Data.Categories) { + suite.Assert().FailNow("Categories field is nil, cannot continue") + } + suite.Assert().Empty(month.Data.Categories) + + // Verify that the envelopes list is empty, not nil + _ = createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: budget.Data.ID}) + + r = test.Request(suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.Month, "YYYY-MM", "2022-01", 1), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusOK) + test.DecodeResponse(suite.T(), &r, &month) + suite.Assert().NotNil(month.Data.Categories[0].Envelopes) + suite.Assert().Empty(month.Data.Categories[0].Envelopes) +} + +func (suite *TestSuiteStandard) TestMonthsGetInvalidRequest() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + + tests := []struct { + name string // name of the test + path string // path to request + testFunc func(t *testing.T, r httptest.ResponseRecorder) // additional tests + status int // expected status + }{ + {"Invalid month", "http://example.com/v4/months?month=-56", nil, http.StatusBadRequest}, + {"Invalid UUID", "http://example.com/v4/months?budget=noUUID", nil, http.StatusBadRequest}, + {"Month query parameter not set", strings.Replace(budget.Data.Links.Month, "YYYY-MM", "0001-01", 1), func(t *testing.T, r httptest.ResponseRecorder) { + assert.Equal(t, "the month query parameter must be set", test.DecodeError(suite.T(), r.Body.Bytes())) + }, http.StatusBadRequest}, + {"No budget with ID", "http://example.com/v4/months?budget=6a463cc8-1938-474a-8aeb-0482b82ffb6f&month=2000-12", func(t *testing.T, r httptest.ResponseRecorder) { + assert.Equal(t, "there is no Budget with this ID", test.DecodeError(suite.T(), r.Body.Bytes())) + }, http.StatusNotFound}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodGet, tt.path, "") + test.AssertHTTPStatus(t, &r, tt.status) + + if tt.testFunc != nil { + tt.testFunc(t, r) + } + }) + } +} + +func (suite *TestSuiteStandard) TestMonthsGetDBFail() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + + suite.CloseDB() + + r := test.Request(suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.Month, "YYYY-MM", "2022-01", 1), "") + test.AssertHTTPStatus(suite.T(), &r, http.StatusInternalServerError) +} + +func (suite *TestSuiteStandard) TestMonthsGetDelete() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + category := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: budget.Data.ID}) + envelope1 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: category.Data.ID}) + envelope2 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: category.Data.ID}) + + monthConfig1 := patchTestMonthConfig(suite.T(), + envelope1.Data.ID, + types.NewMonth(2022, 1), + v4.MonthConfigEditable{Allocation: decimal.NewFromFloat(15.42)}, + ) + + monthConfig2 := patchTestMonthConfig(suite.T(), + envelope2.Data.ID, + types.NewMonth(2022, 1), + v4.MonthConfigEditable{Allocation: decimal.NewFromFloat(15.42)}, + ) + + // Clear allocations + recorder := test.Request(suite.T(), http.MethodDelete, strings.Replace(budget.Data.Links.Month, "YYYY-MM", "2022-01", 1), "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) + + // Verify that allocations are deleted + recorder = test.Request(suite.T(), http.MethodGet, monthConfig1.Data.Links.Self, "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + var response v4.MonthConfigResponse + test.DecodeResponse(suite.T(), &recorder, &response) + assert.True(suite.T(), response.Data.Allocation.IsZero(), "Allocation is not zero after deletion") + + recorder = test.Request(suite.T(), http.MethodGet, monthConfig2.Data.Links.Self, "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + test.DecodeResponse(suite.T(), &recorder, &response) + assert.True(suite.T(), response.Data.Allocation.IsZero(), "Allocation is not zero after deletion") +} + +func (suite *TestSuiteStandard) TestMonthsDeleteFail() { + b := createTestBudget(suite.T(), v4.BudgetEditable{}) + + // Bad Request for invalid UUID + recorder := test.Request(suite.T(), http.MethodDelete, "http://example.com/v4/months?budget=nouuid&month=2022-01", "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) + + // Bad Request for invalid months + recorder = test.Request(suite.T(), http.MethodDelete, fmt.Sprintf("http://example.com/v4/months?budget=%s&month=022-01", b.Data.ID), "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) + + // Not found for non-existing budget + recorder = test.Request(suite.T(), http.MethodDelete, "http://example.com/v4/months?budget=059cdead-249f-4f94-8d29-16a80c6b4a09&month=2032-03", "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) +} + +func (suite *TestSuiteStandard) TestMonthsAllocateBudgeted() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + category := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: budget.Data.ID}) + envelope1 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: category.Data.ID}) + envelope2 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: category.Data.ID}) + archivedEnvelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: category.Data.ID, Archived: true}) + + e1Amount := decimal.NewFromFloat(30) + e2Amount := decimal.NewFromFloat(40) + eArchivedAmount := decimal.NewFromFloat(50) + + january := types.NewMonth(2022, 1) + february := january.AddDate(0, 1) + + // Allocate funds to the months + allocations := []struct { + envelopeID uuid.UUID + month types.Month + amount decimal.Decimal + }{ + {envelope1.Data.ID, january, e1Amount}, + {envelope2.Data.ID, january, e2Amount}, + {archivedEnvelope.Data.ID, january, eArchivedAmount}, + } + + for _, allocation := range allocations { + patchTestMonthConfig(suite.T(), allocation.envelopeID, allocation.month, v4.MonthConfigEditable{ + Allocation: allocation.amount, + }) + } + + // Update in budgeted mode allocations + recorder := test.Request(suite.T(), http.MethodPost, strings.Replace(budget.Data.Links.Month, "YYYY-MM", february.String(), 1), v4.BudgetAllocationMode{Mode: v4.AllocateLastMonthBudget}) + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) + + // Verify the allocation for the first envelope + recorder = test.Request(suite.T(), http.MethodGet, strings.Replace(envelope1.Data.Links.Month, "YYYY-MM", february.String(), 1), "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + var envelope1Month v4.MonthConfigResponse + test.DecodeResponse(suite.T(), &recorder, &envelope1Month) + suite.Assert().True(e1Amount.Equal(envelope1Month.Data.Allocation), "Expected: %s, got %s, Request ID: %s", e1Amount, envelope1Month.Data.Allocation, recorder.Header().Get("x-request-id")) + + // Verify the allocation for the second envelope + recorder = test.Request(suite.T(), http.MethodGet, strings.Replace(envelope2.Data.Links.Month, "YYYY-MM", february.String(), 1), "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + var envelope2Month v4.MonthConfigResponse + test.DecodeResponse(suite.T(), &recorder, &envelope2Month) + suite.Assert().True(e2Amount.Equal(envelope2Month.Data.Allocation), "Expected: %s, got %s, Request ID: %s", e2Amount, envelope2Month.Data.Allocation, recorder.Header().Get("x-request-id")) + + // Verify the allocation for the archived envelope + recorder = test.Request(suite.T(), http.MethodGet, strings.Replace(archivedEnvelope.Data.Links.Month, "YYYY-MM", february.String(), 1), "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + var archivedEnvelopeMonth v4.MonthConfigResponse + test.DecodeResponse(suite.T(), &recorder, &archivedEnvelopeMonth) + + // Quick allocations skip archived envelopes, so this should be zero + suite.Assert().True(archivedEnvelopeMonth.Data.Allocation.IsZero(), "Expected: 0, got %s, Request ID: %s", archivedEnvelopeMonth.Data.Allocation, recorder.Header().Get("x-request-id")) +} + +func (suite *TestSuiteStandard) TestMonthsAllocateSpend() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + cashAccount := createTestAccount(suite.T(), v4.AccountEditable{External: false, OnBudget: true, Name: "TestSetMonthSpend Cash"}) + externalAccount := createTestAccount(suite.T(), v4.AccountEditable{External: true, Name: "TestSetMonthSpend External"}) + category := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: budget.Data.ID}) + envelope1 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: category.Data.ID}) + envelope2 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: category.Data.ID}) + + _ = patchTestMonthConfig(suite.T(), + envelope1.Data.ID, + types.NewMonth(2022, 1), + v4.MonthConfigEditable{Allocation: decimal.NewFromFloat(30)}, + ) + + _ = patchTestMonthConfig(suite.T(), + envelope2.Data.ID, + types.NewMonth(2022, 1), + v4.MonthConfigEditable{Allocation: decimal.NewFromFloat(40)}, + ) + + eID := &envelope1.Data.ID + transaction1 := createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2022, 1, 15, 14, 43, 27, 0, time.UTC), + EnvelopeID: eID, + SourceAccountID: cashAccount.Data.ID, + DestinationAccountID: externalAccount.Data.ID, + Amount: decimal.NewFromFloat(15), + }) + + // Update in budgeted mode allocations + recorder := test.Request(suite.T(), http.MethodPost, strings.Replace(budget.Data.Links.Month, "YYYY-MM", "2022-02", 1), v4.BudgetAllocationMode{Mode: v4.AllocateLastMonthSpend}) + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) + + // Verify the allocation for the first envelope + requestString := strings.Replace(envelope1.Data.Links.Month, "YYYY-MM", "2022-02", 1) + recorder = test.Request(suite.T(), http.MethodGet, requestString, "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + var envelope1Month v4.MonthConfigResponse + test.DecodeResponse(suite.T(), &recorder, &envelope1Month) + + // We allocated by the spend of the month before, so the allocation should equal the amount of the transaction + suite.Assert().True(transaction1.Data.Amount.Equal(envelope1Month.Data.Allocation), "Expected: %s, got %s, Request ID: %s", transaction1.Data.Amount, envelope1Month.Data.Allocation, recorder.Header().Get("x-request-id")) + + // Verify the allocation for the second envelope + recorder = test.Request(suite.T(), http.MethodGet, strings.Replace(envelope2.Data.Links.Month, "YYYY-MM", "2022-02", 1), "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + var envelope2Month v4.MonthConfigResponse + test.DecodeResponse(suite.T(), &recorder, &envelope2Month) + + // No spend on this envelope in January, therefore no allocation in february + suite.Assert().True(envelope2Month.Data.Allocation.Equal(decimal.NewFromFloat(0)), "Expected: 0, got %s, Request ID: %s", envelope2Month.Data.Allocation, recorder.Header().Get("x-request-id")) +} + +func (suite *TestSuiteStandard) TestMonthsPostFails() { + budgetAllocationsLink := createTestBudget(suite.T(), v4.BudgetEditable{}).Data.Links.Month + + tests := []struct { + name string + url string + body string + status int // expected HTTP status + }{ + {"Invalid UUID", "http://example.com/v4/months?budget=nouuid&month=2022-01", "", http.StatusBadRequest}, + {"Invalid month", budgetAllocationsLink, "", http.StatusBadRequest}, + {"Non-existing budget", "http://example.com/v4/months?budget=059cdead-249f-4f94-8d29-16a80c6b4a09&month=2032-03", "", http.StatusNotFound}, + {"Invalid body", strings.Replace(budgetAllocationsLink, "YYYY-MM", "2022-01", 1), `{ "mode": INVALID_JSON" }`, http.StatusBadRequest}, + {"Invalid mode", strings.Replace(budgetAllocationsLink, "YYYY-MM", "2022-01", 1), `{ "mode": "UNKNOWN_MODE" }`, http.StatusBadRequest}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + recorder := test.Request(t, http.MethodPost, tt.url, tt.body) + test.AssertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestMonthsSorting verifies that categories and months are sorted correctly +func (suite *TestSuiteStandard) TestMonthsSorting() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + categoryU := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: budget.Data.ID, Name: "Upkeep"}) + envelopeU := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: categoryU.Data.ID, Name: "Utilities"}) + envelopeM := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: categoryU.Data.ID, Name: "Muppets"}) + + categoryA := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: budget.Data.ID, Name: "Alphabetically first"}) + envelopeB := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: categoryA.Data.ID, Name: "Batteries"}) + envelopeC := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: categoryA.Data.ID, Name: "Chargers"}) + + // Get month data + recorder := test.Request(suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.Month, "YYYY-MM", types.MonthOf(time.Now()).String(), 1), "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + + // Parse month data + var response v4.MonthResponse + test.DecodeResponse(suite.T(), &recorder, &response) + month := response.Data + + assert.Equal(suite.T(), categoryU.Data.ID, month.Categories[1].ID) + assert.Equal(suite.T(), envelopeU.Data.ID, month.Categories[1].Envelopes[1].ID) + assert.Equal(suite.T(), envelopeM.Data.ID, month.Categories[1].Envelopes[0].ID) + + assert.Equal(suite.T(), categoryA.Data.ID, month.Categories[0].ID) + assert.Equal(suite.T(), envelopeB.Data.ID, month.Categories[0].Envelopes[0].ID) + assert.Equal(suite.T(), envelopeC.Data.ID, month.Categories[0].Envelopes[1].ID) +} + +// TestMonths verifies that the monthly calculations are correct. +func (suite *TestSuiteStandard) TestMonths() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + category := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: budget.Data.ID, Name: "Upkeep"}) + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: category.Data.ID, Name: "Utilities"}) + account := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: budget.Data.ID, OnBudget: true, Name: "TestMonth"}) + externalAccount := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: budget.Data.ID, External: true}) + + // Allocate funds to the months + allocations := []struct { + month types.Month + amount decimal.Decimal + }{ + {types.NewMonth(2022, 1), decimal.NewFromFloat(20.99)}, + {types.NewMonth(2022, 2), decimal.NewFromFloat(47.12)}, + {types.NewMonth(2022, 3), decimal.NewFromFloat(31.17)}, + } + + for _, allocation := range allocations { + patchTestMonthConfig(suite.T(), envelope.Data.ID, allocation.month, v4.MonthConfigEditable{ + Allocation: allocation.amount, + }) + } + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2022, 1, 15, 0, 0, 0, 0, time.UTC), + Amount: decimal.NewFromFloat(10.0), + Note: "Water bill for January", + SourceAccountID: account.Data.ID, + DestinationAccountID: externalAccount.Data.ID, + EnvelopeID: &envelope.Data.ID, + }) + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2022, 2, 15, 0, 0, 0, 0, time.UTC), + Amount: decimal.NewFromFloat(5.0), + Note: "Water bill for February", + SourceAccountID: account.Data.ID, + DestinationAccountID: externalAccount.Data.ID, + EnvelopeID: &envelope.Data.ID, + }) + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2022, 3, 15, 0, 0, 0, 0, time.UTC), + Amount: decimal.NewFromFloat(15.0), + Note: "Water bill for March", + SourceAccountID: account.Data.ID, + DestinationAccountID: externalAccount.Data.ID, + EnvelopeID: &envelope.Data.ID, + }) + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2022, 3, 1, 7, 38, 17, 0, time.UTC), + Amount: decimal.NewFromFloat(1500), + Note: "Income for march", + SourceAccountID: externalAccount.Data.ID, + DestinationAccountID: account.Data.ID, + EnvelopeID: nil, + }) + + tests := []struct { + month types.Month + result v4.Month + }{ + { + types.NewMonth(2022, 1), + v4.Month{ + Month: types.NewMonth(2022, 1), + Income: decimal.NewFromFloat(0), + Balance: decimal.NewFromFloat(10.99), + Spent: decimal.NewFromFloat(-10), + Allocation: decimal.NewFromFloat(20.99), + Available: decimal.NewFromFloat(-20.99), + Categories: []v4.CategoryEnvelopes{ + { + Category: *category.Data, + Balance: decimal.NewFromFloat(10.99), + Spent: decimal.NewFromFloat(-10), + Allocation: decimal.NewFromFloat(20.99), + Envelopes: []v4.EnvelopeMonth{ + { + Envelope: *envelope.Data, + Spent: decimal.NewFromFloat(-10), + Balance: decimal.NewFromFloat(10.99), + Allocation: decimal.NewFromFloat(20.99), + }, + }, + }, + }, + }, + }, + { + types.NewMonth(2022, 2), + v4.Month{ + Month: types.NewMonth(2022, 2), + Income: decimal.NewFromFloat(0), + Balance: decimal.NewFromFloat(53.11), + Spent: decimal.NewFromFloat(-5), + Allocation: decimal.NewFromFloat(47.12), + Available: decimal.NewFromFloat(-68.11), + Categories: []v4.CategoryEnvelopes{ + { + Category: *category.Data, + Balance: decimal.NewFromFloat(53.11), + Spent: decimal.NewFromFloat(-5), + Allocation: decimal.NewFromFloat(47.12), + Envelopes: []v4.EnvelopeMonth{ + { + Envelope: *envelope.Data, + Balance: decimal.NewFromFloat(53.11), + Spent: decimal.NewFromFloat(-5), + Allocation: decimal.NewFromFloat(47.12), + }, + }, + }, + }, + }, + }, + { + types.NewMonth(2022, 3), + v4.Month{ + Month: types.NewMonth(2022, 3), + Income: decimal.NewFromFloat(1500), + Balance: decimal.NewFromFloat(69.28), + Spent: decimal.NewFromFloat(-15), + Allocation: decimal.NewFromFloat(31.17), + Available: decimal.NewFromFloat(1400.72), + Categories: []v4.CategoryEnvelopes{ + { + Category: *category.Data, + Balance: decimal.NewFromFloat(69.28), + Spent: decimal.NewFromFloat(-15), + Allocation: decimal.NewFromFloat(31.17), + Envelopes: []v4.EnvelopeMonth{ + { + Envelope: *envelope.Data, + Balance: decimal.NewFromFloat(69.28), + Spent: decimal.NewFromFloat(-15), + Allocation: decimal.NewFromFloat(31.17), + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.month.String(), func(t *testing.T) { + // Get month data + recorder := test.Request(suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.Month, "YYYY-MM", tt.month.String(), 1), "") + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusOK) + + // Parse month data + var response v4.MonthResponse + test.DecodeResponse(t, &recorder, &response) + month := response.Data + + // Verify income calculation + assert.True(t, month.Income.Equal(tt.result.Income)) + + // Verify month balance calculation + assert.True(t, month.Balance.Equal(tt.result.Balance), "Month balance calculation for %v is wrong: should be %v, but is %v: %#v", month.Month, tt.result.Balance, month.Balance, month) + + // Verify allocation calculation + assert.True(t, month.Allocation.Equal(tt.result.Allocation), "Month allocation sum for %v is wrong: should be %v, but is %v: %#v", month.Month, tt.result.Allocation, month.Allocation, month) + + // Verify available calculation + assert.True(t, month.Available.Equal(tt.result.Available), "Month available sum for %v is wrong: should be %v, but is %v: %#v", month.Month, tt.result.Available, month.Available, month) + + // Verify month spent calculation + assert.True(t, month.Spent.Equal(tt.result.Spent), "Month spent is wrong. Should be %v, but is %v: %#v", tt.result.Spent, month.Spent, month) + + if !suite.Assert().Len(month.Categories, 1) { + suite.Assert().FailNow("Response category length does not match!", "Category list does not have exactly 1 item, it has %d, Request ID: %s", len(month.Categories)) + } + + if !suite.Assert().Len(month.Categories[0].Envelopes, 1) { + suite.Assert().FailNow("Response envelope length does not match!", "Envelope list does not have exactly 1 item, it has %d, Request ID: %s", len(month.Categories[0].Envelopes)) + } + + // Verify the links are set correctly + assert.Equal(t, envelope.Data.Links.Month, month.Categories[0].Envelopes[0].Links.Month) + + // Category calculations + expectedCategory := tt.result.Categories[0] + category := month.Categories[0] + + assert.True(t, category.Spent.Equal(expectedCategory.Spent), "Monthly category spent calculation for %v is wrong: should be %v, but is %v: %#v", month.Month, expectedCategory.Spent, category.Spent, month) + assert.True(t, category.Balance.Equal(expectedCategory.Balance), "Monthly category balance calculation for %v is wrong: should be %v, but is %v: %#v", month.Month, expectedCategory.Balance, category.Balance, month) + assert.True(t, category.Allocation.Equal(expectedCategory.Allocation), "Monthly category allocation fetch for %v is wrong: should be %v, but is %v: %#v", month.Month, expectedCategory.Allocation, category.Allocation, month) + + // Envelope calculation + expectedEnvelope := tt.result.Categories[0].Envelopes[0] + envelope := month.Categories[0].Envelopes[0] + + assert.True(t, envelope.Spent.Equal(expectedEnvelope.Spent), "Monthly envelope spent calculation for %v is wrong: should be %v, but is %v: %#v", month.Month, expectedEnvelope.Spent, envelope.Spent, month) + assert.True(t, envelope.Balance.Equal(expectedEnvelope.Balance), "Monthly envelope balance calculation for %v is wrong: should be %v, but is %v: %#v", month.Month, expectedEnvelope.Balance, envelope.Balance, month) + assert.True(t, envelope.Allocation.Equal(expectedEnvelope.Allocation), "Monthly envelope allocation fetch for %v is wrong: should be %v, but is %v: %#v", month.Month, expectedEnvelope.Allocation, envelope.Allocation, month) + }) + } +} diff --git a/pkg/controllers/v4/responses.go b/pkg/controllers/v4/responses.go new file mode 100644 index 00000000..4b2c48ec --- /dev/null +++ b/pkg/controllers/v4/responses.go @@ -0,0 +1,12 @@ +package v4 + +// We use one type per Endpoint so that swagger can parse them - it cannot handle generics yet, see +// https://github.com/swaggo/swag/issues/1170 + +// Pagination contains information about the pagination for collection endpoint responses. +type Pagination struct { + Count int `json:"count" example:"25"` // The amount of records returned in this response + Offset uint `json:"offset" example:"50"` // The offset for the first record returned + Limit int `json:"limit" example:"25"` // The maximum amount of resources to return for this request + Total int64 `json:"total" example:"827"` // The total number of resources matching the query +} diff --git a/pkg/controllers/v4/root.go b/pkg/controllers/v4/root.go new file mode 100644 index 00000000..59b32405 --- /dev/null +++ b/pkg/controllers/v4/root.go @@ -0,0 +1,67 @@ +package v4 + +import ( + "net/http" + + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" +) + +func RegisterRootRoutes(r *gin.RouterGroup) { + r.GET("", Get) + r.DELETE("", Cleanup) + r.OPTIONS("", Options) +} + +type Response struct { + Links Links `json:"links"` // Links for the v4 API +} + +type Links struct { + Accounts string `json:"accounts" example:"https://example.com/api/v4/accounts"` // URL of Account collection endpoint + Budgets string `json:"budgets" example:"https://example.com/api/v4/budgets"` // URL of Budget collection endpoint + Categories string `json:"categories" example:"https://example.com/api/v4/categories"` // URL of Category collection endpoint + Envelopes string `json:"envelopes" example:"https://example.com/api/v4/envelopes"` // URL of Envelope collection endpoint + Goals string `json:"goals" example:"https://example.com/api/v4/goals"` // URL of goal collection endpoint + Import string `json:"import" example:"https://example.com/api/v4/import"` // URL of import list endpoint + MatchRules string `json:"matchRules" example:"https://example.com/api/v4/match-rules"` // URL of Match Rule collection endpoint + Months string `json:"months" example:"https://example.com/api/v4/months"` // URL of Month endpoint + Transactions string `json:"transactions" example:"https://example.com/api/v4/transactions"` // URL of Transaction collection endpoint +} + +// Get returns the link list for v4 +// +// @Summary v4 API +// @Description Returns general information about the v4 API +// @Tags v4 +// @Success 200 {object} Response +// @Router /v4 [get] +func Get(c *gin.Context) { + url := c.GetString(string(models.DBContextURL)) + + c.JSON(http.StatusOK, Response{ + Links: Links{ + Accounts: url + "/v4/accounts", + Budgets: url + "/v4/budgets", + Categories: url + "/v4/categories", + Envelopes: url + "/v4/envelopes", + Goals: url + "/v4/goals", + Import: url + "/v4/import", + MatchRules: url + "/v4/match-rules", + Months: url + "/v4/months", + Transactions: url + "/v4/transactions", + }, + }) +} + +// Options returns the allowed HTTP methods +// +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags v4 +// @Success 204 +// @Router /v4 [options] +func Options(c *gin.Context) { + httputil.OptionsGetDelete(c) +} diff --git a/pkg/controllers/v4/root_test.go b/pkg/controllers/v4/root_test.go new file mode 100644 index 00000000..0410b8fa --- /dev/null +++ b/pkg/controllers/v4/root_test.go @@ -0,0 +1,47 @@ +package v4_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/test" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestGet(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + + r.GET("/v4", func(ctx *gin.Context) { + v4.Get(c) + }) + + // Test contexts cannot be injected any middleware, therefore + // this only tests the path, not the host + l := v4.Response{ + Links: v4.Links{ + Accounts: "/v4/accounts", + Budgets: "/v4/budgets", + Categories: "/v4/categories", + Envelopes: "/v4/envelopes", + Goals: "/v4/goals", + Import: "/v4/import", + MatchRules: "/v4/match-rules", + Months: "/v4/months", + Transactions: "/v4/transactions", + }, + } + + var lr v4.Response + + c.Request, _ = http.NewRequest(http.MethodGet, "http://example.com/v4", nil) + r.ServeHTTP(w, c.Request) + + test.DecodeResponse(t, w, &lr) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, l, lr) +} diff --git a/pkg/controllers/v4/test_list_test.go b/pkg/controllers/v4/test_list_test.go new file mode 100644 index 00000000..4b2b23bf --- /dev/null +++ b/pkg/controllers/v4/test_list_test.go @@ -0,0 +1,33 @@ +package v4_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/envelope-zero/backend/v4/test" +) + +// TestMethodNotAllowed tests some endpoints with disallowed HTTP methods +// to verify that the HTTP 405 - Method Not Allowed status is returned +// correctly +func (suite *TestSuiteStandard) TestMethodNotAllowed() { + tests := []struct { + path string + method string + }{ + {"/", http.MethodPost}, + {"/", http.MethodDelete}, + {"http://example.com/v4", http.MethodPost}, + {"http://example.com/v4/budgets", http.MethodHead}, + {"http://example.com/v4/budgets", http.MethodPut}, + } + + for _, tt := range tests { + suite.T().Run(fmt.Sprintf("%s - %s", tt.path, tt.method), func(t *testing.T) { + recorder := test.Request(suite.T(), tt.method, tt.path, "") + + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusMethodNotAllowed) + }) + } +} diff --git a/pkg/controllers/v4/test_options_test.go b/pkg/controllers/v4/test_options_test.go new file mode 100644 index 00000000..a38f19e6 --- /dev/null +++ b/pkg/controllers/v4/test_options_test.go @@ -0,0 +1,38 @@ +package v4_test + +import ( + "net/http" + "testing" + + "github.com/envelope-zero/backend/v4/test" + "github.com/stretchr/testify/assert" +) + +func (suite *TestSuiteStandard) TestOptionsHeaderResources() { + optionsHeaderTests := []struct { + path string + response string + }{ + {"http://example.com/v4", "OPTIONS, GET, DELETE"}, + {"http://example.com/v4/accounts", "OPTIONS, GET, POST"}, + {"http://example.com/v4/budgets", "OPTIONS, GET, POST"}, + {"http://example.com/v4/categories", "OPTIONS, GET, POST"}, + {"http://example.com/v4/envelopes", "OPTIONS, GET, POST"}, + {"http://example.com/v4/goals", "OPTIONS, GET, POST"}, + {"http://example.com/v4/import", "OPTIONS, GET"}, + {"http://example.com/v4/import/ynab-import-preview", "OPTIONS, POST"}, + {"http://example.com/v4/import/ynab4", "OPTIONS, POST"}, + {"http://example.com/v4/match-rules", "OPTIONS, GET, POST"}, + {"http://example.com/v4/months", "OPTIONS, GET, POST, DELETE"}, + {"http://example.com/v4/transactions", "OPTIONS, GET, POST"}, + } + + for _, tt := range optionsHeaderTests { + suite.T().Run(tt.path, func(t *testing.T) { + recorder := test.Request(suite.T(), http.MethodOptions, tt.path, "") + + assert.Equal(t, http.StatusNoContent, recorder.Code) + assert.Equal(t, recorder.Header().Get("allow"), tt.response) + }) + } +} diff --git a/pkg/controllers/v4/test_suite_test.go b/pkg/controllers/v4/test_suite_test.go new file mode 100644 index 00000000..57ac6e81 --- /dev/null +++ b/pkg/controllers/v4/test_suite_test.go @@ -0,0 +1,52 @@ +package v4_test + +import ( + "log" + "os" + "testing" + + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/stretchr/testify/suite" +) + +type TestSuiteStandard struct { + suite.Suite +} + +// Pseudo-Test run by go test that runs the test suite. +func TestStandard(t *testing.T) { + suite.Run(t, new(TestSuiteStandard)) +} + +func (suite *TestSuiteStandard) SetupSuite() { + os.Setenv("LOG_FORMAT", "human") + os.Setenv("GIN_MODE", "debug") + os.Setenv("API_URL", "http://example.com") +} + +// TearDownTest is called after each test in the suite. +func (suite *TestSuiteStandard) TearDownTest() { + sqlDB, err := models.DB.DB() + if err != nil { + log.Fatalf("Database connection for teardown failed with: %#v", err) + } + sqlDB.Close() +} + +// SetupTest is called before each test in the suite. +func (suite *TestSuiteStandard) SetupTest() { + err := models.Connect(":memory:?_pragma=foreign_keys(1)") + if err != nil { + log.Fatalf("Database initialization failed with: %#v", err) + } +} + +// CloseDB closes the database connection. This enables testing the handling +// of database errors. +func (suite *TestSuiteStandard) CloseDB() { + sqlDB, err := models.DB.DB() + if err != nil { + suite.Assert().FailNowf("Failed to get database resource for teardown: %v", err.Error()) + } + sqlDB.Close() +} diff --git a/pkg/controllers/v4/transaction.go b/pkg/controllers/v4/transaction.go new file mode 100644 index 00000000..b5f16a21 --- /dev/null +++ b/pkg/controllers/v4/transaction.go @@ -0,0 +1,524 @@ +package v4 + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/shopspring/decimal" + "golang.org/x/exp/slices" + "gorm.io/gorm" +) + +// RegisterTransactionRoutes registers the routes for transactions with +// the RouterGroup that is passed. +func RegisterTransactionRoutes(r *gin.RouterGroup) { + // Root group + { + r.OPTIONS("", OptionsTransactions) + r.GET("", GetTransactions) + r.POST("", CreateTransactions) + } + + // Transaction with ID + { + r.OPTIONS("/:id", OptionsTransactionDetail) + r.GET("/:id", GetTransaction) + r.PATCH("/:id", UpdateTransaction) + r.DELETE("/:id", DeleteTransaction) + } +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Transactions +// @Success 204 +// @Router /v4/transactions [options] +func OptionsTransactions(c *gin.Context) { + httputil.OptionsGetPost(c) +} + +// @Summary Allowed HTTP verbs +// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs +// @Tags Transactions +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/transactions/{id} [options] +func OptionsTransactionDetail(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + var t models.Transaction + err = query(c, models.DB.First(&t, id)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + httputil.OptionsGetPatchDelete(c) +} + +// @Summary Get transaction +// @Description Returns a specific transaction +// @Tags Transactions +// @Produce json +// @Success 200 {object} TransactionResponse +// @Failure 400 {object} TransactionResponse +// @Failure 404 {object} TransactionResponse +// @Failure 500 {object} TransactionResponse +// @Param id path string true "ID formatted as string" +// @Router /v4/transactions/{id} [get] +func GetTransaction(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionResponse{ + Error: &e, + }) + return + } + + var transaction models.Transaction + err = query(c, models.DB.First(&transaction, id)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionResponse{ + Error: &e, + }) + return + } + + data := newTransaction(c, transaction) + c.JSON(http.StatusOK, TransactionResponse{Data: &data}) +} + +// @Summary Get transactions +// @Description Returns a list of transactions +// @Tags Transactions +// @Produce json +// @Success 200 {object} TransactionListResponse +// @Failure 400 {object} TransactionListResponse +// @Failure 500 {object} TransactionListResponse +// @Router /v4/transactions [get] +// @Param date query string false "Date of the transaction. Ignores exact time, matches on the day of the RFC3339 timestamp provided." +// @Param fromDate query string false "Transactions at and after this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided." +// @Param untilDate query string false "Transactions before and at this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided." +// @Param amount query string false "Filter by amount" +// @Param amountLessOrEqual query string false "Amount less than or equal to this" +// @Param amountMoreOrEqual query string false "Amount more than or equal to this" +// @Param note query string false "Filter by note" +// @Param budget query string false "Filter by budget ID" +// @Param account query string false "Filter by ID of associated account, regardeless of source or destination" +// @Param source query string false "Filter by source account ID" +// @Param destination query string false "Filter by destination account ID" +// @Param envelope query string false "Filter by envelope ID" +// @Param reconciledSource query bool false "Reconcilication state in source account" +// @Param reconciledDestination query bool false "Reconcilication state in destination account" +// @Param offset query uint false "The offset of the first Transaction returned. Defaults to 0." +// @Param limit query int false "Maximum number of Transactions to return. Defaults to 50." +func GetTransactions(c *gin.Context) { + var filter TransactionQueryFilter + if err := c.Bind(&filter); err != nil { + s := httperrors.ErrInvalidQueryString.Error() + c.JSON(http.StatusBadRequest, TransactionListResponse{ + Error: &s, + }) + return + } + + // Get the fields set in the filter + queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) + + // Convert the QueryFilter to a Create struct + model, err := filter.model() + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionListResponse{ + Error: &e, + }) + return + } + + var q *gorm.DB + q = models.DB.Order("datetime(transactions.date) DESC, datetime(transactions.created_at) DESC").Where(&model, queryFields...) + + // Filter for the transaction being at the same date + if !filter.Date.IsZero() { + date := time.Date(filter.Date.Year(), filter.Date.Month(), filter.Date.Day(), 0, 0, 0, 0, time.UTC) + q = q.Where("transactions.date >= date(?)", date).Where("transactions.date < date(?)", date.AddDate(0, 0, 1)) + } + + if !filter.FromDate.IsZero() { + q = q.Where("transactions.date >= date(?)", time.Date(filter.FromDate.Year(), filter.FromDate.Month(), filter.FromDate.Day(), 0, 0, 0, 0, time.UTC)) + } + + if !filter.UntilDate.IsZero() { + q = q.Where("transactions.date < date(?)", time.Date(filter.UntilDate.Year(), filter.UntilDate.Month(), filter.UntilDate.Day()+1, 0, 0, 0, 0, time.UTC)) + } + + if filter.BudgetID != "" { + budgetID, err := httputil.UUIDFromString(filter.BudgetID) + if !err.Nil() { + s := fmt.Sprintf("Error parsing budget ID for filtering: %s", err.Error()) + c.JSON(err.Status, TransactionListResponse{ + Error: &s, + }) + return + } + + // We join on the source account ID since all resources need to belong to the + // same budget anyways + q = q. + Joins("JOIN accounts on accounts.id = transactions.source_account_id "). + Joins("JOIN budgets on budgets.id = accounts.budget_id"). + Where("budgets.id = ?", budgetID) + } + + if filter.AccountID != "" { + accountID, err := httputil.UUIDFromString(filter.AccountID) + if !err.Nil() { + s := fmt.Sprintf("Error parsing Account ID for filtering: %s", err.Error()) + c.JSON(err.Status, TransactionListResponse{ + Error: &s, + }) + return + } + + q = q.Where(models.DB.Where(&models.Transaction{ + SourceAccountID: accountID, + }).Or(&models.Transaction{ + DestinationAccountID: accountID, + })) + } + + if !filter.AmountLessOrEqual.IsZero() { + q = q.Where("transactions.amount <= ?", filter.AmountLessOrEqual) + } + + if !filter.AmountMoreOrEqual.IsZero() { + q = q.Where("transactions.amount >= ?", filter.AmountMoreOrEqual) + } + + if filter.Note != "" { + q = q.Where("note LIKE ?", fmt.Sprintf("%%%s%%", filter.Note)) + } else if slices.Contains(setFields, "Note") { + q = q.Where("note = ''") + } + + // Set the offset. Does not need checking since the default is 0 + q = q.Offset(int(filter.Offset)) + + // Default to 50 transactions and set the limit + limit := 50 + if slices.Contains(setFields, "Limit") { + limit = filter.Limit + } + q = q.Limit(limit) + + var transactions []models.Transaction + err = query(c, q.Find(&transactions)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionListResponse{ + Error: &e, + }) + return + } + + var count int64 + err = query(c, q.Limit(-1).Offset(-1).Count(&count)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionListResponse{ + Error: &e, + }) + return + } + + data := make([]Transaction, 0) + for _, transaction := range transactions { + data = append(data, newTransaction(c, transaction)) + } + + c.JSON(http.StatusOK, TransactionListResponse{ + Data: data, + Pagination: &Pagination{ + Count: len(data), + Total: count, + Offset: filter.Offset, + Limit: limit, + }, + }) +} + +// @Summary Create transactions +// @Description Creates transactions from the list of submitted transaction data. The response code is the highest response code number that a single transaction creation would have caused. If it is not equal to 201, at least one transaction has an error. +// @Tags Transactions +// @Produce json +// @Success 201 {object} TransactionCreateResponse +// @Failure 400 {object} TransactionCreateResponse +// @Failure 404 {object} TransactionCreateResponse +// @Failure 500 {object} TransactionCreateResponse +// @Param transactions body []TransactionEditable true "Transactions" +// @Router /v4/transactions [post] +func CreateTransactions(c *gin.Context) { + var editables []TransactionEditable + + // Bind data and return error if not possible + err := httputil.BindData(c, &editables) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionCreateResponse{ + Error: &e, + }) + return + } + + // The final http status. Will be modified when errors occur + status := http.StatusCreated + r := TransactionCreateResponse{} + + for _, editable := range editables { + transaction := editable.model() + + err := createTransaction(c, &transaction) + + // Append the error + if !err.Nil() { + status = r.appendError(err, status) + continue + } + + data := newTransaction(c, transaction) + r.Data = append(r.Data, TransactionResponse{Data: &data}) + } + + c.JSON(status, r) +} + +// @Summary Update transaction +// @Description Updates an existing transaction. Only values to be updated need to be specified. +// @Tags Transactions +// @Accept json +// @Produce json +// @Success 200 {object} TransactionResponse +// @Failure 400 {object} TransactionResponse +// @Failure 404 {object} TransactionResponse +// @Failure 500 {object} TransactionResponse +// @Param id path string true "ID formatted as string" +// @Param transaction body TransactionEditable true "Transaction" +// @Router /v4/transactions/{id} [patch] +func UpdateTransaction(c *gin.Context) { + // Get the resource ID + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionResponse{ + Error: &e, + }) + return + } + + // Get the transaction resource + transaction, err := getModelByID[models.Transaction](c, id) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionResponse{ + Error: &e, + }) + return + } + + // Get the fields that are set to be updated + updateFields, err := httputil.GetBodyFields(c, TransactionEditable{}) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionResponse{ + Error: &e, + }) + return + } + + // Bind the update for the patch + var update TransactionEditable + err = httputil.BindData(c, &update) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionResponse{ + Error: &e, + }) + return + } + + // If the amount set via the API request is not existent or + // is 0, we use the old amount + if update.Amount.IsZero() { + update.Amount = transaction.Amount + } + + // Check the source account + sourceAccountID := transaction.SourceAccountID + if update.SourceAccountID != uuid.Nil { + sourceAccountID = update.SourceAccountID + } + sourceAccount, err := getModelByID[models.Account](c, sourceAccountID) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionResponse{ + Error: &e, + }) + return + } + + // Check the destination account + destinationAccountID := transaction.DestinationAccountID + if update.DestinationAccountID != uuid.Nil { + destinationAccountID = update.DestinationAccountID + } + destinationAccount, err := getModelByID[models.Account](c, destinationAccountID) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionResponse{ + Error: &e, + }) + return + } + + // Check the transaction that is set + err = checkTransaction(c, update.model(), sourceAccount, destinationAccount) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionResponse{ + Error: &e, + }) + return + } + + err = query(c, models.DB.Model(&transaction).Select("", updateFields...).Updates(update.model())) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionResponse{ + Error: &e, + }) + return + } + + data := newTransaction(c, transaction) + c.JSON(http.StatusOK, TransactionResponse{Data: &data}) +} + +// @Summary Delete transaction +// @Description Deletes a transaction +// @Tags Transactions +// @Success 204 +// @Failure 400 {object} httperrors.HTTPError +// @Failure 404 {object} httperrors.HTTPError +// @Failure 500 {object} httperrors.HTTPError +// @Param id path string true "ID formatted as string" +// @Router /v4/transactions/{id} [delete] +func DeleteTransaction(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + transaction, err := getModelByID[models.Transaction](c, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + err = query(c, models.DB.Delete(&transaction)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusNoContent, nil) +} + +// createTransaction creates a single transaction after verifying it is a valid transaction. +func createTransaction(c *gin.Context, model *models.Transaction) httperrors.Error { + // Check the source account + sourceAccount, err := getModelByID[models.Account](c, model.SourceAccountID) + if !err.Nil() { + return err + } + + // Check the destination account + destinationAccount, err := getModelByID[models.Account](c, model.DestinationAccountID) + if !err.Nil() { + return err + } + + // Check the transaction + err = checkTransaction(c, *model, sourceAccount, destinationAccount) + if !err.Nil() { + return err + } + + // Set the transaction's budget ID to the budget id of the source account + // Since they need to be in the same budget, this can easily be done. + // + // This is needed because we're removing the budgetId field in API v4, see + // https://github.com/envelope-zero/backend/issues/922 + model.BudgetID = sourceAccount.BudgetID + + dbErr := models.DB.Create(&model).Error + if dbErr != nil { + return httperrors.GenericDBError[models.Transaction](models.Transaction{}, c, dbErr) + } + + return httperrors.Error{} +} + +// checkTransaction verifies that the transaction is correct +// +// It checks that +// - the transaction is not between two external accounts +// - if an envelope is set: the transaction is not between two on-budget accounts +// - if an envelope is set: the envelope exists +func checkTransaction(c *gin.Context, transaction models.Transaction, source, destination models.Account) httperrors.Error { + if !decimal.Decimal.IsPositive(transaction.Amount) { + return httperrors.Error{Err: errors.New("the transaction amount must be positive"), Status: http.StatusBadRequest} + } + + if source.External && destination.External { + return httperrors.Error{Err: errors.New("a transaction between two external accounts is not possible"), Status: http.StatusBadRequest} + } + + // Check envelope being set for transfer between on-budget accounts + if transaction.EnvelopeID != nil && *transaction.EnvelopeID != uuid.Nil { + if source.OnBudget && destination.OnBudget { + // TODO: Verify this state in the model hooks + return httperrors.Error{Err: errors.New("transfers between two on-budget accounts must not have an envelope set. Such a transaction would be incoming and outgoing for this envelope at the same time, which is not possible"), Status: http.StatusBadRequest} + } + _, err := getModelByID[models.Envelope](c, *transaction.EnvelopeID) + return err + } + + return httperrors.Error{} +} diff --git a/pkg/controllers/v4/transaction_test.go b/pkg/controllers/v4/transaction_test.go new file mode 100644 index 00000000..84b0e2c1 --- /dev/null +++ b/pkg/controllers/v4/transaction_test.go @@ -0,0 +1,562 @@ +package v4_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/envelope-zero/backend/v4/test" + "github.com/google/uuid" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" +) + +// createTestTransaction creates a test transactions via the v4 API. +func createTestTransaction(t *testing.T, transaction v4.TransactionEditable, expectedStatus ...int) v4.TransactionResponse { + if transaction.SourceAccountID == uuid.Nil { + transaction.SourceAccountID = createTestAccount(t, v4.AccountEditable{Name: "Source Account"}).Data.ID + } + + if transaction.DestinationAccountID == uuid.Nil { + transaction.DestinationAccountID = createTestAccount(t, v4.AccountEditable{Name: "Destination Account"}).Data.ID + } + + if transaction.EnvelopeID == &uuid.Nil { + *transaction.EnvelopeID = createTestEnvelope(t, v4.EnvelopeEditable{Name: "Transaction Test Envelope"}).Data.ID + } + + // Default to 201 Created as expected status + if len(expectedStatus) == 0 { + expectedStatus = append(expectedStatus, http.StatusCreated) + } + + reqBody := []v4.TransactionEditable{transaction} + + r := test.Request(t, http.MethodPost, "http://example.com/v4/transactions", reqBody) + test.AssertHTTPStatus(t, &r, expectedStatus...) + + var tr v4.TransactionCreateResponse + test.DecodeResponse(t, &r, &tr) + + return tr.Data[0] +} + +// TestTransactionsOptions verifies that the HTTP OPTIONS response for //transactions/{id} is correct. +func (suite *TestSuiteStandard) TestTransactionsOptions() { + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + id string // String to use as ID. Ignored when pathFunc is non-nil + pathFunc func() string // Function returning the path + }{ + { + "Does not exist", + http.StatusNotFound, + uuid.New().String(), + nil, + }, + { + "Invalid UUID", + http.StatusBadRequest, + "NotParseableAsUUID", + nil, + }, + { + "Success", + http.StatusNoContent, + "", + func() string { + return createTestTransaction(suite.T(), v4.TransactionEditable{Amount: decimal.NewFromFloat(31)}).Data.Links.Self + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var p string + if tt.pathFunc != nil { + p = tt.pathFunc() + } else { + p = fmt.Sprintf("%s/%s", "http://example.com/v4/transactions", tt.id) + } + + r := test.Request(t, http.MethodOptions, p, "") + test.AssertHTTPStatus(t, &r, tt.status) + + if tt.status == http.StatusNoContent { + assert.Equal(t, "OPTIONS, GET, PATCH, DELETE", r.Header().Get("allow")) + } + }) + } +} + +// TestTransactionsDatabaseError verifies that the endpoints return the appropriate +// error when the database is disconncted. +func (suite *TestSuiteStandard) TestTransactionsDatabaseError() { + tests := []struct { + name string // Name of the test + path string // Path to send request to + method string // HTTP method to use + body string // The request body + }{ + {"GET Collection", "", http.MethodGet, ""}, + // Skipping POST Collection here since we need to check the indivdual transactions for that one + {"OPTIONS Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodOptions, ""}, + {"GET Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodGet, ""}, + {"PATCH Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodPatch, ""}, + {"DELETE Single", fmt.Sprintf("/%s", uuid.New().String()), http.MethodDelete, ""}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + suite.CloseDB() + + recorder := test.Request(t, tt.method, fmt.Sprintf("http://example.com/v4/transactions%s", tt.path), tt.body) + test.AssertHTTPStatus(t, &recorder, http.StatusInternalServerError) + assert.Equal(t, httperrors.ErrDatabaseClosed.Error(), test.DecodeError(t, recorder.Body.Bytes())) + }) + } +} + +// TestTransactionsGet verifies that transactions can be read from the API. +// It also acts as a regression test for a bug where transactions were sorted by date(date) +// instead of datetime(date), leading to transactions being correctly sorted by dates, but +// not correctly sorted when multiple transactions occurred on a day. In that case, the +// oldest transaction would be at the bottom and not at the top. +func (suite *TestSuiteStandard) TestTransactionsGet() { + t1 := createTestTransaction(suite.T(), v4.TransactionEditable{ + Amount: decimal.NewFromFloat(17.23), + Date: time.Date(2023, 11, 10, 10, 11, 12, 0, time.UTC), + }) + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Amount: decimal.NewFromFloat(23.42), + Date: time.Date(2023, 11, 10, 11, 12, 13, 0, time.UTC), + }) + + // Need to sleep 1 second because SQLite datetime only has second precision + time.Sleep(1 * time.Second) + + t3 := createTestTransaction(suite.T(), v4.TransactionEditable{ + Amount: decimal.NewFromFloat(44.05), + Date: time.Date(2023, 11, 10, 10, 11, 12, 0, time.UTC), + }) + + recorder := test.Request(suite.T(), http.MethodGet, "http://example.com/v4/transactions", "") + + var response v4.TransactionListResponse + test.DecodeResponse(suite.T(), &recorder, &response) + + assert.Equal(suite.T(), 200, recorder.Code) + assert.Len(suite.T(), response.Data, 3) + + // Verify that the transaction with the earlier date is the last in the list + assert.Equal(suite.T(), t1.Data.ID, response.Data[2].ID, t1.Data.CreatedAt) + + // Verify that the transaction added for the same time as the first, but added later + // is before the other + assert.Equal(suite.T(), t3.Data.ID, response.Data[1].ID, t3.Data.CreatedAt) +} + +// TestTransactionsGetFilter verifies that filtering transactions works as expected. +func (suite *TestSuiteStandard) TestTransactionsGetFilter() { + b := createTestBudget(suite.T(), v4.BudgetEditable{}) + + a1 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 1"}) + a2 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 2"}) + a3 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 3"}) + + c := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: b.Data.ID}) + + e1 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: c.Data.ID}) + e2 := createTestEnvelope(suite.T(), v4.EnvelopeEditable{CategoryID: c.Data.ID}) + + e1ID := &e1.Data.ID + e2ID := &e2.Data.ID + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2018, 9, 5, 17, 13, 29, 45256, time.UTC), + Amount: decimal.NewFromFloat(2.718), + Note: "This was an important expense", + EnvelopeID: e1ID, + SourceAccountID: a1.Data.ID, + DestinationAccountID: a2.Data.ID, + ReconciledSource: true, + ReconciledDestination: false, + }) + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2016, 5, 1, 14, 13, 25, 584575, time.UTC), + Amount: decimal.NewFromFloat(11235.813), + Note: "Not important", + EnvelopeID: e2ID, + SourceAccountID: a2.Data.ID, + DestinationAccountID: a1.Data.ID, + ReconciledSource: true, + ReconciledDestination: true, + }) + + _ = createTestTransaction(suite.T(), v4.TransactionEditable{ + Date: time.Date(2021, 2, 6, 5, 1, 0, 585, time.UTC), + Amount: decimal.NewFromFloat(2.718), + Note: "", + EnvelopeID: e1ID, + SourceAccountID: a3.Data.ID, + DestinationAccountID: a2.Data.ID, + ReconciledSource: false, + ReconciledDestination: true, + }) + + tests := []struct { + name string + query string + len int + }{ + {"After all dates", fmt.Sprintf("fromDate=%s", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 0}, + {"After date", fmt.Sprintf("fromDate=%s", time.Date(2017, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 2}, + {"Amount less or equal to 2.71", "amountLessOrEqual=2.71", 0}, + {"Amount less or equal to 2.718", "amountLessOrEqual=2.718", 2}, + {"Amount less or equal to 1000", "amountLessOrEqual=1000", 2}, + {"Amount more or equal to 1 and less than 3", "amountMoreOrEqual=1&amountLessOrEqual=3", 2}, + {"Amount more or equal to 2.718", "amountMoreOrEqual=2.718", 3}, + {"Amount more or equal to 100 and less than 10", "amountMoreOrEqual=100&amountLessOrEqual=10", 0}, + {"Amount more or equal to 100", "amountMoreOrEqual=100", 1}, + {"Amount more or equal to 11235.813", "amountMoreOrEqual=11235.813", 1}, + {"Amount more or equal to 99999", "amountMoreOrEqual=99999", 0}, + {"Before all dates", fmt.Sprintf("untilDate=%s", time.Date(2010, 8, 17, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 0}, + {"Before date", fmt.Sprintf("untilDate=%s", time.Date(2017, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 1}, + {"Between two dates", fmt.Sprintf("untilDate=%s&fromDate=%s", time.Date(2019, 8, 17, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), time.Date(2015, 12, 24, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 2}, + {"Budget Match", fmt.Sprintf("budget=%s", b.Data.ID), 3}, + {"Destination Account", fmt.Sprintf("destination=%s", a2.Data.ID), 2}, + {"Envelope 2", fmt.Sprintf("envelope=%s", e2.Data.ID), 1}, + {"Exact Amount", fmt.Sprintf("amount=%s", decimal.NewFromFloat(2.718).String()), 2}, + {"Exact Time", fmt.Sprintf("date=%s", time.Date(2021, 2, 6, 5, 1, 0, 585, time.UTC).Format(time.RFC3339Nano)), 1}, + {"Existing Account 1", fmt.Sprintf("account=%s", a1.Data.ID), 2}, + {"Existing Account 2", fmt.Sprintf("account=%s", a2.Data.ID), 3}, + {"Fuzzy note", "note=important", 2}, + {"Impossible between two dates", fmt.Sprintf("fromDate=%s&untilDate=%s", time.Date(2019, 8, 17, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), time.Date(2015, 12, 24, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 0}, + {"Limit and Fuzzy Note", "limit=1¬e=important", 1}, + {"Limit and Offset", "limit=1&offset=1", 1}, + {"Limit negative", "limit=-123", 3}, + {"Limit positive", "limit=2", 2}, + {"Limit unset", "limit=-1", 3}, + {"Limit zero", "limit=0", 0}, + {"No note", "note=", 1}, + {"Non-existing Account", "account=534a3562-c5e8-46d1-a2e2-e96c00e7efec", 0}, + {"Non-existing Source Account", "source=3340a084-acf8-4cb4-8f86-9e7f88a86190", 0}, + {"Not reconciled in destination account", "reconciledDestination=false", 1}, + {"Not reconciled in source account", "reconciledSource=false", 1}, + {"Note", "note=Not important", 1}, + {"Offset and Fuzzy Note", "offset=2¬e=important", 0}, + {"Offset higher than number", "offset=5", 0}, + {"Offset positive", "offset=2", 1}, + {"Offset zero", "offset=0", 3}, + {"Reconciled in destination account", "reconciledDestination=true", 2}, + {"Reconciled in source account", "reconciledSource=true", 2}, + {"Regression - For 'account', query needs to be ORed between the accounts and ANDed with all other conditions", fmt.Sprintf("note=&account=%s", a2.Data.ID), 1}, + {"Regression #749", fmt.Sprintf("untilDate=%s", time.Date(2021, 2, 6, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 3}, + {"Same date", fmt.Sprintf("date=%s", time.Date(2021, 2, 6, 7, 0, 0, 700, time.UTC).Format(time.RFC3339Nano)), 1}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var re v4.TransactionListResponse + r := test.Request(t, http.MethodGet, fmt.Sprintf("/v4/transactions?%s", tt.query), "") + test.AssertHTTPStatus(t, &r, http.StatusOK) + test.DecodeResponse(t, &r, &re) + + assert.Equal(t, tt.len, len(re.Data), "Request ID: %s", r.Result().Header.Get("x-request-id")) + }) + } +} + +// TestTransactionsGetInvalidQuery verifies that invalid filtering queries +// return a HTTP Bad Request. +func (suite *TestSuiteStandard) TestTransactionsGetInvalidQuery() { + tests := []string{ + "source=MaybeADog", + "destination=OrARat?", + "envelope=NopeDefinitelyAMole", + "date=A long time ago", + "amount=Seventeen Cents", + "reconciledSource=I don't think so", + "account=ItIsAHippo!", + "offset=-1", // offset is a uint + "limit=name", // limit is an int + } + + for _, tt := range tests { + suite.T().Run(tt, func(t *testing.T) { + recorder := test.Request(t, http.MethodGet, fmt.Sprintf("http://example.com/v4/transactions?%s", tt), "") + test.AssertHTTPStatus(t, &recorder, http.StatusBadRequest) + }) + } +} + +// TestTransactionsCreateInvalidBody verifies that creation of transactions +// with an unparseable request body returns a HTTP Bad Request. +func (suite *TestSuiteStandard) TestTransactionsCreateInvalidBody() { + r := test.Request(suite.T(), http.MethodPost, "http://example.com/v4/transactions", `{ Invalid request": Body }`) + test.AssertHTTPStatus(suite.T(), &r, http.StatusBadRequest) + + var tr v4.TransactionCreateResponse + test.DecodeResponse(suite.T(), &r, &tr) + + assert.Equal(suite.T(), httperrors.ErrInvalidBody.Error(), *tr.Error) + assert.Nil(suite.T(), tr.Data) +} + +// TestTransactionsCreate verifies that transaction creation works. +func (suite *TestSuiteStandard) TestTransactionsCreate() { + budget := createTestBudget(suite.T(), v4.BudgetEditable{}) + internalAccount := createTestAccount(suite.T(), v4.AccountEditable{External: false, BudgetID: budget.Data.ID, Name: "TestTransactionsCreate Internal"}) + externalAccount := createTestAccount(suite.T(), v4.AccountEditable{External: true, BudgetID: budget.Data.ID, Name: "TestTransactionsCreate External"}) + + tests := []struct { + name string + transactions []models.Transaction + expectedStatus int + expectedError *error // Error expected in the response + expectedErrors []string // Errors expected for the individual transactions + }{ + { + "One success, one fail", + []models.Transaction{ + { + SourceAccountID: uuid.New(), + Amount: decimal.NewFromFloat(17.23), + Note: "v4 non-existing budget ID", + }, + { + SourceAccountID: internalAccount.Data.ID, + DestinationAccountID: externalAccount.Data.ID, + Amount: decimal.NewFromFloat(57.01), + }, + }, + http.StatusNotFound, + nil, + []string{ + "there is no Account with this ID", + "", + }, + }, + { + "Both succeed", + []models.Transaction{ + { + SourceAccountID: internalAccount.Data.ID, + DestinationAccountID: externalAccount.Data.ID, + Amount: decimal.NewFromFloat(17.23), + }, + { + SourceAccountID: internalAccount.Data.ID, + DestinationAccountID: externalAccount.Data.ID, + Amount: decimal.NewFromFloat(57.01), + }, + }, + http.StatusCreated, + nil, + []string{ + "", + "", + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPost, "http://example.com/v4/transactions", tt.transactions) + test.AssertHTTPStatus(t, &r, tt.expectedStatus) + + var tr v4.TransactionCreateResponse + test.DecodeResponse(t, &r, &tr) + + for i, transaction := range tr.Data { + if tt.expectedErrors[i] == "" { + assert.Equal(t, fmt.Sprintf("http://example.com/v4/transactions/%s", transaction.Data.ID), transaction.Data.Links.Self) + } else { + // This needs to be in the else to prevent nil pointer errors since we're dereferencing pointers + assert.Equal(t, tt.expectedErrors[i], *transaction.Error) + } + } + }) + } +} + +// TestTransactionsGetSingle verifies that a transaction can be read from the API via its link +// and that the link is for API v4. +func (suite *TestSuiteStandard) TestTransactionsGetSingle() { + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + id string // String to use as ID. Ignored when pathFunc is non-nil + pathFunc func() string // Function returning the path + }{ + { + "Standard transaction", + http.StatusOK, + "", + func() string { + return createTestTransaction(suite.T(), v4.TransactionEditable{Amount: decimal.NewFromFloat(13.71)}).Data.Links.Self + }, + }, + { + "Invalid UUID", + http.StatusBadRequest, + "NotParseableAsUUID", + nil, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var p string + if tt.pathFunc != nil { + p = tt.pathFunc() + } else { + p = fmt.Sprintf("%s/%s", "http://example.com/v4/transactions", tt.id) + } + + r := test.Request(suite.T(), http.MethodGet, p, "") + test.AssertHTTPStatus(suite.T(), &r, tt.status) + }) + } +} + +// TestTransactionsDelete verifies the correct success and error responses +// for DELETE requests. +func (suite *TestSuiteStandard) TestTransactionsDelete() { + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + id string // String to use as ID. + }{ + { + "Standard deletion", + http.StatusNoContent, + createTestTransaction(suite.T(), v4.TransactionEditable{Amount: decimal.NewFromFloat(123.12)}).Data.ID.String(), + }, + { + "Does not exist", + http.StatusNotFound, + "4bcb6d09-ced1-41e8-a3fe-bf4f16c5e501", + }, + { + "Null transaction", + http.StatusBadRequest, + "00000000-0000-0000-0000-000000000000", + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + p := fmt.Sprintf("%s/%s", "http://example.com/v4/transactions", tt.id) + + r := test.Request(t, http.MethodDelete, p, "") + test.AssertHTTPStatus(t, &r, tt.status) + }) + } +} + +// TestTransactionsUpdateFail verifies that transaction updates fail where they should. +func (suite *TestSuiteStandard) TestTransactionsUpdateFail() { + transaction := createTestTransaction(suite.T(), v4.TransactionEditable{Amount: decimal.NewFromFloat(584.42), Note: "Test note for transaction"}) + + tests := []struct { + name string // Name for the test + status int // Expected HTTP status + body any // Body to send to the PATCH endpoint + }{ + { + "Source Equals Destination", + http.StatusBadRequest, + map[string]any{ + "destinationAccountId": transaction.Data.SourceAccountID, + }, + }, + { + "Invalid body", + http.StatusBadRequest, + `{ "amount": 2" }`, + }, + { + "Invalid type", + http.StatusBadRequest, + map[string]any{ + "amount": false, + }, + }, + { + "Negative amount", + http.StatusBadRequest, + `{ "amount": -58.23 }`, + }, + { + "Empty source account", + http.StatusNotFound, + models.Transaction{SourceAccountID: uuid.New()}, + }, + { + "Empty destination account", + http.StatusNotFound, + models.Transaction{DestinationAccountID: uuid.New()}, + }, + { + "Non-existing envelope", + http.StatusNotFound, + `{ "envelopeId": "e6fa8eb5-5f2c-4292-8ef9-02f0c2af1ce4" }`, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPatch, transaction.Data.Links.Self, tt.body) + test.AssertHTTPStatus(t, &r, tt.status) + }) + } +} + +// TestUpdateNonExistingTransaction verifies that patching a non-existent transaction returns a 404. +func (suite *TestSuiteStandard) TestUpdateNonExistingTransaction() { + recorder := test.Request(suite.T(), http.MethodPatch, "http://example.com/v4/transactions/6ae3312c-23cf-4225-9a81-4f218ba41b00", `{ "note": "2" }`) + test.AssertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) +} + +// TestTransactionsUpdate verifies that transaction updates are successful. +func (suite *TestSuiteStandard) TestTransactionsUpdate() { + envelope := createTestEnvelope(suite.T(), v4.EnvelopeEditable{}) + transaction := createTestTransaction(suite.T(), v4.TransactionEditable{ + Amount: decimal.NewFromFloat(23.14), + Note: "Test note for transaction", + SourceAccountID: createTestAccount(suite.T(), v4.AccountEditable{Name: "Internal Source Account", External: false}).Data.ID, + DestinationAccountID: createTestAccount(suite.T(), v4.AccountEditable{Name: "External destination account", External: true}).Data.ID, + EnvelopeID: &envelope.Data.ID, + }) + + tests := []struct { + name string // Name for the test + body any // Body to send to the PATCH endpoint + }{ + { + "Empty note", + map[string]any{ + "note": "", + }, + }, + { + "No Envelope", + `{ "envelopeId": null }`, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(t, http.MethodPatch, transaction.Data.Links.Self, tt.body) + test.AssertHTTPStatus(t, &r, http.StatusOK) + }) + } +} diff --git a/pkg/controllers/v4/transaction_types.go b/pkg/controllers/v4/transaction_types.go new file mode 100644 index 00000000..1337d779 --- /dev/null +++ b/pkg/controllers/v4/transaction_types.go @@ -0,0 +1,164 @@ +package v4 + +import ( + "fmt" + "time" + + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +type TransactionEditable struct { + Date time.Time `json:"date" example:"1815-12-10T18:43:00.271152Z"` // Date of the transaction. Time is currently only used for sorting + + // The maximum value is "999999999999.99999999", swagger unfortunately rounds this. + Amount decimal.Decimal `json:"amount" example:"14.03" minimum:"0.00000001" maximum:"999999999999.99999999" multipleOf:"0.00000001"` // The amount for the transaction + + Note string `json:"note" example:"Lunch" default:""` // A note + SourceAccountID uuid.UUID `json:"sourceAccountId" example:"fd81dc45-a3a2-468e-a6fa-b2618f30aa45"` // ID of the source account + DestinationAccountID uuid.UUID `json:"destinationAccountId" example:"8e16b456-a719-48ce-9fec-e115cfa7cbcc"` // ID of the destination account + EnvelopeID *uuid.UUID `json:"envelopeId" example:"2649c965-7999-4873-ae16-89d5d5fa972e"` // ID of the envelope + ReconciledSource bool `json:"reconciledSource" example:"true" default:"false"` // Is the transaction reconciled in the source account? + ReconciledDestination bool `json:"reconciledDestination" example:"true" default:"false"` // Is the transaction reconciled in the destination account? + + AvailableFrom types.Month `json:"availableFrom" example:"2021-11-17T00:00:00Z"` // The date from which on the transaction amount is available for budgeting. Only used for income transactions. Defaults to the transaction date. + + ImportHash string `json:"importHash" example:"867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" default:""` // The SHA256 hash of a unique combination of values to use in duplicate detection +} + +// model returns the database resource for the API representation of the editable fields +func (editable TransactionEditable) model() models.Transaction { + return models.Transaction{ + Date: editable.Date, + Amount: editable.Amount, + Note: editable.Note, + SourceAccountID: editable.SourceAccountID, + DestinationAccountID: editable.DestinationAccountID, + EnvelopeID: editable.EnvelopeID, + ReconciledSource: editable.ReconciledSource, + ReconciledDestination: editable.ReconciledDestination, + AvailableFrom: editable.AvailableFrom, + ImportHash: editable.ImportHash, + } +} + +type TransactionLinks struct { + Self string `json:"self" example:"https://example.com/api/v4/transactions/d430d7c3-d14c-4712-9336-ee56965a6673"` // The transaction itself +} + +// Transaction is the representation of a Transaction in API v4. +type Transaction struct { + models.DefaultModel + TransactionEditable + Links TransactionLinks `json:"links"` +} + +// newTransaction returns the API v4 representation of the resource +func newTransaction(c *gin.Context, model models.Transaction) Transaction { + url := c.GetString(string(models.DBContextURL)) + + return Transaction{ + DefaultModel: model.DefaultModel, + TransactionEditable: TransactionEditable{ + Date: model.Date, + Amount: model.Amount, + Note: model.Note, + SourceAccountID: model.SourceAccountID, + DestinationAccountID: model.DestinationAccountID, + EnvelopeID: model.EnvelopeID, + ReconciledSource: model.ReconciledSource, + ReconciledDestination: model.ReconciledDestination, + AvailableFrom: model.AvailableFrom, + ImportHash: model.ImportHash, + }, + Links: TransactionLinks{ + Self: fmt.Sprintf("%s/v4/transactions/%s", url, model.ID), + }, + } +} + +type TransactionListResponse struct { + Data []Transaction `json:"data"` // List of transactions + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Pagination *Pagination `json:"pagination"` // Pagination information +} + +type TransactionCreateResponse struct { + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Data []TransactionResponse `json:"data"` // List of created Transactions +} + +func (t *TransactionCreateResponse) appendError(err httperrors.Error, status int) int { + s := err.Error() + t.Data = append(t.Data, TransactionResponse{Error: &s}) + + // The final status code is the highest HTTP status code number + if err.Status > status { + status = err.Status + } + + return status +} + +type TransactionResponse struct { + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred for this transaction + Data *Transaction `json:"data"` // The Transaction data, if creation was successful +} + +type TransactionQueryFilter struct { + Date time.Time `form:"date" filterField:"false"` // Exact date. Time is ignored. + FromDate time.Time `form:"fromDate" filterField:"false"` // From this date. Time is ignored. + UntilDate time.Time `form:"untilDate" filterField:"false"` // Until this date. Time is ignored. + Amount decimal.Decimal `form:"amount"` // Exact amount + AmountLessOrEqual decimal.Decimal `form:"amountLessOrEqual" filterField:"false"` // Amount less than or equal to this + AmountMoreOrEqual decimal.Decimal `form:"amountMoreOrEqual" filterField:"false"` // Amount more than or equal to this + Note string `form:"note" filterField:"false"` // Note contains this string + BudgetID string `form:"budget" filterField:"false"` // ID of the budget + SourceAccountID string `form:"source"` // ID of the source account + DestinationAccountID string `form:"destination"` // ID of the destination account + EnvelopeID string `form:"envelope"` // ID of the envelope + ReconciledSource bool `form:"reconciledSource"` // Is the transaction reconciled in the source account? + ReconciledDestination bool `form:"reconciledDestination"` // Is the transaction reconciled in the destination account? + AccountID string `form:"account" filterField:"false"` // ID of either source or destination account + Offset uint `form:"offset" filterField:"false"` // The offset of the first Transaction returned. Defaults to 0. + Limit int `form:"limit" filterField:"false"` // Maximum number of transactions to return. Defaults to 50. +} + +func (f TransactionQueryFilter) model() (models.Transaction, httperrors.Error) { + sourceAccountID, err := httputil.UUIDFromString(f.SourceAccountID) + if !err.Nil() { + return models.Transaction{}, err + } + + destinationAccountID, err := httputil.UUIDFromString(f.DestinationAccountID) + if !err.Nil() { + return models.Transaction{}, err + } + + envelopeID, err := httputil.UUIDFromString(f.EnvelopeID) + if !err.Nil() { + return models.Transaction{}, err + } + + // If the envelopeID is nil, use an actual nil, not uuid.Nil + var eID *uuid.UUID + if envelopeID != uuid.Nil { + eID = &envelopeID + } + + // This does not set the string or date fields since they are + // handled in the controller function + return TransactionEditable{ + Amount: f.Amount, + SourceAccountID: sourceAccountID, + DestinationAccountID: destinationAccountID, + EnvelopeID: eID, + ReconciledSource: f.ReconciledSource, + ReconciledDestination: f.ReconciledDestination, + }.model(), httperrors.Error{} +} diff --git a/pkg/controllers/v4/types.go b/pkg/controllers/v4/types.go new file mode 100644 index 00000000..abb1b985 --- /dev/null +++ b/pkg/controllers/v4/types.go @@ -0,0 +1,15 @@ +package v4 + +import "time" + +type URITime struct { + Time time.Time `uri:"time" example:"2024-01-07T18:43:00.271152Z"` +} + +type URIMonth struct { + Month time.Time `uri:"month" time_format:"2006-01" time_utc:"1" example:"2013-11"` // Year and month +} + +type QueryMonth struct { + Month time.Time `form:"month" time_format:"2006-01" time_utc:"1" example:"2022-07"` // Year and month +} diff --git a/pkg/models/account.go b/pkg/models/account.go index 837a1ff7..53ce7e91 100644 --- a/pkg/models/account.go +++ b/pkg/models/account.go @@ -87,6 +87,7 @@ func (a Account) Transactions(db *gorm.DB) []Transaction { } // Transactions returns all transactions for this account. +// TODO: Remove in favor of ReconciledBalance func (a Account) SumReconciled(db *gorm.DB) (balance decimal.Decimal, err error) { var transactions []Transaction @@ -120,6 +121,8 @@ func (a Account) SumReconciled(db *gorm.DB) (balance decimal.Decimal, err error) // // The balance Decimal is the actual account balance, factoring in all transactions before the end of the month. // The available Decimal is the sum that is available for budgeting at the end of the specified month. +// +// TODO: Get rid of this in favor of Balance() func (a Account) GetBalanceMonth(db *gorm.DB, month types.Month) (balance, available decimal.Decimal, err error) { var transactions []Transaction @@ -165,6 +168,72 @@ func (a Account) GetBalanceMonth(db *gorm.DB, month types.Month) (balance, avail return } +// Balance calculates the balance of the account at a specific point in time, including all transactions +func (a Account) Balance(db *gorm.DB, time time.Time) (balance decimal.Decimal, err error) { + var transactions []Transaction + + query := db. + Preload("DestinationAccount"). + Preload("SourceAccount"). + Where( + db.Where(Transaction{DestinationAccountID: a.ID}). + Or(db.Where(Transaction{SourceAccountID: a.ID}))). + Where("datetime(transactions.date) < datetime(?)", time) + + err = query.Find(&transactions).Error + if err != nil { + return decimal.Zero, err + } + + if a.InitialBalanceDate != nil && time.After(*a.InitialBalanceDate) { + balance = a.InitialBalance + } + + // Add incoming transactions, subtract outgoing transactions + for _, transaction := range transactions { + if transaction.DestinationAccountID == a.ID { + balance = balance.Add(transaction.Amount) + } else { + balance = balance.Sub(transaction.Amount) + } + } + + return +} + +// ReconciledBalance calculates the reconciled balance at a specific point in time +func (a Account) ReconciledBalance(db *gorm.DB, time time.Time) (balance decimal.Decimal, err error) { + var transactions []Transaction + + err = db. + Preload("DestinationAccount"). + Preload("SourceAccount"). + Where( + db.Where(Transaction{DestinationAccountID: a.ID, ReconciledDestination: true}). + Or(db.Where(Transaction{SourceAccountID: a.ID, ReconciledSource: true}))). + Where("datetime(transactions.date) < datetime(?)", time). + Find(&transactions).Error + + if err != nil { + return decimal.Zero, err + } + + if a.InitialBalanceDate != nil && time.After(*a.InitialBalanceDate) { + balance = a.InitialBalance + } + + // Add incoming transactions, subtract outgoing transactions + for _, t := range transactions { + if t.DestinationAccountID == a.ID { + balance = balance.Add(t.Amount) + } else { + balance = balance.Sub(t.Amount) + } + } + + return +} + // SetRecentEnvelopes returns the most common envelopes used in the last 50 // transactions where the account is the destination account. // diff --git a/pkg/models/account_test.go b/pkg/models/account_test.go index b0e64a0f..3dcff899 100644 --- a/pkg/models/account_test.go +++ b/pkg/models/account_test.go @@ -114,11 +114,19 @@ func (suite *TestSuiteStandard) TestAccountCalculations() { reconciled, err = account.SumReconciled(models.DB) assert.Nil(suite.T(), err) + balanceOnly, err := account.Balance(models.DB, time.Now().AddDate(1, 0, 0)) // Adding a year so that we cover all transactions + assert.Nil(suite.T(), err) + + reconciledOnly, err := account.ReconciledBalance(models.DB, time.Now()) + assert.Nil(suite.T(), err) + expected = outgoingTransaction.Amount.Neg().Add(account.InitialBalance).Add(decimal.NewFromFloat(100)) // Add 100 for futureIncomeTransaction assert.True(suite.T(), balance.Equal(expected), "Balance for account is not correct. Should be: %v but is %v", expected, balance) + assert.True(suite.T(), balanceOnly.Equal(expected), "Balance for account is not correct. Should be: %v but is %v", expected, balanceOnly) expected = decimal.NewFromFloat(0).Add(account.InitialBalance) assert.True(suite.T(), reconciled.Equal(expected), "Reconciled balance for account is not correct. Should be: %v but is %v", expected, reconciled) + assert.True(suite.T(), reconciledOnly.Equal(expected), "Reconciled balance for account is not correct. Should be: %v but is %v", expected, reconciledOnly) } func (suite *TestSuiteStandard) TestAccountTransactions() { diff --git a/pkg/router/router.go b/pkg/router/router.go index 6db4659d..e1e68685 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -10,6 +10,7 @@ import ( "github.com/envelope-zero/backend/v4/pkg/controllers/healthz" "github.com/envelope-zero/backend/v4/pkg/controllers/root" v3 "github.com/envelope-zero/backend/v4/pkg/controllers/v3" + v4 "github.com/envelope-zero/backend/v4/pkg/controllers/v4" version_controller "github.com/envelope-zero/backend/v4/pkg/controllers/version" "github.com/envelope-zero/backend/v4/pkg/httperrors" "github.com/gin-contrib/cors" @@ -140,4 +141,19 @@ func AttachRoutes(group *gin.RouterGroup) { v3.RegisterMonthRoutes(v3Group.Group("/months")) v3.RegisterTransactionRoutes(v3Group.Group("/transactions")) } + + { + v4Group := group.Group("/v4") + v4.RegisterRootRoutes(v4Group.Group("")) + v4.RegisterAccountRoutes(v4Group.Group("/accounts")) + v4.RegisterBudgetRoutes(v4Group.Group("/budgets")) + v4.RegisterCategoryRoutes(v4Group.Group("/categories")) + v4.RegisterEnvelopeRoutes(v4Group.Group("/envelopes")) + v4.RegisterGoalRoutes(v4Group.Group("/goals")) + v4.RegisterImportRoutes(v4Group.Group("/import")) + v4.RegisterMatchRuleRoutes(v4Group.Group("/match-rules")) + v4.RegisterMonthConfigRoutes(v4Group.Group("/envelopes")) + v4.RegisterMonthRoutes(v4Group.Group("/months")) + v4.RegisterTransactionRoutes(v4Group.Group("/transactions")) + } }