From 66bbdefd6f4e1ce417f571e337753f563c71ccfa Mon Sep 17 00:00:00 2001 From: Morre Date: Sun, 31 Dec 2023 15:52:51 +0100 Subject: [PATCH 01/15] chore!: remove API v1 and v2 This removes the API versions v1 and v2. This commit also removes the allocation model and migrates all allocations to month configs. BREAKING CHANGE: removes /v1 and /v2 APIs --- api/docs.go | 8733 ++++------------------ api/swagger.json | 8733 ++++------------------ api/swagger.yaml | 4248 +---------- pkg/controllers/account_v1.go | 368 - pkg/controllers/account_v1_test.go | 343 - pkg/controllers/account_v2.go | 157 - pkg/controllers/account_v2_test.go | 132 - pkg/controllers/account_v3_test.go | 4 +- pkg/controllers/allocation.go | 337 - pkg/controllers/allocation_test.go | 334 - pkg/controllers/budget_v1.go | 682 -- pkg/controllers/budget_v1_test.go | 718 -- pkg/controllers/category_v1.go | 347 - pkg/controllers/category_v1_test.go | 313 - pkg/controllers/category_v3_test.go | 6 +- pkg/controllers/cleanup.go | 64 - pkg/controllers/cleanup_test.go | 52 - pkg/controllers/cleanup_v3.go | 2 +- pkg/controllers/cleanup_v3_test.go | 20 +- pkg/controllers/database.go | 15 - pkg/controllers/envelope_v1.go | 378 - pkg/controllers/envelope_v1_test.go | 447 -- pkg/controllers/generics.go | 29 - pkg/controllers/import_v1.go | 259 - pkg/controllers/import_v1_test.go | 329 - pkg/controllers/match_rule_v2.go | 330 - pkg/controllers/match_rule_v2_test.go | 118 - pkg/controllers/month_config_v1.go | 408 - pkg/controllers/month_config_v1_test.go | 312 - pkg/controllers/month_config_v3.go | 60 +- pkg/controllers/month_config_v3_test.go | 49 +- pkg/controllers/month_config_v3_types.go | 13 + pkg/controllers/month_v1.go | 211 - pkg/controllers/month_v1_test.go | 280 - pkg/controllers/month_v3.go | 58 +- pkg/controllers/month_v3_test.go | 92 +- pkg/controllers/rename_rule.go | 320 - pkg/controllers/test_create_test.go | 9 +- pkg/controllers/test_helpers_test.go | 8 - pkg/controllers/test_list_test.go | 6 +- pkg/controllers/test_options_test.go | 13 - pkg/controllers/transaction.go | 79 - pkg/controllers/transaction_v1.go | 425 -- pkg/controllers/transaction_v1_test.go | 711 -- pkg/controllers/transaction_v2.go | 116 - pkg/controllers/transaction_v2_test.go | 89 - pkg/importer/creator.go | 16 +- pkg/importer/parser/ynab4/parse.go | 48 +- pkg/importer/parser/ynab4/parse_test.go | 28 - pkg/importer/types.go | 7 - pkg/models/allocation.go | 36 - pkg/models/allocation_test.go | 23 - pkg/models/budget.go | 123 +- pkg/models/budget_test.go | 243 +- pkg/models/database.go | 59 +- pkg/models/database_test.go | 29 + pkg/models/envelope.go | 55 +- pkg/models/envelope_test.go | 20 +- pkg/models/month_config.go | 37 +- pkg/models/test_suite_test.go | 12 - pkg/router/router.go | 128 +- pkg/router/router_test.go | 271 - 62 files changed, 3771 insertions(+), 28121 deletions(-) delete mode 100644 pkg/controllers/account_v1.go delete mode 100644 pkg/controllers/account_v1_test.go delete mode 100644 pkg/controllers/account_v2.go delete mode 100644 pkg/controllers/account_v2_test.go delete mode 100644 pkg/controllers/allocation.go delete mode 100644 pkg/controllers/allocation_test.go delete mode 100644 pkg/controllers/budget_v1.go delete mode 100644 pkg/controllers/budget_v1_test.go delete mode 100644 pkg/controllers/category_v1.go delete mode 100644 pkg/controllers/category_v1_test.go delete mode 100644 pkg/controllers/cleanup.go delete mode 100644 pkg/controllers/cleanup_test.go delete mode 100644 pkg/controllers/envelope_v1.go delete mode 100644 pkg/controllers/envelope_v1_test.go delete mode 100644 pkg/controllers/import_v1.go delete mode 100644 pkg/controllers/import_v1_test.go delete mode 100644 pkg/controllers/match_rule_v2.go delete mode 100644 pkg/controllers/match_rule_v2_test.go delete mode 100644 pkg/controllers/month_config_v1.go delete mode 100644 pkg/controllers/month_config_v1_test.go create mode 100644 pkg/controllers/month_config_v3_types.go delete mode 100644 pkg/controllers/month_v1.go delete mode 100644 pkg/controllers/month_v1_test.go delete mode 100644 pkg/controllers/rename_rule.go delete mode 100644 pkg/controllers/transaction_v1.go delete mode 100644 pkg/controllers/transaction_v1_test.go delete mode 100644 pkg/controllers/transaction_v2.go delete mode 100644 pkg/controllers/transaction_v2_test.go delete mode 100644 pkg/models/allocation.go delete mode 100644 pkg/models/allocation_test.go delete mode 100644 pkg/router/router_test.go diff --git a/api/docs.go b/api/docs.go index 5693c168..35e84d8c 100644 --- a/api/docs.go +++ b/api/docs.go @@ -79,19 +79,18 @@ const docTemplate = `{ } } }, - "/v1": { + "/v3": { "get": { - "description": "Returns general information about the v1 API", + "description": "Returns general information about the v3 API", "tags": [ - "v1" + "v3" ], - "summary": "v1 API", - "deprecated": true, + "summary": "v3 API", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/router.V1Response" + "$ref": "#/definitions/router.V3Response" } } } @@ -99,14 +98,27 @@ const docTemplate = `{ "delete": { "description": "Permanently deletes all resources", "tags": [ - "v1" + "v3" ], "summary": "Delete everything", - "deprecated": true, + "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": { @@ -118,10 +130,9 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "v1" + "v3" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -129,7 +140,7 @@ const docTemplate = `{ } } }, - "/v1/accounts": { + "/v3/accounts": { "get": { "description": "Returns a list of accounts", "produces": [ @@ -139,7 +150,6 @@ const docTemplate = `{ "Accounts" ], "summary": "List accounts", - "deprecated": true, "parameters": [ { "type": "string", @@ -173,8 +183,8 @@ const docTemplate = `{ }, { "type": "boolean", - "description": "Is the account hidden?", - "name": "hidden", + "description": "Is the account archived?", + "name": "archived", "in": "query" }, { @@ -182,47 +192,61 @@ const docTemplate = `{ "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/controllers.AccountListResponse" + "$ref": "#/definitions/controllers.AccountListResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountListResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountListResponseV3" } } } }, "post": { - "description": "Creates a new account", + "description": "Creates new accounts", "produces": [ "application/json" ], "tags": [ "Accounts" ], - "summary": "Create account", - "deprecated": true, + "summary": "Creates accounts", "parameters": [ { - "description": "Account", - "name": "account", + "description": "Accounts", + "name": "accounts", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.AccountCreate" + "type": "array", + "items": { + "$ref": "#/definitions/controllers.AccountCreateV3" + } } } ], @@ -230,25 +254,25 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.AccountResponse" + "$ref": "#/definitions/controllers.AccountCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountCreateResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountCreateResponseV3" } } } @@ -259,7 +283,6 @@ const docTemplate = `{ "Accounts" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -267,7 +290,7 @@ const docTemplate = `{ } } }, - "/v1/accounts/{id}": { + "/v3/accounts/{id}": { "get": { "description": "Returns a specific account", "produces": [ @@ -277,7 +300,6 @@ const docTemplate = `{ "Accounts" ], "summary": "Get account", - "deprecated": true, "parameters": [ { "type": "string", @@ -291,25 +313,25 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.AccountResponse" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } } } @@ -323,7 +345,6 @@ const docTemplate = `{ "Accounts" ], "summary": "Delete account", - "deprecated": true, "parameters": [ { "type": "string", @@ -363,7 +384,6 @@ const docTemplate = `{ "Accounts" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", @@ -406,7 +426,6 @@ const docTemplate = `{ "Accounts" ], "summary": "Update account", - "deprecated": true, "parameters": [ { "type": "string", @@ -421,7 +440,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.AccountCreate" + "$ref": "#/definitions/controllers.AccountCreateV3" } } ], @@ -429,58 +448,75 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.AccountResponse" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } } } } }, - "/v1/allocations": { + "/v3/budgets": { "get": { - "description": "Returns a list of allocations", + "description": "Returns a list of budgets", "produces": [ "application/json" ], "tags": [ - "Allocations" + "Budgets" ], - "summary": "Get allocations", - "deprecated": true, + "summary": "List budgets", "parameters": [ { "type": "string", - "description": "Filter by month", - "name": "month", + "description": "Filter by name", + "name": "name", "in": "query" }, { "type": "string", - "description": "Filter by amount", - "name": "amount", + "description": "Filter by note", + "name": "note", "in": "query" }, { "type": "string", - "description": "Filter by envelope ID", - "name": "envelope", + "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" } ], @@ -488,41 +524,37 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.AllocationListResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetListResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetListResponseV3" } } } }, "post": { - "description": "Create a new allocation of funds to an envelope for a specific month", + "description": "Creates a new budget", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Allocations" + "Budgets" ], - "summary": "Create allocations", - "deprecated": true, + "summary": "Create budget", "parameters": [ { - "description": "Allocation", - "name": "allocation", + "description": "Budget", + "name": "budget", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.AllocationCreate" + "$ref": "#/definitions/models.BudgetCreate" } } ], @@ -530,25 +562,19 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.AllocationResponse" + "$ref": "#/definitions/controllers.BudgetCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetCreateResponseV3" } } } @@ -556,10 +582,9 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Allocations" + "Budgets" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -567,17 +592,16 @@ const docTemplate = `{ } } }, - "/v1/allocations/{id}": { + "/v3/budgets/{id}": { "get": { - "description": "Returns a specific allocation", + "description": "Returns a specific budget", "produces": [ "application/json" ], "tags": [ - "Allocations" + "Budgets" ], - "summary": "Get allocation", - "deprecated": true, + "summary": "Get budget", "parameters": [ { "type": "string", @@ -591,36 +615,35 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.AllocationResponse" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } } } }, "delete": { - "description": "Deletes an allocation", + "description": "Deletes a budget", "tags": [ - "Allocations" + "Budgets" ], - "summary": "Delete allocation", - "deprecated": true, + "summary": "Delete budget", "parameters": [ { "type": "string", @@ -657,10 +680,9 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Allocations" + "Budgets" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", @@ -695,7 +717,7 @@ const docTemplate = `{ } }, "patch": { - "description": "Update an allocation. Only values to be updated need to be specified.", + "description": "Update an existing budget. Only values to be updated need to be specified.", "consumes": [ "application/json" ], @@ -703,10 +725,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Allocations" + "Budgets" ], - "summary": "Update allocation", - "deprecated": true, + "summary": "Update budget", "parameters": [ { "type": "string", @@ -716,12 +737,12 @@ const docTemplate = `{ "required": true }, { - "description": "Allocation", - "name": "allocation", + "description": "Budget", + "name": "budget", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.AllocationCreate" + "$ref": "#/definitions/models.BudgetCreate" } } ], @@ -729,41 +750,40 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.AllocationResponse" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } } } } }, - "/v1/budgets": { + "/v3/categories": { "get": { - "description": "Returns a list of budgets", + "description": "Returns a list of categories", "produces": [ "application/json" ], "tags": [ - "Budgets" + "Categories" ], - "summary": "List budgets", - "deprecated": true, + "summary": "Get categories", "parameters": [ { "type": "string", @@ -779,8 +799,14 @@ const docTemplate = `{ }, { "type": "string", - "description": "Filter by currency", - "name": "currency", + "description": "Filter by budget ID", + "name": "budget", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the category hidden?", + "name": "hidden", "in": "query" }, { @@ -788,44 +814,61 @@ const docTemplate = `{ "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/controllers.BudgetListResponse" + "$ref": "#/definitions/controllers.CategoryListResponseV3" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/controllers.CategoryListResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryListResponseV3" } } } }, "post": { - "description": "Creates a new budget", - "consumes": [ - "application/json" - ], + "description": "Creates a new category", "produces": [ "application/json" ], "tags": [ - "Budgets" + "Categories" ], - "summary": "Create budget", - "deprecated": true, + "summary": "Create category", "parameters": [ { - "description": "Budget", - "name": "budget", + "description": "Categories", + "name": "categories", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.BudgetCreate" + "type": "array", + "items": { + "$ref": "#/definitions/controllers.CategoryCreateV3" + } } } ], @@ -833,19 +876,25 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.BudgetResponse" + "$ref": "#/definitions/controllers.CategoryCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryCreateResponseV3" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/controllers.CategoryCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryCreateResponseV3" } } } @@ -853,10 +902,9 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Budgets" + "Categories" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -864,17 +912,16 @@ const docTemplate = `{ } } }, - "/v1/budgets/{id}": { + "/v3/categories/{id}": { "get": { - "description": "Returns a specific budget", + "description": "Returns a specific category", "produces": [ "application/json" ], "tags": [ - "Budgets" + "Categories" ], - "summary": "Get budget", - "deprecated": true, + "summary": "Get category", "parameters": [ { "type": "string", @@ -888,36 +935,35 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.BudgetResponse" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } } } }, "delete": { - "description": "Deletes a budget", + "description": "Deletes a category", "tags": [ - "Budgets" + "Categories" ], - "summary": "Delete budget", - "deprecated": true, + "summary": "Delete category", "parameters": [ { "type": "string", @@ -954,10 +1000,9 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Budgets" + "Categories" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", @@ -992,7 +1037,7 @@ const docTemplate = `{ } }, "patch": { - "description": "Update an existing budget. Only values to be updated need to be specified.", + "description": "Update an existing category. Only values to be updated need to be specified.", "consumes": [ "application/json" ], @@ -1000,10 +1045,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Budgets" + "Categories" ], - "summary": "Update budget", - "deprecated": true, + "summary": "Update category", "parameters": [ { "type": "string", @@ -1013,12 +1057,12 @@ const docTemplate = `{ "required": true }, { - "description": "Budget", - "name": "budget", + "description": "Category", + "name": "category", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.BudgetCreate" + "$ref": "#/definitions/controllers.CategoryCreateV3" } } ], @@ -1026,91 +1070,178 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.BudgetResponse" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } } } } }, - "/v1/budgets/{id}/{month}": { + "/v3/envelopes": { "get": { - "description": "Returns data about a budget for a for a specific month. **Use GET /month endpoint with month and budgetId query parameters instead.**", + "description": "Returns a list of envelopes", "produces": [ "application/json" ], "tags": [ - "Budgets" + "Envelopes" ], - "summary": "Get Budget month data", - "deprecated": true, + "summary": "Get envelopes", "parameters": [ { "type": "string", - "description": "ID formatted as string", - "name": "id", - "in": "path", - "required": true + "description": "Filter by name", + "name": "name", + "in": "query" }, { "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true + "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/controllers.BudgetMonthResponse" + "$ref": "#/definitions/controllers.EnvelopeListResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeListResponseV3" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/controllers.EnvelopeListResponseV3" + } + } + } + }, + "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/controllers.EnvelopeCreateV3" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" } } } }, "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs. **Use OPTIONS /month endpoint with month and budgetId query parameters instead.**", + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Budgets" + "Envelopes" ], "summary": "Allowed HTTP verbs", - "deprecated": true, + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v3/envelopes/{id}": { + "get": { + "description": "Returns a specific Envelope", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Get Envelope", "parameters": [ { "type": "string", @@ -1118,71 +1249,48 @@ const docTemplate = `{ "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" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.EnvelopeResponseV3" + } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } } } - } - }, - "/v1/budgets/{id}/{month}/allocations": { - "post": { - "description": "Sets allocations for a month for all envelopes that do not have an allocation yet. **Deprecated. Use POST /month endpoint with month and budgetId query parameters instead.**", + }, + "delete": { + "description": "Deletes an envelope", "tags": [ - "Budgets" + "Envelopes" ], - "summary": "Set allocations for a month", - "deprecated": true, + "summary": "Delete envelope", "parameters": [ { "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Budget ID formatted as string", + "description": "ID formatted as string", "name": "id", "in": "path", "required": true - }, - { - "description": "Budget", - "name": "mode", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controllers.BudgetAllocationMode" - } } ], "responses": { @@ -1209,24 +1317,16 @@ const docTemplate = `{ } } }, - "delete": { - "description": "Deletes all allocation for the specified month. **Use DELETE /month endpoint with month and budgetId query parameters instead.**", + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Budgets" + "Envelopes" ], - "summary": "Delete allocations for a month", - "deprecated": true, + "summary": "Allowed HTTP verbs", "parameters": [ { "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Budget ID formatted as string", + "description": "ID formatted as string", "name": "id", "in": "path", "required": true @@ -1242,6 +1342,12 @@ const docTemplate = `{ "$ref": "#/definitions/httperrors.HTTPError" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -1250,13 +1356,18 @@ const docTemplate = `{ } } }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs. **Use OPTIONS /month endpoint with month and budgetId query parameters instead.**", + "patch": { + "description": "Updates an existing envelope. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ - "Budgets" + "Envelopes" ], - "summary": "Allowed HTTP verbs", - "deprecated": true, + "summary": "Update envelope", "parameters": [ { "type": "string", @@ -1266,146 +1377,92 @@ const docTemplate = `{ "required": true }, { - "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true + "description": "Envelope", + "name": "envelope", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.EnvelopeCreateV3" + } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.EnvelopeResponseV3" + } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } } } } }, - "/v1/categories": { + "/v3/envelopes/{id}/{month}": { "get": { - "description": "Returns a list of categories", + "description": "Returns configuration for a specific month", "produces": [ "application/json" ], "tags": [ - "Categories" + "Envelopes" ], - "summary": "Get categories", - "deprecated": true, + "summary": "Get MonthConfig", "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 hidden?", - "name": "hidden", - "in": "query" + "description": "ID of the Envelope", + "name": "id", + "in": "path", + "required": true }, { "type": "string", - "description": "Search for this text in name and note", - "name": "search", - "in": "query" + "description": "The month in YYYY-MM format", + "name": "month", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.CategoryListResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - } - } - }, - "post": { - "description": "Creates a new category", - "produces": [ - "application/json" - ], - "tags": [ - "Categories" - ], - "summary": "Create category", - "deprecated": true, - "parameters": [ - { - "description": "Category", - "name": "category", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.CategoryCreate" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponse" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } } } @@ -1413,76 +1470,21 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Categories" + "Envelopes" ], "summary": "Allowed HTTP verbs", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v1/categories/{id}": { - "get": { - "description": "Returns a specific category", - "produces": [ - "application/json" - ], - "tags": [ - "Categories" - ], - "summary": "Get category", - "deprecated": true, "parameters": [ { "type": "string", - "description": "ID formatted as string", + "description": "ID of the Envelope", "name": "id", "in": "path", "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponse" - } - }, - "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 a category", - "tags": [ - "Categories" - ], - "summary": "Delete category", - "deprecated": true, - "parameters": [ { "type": "string", - "description": "ID formatted as string", - "name": "id", + "description": "The month in YYYY-MM format", + "name": "month", "in": "path", "required": true } @@ -1496,131 +1498,81 @@ const docTemplate = `{ "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", + "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": [ - "Categories" + "Envelopes" ], - "summary": "Allowed HTTP verbs", - "deprecated": true, + "summary": "Update MonthConfig", "parameters": [ { "type": "string", - "description": "ID formatted as string", + "description": "ID of the Envelope", "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", - "deprecated": true, - "parameters": [ { "type": "string", - "description": "ID formatted as string", - "name": "id", + "description": "The month in YYYY-MM format", + "name": "month", "in": "path", "required": true }, { - "description": "Category", - "name": "category", + "description": "MonthConfig", + "name": "monthConfig", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.CategoryCreate" + "$ref": "#/definitions/controllers.MonthConfigCreateV3" } } ], "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { - "$ref": "#/definitions/controllers.CategoryResponse" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } } } } }, - "/v1/envelopes": { + "/v3/goals": { "get": { - "description": "Returns a list of envelopes", + "description": "Returns a list of goals", "produces": [ "application/json" ], "tags": [ - "Envelopes" + "Goals" ], - "summary": "Get envelopes", - "deprecated": true, + "summary": "Get goals", "parameters": [ { "type": "string", @@ -1636,62 +1588,112 @@ const docTemplate = `{ }, { "type": "string", - "description": "Filter by category ID", - "name": "category", + "description": "Search for this text in name and note", + "name": "search", "in": "query" }, { "type": "boolean", - "description": "Is the envelope hidden?", - "name": "hidden", + "description": "Is the goal archived?", + "name": "archived", "in": "query" }, { "type": "string", - "description": "Search for this text in name and note", - "name": "search", + "description": "Filter by envelope ID", + "name": "envelope", "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeListResponse" - } }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } + { + "type": "string", + "description": "Month of the goal. Ignores exact time, matches on the month of the RFC3339 timestamp provided.", + "name": "month", + "in": "query" }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } + { + "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/controllers.GoalListResponseV3" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/controllers.GoalListResponseV3" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/controllers.GoalListResponseV3" + } } } }, "post": { - "description": "Creates a new envelope", + "description": "Creates new goals", "produces": [ "application/json" ], "tags": [ - "Envelopes" + "Goals" ], - "summary": "Create envelope", - "deprecated": true, + "summary": "Create goals", "parameters": [ { - "description": "Envelope", - "name": "envelope", + "description": "Goals", + "name": "goals", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.EnvelopeCreate" + "type": "array", + "items": { + "$ref": "#/definitions/controllers.GoalV3Editable" + } } } ], @@ -1699,25 +1701,25 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponse" + "$ref": "#/definitions/controllers.GoalCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalCreateResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalCreateResponseV3" } } } @@ -1725,10 +1727,9 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Envelopes" + "Goals" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -1736,17 +1737,16 @@ const docTemplate = `{ } } }, - "/v1/envelopes/{id}": { + "/v3/goals/{id}": { "get": { - "description": "Returns a specific envelope", + "description": "Returns a specific goal", "produces": [ "application/json" ], "tags": [ - "Envelopes" + "Goals" ], - "summary": "Get envelope", - "deprecated": true, + "summary": "Get goal", "parameters": [ { "type": "string", @@ -1760,36 +1760,35 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponse" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } } } }, "delete": { - "description": "Deletes an envelope", + "description": "Deletes a goal", "tags": [ - "Envelopes" + "Goals" ], - "summary": "Delete envelope", - "deprecated": true, + "summary": "Delete goal", "parameters": [ { "type": "string", @@ -1826,10 +1825,9 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Envelopes" + "Goals" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", @@ -1864,7 +1862,7 @@ const docTemplate = `{ } }, "patch": { - "description": "Updates an existing envelope. Only values to be updated need to be specified.", + "description": "Updates an existing goal. Only values to be updated need to be specified.", "consumes": [ "application/json" ], @@ -1872,10 +1870,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Envelopes" + "Goals" ], - "summary": "Update envelope", - "deprecated": true, + "summary": "Update goal", "parameters": [ { "type": "string", @@ -1885,12 +1882,12 @@ const docTemplate = `{ "required": true }, { - "description": "Envelope", - "name": "envelope", + "description": "Goal", + "name": "goal", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.EnvelopeCreate" + "$ref": "#/definitions/controllers.GoalV3Editable" } } ], @@ -1898,139 +1895,52 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponse" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } } } } }, - "/v1/envelopes/{id}/{month}": { + "/v3/import": { "get": { - "description": "Returns data about an envelope for a for a specific month. **Use GET /month endpoint with month and budgetId query parameters instead.**", - "produces": [ - "application/json" - ], + "description": "Returns general information about the v3 API", "tags": [ - "Envelopes" - ], - "summary": "Get Envelope month data", - "deprecated": true, - "parameters": [ - { - "type": "string", - "description": "ID formatted as string", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true - } + "Import" ], + "summary": "Import API overview", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.EnvelopeMonthResponse" - } - }, - "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" - } - } - } - } - }, - "/v1/import": { - "post": { - "description": "Imports budgets from YNAB 4. **Please use /v1/import/ynab4, which works exactly the same.**", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Import" - ], - "summary": "Import", - "deprecated": true, - "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": { - "204": { - "description": "No Content" - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.ImportV3Response" } } } }, "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs. **Please use /v1/import/ynab4, which works exactly the same.**", + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs.", "tags": [ "Import" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -2038,7 +1948,7 @@ const docTemplate = `{ } } }, - "/v1/import/ynab-import-preview": { + "/v3/import/ynab-import-preview": { "post": { "description": "Returns a preview of transactions to be imported after parsing a YNAB Import format csv file", "consumes": [ @@ -2051,7 +1961,6 @@ const docTemplate = `{ "Import" ], "summary": "Transaction Import Preview", - "deprecated": true, "parameters": [ { "type": "file", @@ -2071,25 +1980,25 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.ImportPreviewList" + "$ref": "#/definitions/controllers.ImportPreviewListV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.ImportPreviewListV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.ImportPreviewListV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.ImportPreviewListV3" } } } @@ -2100,7 +2009,6 @@ const docTemplate = `{ "Import" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -2108,7 +2016,7 @@ const docTemplate = `{ } } }, - "/v1/import/ynab4": { + "/v3/import/ynab4": { "post": { "description": "Imports budgets from YNAB 4", "consumes": [ @@ -2121,7 +2029,6 @@ const docTemplate = `{ "Import" ], "summary": "Import YNAB 4 budget", - "deprecated": true, "parameters": [ { "type": "file", @@ -2141,19 +2048,19 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.BudgetResponse" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } } } @@ -2164,7 +2071,6 @@ const docTemplate = `{ "Import" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -2172,28 +2078,45 @@ const docTemplate = `{ } } }, - "/v1/month-configs": { + "/v3/match-rules": { "get": { - "description": "Returns a list of MonthConfigs", + "description": "Returns a list of matchRules", "produces": [ "application/json" ], "tags": [ - "MonthConfigs" + "MatchRules" ], - "summary": "List MonthConfigs", - "deprecated": true, + "summary": "Get matchRules", "parameters": [ + { + "type": "integer", + "description": "Filter by priority", + "name": "priority", + "in": "query" + }, { "type": "string", - "description": "Filter by name", - "name": "envelope", + "description": "Filter by match", + "name": "match", "in": "query" }, { "type": "string", - "description": "Filter by month", - "name": "month", + "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" } ], @@ -2201,177 +2124,145 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.MonthConfigListResponse" + "$ref": "#/definitions/controllers.MatchRuleListResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleListResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleListResponseV3" } } } }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs.", - "tags": [ - "MonthConfigs" - ], - "summary": "Allowed HTTP verbs", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v1/month-configs/{id}/{month}": { - "get": { - "description": "Returns configuration for a specific month", + "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": [ - "MonthConfigs" + "MatchRules" ], - "summary": "Get MonthConfig", - "deprecated": true, + "summary": "Create matchRules", "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": "MatchRules", + "name": "matchRules", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.MatchRuleCreate" + } + } } ], "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponse" + "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" } } } }, - "post": { - "description": "Creates a new MonthConfig", + "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" + } + } + } + }, + "/v3/match-rules/{id}": { + "get": { + "description": "Returns a specific matchRule", "produces": [ "application/json" ], "tags": [ - "MonthConfigs" + "MatchRules" ], - "summary": "Create MonthConfig", - "deprecated": true, + "summary": "Get matchRule", "parameters": [ { "type": "string", - "description": "ID of the Envelope", + "description": "ID formatted as string", "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/models.MonthConfigCreate" - } } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponse" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } } } }, "delete": { - "description": "Deletes configuration settings for a specific month", - "produces": [ - "application/json" - ], + "description": "Deletes an matchRule", "tags": [ - "MonthConfigs" + "MatchRules" ], - "summary": "Delete MonthConfig", - "deprecated": true, + "summary": "Delete matchRule", "parameters": [ { "type": "string", - "description": "ID of the Envelope", + "description": "ID formatted as string", "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true } ], "responses": { @@ -2401,24 +2292,16 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "MonthConfigs" + "MatchRules" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", - "description": "ID of the Envelope", + "description": "ID formatted as string", "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true } ], "responses": { @@ -2446,69 +2329,64 @@ const docTemplate = `{ } }, "patch": { - "description": "Changes settings of an existing MonthConfig", + "description": "Update a matchRule. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "MonthConfigs" + "MatchRules" ], - "summary": "Update MonthConfig", - "deprecated": true, + "summary": "Update matchRule", "parameters": [ { "type": "string", - "description": "ID of the Envelope", + "description": "ID formatted as string", "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", + "description": "MatchRule", + "name": "matchRule", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.MonthConfigCreate" + "$ref": "#/definitions/models.MatchRuleCreate" } } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponse" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } } } } }, - "/v1/months": { + "/v3/months": { "get": { "description": "Returns data about a specific month.", "produces": [ @@ -2518,7 +2396,6 @@ const docTemplate = `{ "Months" ], "summary": "Get data about a month", - "deprecated": true, "parameters": [ { "type": "string", @@ -2539,25 +2416,25 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.MonthResponse" + "$ref": "#/definitions/controllers.MonthResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthResponseV3" } } } @@ -2568,7 +2445,6 @@ const docTemplate = `{ "Months" ], "summary": "Set allocations for a month", - "deprecated": true, "parameters": [ { "type": "string", @@ -2624,7 +2500,6 @@ const docTemplate = `{ "Months" ], "summary": "Delete allocations for a month", - "deprecated": true, "parameters": [ { "type": "string", @@ -2671,7 +2546,6 @@ const docTemplate = `{ "Months" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -2679,7 +2553,7 @@ const docTemplate = `{ } } }, - "/v1/transactions": { + "/v3/transactions": { "get": { "description": "Returns a list of transactions", "produces": [ @@ -2689,7 +2563,6 @@ const docTemplate = `{ "Transactions" ], "summary": "Get transactions", - "deprecated": true, "parameters": [ { "type": "string", @@ -2763,12 +2636,6 @@ const docTemplate = `{ "name": "envelope", "in": "query" }, - { - "type": "boolean", - "description": "DEPRECATED. Filter by reconcilication state", - "name": "reconciled", - "in": "query" - }, { "type": "boolean", "description": "Reconcilication state in source account", @@ -2780,47 +2647,61 @@ const docTemplate = `{ "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/controllers.TransactionListResponse" + "$ref": "#/definitions/controllers.TransactionListResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionListResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionListResponseV3" } } } }, "post": { - "description": "Creates a new transaction", + "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 transaction", - "deprecated": true, + "summary": "Create transactions", "parameters": [ { - "description": "Transaction", - "name": "transaction", + "description": "Transactions", + "name": "transactions", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.TransactionCreate" + "type": "array", + "items": { + "$ref": "#/definitions/models.TransactionCreate" + } } } ], @@ -2828,25 +2709,25 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.TransactionResponse" + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" } } } @@ -2857,7 +2738,6 @@ const docTemplate = `{ "Transactions" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -2865,7 +2745,7 @@ const docTemplate = `{ } } }, - "/v1/transactions/{id}": { + "/v3/transactions/{id}": { "get": { "description": "Returns a specific transaction", "produces": [ @@ -2875,7 +2755,6 @@ const docTemplate = `{ "Transactions" ], "summary": "Get transaction", - "deprecated": true, "parameters": [ { "type": "string", @@ -2889,25 +2768,25 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.TransactionResponse" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } } } @@ -2918,7 +2797,6 @@ const docTemplate = `{ "Transactions" ], "summary": "Delete transaction", - "deprecated": true, "parameters": [ { "type": "string", @@ -2958,7 +2836,6 @@ const docTemplate = `{ "Transactions" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", @@ -3004,7 +2881,6 @@ const docTemplate = `{ "Transactions" ], "summary": "Update transaction", - "deprecated": true, "parameters": [ { "type": "string", @@ -3027,43 +2903,42 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.TransactionResponse" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } } } } }, - "/v2": { + "/version": { "get": { - "description": "Returns general information about the v2 API", + "description": "Returns the software version of the API", "tags": [ - "v2" + "General" ], - "summary": "v2 API", - "deprecated": true, + "summary": "API version", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/router.V2Response" + "$ref": "#/definitions/router.VersionResponse" } } } @@ -3071,5397 +2946,97 @@ const docTemplate = `{ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "v2" + "General" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" } } } + } + }, + "definitions": { + "controllers.AccountCreateResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of created Accounts", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.AccountResponseV3" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } }, - "/v2/accounts": { - "get": { - "description": "Returns a list of accounts", - "produces": [ - "application/json" - ], - "tags": [ - "Accounts" - ], - "summary": "List accounts", - "deprecated": true, - "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 hidden?", - "name": "hidden", - "in": "query" - }, - { - "type": "string", - "description": "Search for this text in name and note", - "name": "search", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.AccountListResponse" - } - }, - "400": { - "description": "Bad Request", - "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", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v2/match-rules": { - "get": { - "description": "Returns a list of matchRules", - "produces": [ - "application/json" - ], - "tags": [ - "MatchRules" - ], - "summary": "Get matchRules", - "deprecated": true, - "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" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.MatchRule" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - } - } - }, - "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", - "deprecated": true, - "parameters": [ - { - "description": "MatchRules", - "name": "matchRules", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.MatchRuleCreate" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - } - } - }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", - "tags": [ - "MatchRules" - ], - "summary": "Allowed HTTP verbs", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v2/match-rules/{id}": { - "get": { - "description": "Returns a specific matchRule", - "produces": [ - "application/json" - ], - "tags": [ - "MatchRules" - ], - "summary": "Get matchRule", - "deprecated": true, - "parameters": [ - { - "type": "string", - "description": "ID formatted as string", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/models.MatchRule" - } - }, - "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 an matchRule", - "tags": [ - "MatchRules" - ], - "summary": "Delete matchRule", - "deprecated": true, - "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", - "deprecated": true, - "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 matchRule. Only values to be updated need to be specified.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "MatchRules" - ], - "summary": "Update matchRule", - "deprecated": true, - "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/models.MatchRuleCreate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/models.MatchRule" - } - }, - "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" - } - } - } - } - }, - "/v2/rename-rules": { - "get": { - "description": "Returns a list of renameRules", - "produces": [ - "application/json" - ], - "tags": [ - "RenameRules" - ], - "summary": "Get renameRules", - "deprecated": true, - "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" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.RenameRuleListResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - } - } - }, - "post": { - "description": "Creates renameRules from the list of submitted renameRule data. The response code is the highest response code number that a single renameRule creation would have caused. If it is not equal to 201, at least one renameRule has an error.", - "produces": [ - "application/json" - ], - "tags": [ - "RenameRules" - ], - "summary": "Create renameRules", - "deprecated": true, - "parameters": [ - { - "description": "RenameRules", - "name": "renameRules", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.MatchRuleCreate" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - } - } - }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", - "tags": [ - "RenameRules" - ], - "summary": "Allowed HTTP verbs", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v2/rename-rules/{id}": { - "get": { - "description": "Returns a specific renameRule", - "produces": [ - "application/json" - ], - "tags": [ - "RenameRules" - ], - "summary": "Get renameRule", - "deprecated": true, - "parameters": [ - { - "type": "string", - "description": "ID formatted as string", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.RenameRuleResponse" - } - }, - "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 an renameRule", - "tags": [ - "RenameRules" - ], - "summary": "Delete renameRule", - "deprecated": true, - "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": [ - "RenameRules" - ], - "summary": "Allowed HTTP verbs", - "deprecated": true, - "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 renameRule. Only values to be updated need to be specified.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "RenameRules" - ], - "summary": "Update renameRule", - "deprecated": true, - "parameters": [ - { - "type": "string", - "description": "ID formatted as string", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "RenameRule", - "name": "renameRule", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.MatchRuleCreate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.RenameRuleResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - } - } - } - }, - "/v2/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.", - "produces": [ - "application/json" - ], - "tags": [ - "Transactions" - ], - "summary": "Create transactions", - "deprecated": true, - "parameters": [ - { - "description": "Transactions", - "name": "transactions", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.TransactionCreate" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseTransactionV2" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseTransactionV2" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseTransactionV2" - } - } - } - } - }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", - "tags": [ - "Transactions" - ], - "summary": "Allowed HTTP verbs", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v3": { - "get": { - "description": "Returns general information about the v3 API", - "tags": [ - "v3" - ], - "summary": "v3 API", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/router.V3Response" - } - } - } - }, - "delete": { - "description": "Permanently deletes all resources", - "tags": [ - "v3" - ], - "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" - } - } - } - }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", - "tags": [ - "v3" - ], - "summary": "Allowed HTTP verbs", - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v3/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/controllers.AccountListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.AccountListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.AccountListResponseV3" - } - } - } - }, - "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/controllers.AccountCreateV3" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.AccountCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.AccountCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.AccountCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.AccountCreateResponseV3" - } - } - } - }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", - "tags": [ - "Accounts" - ], - "summary": "Allowed HTTP verbs", - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v3/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/controllers.AccountResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - } - } - }, - "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/controllers.AccountCreateV3" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - } - } - } - }, - "/v3/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/controllers.BudgetListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.BudgetListResponseV3" - } - } - } - }, - "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": { - "$ref": "#/definitions/models.BudgetCreate" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.BudgetCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.BudgetCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.BudgetCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.BudgetResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - } - } - }, - "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/models.BudgetCreate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - } - } - } - }, - "/v3/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 hidden?", - "name": "hidden", - "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/controllers.CategoryListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.CategoryListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.CategoryListResponseV3" - } - } - } - }, - "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/controllers.CategoryCreateV3" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.CategoryCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.CategoryCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.CategoryCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.CategoryCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.CategoryResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - } - } - }, - "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/controllers.CategoryCreateV3" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - } - } - } - }, - "/v3/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/controllers.EnvelopeListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeListResponseV3" - } - } - } - }, - "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/controllers.EnvelopeCreateV3" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.EnvelopeResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - } - } - }, - "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/controllers.EnvelopeCreateV3" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - } - } - } - }, - "/v3/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/controllers.MonthConfigResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - } - } - }, - "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/controllers.MonthConfigCreateV3" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - } - } - } - }, - "/v3/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/controllers.GoalListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.GoalListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.GoalListResponseV3" - } - } - } - }, - "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/controllers.GoalV3Editable" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.GoalCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.GoalCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.GoalCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.GoalCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.GoalResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - } - } - }, - "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/controllers.GoalV3Editable" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - } - } - } - }, - "/v3/import": { - "get": { - "description": "Returns general information about the v3 API", - "tags": [ - "Import" - ], - "summary": "Import API overview", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.ImportV3Response" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.ImportPreviewListV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.ImportPreviewListV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.ImportPreviewListV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.ImportPreviewListV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.BudgetResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.MatchRuleListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleListResponseV3" - } - } - } - }, - "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/models.MatchRuleCreate" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.MatchRuleResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - } - } - }, - "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/models.MatchRuleCreate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - } - } - } - }, - "/v3/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/controllers.MonthResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MonthResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MonthResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MonthResponseV3" - } - } - } - }, - "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/controllers.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" - } - } - } - }, - "/v3/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/controllers.TransactionListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.TransactionListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.TransactionListResponseV3" - } - } - } - }, - "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/models.TransactionCreate" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.TransactionCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.TransactionCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.TransactionCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.TransactionCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.TransactionResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - } - } - }, - "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/models.TransactionCreate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - } - } - } - }, - "/version": { - "get": { - "description": "Returns the software version of the API", - "tags": [ - "General" - ], - "summary": "API version", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/router.VersionResponse" - } - } - } - }, - "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": { - "controllers.Account": { - "type": "object", - "properties": { - "archived": { - "description": "Is the account archived?", - "type": "boolean", - "default": false, - "example": true - }, - "balance": { - "description": "Balance of the account, including all transactions referencing it", - "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 - }, - "hidden": { - "description": "Is the account archived?", - "type": "boolean", - "default": false, - "example": true - }, - "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, - "initialBalance": { - "description": "Balance of the account before any transactions were recorded", - "type": "number", - "default": 0, - "example": 173.12 - }, - "initialBalanceDate": { - "description": "Date of the initial balance", - "type": "string", - "example": "2017-05-12T00:00:00Z" - }, - "links": { - "type": "object", - "properties": { - "self": { - "description": "The account itself", - "type": "string", - "example": "https://example.com/api/v1/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" - }, - "transactions": { - "description": "Transactions referencing the account", - "type": "string", - "example": "https://example.com/api/v1/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" - } - } - }, - "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": { - "$ref": "#/definitions/models.Envelope" - } - }, - "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" - } - } - }, - "controllers.AccountCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of created Accounts", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.AccountCreateV3": { - "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, - "initialBalance": { - "description": "Balance of the account before any transactions were recorded", - "type": "number", - "default": 0, - "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 - } - } - }, - "controllers.AccountListResponse": { - "type": "object", - "properties": { - "data": { - "description": "List of accounts", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Account" - } - } - } - }, - "controllers.AccountListResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of accounts", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.AccountV3" - } - }, - "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/controllers.Pagination" - } - ] - } - } - }, - "controllers.AccountResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the account", - "allOf": [ - { - "$ref": "#/definitions/controllers.Account" - } - ] - } - } - }, - "controllers.AccountResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "Data for the account", - "allOf": [ - { - "$ref": "#/definitions/controllers.AccountV3" - } - ] - }, - "error": { - "description": "The error, if any occurred for this transaction", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.AccountV3": { - "type": "object", - "properties": { - "archived": { - "description": "Is the account archived?", - "type": "boolean", - "default": false, - "example": true - }, - "balance": { - "description": "Balance of the account, including all transactions referencing it", - "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 - }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, - "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, - "initialBalance": { - "description": "Balance of the account before any transactions were recorded", - "type": "number", - "default": 0, - "example": 173.12 - }, - "initialBalanceDate": { - "description": "Date of the initial balance", - "type": "string", - "example": "2017-05-12T00:00:00Z" - }, - "links": { - "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" - } - } - }, - "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" - } - } - }, - "controllers.Allocation": { - "type": "object", - "properties": { - "amount": { - "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", - "type": "number", - "maximum": 1000000000000, - "minimum": 1e-8, - "multipleOf": 1e-8, - "example": 22.01 - }, - "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": "ID of the envelope", - "type": "string", - "example": "a0909e84-e8f9-4cb6-82a5-025dff105ff2" - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "type": "object", - "properties": { - "self": { - "description": "The allocation itself", - "type": "string", - "example": "https://example.com/api/v1/allocations/902cd93c-3724-4e46-8540-d014131282fc" - } - } - }, - "month": { - "description": "Only year and month of this timestamp are used, everything else is ignored. This will always be set to 00:00 UTC on the first of the specified month", - "type": "string", - "example": "2021-12-01T00:00:00.000000Z" - }, - "updatedAt": { - "description": "Last time the resource was updated", - "type": "string", - "example": "2022-04-17T20:14:01.048145Z" - } - } - }, - "controllers.AllocationListResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the allocation", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Allocation" - } - } - } - }, - "controllers.AllocationMode": { - "type": "string", - "enum": [ - "ALLOCATE_LAST_MONTH_BUDGET", - "ALLOCATE_LAST_MONTH_SPEND" - ], - "x-enum-varnames": [ - "AllocateLastMonthBudget", - "AllocateLastMonthSpend" - ] - }, - "controllers.AllocationResponse": { - "type": "object", - "properties": { - "data": { - "description": "List of allocations", - "allOf": [ - { - "$ref": "#/definitions/controllers.Allocation" - } - ] - } - } - }, - "controllers.Budget": { - "type": "object", - "properties": { - "balance": { - "description": "DEPRECATED. Will be removed in API v2, see https://github.com/envelope-zero/backend/issues/526.", - "type": "number", - "example": 3423.42 - }, - "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": { - "type": "object", - "properties": { - "accounts": { - "description": "Accounts for this budget", - "type": "string", - "example": "https://example.com/api/v1/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" - }, - "categories": { - "description": "Categories for this budget", - "type": "string", - "example": "https://example.com/api/v1/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" - }, - "envelopes": { - "description": "Envelopes for this budget", - "type": "string", - "example": "https://example.com/api/v1/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" - }, - "groupedMonth": { - "description": "This uses 'YYYY-MM' for clients to replace with the actual year and month.", - "type": "string", - "example": "https://example.com/api/v1/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf\u0026month=YYYY-MM" - }, - "month": { - "description": "This uses 'YYYY-MM' for clients to replace with the actual year and month.", - "type": "string", - "example": "https://example.com/api/v1/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf/YYYY-MM" - }, - "monthAllocations": { - "description": "This uses 'YYYY-MM' for clients to replace with the actual year and month.", - "type": "string", - "example": "https://example.com/api/v1/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf\u0026month=YYYY-MM" - }, - "self": { - "description": "The budget itself", - "type": "string", - "example": "https://example.com/api/v1/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf" - }, - "transactions": { - "description": "Transactions for this budget", - "type": "string", - "example": "https://example.com/api/v1/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" - } - } - }, - "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" - } - } - }, - "controllers.BudgetAllocationMode": { - "type": "object", - "properties": { - "mode": { - "description": "Mode to allocate budget with", - "allOf": [ - { - "$ref": "#/definitions/controllers.AllocationMode" - } - ], - "example": "ALLOCATE_LAST_MONTH_SPEND" - } - } - }, - "controllers.BudgetCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of created Budgets", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.BudgetListResponse": { - "type": "object", - "properties": { - "data": { - "description": "List of budgets", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Budget" - } - } - } - }, - "controllers.BudgetListResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of budgets", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.BudgetV3" - } - }, - "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/controllers.Pagination" - } - ] - } - } - }, - "controllers.BudgetMonthResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the budget's month", - "allOf": [ - { - "$ref": "#/definitions/models.BudgetMonth" - } - ] - } - } - }, - "controllers.BudgetResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the budget", - "allOf": [ - { - "$ref": "#/definitions/controllers.Budget" - } - ] - } - } - }, - "controllers.BudgetResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "Data for the budget", - "allOf": [ - { - "$ref": "#/definitions/controllers.BudgetV3" - } - ] - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.BudgetV3": { - "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": { - "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" - } - } - }, - "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" - } - } - }, - "controllers.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": "Envelopes for the category", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Envelope" - } - }, - "hidden": { - "description": "Is the category hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "type": "object", - "properties": { - "envelopes": { - "description": "Envelopes for this category", - "type": "string", - "example": "https://example.com/api/v1/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f" - }, - "self": { - "description": "The category itself", - "type": "string", - "example": "https://example.com/api/v1/categories/3b1ea324-d438-4419-882a-2fc91d71772f" - } - } - }, - "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" - } - } - }, - "controllers.CategoryCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of the created Categories or their respective error", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.CategoryCreateV3": { - "type": "object", - "properties": { - "archived": { - "description": "Is the category hidden?", - "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" - } - } - }, - "controllers.CategoryEnvelopesV3": { - "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/controllers.EnvelopeMonthV3" - } - }, - "hidden": { - "description": "Is the category hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "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" - } - } - }, - "controllers.CategoryListResponse": { - "type": "object", - "properties": { - "data": { - "description": "List of categories", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Category" - } - } - } - }, - "controllers.CategoryListResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of Categories", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.CategoryV3" - } - }, - "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/controllers.Pagination" - } - ] - } - } - }, - "controllers.CategoryResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the category", - "allOf": [ - { - "$ref": "#/definitions/controllers.Category" - } - ] - } - } - }, - "controllers.CategoryResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "Data for the Category", - "allOf": [ - { - "$ref": "#/definitions/controllers.CategoryV3" - } - ] - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.CategoryV3": { - "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": "Envelopes for the category", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.EnvelopeV3" - } - }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "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" - } - } - }, - "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" - } - } - }, - "controllers.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" - }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "description": "Links to related resources", - "type": "object", - "properties": { - "allocations": { - "description": "the envelope's allocations", - "type": "string", - "example": "https://example.com/api/v1/allocations?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166" - }, - "month": { - "description": "Month information endpoint. This will always end in 'YYYY-MM' for clients to use replace with actual numbers.", - "type": "string", - "example": "https://example.com/api/v1/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM" - }, - "self": { - "description": "The envelope itself", - "type": "string", - "example": "https://example.com/api/v1/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166" - }, - "transactions": { - "description": "The envelope's transactions", - "type": "string", - "example": "https://example.com/api/v1/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166" - } - } - }, - "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" - } - } - }, - "controllers.EnvelopeCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "Data for the Envelope", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.EnvelopeCreateV3": { - "type": "object", - "properties": { - "archived": { - "description": "Is the envelope hidden?", - "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" - } - } - }, - "controllers.EnvelopeListResponse": { - "type": "object", - "properties": { - "data": { - "description": "List of Envelopes", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Envelope" - } - } - } - }, - "controllers.EnvelopeListResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of Envelopes", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.EnvelopeV3" - } - }, - "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/controllers.Pagination" - } - ] - } - } - }, - "controllers.EnvelopeMonthResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the month for the envelope", - "allOf": [ - { - "$ref": "#/definitions/models.EnvelopeMonth" - } - ] - } - } - }, - "controllers.EnvelopeMonthV3": { - "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" - }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "$ref": "#/definitions/controllers.EnvelopeV3Links" - }, - "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" - } - } - }, - "controllers.EnvelopeResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the Envelope", - "allOf": [ - { - "$ref": "#/definitions/controllers.Envelope" - } - ] - } - } - }, - "controllers.EnvelopeResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "Data for the Envelope", - "allOf": [ - { - "$ref": "#/definitions/controllers.EnvelopeV3" - } - ] - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.EnvelopeV3": { - "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" - }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "description": "Links to related resources", - "allOf": [ - { - "$ref": "#/definitions/controllers.EnvelopeV3Links" - } - ] - }, - "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" - } - } - }, - "controllers.EnvelopeV3Links": { - "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" - } - } - }, - "controllers.GoalCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of created resources", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.GoalListResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of resources", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.GoalV3" - } - }, - "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/controllers.Pagination" - } - ] - } - } - }, - "controllers.GoalResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "The resource", - "allOf": [ - { - "$ref": "#/definitions/controllers.GoalV3" - } - ] - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.GoalV3": { + "controllers.AccountCreateV3": { "type": "object", "properties": { - "amount": { - "description": "How much money should be saved for this goal?", - "type": "number", - "default": 0, - "example": 127 - }, "archived": { - "description": "If this goal is still in use or not", + "description": "Is the account archived?", "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/controllers.GoalV3Links" - }, - "month": { - "description": "The month the balance of the envelope should be the set amount", - "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", + "budgetId": { + "description": "ID of the budget this account belongs to", "type": "string", - "example": "2022-04-17T20:14:01.048145Z" - } - } - }, - "controllers.GoalV3Editable": { - "type": "object", - "properties": { - "amount": { - "description": "How much money should be saved for this goal?", - "type": "number", - "default": 0, - "example": 127 + "example": "550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, - "archived": { - "description": "If this goal is still in use or not", + "external": { + "description": "Does the account belong to the budget owner 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 balance of the envelope should be the set amount", - "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" - } - } - }, - "controllers.GoalV3Links": { - "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" - } - } - }, - "controllers.ImportPreviewList": { - "type": "object", - "properties": { - "data": { - "description": "List of transaction previews", - "type": "array", - "items": { - "$ref": "#/definitions/importer.TransactionPreview" - } - } - } - }, - "controllers.ImportPreviewListV3": { - "type": "object", - "properties": { - "data": { - "description": "List of transaction previews", - "type": "array", - "items": { - "$ref": "#/definitions/importer.TransactionPreviewV3" - } - }, - "error": { - "description": "The error, if any occurred for this Match Rule", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.ImportV3Links": { - "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" - } - } - }, - "controllers.ImportV3Response": { - "type": "object", - "properties": { - "links": { - "description": "Links for the v3 API", - "allOf": [ - { - "$ref": "#/definitions/controllers.ImportV3Links" - } - ] - } - } - }, - "controllers.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" + "example": false }, - "id": { - "description": "UUID for the resource", + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "type": "object", - "properties": { - "self": { - "description": "The match rule itself", - "type": "string", - "example": "https://example.com/api/v2/match-rules/95685c82-53c6-455d-b235-f49960b73b21" - } - } + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" }, - "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*" + "initialBalance": { + "description": "Balance of the account before any transactions were recorded", + "type": "number", + "default": 0, + "example": 173.12 }, - "priority": { - "description": "The priority of the match rule", - "type": "integer", - "example": 3 + "initialBalanceDate": { + "description": "Date of the initial balance", + "type": "string", + "example": "2017-05-12T00:00:00Z" }, - "updatedAt": { - "description": "Last time the resource was updated", + "name": { + "description": "Name of the account", "type": "string", - "example": "2022-04-17T20:14:01.048145Z" - } - } - }, - "controllers.MatchRuleCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of created Match Rules", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } + "example": "Cash" }, - "error": { - "description": "The error, if any occurred", + "note": { + "description": "A longer description for the account", "type": "string", - "example": "the specified resource ID is not a valid UUID" + "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 } } }, - "controllers.MatchRuleListResponseV3": { + "controllers.AccountListResponseV3": { "type": "object", "properties": { "data": { - "description": "List of Match Rules", + "description": "List of accounts", "type": "array", "items": { - "$ref": "#/definitions/controllers.MatchRuleV3" + "$ref": "#/definitions/controllers.AccountV3" } }, "error": { @@ -8479,31 +3054,42 @@ const docTemplate = `{ } } }, - "controllers.MatchRuleResponseV3": { + "controllers.AccountResponseV3": { "type": "object", "properties": { "data": { - "description": "The Match Rule data, if creation was successful", + "description": "Data for the account", "allOf": [ { - "$ref": "#/definitions/controllers.MatchRuleV3" + "$ref": "#/definitions/controllers.AccountV3" } ] }, "error": { - "description": "The error, if any occurred for this Match Rule", + "description": "The error, if any occurred for this transaction", "type": "string", "example": "the specified resource ID is not a valid UUID" } } }, - "controllers.MatchRuleV3": { + "controllers.AccountV3": { "type": "object", "properties": { - "accountId": { - "description": "The account to map matching transactions to", + "archived": { + "description": "Is the account archived?", + "type": "boolean", + "default": false, + "example": true + }, + "balance": { + "description": "Balance of the account, including all transactions referencing it", + "type": "number", + "example": 2735.17 + }, + "budgetId": { + "description": "ID of the budget this account belongs to", "type": "string", - "example": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" + "example": "550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, "createdAt": { "description": "Time the resource was created", @@ -8515,98 +3101,79 @@ const docTemplate = `{ "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 + }, + "hidden": { + "description": "Remove the hidden field", + "type": "boolean" + }, "id": { "description": "UUID for the resource", "type": "string", "example": "65392deb-5e92-4268-b114-297faad6cdce" }, - "links": { - "type": "object", - "properties": { - "self": { - "description": "The match rule itself", - "type": "string", - "example": "https://example.com/api/v3/match-rules/95685c82-53c6-455d-b235-f49960b73b21" - } - } - }, - "match": { - "description": "The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive.", + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", "type": "string", - "example": "Bank*" - }, - "priority": { - "description": "The priority of the match rule", - "type": "integer", - "example": 3 + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" }, - "updatedAt": { - "description": "Last time the resource was updated", - "type": "string", - "example": "2022-04-17T20:14:01.048145Z" - } - } - }, - "controllers.MonthConfig": { - "type": "object", - "properties": { - "allocation": { - "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "initialBalance": { + "description": "Balance of the account before any transactions were recorded", "type": "number", - "maximum": 1000000000000, - "minimum": 1e-8, - "multipleOf": 1e-8, - "example": 22.01 - }, - "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" + "default": 0, + "example": 173.12 }, - "envelopeId": { - "description": "ID of the envelope", + "initialBalanceDate": { + "description": "Date of the initial balance", "type": "string", - "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" + "example": "2017-05-12T00:00:00Z" }, "links": { "type": "object", "properties": { - "envelope": { - "description": "The envelope this config belongs to", + "self": { + "description": "The account itself", "type": "string", - "example": "https://example.com/api/v1/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b" + "example": "https://example.com/api/v3/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" }, - "self": { - "description": "The month config itself", + "transactions": { + "description": "Transactions referencing the account", "type": "string", - "example": "https://example.com/api/v1/month-configs/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" + "example": "https://example.com/api/v3/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" } } }, - "month": { - "description": "The month. This is always set to 00:00 UTC on the first of the month.", + "name": { + "description": "Name of the account", "type": "string", - "example": "1969-06-01T00:00:00.000000Z" + "example": "Cash" }, "note": { - "description": "A note for the month config", + "description": "A longer description for the account", "type": "string", - "example": "Added 200€ here because we replaced Tim's expensive vase" + "example": "Money in my wallet" }, - "overspendMode": { - "description": "The overspend handling mode to use. Deprecated, will be removed with 4.0.0 release and is not used in API v3 anymore", - "default": "AFFECT_AVAILABLE", - "allOf": [ - { - "$ref": "#/definitions/models.OverspendMode" - } - ], - "example": "AFFECT_ENVELOPE" + "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", @@ -8615,57 +3182,81 @@ const docTemplate = `{ } } }, - "controllers.MonthConfigCreateV3": { + "controllers.AllocationMode": { + "type": "string", + "enum": [ + "ALLOCATE_LAST_MONTH_BUDGET", + "ALLOCATE_LAST_MONTH_SPEND" + ], + "x-enum-varnames": [ + "AllocateLastMonthBudget", + "AllocateLastMonthSpend" + ] + }, + "controllers.BudgetAllocationMode": { "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 - }, - "note": { - "description": "A note for the month config", - "type": "string", - "example": "Added 200€ here because we replaced Tim's expensive vase" + "mode": { + "description": "Mode to allocate budget with", + "allOf": [ + { + "$ref": "#/definitions/controllers.AllocationMode" + } + ], + "example": "ALLOCATE_LAST_MONTH_SPEND" } } }, - "controllers.MonthConfigListResponse": { + "controllers.BudgetCreateResponseV3": { "type": "object", "properties": { "data": { - "description": "List of month configs", + "description": "List of created Budgets", "type": "array", "items": { - "$ref": "#/definitions/controllers.MonthConfig" + "$ref": "#/definitions/controllers.BudgetResponseV3" } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" } } }, - "controllers.MonthConfigResponse": { + "controllers.BudgetListResponseV3": { "type": "object", "properties": { "data": { - "description": "Data for the month", + "description": "List of budgets", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.BudgetV3" + } + }, + "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/controllers.MonthConfig" + "$ref": "#/definitions/controllers.Pagination" } ] } } }, - "controllers.MonthConfigResponseV3": { + "controllers.BudgetResponseV3": { "type": "object", "properties": { "data": { - "description": "Config for the month", + "description": "Data for the budget", "allOf": [ { - "$ref": "#/definitions/controllers.MonthConfigV3" + "$ref": "#/definitions/controllers.BudgetV3" } ] }, @@ -8673,63 +3264,76 @@ const docTemplate = `{ "description": "The error, if any occurred", "type": "string", "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.MonthConfigV3": { - "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 - }, + } + } + }, + "controllers.BudgetV3": { + "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" }, - "envelopeId": { - "description": "ID of the envelope", + "id": { + "description": "UUID for the resource", "type": "string", - "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" + "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { "type": "object", "properties": { - "envelope": { - "description": "The Envelope this config belongs to", + "accounts": { + "description": "Accounts for this budget", "type": "string", - "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b" + "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 Month Config itself", + "description": "The budget itself", "type": "string", - "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" + "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" } } }, - "month": { - "description": "The month. This is always set to 00:00 UTC on the first of the month.", + "name": { + "description": "Name of the budget", "type": "string", - "example": "1969-06-01T00:00:00.000000Z" + "example": "Morre's Budget" }, "note": { - "description": "A note for the month config", + "description": "A longer description of the budget", "type": "string", - "example": "Added 200€ here because we replaced Tim's expensive vase" - }, - "overspendMode": { - "description": "Ignore this. It is here to override the OverspendMode from models.MonthConfigCreate and will be removed with 4.0.0", - "type": "string" + "example": "My personal expenses" }, "updatedAt": { "description": "Last time the resource was updated", @@ -8738,268 +3342,230 @@ const docTemplate = `{ } } }, - "controllers.MonthResponse": { + "controllers.CategoryCreateResponseV3": { "type": "object", "properties": { "data": { - "description": "Data for the month", - "allOf": [ - { - "$ref": "#/definitions/models.Month" - } - ] + "description": "List of the created Categories or their respective error", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.CategoryResponseV3" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" } } }, - "controllers.MonthResponseV3": { + "controllers.CategoryCreateV3": { "type": "object", "properties": { - "data": { - "description": "Data for the month", - "allOf": [ - { - "$ref": "#/definitions/controllers.MonthV3" - } - ] + "archived": { + "description": "Is the category hidden?", + "type": "boolean", + "default": false, + "example": true }, - "error": { - "description": "The error, if any occurred", - "type": "string" + "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" } } }, - "controllers.MonthV3": { + "controllers.CategoryEnvelopesV3": { "type": "object", "properties": { "allocation": { - "description": "The sum of all allocations for this month", + "description": "Sum of allocations for the envelopes", "type": "number", - "example": 1200.5 + "example": 90 }, - "available": { - "description": "The amount available to budget", - "type": "number", - "example": 217.34 + "archived": { + "description": "Is the Category archived?", + "type": "boolean", + "default": false, + "example": true }, "balance": { - "description": "The sum of all envelope balances", + "description": "Sum of the balances of the envelopes", "type": "number", - "example": 5231.37 + "example": -10.13 }, - "categories": { - "description": "A list of envelope month calculations grouped by category", + "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/controllers.CategoryEnvelopesV3" + "$ref": "#/definitions/controllers.EnvelopeMonthV3" } }, + "hidden": { + "description": "Is the category hidden?", + "type": "boolean", + "default": false, + "example": true + }, "id": { - "description": "The ID of the Budget", + "description": "UUID for the resource", "type": "string", - "example": "1e777d24-3f5b-4c43-8000-04f65f895578" - }, - "income": { - "description": "The total income for the month (sum of all incoming transactions without an Envelope)", - "type": "number", - "example": 2317.34 + "example": "65392deb-5e92-4268-b114-297faad6cdce" }, - "month": { - "description": "The month", + "name": { + "description": "Name of the category", "type": "string", - "example": "2006-05-01T00:00:00.000000Z" + "example": "Saving" }, - "name": { - "description": "The name of the Budget", + "note": { + "description": "Notes about the category", "type": "string", - "example": "Zero budget" + "example": "All envelopes for long-term saving" }, "spent": { - "description": "The amount of money spent in this month", + "description": "Sum spent for all envelopes", "type": "number", - "example": 133.7 - } - } - }, - "controllers.Pagination": { - "type": "object", - "properties": { - "count": { - "description": "The amount of records returned in this response", - "type": "integer", - "example": 25 - }, - "limit": { - "description": "The maximum amount of resources to return for this request", - "type": "integer", - "example": 25 - }, - "offset": { - "description": "The offset for the first record returned", - "type": "integer", - "example": 50 + "example": 100.13 }, - "total": { - "description": "The total number of resources matching the query", - "type": "integer", - "example": 827 + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" } } }, - "controllers.RenameRuleListResponse": { + "controllers.CategoryListResponseV3": { "type": "object", "properties": { "data": { - "description": "List of rename rules", + "description": "List of Categories", "type": "array", "items": { - "$ref": "#/definitions/models.MatchRule" + "$ref": "#/definitions/controllers.CategoryV3" } - } - } - }, - "controllers.RenameRuleResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the rename rule", - "allOf": [ - { - "$ref": "#/definitions/models.MatchRule" - } - ] - } - } - }, - "controllers.ResponseMatchRule": { - "type": "object", - "properties": { - "data": { - "description": "This field contains the MatchRule data", + }, + "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/controllers.MatchRule" + "$ref": "#/definitions/controllers.Pagination" } ] - }, - "error": { - "description": "This field contains a human readable error message", - "type": "string", - "example": "A human readable error message" } } }, - "controllers.ResponseTransactionV2": { + "controllers.CategoryResponseV3": { "type": "object", "properties": { "data": { - "description": "This field contains the Transaction data", + "description": "Data for the Category", "allOf": [ { - "$ref": "#/definitions/controllers.TransactionV2" + "$ref": "#/definitions/controllers.CategoryV3" } ] }, "error": { - "description": "This field contains a human readable error message", + "description": "The error, if any occurred", "type": "string", - "example": "A human readable error message" + "example": "the specified resource ID is not a valid UUID" } } }, - "controllers.Transaction": { + "controllers.CategoryV3": { "type": "object", "properties": { - "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 - }, - "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" + "archived": { + "description": "Is the Category archived?", + "type": "boolean", + "default": false, + "example": true }, "budgetId": { - "description": "ID of the budget", + "description": "ID of the budget the category belongs to", "type": "string", - "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" + "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" }, "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" + "envelopes": { + "description": "Envelopes for the category", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.EnvelopeV3" + } }, - "envelopeId": { - "description": "ID of the envelope", - "type": "string", - "example": "2649c965-7999-4873-ae16-89d5d5fa972e" + "hidden": { + "description": "Remove the hidden field", + "type": "boolean" }, "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, "links": { - "description": "Links for the transaction", "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 transaction itself", + "description": "The category itself", "type": "string", - "example": "https://example.com/api/v1/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" + "example": "https://example.com/api/v3/categories/3b1ea324-d438-4419-882a-2fc91d71772f" } } }, - "note": { - "description": "A note", + "name": { + "description": "Name of the category", "type": "string", - "example": "Lunch" - }, - "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", - "type": "boolean", - "default": false, - "example": true - }, - "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 + "example": "Saving" }, - "sourceAccountId": { - "description": "ID of the source account", + "note": { + "description": "Notes about the category", "type": "string", - "example": "fd81dc45-a3a2-468e-a6fa-b2618f30aa45" + "example": "All envelopes for long-term saving" }, "updatedAt": { "description": "Last time the resource was updated", @@ -9008,14 +3574,14 @@ const docTemplate = `{ } } }, - "controllers.TransactionCreateResponseV3": { + "controllers.EnvelopeCreateResponseV3": { "type": "object", "properties": { "data": { - "description": "List of created Transactions", + "description": "Data for the Envelope", "type": "array", "items": { - "$ref": "#/definitions/controllers.TransactionResponseV3" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } }, "error": { @@ -9025,26 +3591,40 @@ const docTemplate = `{ } } }, - "controllers.TransactionListResponse": { + "controllers.EnvelopeCreateV3": { "type": "object", "properties": { - "data": { - "description": "List of transactions", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Transaction" - } + "archived": { + "description": "Is the envelope hidden?", + "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" } } }, - "controllers.TransactionListResponseV3": { + "controllers.EnvelopeListResponseV3": { "type": "object", "properties": { "data": { - "description": "List of transactions", + "description": "List of Envelopes", "type": "array", "items": { - "$ref": "#/definitions/controllers.TransactionV3" + "$ref": "#/definitions/controllers.EnvelopeV3" } }, "error": { @@ -9062,231 +3642,144 @@ const docTemplate = `{ } } }, - "controllers.TransactionResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the transaction", - "allOf": [ - { - "$ref": "#/definitions/controllers.Transaction" - } - ] - } - } - }, - "controllers.TransactionResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "The Transaction data, if creation was successful", - "allOf": [ - { - "$ref": "#/definitions/controllers.TransactionV3" - } - ] - }, - "error": { - "description": "The error, if any occurred for this transaction", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.TransactionV2": { + "controllers.EnvelopeMonthV3": { "type": "object", "properties": { - "amount": { - "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "allocation": { + "description": "The amount of money allocated", "type": "number", - "maximum": 1000000000000, - "minimum": 1e-8, - "multipleOf": 1e-8, - "example": 14.03 + "example": 85.44 }, - "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" + "archived": { + "description": "Is the Envelope archived?", + "type": "boolean", + "default": false, + "example": true }, - "budgetId": { - "description": "ID of the budget", + "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": "55eecbd8-7c46-4b06-ada9-f287802fb05e" + "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" }, "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" + "hidden": { + "description": "Is the envelope hidden?", + "type": "boolean", + "default": false, + "example": true }, "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, "links": { - "description": "Links for the transaction", - "type": "object", - "properties": { - "self": { - "description": "The transaction itself", - "type": "string", - "example": "https://example.com/api/v2/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" - } - } + "$ref": "#/definitions/controllers.EnvelopeV3Links" }, - "note": { - "description": "A note", + "name": { + "description": "Name of the envelope", "type": "string", - "example": "Lunch" - }, - "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", - "type": "boolean", - "default": false, - "example": true - }, - "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 + "example": "Groceries" }, - "sourceAccountId": { - "description": "ID of the source account", + "note": { + "description": "Notes about the envelope", "type": "string", - "example": "fd81dc45-a3a2-468e-a6fa-b2618f30aa45" + "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" + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "controllers.EnvelopeResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "Data for the Envelope", + "allOf": [ + { + "$ref": "#/definitions/controllers.EnvelopeV3" + } + ] + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" } } }, - "controllers.TransactionV3": { + "controllers.EnvelopeV3": { "type": "object", "properties": { - "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 - }, - "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" + "archived": { + "description": "Is the Envelope archived?", + "type": "boolean", + "default": false, + "example": true }, - "budgetId": { - "description": "ID of the budget", + "categoryId": { + "description": "ID of the category the envelope belongs to", "type": "string", - "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" + "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" }, "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" + "hidden": { + "description": "Remove the hidden field", + "type": "boolean" }, "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, "links": { - "description": "Links for the transaction", - "type": "object", - "properties": { - "self": { - "description": "The transaction itself", - "type": "string", - "example": "https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" + "description": "Links to related resources", + "allOf": [ + { + "$ref": "#/definitions/controllers.EnvelopeV3Links" } - } + ] }, - "note": { - "description": "A note", + "name": { + "description": "Name of the envelope", "type": "string", - "example": "Lunch" - }, - "reconciled": { - "description": "Remove the reconciled field", - "type": "boolean" - }, - "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 + "example": "Groceries" }, - "sourceAccountId": { - "description": "ID of the source account", + "note": { + "description": "Notes about the envelope", "type": "string", - "example": "fd81dc45-a3a2-468e-a6fa-b2618f30aa45" + "example": "For stuff bought at supermarkets and drugstores" }, "updatedAt": { "description": "Last time the resource was updated", @@ -9295,268 +3788,310 @@ const docTemplate = `{ } } }, - "httperrors.HTTPError": { + "controllers.EnvelopeV3Links": { "type": "object", "properties": { - "error": { + "month": { + "description": "The MonthConfig for the envelope", "type": "string", - "example": "An ID specified in the query string was not a valid UUID" + "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" } } }, - "importer.TransactionPreview": { + "controllers.GoalCreateResponseV3": { "type": "object", "properties": { - "destinationAccountName": { - "description": "Name of the destination account from the CSV file", - "type": "string", - "example": "Deutsche Bahn" - }, - "duplicateTransactionIds": { - "description": "IDs of transactions that this transaction duplicates", + "data": { + "description": "List of created resources", "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, - "matchRuleId": { - "description": "ID of the match rule that was applied to this transaction preview", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "042d101d-f1de-4403-9295-59dc0ea58677" + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "controllers.GoalListResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of resources", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.GoalV3" + } }, - "renameRuleId": { - "description": "ID of the match rule that was applied to this transaction preview. This is kept for backwards compatibility and will be removed with API version 3", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "042d101d-f1de-4403-9295-59dc0ea58677" + "example": "the specified resource ID is not a valid UUID" }, - "sourceAccountName": { - "description": "Name of the source account from the CSV file", - "type": "string", - "example": "Employer" + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/controllers.Pagination" + } + ] + } + } + }, + "controllers.GoalResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "The resource", + "allOf": [ + { + "$ref": "#/definitions/controllers.GoalV3" + } + ] }, - "transaction": { - "$ref": "#/definitions/models.TransactionCreate" + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" } } }, - "importer.TransactionPreviewV3": { + "controllers.GoalV3": { "type": "object", "properties": { - "destinationAccountName": { - "description": "Name of the destination account from the CSV file", + "amount": { + "description": "How much money should be saved for this goal?", + "type": "number", + "default": 0, + "example": 127 + }, + "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": "Deutsche Bahn" + "example": "2022-04-02T19:28:44.491514Z" }, - "duplicateTransactionIds": { - "description": "IDs of transactions that this transaction duplicates", - "type": "array", - "items": { - "type": "string" - } + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" }, - "matchRuleId": { - "description": "ID of the match rule that was applied to this transaction preview", + "envelopeId": { + "description": "The ID of the envelope this goal is for", "type": "string", - "example": "042d101d-f1de-4403-9295-59dc0ea58677" + "example": "f81566d9-af4d-4f13-9830-c62c4b5e4c7e" }, - "sourceAccountName": { - "description": "Name of the source account from the CSV file", + "id": { + "description": "UUID for the resource", "type": "string", - "example": "Employer" + "example": "65392deb-5e92-4268-b114-297faad6cdce" }, - "transaction": { - "$ref": "#/definitions/models.TransactionCreate" + "links": { + "$ref": "#/definitions/controllers.GoalV3Links" + }, + "month": { + "description": "The month the balance of the envelope should be the set amount", + "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" } } }, - "models.AccountCreate": { + "controllers.GoalV3Editable": { "type": "object", "properties": { - "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 + "amount": { + "description": "How much money should be saved for this goal?", + "type": "number", + "default": 0, + "example": 127 }, - "hidden": { - "description": "Is the account archived?", + "archived": { + "description": "If this goal is still in use or not", "type": "boolean", "default": false, "example": true }, - "importHash": { - "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", + "envelopeId": { + "description": "The ID of the envelope this goal is for", "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, - "initialBalance": { - "description": "Balance of the account before any transactions were recorded", - "type": "number", - "default": 0, - "example": 173.12 + "example": "f81566d9-af4d-4f13-9830-c62c4b5e4c7e" }, - "initialBalanceDate": { - "description": "Date of the initial balance", + "month": { + "description": "The month the balance of the envelope should be the set amount", "type": "string", - "example": "2017-05-12T00:00:00Z" + "example": "2024-07-01T00:00:00.000000Z" }, "name": { - "description": "Name of the account", + "description": "Name of the goal", "type": "string", - "example": "Cash" + "example": "New TV" }, "note": { - "description": "A longer description for the account", + "description": "Note about the goal", "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 + "example": "We want to replace the old CRT TV soon-ish" } } }, - "models.AllocationCreate": { + "controllers.GoalV3Links": { "type": "object", "properties": { - "amount": { - "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", - "type": "number", - "maximum": 1000000000000, - "minimum": 1e-8, - "multipleOf": 1e-8, - "example": 22.01 - }, - "envelopeId": { - "description": "ID of the envelope", + "envelope": { + "description": "The Envelope this goal references", "type": "string", - "example": "a0909e84-e8f9-4cb6-82a5-025dff105ff2" + "example": "https://example.com/api/v3/envelopes/c1a96ae4-80e3-4827-8ed0-c7656f224fee" }, - "month": { - "description": "Only year and month of this timestamp are used, everything else is ignored. This will always be set to 00:00 UTC on the first of the specified month", + "self": { + "description": "The Goal itself", "type": "string", - "example": "2021-12-01T00:00:00.000000Z" + "example": "https://example.com/api/v3/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c" } } }, - "models.BudgetCreate": { + "controllers.ImportPreviewListV3": { "type": "object", "properties": { - "currency": { - "description": "The currency for the budget", - "type": "string", - "example": "€" + "data": { + "description": "List of transaction previews", + "type": "array", + "items": { + "$ref": "#/definitions/importer.TransactionPreviewV3" + } }, - "name": { - "description": "Name of the budget", + "error": { + "description": "The error, if any occurred for this Match Rule", "type": "string", - "example": "Morre's Budget" + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "controllers.ImportV3Links": { + "type": "object", + "properties": { + "matchRules": { + "description": "URL of YNAB Import preview endpoint", + "type": "string", + "example": "https://example.com/api/v3/import/ynab-import-preview" }, - "note": { - "description": "A longer description of the budget", + "transactions": { + "description": "URL of YNAB4 import endpoint", "type": "string", - "example": "My personal expenses" + "example": "https://example.com/api/v3/import/ynab4" } } }, - "models.BudgetMonth": { + "controllers.ImportV3Response": { "type": "object", "properties": { - "available": { - "description": "The amount of money still available to budget.", - "type": "number", - "example": 217.34 - }, - "budgeted": { - "description": "Amount of money that has been allocated to envelopes", - "type": "number", - "example": 2100 - }, - "envelopes": { - "description": "The envelopes this budget has, with detailed calculations", + "links": { + "description": "Links for the v3 API", + "allOf": [ + { + "$ref": "#/definitions/controllers.ImportV3Links" + } + ] + } + } + }, + "controllers.MatchRuleCreateResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of created Match Rules", "type": "array", "items": { - "$ref": "#/definitions/models.EnvelopeMonth" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, - "id": { - "description": "The ID of the Budget", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "1e777d24-3f5b-4c43-8000-04f65f895578" - }, - "income": { - "description": "Income. This is all money that is sent from off-budget to on-budget accounts without an envelope set.", - "type": "number", - "example": 2317.34 + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "controllers.MatchRuleListResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of Match Rules", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.MatchRuleV3" + } }, - "month": { - "description": "Month these calculations are made for", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "2006-05-01T00:00:00.000000Z" + "example": "the specified resource ID is not a valid UUID" }, - "name": { - "description": "The name of the Budget", - "type": "string", - "example": "Groceries" + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/controllers.Pagination" + } + ] } } }, - "models.CategoryCreate": { + "controllers.MatchRuleResponseV3": { "type": "object", "properties": { - "budgetId": { - "description": "ID of the budget the category belongs to", - "type": "string", - "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" - }, - "hidden": { - "description": "Is the category hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "name": { - "description": "Name of the category", - "type": "string", - "example": "Saving" + "data": { + "description": "The Match Rule data, if creation was successful", + "allOf": [ + { + "$ref": "#/definitions/controllers.MatchRuleV3" + } + ] }, - "note": { - "description": "Notes about the category", + "error": { + "description": "The error, if any occurred for this Match Rule", "type": "string", - "example": "All envelopes for long-term saving" + "example": "the specified resource ID is not a valid UUID" } } }, - "models.CategoryEnvelopes": { + "controllers.MatchRuleV3": { "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", + "accountId": { + "description": "The account to map matching transactions to", "type": "string", - "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" + "example": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" }, "createdAt": { "description": "Time the resource was created", @@ -9568,59 +4103,84 @@ const docTemplate = `{ "type": "string", "example": "2022-04-22T21:01:05.058161Z" }, - "envelopes": { - "description": "Slice of all envelopes", - "type": "array", - "items": { - "$ref": "#/definitions/models.EnvelopeMonth" - } - }, - "hidden": { - "description": "Is the category hidden?", - "type": "boolean", - "default": false, - "example": true - }, "id": { "description": "UUID for the resource", "type": "string", "example": "65392deb-5e92-4268-b114-297faad6cdce" }, - "name": { - "description": "Name of the category", - "type": "string", - "example": "Saving" + "links": { + "type": "object", + "properties": { + "self": { + "description": "The match rule itself", + "type": "string", + "example": "https://example.com/api/v3/match-rules/95685c82-53c6-455d-b235-f49960b73b21" + } + } }, - "note": { - "description": "Notes about the category", + "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": "All envelopes for long-term saving" + "example": "Bank*" }, - "spent": { - "description": "Sum spent for all envelopes", - "type": "number", - "example": 100.13 + "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" + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "controllers.MonthConfigCreateV3": { + "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 + }, + "note": { + "description": "A note for the month config", + "type": "string", + "example": "Added 200€ here because we replaced Tim's expensive vase" } } }, - "models.Envelope": { + "controllers.MonthConfigResponseV3": { "type": "object", "properties": { - "archived": { - "description": "Is the Envelope archived?", - "type": "boolean", - "default": false, - "example": true + "data": { + "description": "Config for the month", + "allOf": [ + { + "$ref": "#/definitions/controllers.MonthConfigV3" + } + ] }, - "categoryId": { - "description": "ID of the category the envelope belongs to", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "controllers.MonthConfigV3": { + "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 }, "createdAt": { "description": "Time the resource was created", @@ -9632,26 +4192,39 @@ const docTemplate = `{ "type": "string", "example": "2022-04-22T21:01:05.058161Z" }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "id": { - "description": "UUID for the resource", + "envelopeId": { + "description": "ID of the envelope", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" + "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" }, - "name": { - "description": "Name of the envelope", + "links": { + "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" + }, + "self": { + "description": "The Month Config itself", + "type": "string", + "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" + } + } + }, + "month": { + "description": "The month. This is always set to 00:00 UTC on the first of the month.", "type": "string", - "example": "Groceries" + "example": "1969-06-01T00:00:00.000000Z" }, "note": { - "description": "Notes about the envelope", + "description": "A note for the month config", "type": "string", - "example": "For stuff bought at supermarkets and drugstores" + "example": "Added 200€ here because we replaced Tim's expensive vase" + }, + "overspendMode": { + "description": "Ignore this. It is here to override the OverspendMode from models.MonthConfigCreate and will be removed with 4.0.0", + "type": "string" }, "updatedAt": { "description": "Last time the resource was updated", @@ -9660,154 +4233,252 @@ const docTemplate = `{ } } }, - "models.EnvelopeCreate": { + "controllers.MonthResponseV3": { "type": "object", "properties": { - "categoryId": { - "description": "ID of the category the envelope belongs to", - "type": "string", - "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" - }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "name": { - "description": "Name of the envelope", - "type": "string", - "example": "Groceries" + "data": { + "description": "Data for the month", + "allOf": [ + { + "$ref": "#/definitions/controllers.MonthV3" + } + ] }, - "note": { - "description": "Notes about the envelope", - "type": "string", - "example": "For stuff bought at supermarkets and drugstores" + "error": { + "description": "The error, if any occurred", + "type": "string" } } }, - "models.EnvelopeMonth": { + "controllers.MonthV3": { "type": "object", "properties": { "allocation": { - "description": "The amount of money allocated", + "description": "The sum of all allocations for this month", "type": "number", - "example": 85.44 + "example": 1200.5 }, - "archived": { - "description": "Is the Envelope archived?", - "type": "boolean", - "default": false, - "example": true + "available": { + "description": "The amount available to budget", + "type": "number", + "example": 217.34 }, "balance": { - "description": "The balance at the end of the monht", + "description": "The sum of all envelope balances", "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" + "example": 5231.37 }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true + "categories": { + "description": "A list of envelope month calculations grouped by category", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.CategoryEnvelopesV3" + } }, "id": { - "description": "UUID for the resource", + "description": "The ID of the Budget", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" + "example": "1e777d24-3f5b-4c43-8000-04f65f895578" }, - "links": { - "description": "Linked resources", - "allOf": [ - { - "$ref": "#/definitions/models.EnvelopeMonthLinks" - } - ] + "income": { + "description": "The total income for the month (sum of all incoming transactions without an Envelope)", + "type": "number", + "example": 2317.34 }, "month": { - "description": "This is always set to 00:00 UTC on the first of the month. **This field is deprecated and will be removed in v2**", + "description": "The month", "type": "string", - "example": "1969-06-01T00:00:00.000000Z" + "example": "2006-05-01T00:00:00.000000Z" }, "name": { - "description": "Name of the envelope", - "type": "string", - "example": "Groceries" - }, - "note": { - "description": "Notes about the envelope", + "description": "The name of the Budget", "type": "string", - "example": "For stuff bought at supermarkets and drugstores" + "example": "Zero budget" }, "spent": { - "description": "The amount spent over the whole month", + "description": "The amount of money spent in this month", "type": "number", - "example": 73.12 + "example": 133.7 + } + } + }, + "controllers.Pagination": { + "type": "object", + "properties": { + "count": { + "description": "The amount of records returned in this response", + "type": "integer", + "example": 25 }, - "updatedAt": { - "description": "Last time the resource was updated", + "limit": { + "description": "The maximum amount of resources to return for this request", + "type": "integer", + "example": 25 + }, + "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 + } + } + }, + "controllers.TransactionCreateResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of created Transactions", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.TransactionResponseV3" + } + }, + "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.EnvelopeMonthLinks": { + "controllers.TransactionListResponseV3": { "type": "object", "properties": { - "allocation": { - "description": "The allocations for this envelope for this month", + "data": { + "description": "List of transactions", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.TransactionV3" + } + }, + "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/controllers.Pagination" + } + ] + } + } + }, + "controllers.TransactionResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "The Transaction data, if creation was successful", + "allOf": [ + { + "$ref": "#/definitions/controllers.TransactionV3" + } + ] + }, + "error": { + "description": "The error, if any occurred for this transaction", "type": "string", - "example": "https://example.com/api/v1/allocations/772d6956-ecba-485b-8a27-46a506c5a2a3" + "example": "the specified resource ID is not a valid UUID" } } }, - "models.MatchRule": { + "controllers.TransactionV3": { "type": "object", "properties": { - "accountId": { - "description": "The account to map matching transactions to", + "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 + }, + "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": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" + "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", "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" }, - "match": { - "description": "The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive.", + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", "type": "string", - "example": "Bank*" + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" }, - "priority": { - "description": "The priority of the match rule", - "type": "integer", - "example": 3 + "links": { + "description": "Links for the transaction", + "type": "object", + "properties": { + "self": { + "description": "The transaction itself", + "type": "string", + "example": "https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" + } + } + }, + "note": { + "description": "A note", + "type": "string", + "example": "Lunch" + }, + "reconciled": { + "description": "Remove the reconciled field", + "type": "boolean" + }, + "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", @@ -9816,100 +4487,82 @@ const docTemplate = `{ } } }, - "models.MatchRuleCreate": { + "httperrors.HTTPError": { "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.", + "error": { "type": "string", - "example": "Bank*" - }, - "priority": { - "description": "The priority of the match rule", - "type": "integer", - "example": 3 + "example": "An ID specified in the query string was not a valid UUID" } } }, - "models.Month": { + "importer.TransactionPreviewV3": { "type": "object", "properties": { - "allocation": { - "description": "The sum of all allocations for this month", - "type": "number", - "example": 1200.5 - }, - "available": { - "description": "The amount available to budget", - "type": "number", - "example": 217.34 - }, - "balance": { - "description": "The sum of all envelope balances", - "type": "number", - "example": 5231.37 - }, - "budgeted": { - "description": "The sum of all allocations for the month. **Deprecated, please use the ` + "`" + `allocation` + "`" + ` field**", - "type": "number", - "example": 2100 + "destinationAccountName": { + "description": "Name of the destination account from the CSV file", + "type": "string", + "example": "Deutsche Bahn" }, - "categories": { - "description": "A list of envelope month calculations grouped by category", + "duplicateTransactionIds": { + "description": "IDs of transactions that this transaction duplicates", "type": "array", "items": { - "$ref": "#/definitions/models.CategoryEnvelopes" + "type": "string" } }, - "id": { - "description": "The ID of the Budget", + "matchRuleId": { + "description": "ID of the match rule that was applied to this transaction preview", "type": "string", - "example": "1e777d24-3f5b-4c43-8000-04f65f895578" + "example": "042d101d-f1de-4403-9295-59dc0ea58677" }, - "income": { - "description": "The total income for the month (sum of all incoming transactions without an Envelope)", - "type": "number", - "example": 2317.34 + "sourceAccountName": { + "description": "Name of the source account from the CSV file", + "type": "string", + "example": "Employer" }, - "month": { - "description": "The month", + "transaction": { + "$ref": "#/definitions/models.TransactionCreate" + } + } + }, + "models.BudgetCreate": { + "type": "object", + "properties": { + "currency": { + "description": "The currency for the budget", "type": "string", - "example": "2006-05-01T00:00:00.000000Z" + "example": "€" }, "name": { - "description": "The name of the Budget", + "description": "Name of the budget", "type": "string", - "example": "Zero budget" + "example": "Morre's Budget" }, - "spent": { - "description": "The amount of money spent in this month", - "type": "number", - "example": 133.7 + "note": { + "description": "A longer description of the budget", + "type": "string", + "example": "My personal expenses" } } }, - "models.MonthConfigCreate": { + "models.MatchRuleCreate": { "type": "object", "properties": { - "note": { - "description": "A note for the month config", + "accountId": { + "description": "The account to map matching transactions to", "type": "string", - "example": "Added 200€ here because we replaced Tim's expensive vase" + "example": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" }, - "overspendMode": { - "description": "The overspend handling mode to use. Deprecated, will be removed with 4.0.0 release and is not used in API v3 anymore", - "default": "AFFECT_AVAILABLE", - "allOf": [ - { - "$ref": "#/definitions/models.OverspendMode" - } - ], - "example": "AFFECT_ENVELOPE" + "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 } } }, @@ -10013,16 +4666,6 @@ const docTemplate = `{ "type": "string", "example": "https://example.com/api/metrics" }, - "v1": { - "description": "List endpoint for all v1 endpoints", - "type": "string", - "example": "https://example.com/api/v1" - }, - "v2": { - "description": "List endpoint for all v2 endpoints", - "type": "string", - "example": "https://example.com/api/v2" - }, "v3": { "description": "List endpoint for all v3 endpoints", "type": "string", @@ -10048,102 +4691,6 @@ const docTemplate = `{ } } }, - "router.V1Links": { - "type": "object", - "properties": { - "accounts": { - "description": "URL of account list endpoint", - "type": "string", - "example": "https://example.com/api/v1/accounts" - }, - "allocations": { - "description": "URL of allocation list endpoint", - "type": "string", - "example": "https://example.com/api/v1/allocations" - }, - "budgets": { - "description": "URL of budget list endpoint", - "type": "string", - "example": "https://example.com/api/v1/budgets" - }, - "categories": { - "description": "URL of category list endpoint", - "type": "string", - "example": "https://example.com/api/v1/categories" - }, - "envelopes": { - "description": "URL of envelope list endpoint", - "type": "string", - "example": "https://example.com/api/v1/envelopes" - }, - "import": { - "description": "URL of import list endpoint", - "type": "string", - "example": "https://example.com/api/v1/import" - }, - "months": { - "description": "URL of month list endpoint", - "type": "string", - "example": "https://example.com/api/v1/months" - }, - "transactions": { - "description": "URL of transaction list endpoint", - "type": "string", - "example": "https://example.com/api/v1/transactions" - } - } - }, - "router.V1Response": { - "type": "object", - "properties": { - "links": { - "description": "Links for the v1 API", - "allOf": [ - { - "$ref": "#/definitions/router.V1Links" - } - ] - } - } - }, - "router.V2Links": { - "type": "object", - "properties": { - "accounts": { - "description": "URL of transaction list endpoint", - "type": "string", - "example": "https://example.com/api/v2/accounts" - }, - "match-rules": { - "description": "URL of match-rule list endpoint", - "type": "string", - "example": "https://example.com/api/v2/match-rules" - }, - "rename-rules": { - "description": "URL of rename-rule list endpoint", - "type": "string", - "example": "https://example.com/api/v2/rename-rules" - }, - "transactions": { - "description": "URL of transaction list endpoint", - "type": "string", - "example": "https://example.com/api/v2/transactions" - } - } - }, - "router.V2Response": { - "type": "object", - "properties": { - "links": { - "description": "Links for the v2 API", - "allOf": [ - { - "$ref": "#/definitions/router.V2Links" - } - ] - } - } - }, "router.V3Links": { "type": "object", "properties": { @@ -10198,7 +4745,7 @@ const docTemplate = `{ "type": "object", "properties": { "links": { - "description": "Links for the v2 API", + "description": "Links for the v3 API", "allOf": [ { "$ref": "#/definitions/router.V3Links" diff --git a/api/swagger.json b/api/swagger.json index ad11ded3..1eb0632d 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -68,19 +68,18 @@ } } }, - "/v1": { + "/v3": { "get": { - "description": "Returns general information about the v1 API", + "description": "Returns general information about the v3 API", "tags": [ - "v1" + "v3" ], - "summary": "v1 API", - "deprecated": true, + "summary": "v3 API", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/router.V1Response" + "$ref": "#/definitions/router.V3Response" } } } @@ -88,14 +87,27 @@ "delete": { "description": "Permanently deletes all resources", "tags": [ - "v1" + "v3" ], "summary": "Delete everything", - "deprecated": true, + "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": { @@ -107,10 +119,9 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "v1" + "v3" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -118,7 +129,7 @@ } } }, - "/v1/accounts": { + "/v3/accounts": { "get": { "description": "Returns a list of accounts", "produces": [ @@ -128,7 +139,6 @@ "Accounts" ], "summary": "List accounts", - "deprecated": true, "parameters": [ { "type": "string", @@ -162,8 +172,8 @@ }, { "type": "boolean", - "description": "Is the account hidden?", - "name": "hidden", + "description": "Is the account archived?", + "name": "archived", "in": "query" }, { @@ -171,47 +181,61 @@ "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/controllers.AccountListResponse" + "$ref": "#/definitions/controllers.AccountListResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountListResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountListResponseV3" } } } }, "post": { - "description": "Creates a new account", + "description": "Creates new accounts", "produces": [ "application/json" ], "tags": [ "Accounts" ], - "summary": "Create account", - "deprecated": true, + "summary": "Creates accounts", "parameters": [ { - "description": "Account", - "name": "account", + "description": "Accounts", + "name": "accounts", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.AccountCreate" + "type": "array", + "items": { + "$ref": "#/definitions/controllers.AccountCreateV3" + } } } ], @@ -219,25 +243,25 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.AccountResponse" + "$ref": "#/definitions/controllers.AccountCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountCreateResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountCreateResponseV3" } } } @@ -248,7 +272,6 @@ "Accounts" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -256,7 +279,7 @@ } } }, - "/v1/accounts/{id}": { + "/v3/accounts/{id}": { "get": { "description": "Returns a specific account", "produces": [ @@ -266,7 +289,6 @@ "Accounts" ], "summary": "Get account", - "deprecated": true, "parameters": [ { "type": "string", @@ -280,25 +302,25 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.AccountResponse" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } } } @@ -312,7 +334,6 @@ "Accounts" ], "summary": "Delete account", - "deprecated": true, "parameters": [ { "type": "string", @@ -352,7 +373,6 @@ "Accounts" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", @@ -395,7 +415,6 @@ "Accounts" ], "summary": "Update account", - "deprecated": true, "parameters": [ { "type": "string", @@ -410,7 +429,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.AccountCreate" + "$ref": "#/definitions/controllers.AccountCreateV3" } } ], @@ -418,58 +437,75 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.AccountResponse" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.AccountResponseV3" } } } } }, - "/v1/allocations": { + "/v3/budgets": { "get": { - "description": "Returns a list of allocations", + "description": "Returns a list of budgets", "produces": [ "application/json" ], "tags": [ - "Allocations" + "Budgets" ], - "summary": "Get allocations", - "deprecated": true, + "summary": "List budgets", "parameters": [ { "type": "string", - "description": "Filter by month", - "name": "month", + "description": "Filter by name", + "name": "name", "in": "query" }, { "type": "string", - "description": "Filter by amount", - "name": "amount", + "description": "Filter by note", + "name": "note", "in": "query" }, { "type": "string", - "description": "Filter by envelope ID", - "name": "envelope", + "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" } ], @@ -477,41 +513,37 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.AllocationListResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetListResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetListResponseV3" } } } }, "post": { - "description": "Create a new allocation of funds to an envelope for a specific month", + "description": "Creates a new budget", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Allocations" + "Budgets" ], - "summary": "Create allocations", - "deprecated": true, + "summary": "Create budget", "parameters": [ { - "description": "Allocation", - "name": "allocation", + "description": "Budget", + "name": "budget", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.AllocationCreate" + "$ref": "#/definitions/models.BudgetCreate" } } ], @@ -519,25 +551,19 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.AllocationResponse" + "$ref": "#/definitions/controllers.BudgetCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetCreateResponseV3" } } } @@ -545,10 +571,9 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Allocations" + "Budgets" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -556,17 +581,16 @@ } } }, - "/v1/allocations/{id}": { + "/v3/budgets/{id}": { "get": { - "description": "Returns a specific allocation", + "description": "Returns a specific budget", "produces": [ "application/json" ], "tags": [ - "Allocations" + "Budgets" ], - "summary": "Get allocation", - "deprecated": true, + "summary": "Get budget", "parameters": [ { "type": "string", @@ -580,36 +604,35 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.AllocationResponse" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } } } }, "delete": { - "description": "Deletes an allocation", + "description": "Deletes a budget", "tags": [ - "Allocations" + "Budgets" ], - "summary": "Delete allocation", - "deprecated": true, + "summary": "Delete budget", "parameters": [ { "type": "string", @@ -646,10 +669,9 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Allocations" + "Budgets" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", @@ -684,7 +706,7 @@ } }, "patch": { - "description": "Update an allocation. Only values to be updated need to be specified.", + "description": "Update an existing budget. Only values to be updated need to be specified.", "consumes": [ "application/json" ], @@ -692,10 +714,9 @@ "application/json" ], "tags": [ - "Allocations" + "Budgets" ], - "summary": "Update allocation", - "deprecated": true, + "summary": "Update budget", "parameters": [ { "type": "string", @@ -705,12 +726,12 @@ "required": true }, { - "description": "Allocation", - "name": "allocation", + "description": "Budget", + "name": "budget", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.AllocationCreate" + "$ref": "#/definitions/models.BudgetCreate" } } ], @@ -718,41 +739,40 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.AllocationResponse" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } } } } }, - "/v1/budgets": { + "/v3/categories": { "get": { - "description": "Returns a list of budgets", + "description": "Returns a list of categories", "produces": [ "application/json" ], "tags": [ - "Budgets" + "Categories" ], - "summary": "List budgets", - "deprecated": true, + "summary": "Get categories", "parameters": [ { "type": "string", @@ -768,8 +788,14 @@ }, { "type": "string", - "description": "Filter by currency", - "name": "currency", + "description": "Filter by budget ID", + "name": "budget", + "in": "query" + }, + { + "type": "boolean", + "description": "Is the category hidden?", + "name": "hidden", "in": "query" }, { @@ -777,44 +803,61 @@ "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/controllers.BudgetListResponse" + "$ref": "#/definitions/controllers.CategoryListResponseV3" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/controllers.CategoryListResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryListResponseV3" } } } }, "post": { - "description": "Creates a new budget", - "consumes": [ - "application/json" - ], + "description": "Creates a new category", "produces": [ "application/json" ], "tags": [ - "Budgets" + "Categories" ], - "summary": "Create budget", - "deprecated": true, + "summary": "Create category", "parameters": [ { - "description": "Budget", - "name": "budget", + "description": "Categories", + "name": "categories", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.BudgetCreate" + "type": "array", + "items": { + "$ref": "#/definitions/controllers.CategoryCreateV3" + } } } ], @@ -822,19 +865,25 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.BudgetResponse" + "$ref": "#/definitions/controllers.CategoryCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryCreateResponseV3" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/controllers.CategoryCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryCreateResponseV3" } } } @@ -842,10 +891,9 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Budgets" + "Categories" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -853,17 +901,16 @@ } } }, - "/v1/budgets/{id}": { + "/v3/categories/{id}": { "get": { - "description": "Returns a specific budget", + "description": "Returns a specific category", "produces": [ "application/json" ], "tags": [ - "Budgets" + "Categories" ], - "summary": "Get budget", - "deprecated": true, + "summary": "Get category", "parameters": [ { "type": "string", @@ -877,36 +924,35 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.BudgetResponse" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } } } }, "delete": { - "description": "Deletes a budget", + "description": "Deletes a category", "tags": [ - "Budgets" + "Categories" ], - "summary": "Delete budget", - "deprecated": true, + "summary": "Delete category", "parameters": [ { "type": "string", @@ -943,10 +989,9 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Budgets" + "Categories" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", @@ -981,7 +1026,7 @@ } }, "patch": { - "description": "Update an existing budget. Only values to be updated need to be specified.", + "description": "Update an existing category. Only values to be updated need to be specified.", "consumes": [ "application/json" ], @@ -989,10 +1034,9 @@ "application/json" ], "tags": [ - "Budgets" + "Categories" ], - "summary": "Update budget", - "deprecated": true, + "summary": "Update category", "parameters": [ { "type": "string", @@ -1002,12 +1046,12 @@ "required": true }, { - "description": "Budget", - "name": "budget", + "description": "Category", + "name": "category", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.BudgetCreate" + "$ref": "#/definitions/controllers.CategoryCreateV3" } } ], @@ -1015,91 +1059,178 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.BudgetResponse" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.CategoryResponseV3" } } } } }, - "/v1/budgets/{id}/{month}": { + "/v3/envelopes": { "get": { - "description": "Returns data about a budget for a for a specific month. **Use GET /month endpoint with month and budgetId query parameters instead.**", + "description": "Returns a list of envelopes", "produces": [ "application/json" ], "tags": [ - "Budgets" + "Envelopes" ], - "summary": "Get Budget month data", - "deprecated": true, + "summary": "Get envelopes", "parameters": [ { "type": "string", - "description": "ID formatted as string", - "name": "id", - "in": "path", - "required": true + "description": "Filter by name", + "name": "name", + "in": "query" }, { "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true + "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/controllers.BudgetMonthResponse" + "$ref": "#/definitions/controllers.EnvelopeListResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeListResponseV3" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/controllers.EnvelopeListResponseV3" + } + } + } + }, + "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/controllers.EnvelopeCreateV3" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" } } } }, "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs. **Use OPTIONS /month endpoint with month and budgetId query parameters instead.**", + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Budgets" + "Envelopes" ], "summary": "Allowed HTTP verbs", - "deprecated": true, + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/v3/envelopes/{id}": { + "get": { + "description": "Returns a specific Envelope", + "produces": [ + "application/json" + ], + "tags": [ + "Envelopes" + ], + "summary": "Get Envelope", "parameters": [ { "type": "string", @@ -1107,71 +1238,48 @@ "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" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.EnvelopeResponseV3" + } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } } } - } - }, - "/v1/budgets/{id}/{month}/allocations": { - "post": { - "description": "Sets allocations for a month for all envelopes that do not have an allocation yet. **Deprecated. Use POST /month endpoint with month and budgetId query parameters instead.**", + }, + "delete": { + "description": "Deletes an envelope", "tags": [ - "Budgets" + "Envelopes" ], - "summary": "Set allocations for a month", - "deprecated": true, + "summary": "Delete envelope", "parameters": [ { "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Budget ID formatted as string", + "description": "ID formatted as string", "name": "id", "in": "path", "required": true - }, - { - "description": "Budget", - "name": "mode", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controllers.BudgetAllocationMode" - } } ], "responses": { @@ -1198,24 +1306,16 @@ } } }, - "delete": { - "description": "Deletes all allocation for the specified month. **Use DELETE /month endpoint with month and budgetId query parameters instead.**", + "options": { + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Budgets" + "Envelopes" ], - "summary": "Delete allocations for a month", - "deprecated": true, + "summary": "Allowed HTTP verbs", "parameters": [ { "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Budget ID formatted as string", + "description": "ID formatted as string", "name": "id", "in": "path", "required": true @@ -1231,6 +1331,12 @@ "$ref": "#/definitions/httperrors.HTTPError" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httperrors.HTTPError" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -1239,13 +1345,18 @@ } } }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs. **Use OPTIONS /month endpoint with month and budgetId query parameters instead.**", + "patch": { + "description": "Updates an existing envelope. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], "tags": [ - "Budgets" + "Envelopes" ], - "summary": "Allowed HTTP verbs", - "deprecated": true, + "summary": "Update envelope", "parameters": [ { "type": "string", @@ -1255,146 +1366,92 @@ "required": true }, { - "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true + "description": "Envelope", + "name": "envelope", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.EnvelopeCreateV3" + } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.EnvelopeResponseV3" + } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } } } } }, - "/v1/categories": { + "/v3/envelopes/{id}/{month}": { "get": { - "description": "Returns a list of categories", + "description": "Returns configuration for a specific month", "produces": [ "application/json" ], "tags": [ - "Categories" + "Envelopes" ], - "summary": "Get categories", - "deprecated": true, + "summary": "Get MonthConfig", "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 hidden?", - "name": "hidden", - "in": "query" + "description": "ID of the Envelope", + "name": "id", + "in": "path", + "required": true }, { "type": "string", - "description": "Search for this text in name and note", - "name": "search", - "in": "query" + "description": "The month in YYYY-MM format", + "name": "month", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.CategoryListResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - } - } - }, - "post": { - "description": "Creates a new category", - "produces": [ - "application/json" - ], - "tags": [ - "Categories" - ], - "summary": "Create category", - "deprecated": true, - "parameters": [ - { - "description": "Category", - "name": "category", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.CategoryCreate" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponse" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } } } @@ -1402,76 +1459,21 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Categories" + "Envelopes" ], "summary": "Allowed HTTP verbs", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v1/categories/{id}": { - "get": { - "description": "Returns a specific category", - "produces": [ - "application/json" - ], - "tags": [ - "Categories" - ], - "summary": "Get category", - "deprecated": true, "parameters": [ { "type": "string", - "description": "ID formatted as string", + "description": "ID of the Envelope", "name": "id", "in": "path", "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponse" - } - }, - "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 a category", - "tags": [ - "Categories" - ], - "summary": "Delete category", - "deprecated": true, - "parameters": [ { "type": "string", - "description": "ID formatted as string", - "name": "id", + "description": "The month in YYYY-MM format", + "name": "month", "in": "path", "required": true } @@ -1485,131 +1487,81 @@ "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", + "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": [ - "Categories" + "Envelopes" ], - "summary": "Allowed HTTP verbs", - "deprecated": true, + "summary": "Update MonthConfig", "parameters": [ { "type": "string", - "description": "ID formatted as string", + "description": "ID of the Envelope", "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", - "deprecated": true, - "parameters": [ { "type": "string", - "description": "ID formatted as string", - "name": "id", + "description": "The month in YYYY-MM format", + "name": "month", "in": "path", "required": true }, { - "description": "Category", - "name": "category", + "description": "MonthConfig", + "name": "monthConfig", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.CategoryCreate" + "$ref": "#/definitions/controllers.MonthConfigCreateV3" } } ], "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { - "$ref": "#/definitions/controllers.CategoryResponse" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthConfigResponseV3" } } } } }, - "/v1/envelopes": { + "/v3/goals": { "get": { - "description": "Returns a list of envelopes", + "description": "Returns a list of goals", "produces": [ "application/json" ], "tags": [ - "Envelopes" + "Goals" ], - "summary": "Get envelopes", - "deprecated": true, + "summary": "Get goals", "parameters": [ { "type": "string", @@ -1625,62 +1577,112 @@ }, { "type": "string", - "description": "Filter by category ID", - "name": "category", + "description": "Search for this text in name and note", + "name": "search", "in": "query" }, { "type": "boolean", - "description": "Is the envelope hidden?", - "name": "hidden", + "description": "Is the goal archived?", + "name": "archived", "in": "query" }, { "type": "string", - "description": "Search for this text in name and note", - "name": "search", + "description": "Filter by envelope ID", + "name": "envelope", "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeListResponse" - } }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } + { + "type": "string", + "description": "Month of the goal. Ignores exact time, matches on the month of the RFC3339 timestamp provided.", + "name": "month", + "in": "query" }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } + { + "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/controllers.GoalListResponseV3" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/controllers.GoalListResponseV3" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/controllers.GoalListResponseV3" + } } } }, "post": { - "description": "Creates a new envelope", + "description": "Creates new goals", "produces": [ "application/json" ], "tags": [ - "Envelopes" + "Goals" ], - "summary": "Create envelope", - "deprecated": true, + "summary": "Create goals", "parameters": [ { - "description": "Envelope", - "name": "envelope", + "description": "Goals", + "name": "goals", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.EnvelopeCreate" + "type": "array", + "items": { + "$ref": "#/definitions/controllers.GoalV3Editable" + } } } ], @@ -1688,25 +1690,25 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponse" + "$ref": "#/definitions/controllers.GoalCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalCreateResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalCreateResponseV3" } } } @@ -1714,10 +1716,9 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Envelopes" + "Goals" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -1725,17 +1726,16 @@ } } }, - "/v1/envelopes/{id}": { + "/v3/goals/{id}": { "get": { - "description": "Returns a specific envelope", + "description": "Returns a specific goal", "produces": [ "application/json" ], "tags": [ - "Envelopes" + "Goals" ], - "summary": "Get envelope", - "deprecated": true, + "summary": "Get goal", "parameters": [ { "type": "string", @@ -1749,36 +1749,35 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponse" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } } } }, "delete": { - "description": "Deletes an envelope", + "description": "Deletes a goal", "tags": [ - "Envelopes" + "Goals" ], - "summary": "Delete envelope", - "deprecated": true, + "summary": "Delete goal", "parameters": [ { "type": "string", @@ -1815,10 +1814,9 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "Envelopes" + "Goals" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", @@ -1853,7 +1851,7 @@ } }, "patch": { - "description": "Updates an existing envelope. Only values to be updated need to be specified.", + "description": "Updates an existing goal. Only values to be updated need to be specified.", "consumes": [ "application/json" ], @@ -1861,10 +1859,9 @@ "application/json" ], "tags": [ - "Envelopes" + "Goals" ], - "summary": "Update envelope", - "deprecated": true, + "summary": "Update goal", "parameters": [ { "type": "string", @@ -1874,12 +1871,12 @@ "required": true }, { - "description": "Envelope", - "name": "envelope", + "description": "Goal", + "name": "goal", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.EnvelopeCreate" + "$ref": "#/definitions/controllers.GoalV3Editable" } } ], @@ -1887,139 +1884,52 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponse" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.GoalResponseV3" } } } } }, - "/v1/envelopes/{id}/{month}": { + "/v3/import": { "get": { - "description": "Returns data about an envelope for a for a specific month. **Use GET /month endpoint with month and budgetId query parameters instead.**", - "produces": [ - "application/json" - ], + "description": "Returns general information about the v3 API", "tags": [ - "Envelopes" - ], - "summary": "Get Envelope month data", - "deprecated": true, - "parameters": [ - { - "type": "string", - "description": "ID formatted as string", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true - } + "Import" ], + "summary": "Import API overview", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.EnvelopeMonthResponse" - } - }, - "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" - } - } - } - } - }, - "/v1/import": { - "post": { - "description": "Imports budgets from YNAB 4. **Please use /v1/import/ynab4, which works exactly the same.**", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Import" - ], - "summary": "Import", - "deprecated": true, - "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": { - "204": { - "description": "No Content" - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.ImportV3Response" } } } }, "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs. **Please use /v1/import/ynab4, which works exactly the same.**", + "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs.", "tags": [ "Import" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -2027,7 +1937,7 @@ } } }, - "/v1/import/ynab-import-preview": { + "/v3/import/ynab-import-preview": { "post": { "description": "Returns a preview of transactions to be imported after parsing a YNAB Import format csv file", "consumes": [ @@ -2040,7 +1950,6 @@ "Import" ], "summary": "Transaction Import Preview", - "deprecated": true, "parameters": [ { "type": "file", @@ -2060,25 +1969,25 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.ImportPreviewList" + "$ref": "#/definitions/controllers.ImportPreviewListV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.ImportPreviewListV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.ImportPreviewListV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.ImportPreviewListV3" } } } @@ -2089,7 +1998,6 @@ "Import" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -2097,7 +2005,7 @@ } } }, - "/v1/import/ynab4": { + "/v3/import/ynab4": { "post": { "description": "Imports budgets from YNAB 4", "consumes": [ @@ -2110,7 +2018,6 @@ "Import" ], "summary": "Import YNAB 4 budget", - "deprecated": true, "parameters": [ { "type": "file", @@ -2130,19 +2037,19 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.BudgetResponse" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.BudgetResponseV3" } } } @@ -2153,7 +2060,6 @@ "Import" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -2161,28 +2067,45 @@ } } }, - "/v1/month-configs": { + "/v3/match-rules": { "get": { - "description": "Returns a list of MonthConfigs", + "description": "Returns a list of matchRules", "produces": [ "application/json" ], "tags": [ - "MonthConfigs" + "MatchRules" ], - "summary": "List MonthConfigs", - "deprecated": true, + "summary": "Get matchRules", "parameters": [ + { + "type": "integer", + "description": "Filter by priority", + "name": "priority", + "in": "query" + }, { "type": "string", - "description": "Filter by name", - "name": "envelope", + "description": "Filter by match", + "name": "match", "in": "query" }, { "type": "string", - "description": "Filter by month", - "name": "month", + "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" } ], @@ -2190,177 +2113,145 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.MonthConfigListResponse" + "$ref": "#/definitions/controllers.MatchRuleListResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleListResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleListResponseV3" } } } }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs.", - "tags": [ - "MonthConfigs" - ], - "summary": "Allowed HTTP verbs", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v1/month-configs/{id}/{month}": { - "get": { - "description": "Returns configuration for a specific month", + "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": [ - "MonthConfigs" + "MatchRules" ], - "summary": "Get MonthConfig", - "deprecated": true, + "summary": "Create matchRules", "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": "MatchRules", + "name": "matchRules", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.MatchRuleCreate" + } + } } ], "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponse" + "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" } } } }, - "post": { - "description": "Creates a new MonthConfig", + "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" + } + } + } + }, + "/v3/match-rules/{id}": { + "get": { + "description": "Returns a specific matchRule", "produces": [ "application/json" ], "tags": [ - "MonthConfigs" + "MatchRules" ], - "summary": "Create MonthConfig", - "deprecated": true, + "summary": "Get matchRule", "parameters": [ { "type": "string", - "description": "ID of the Envelope", + "description": "ID formatted as string", "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/models.MonthConfigCreate" - } } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponse" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } } } }, "delete": { - "description": "Deletes configuration settings for a specific month", - "produces": [ - "application/json" - ], + "description": "Deletes an matchRule", "tags": [ - "MonthConfigs" + "MatchRules" ], - "summary": "Delete MonthConfig", - "deprecated": true, + "summary": "Delete matchRule", "parameters": [ { "type": "string", - "description": "ID of the Envelope", + "description": "ID formatted as string", "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true } ], "responses": { @@ -2390,24 +2281,16 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "MonthConfigs" + "MatchRules" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", - "description": "ID of the Envelope", + "description": "ID formatted as string", "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "description": "The month in YYYY-MM format", - "name": "month", - "in": "path", - "required": true } ], "responses": { @@ -2435,69 +2318,64 @@ } }, "patch": { - "description": "Changes settings of an existing MonthConfig", + "description": "Update a matchRule. Only values to be updated need to be specified.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "MonthConfigs" + "MatchRules" ], - "summary": "Update MonthConfig", - "deprecated": true, + "summary": "Update matchRule", "parameters": [ { "type": "string", - "description": "ID of the Envelope", + "description": "ID formatted as string", "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", + "description": "MatchRule", + "name": "matchRule", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.MonthConfigCreate" + "$ref": "#/definitions/models.MatchRuleCreate" } } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponse" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } } } } }, - "/v1/months": { + "/v3/months": { "get": { "description": "Returns data about a specific month.", "produces": [ @@ -2507,7 +2385,6 @@ "Months" ], "summary": "Get data about a month", - "deprecated": true, "parameters": [ { "type": "string", @@ -2528,25 +2405,25 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.MonthResponse" + "$ref": "#/definitions/controllers.MonthResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.MonthResponseV3" } } } @@ -2557,7 +2434,6 @@ "Months" ], "summary": "Set allocations for a month", - "deprecated": true, "parameters": [ { "type": "string", @@ -2613,7 +2489,6 @@ "Months" ], "summary": "Delete allocations for a month", - "deprecated": true, "parameters": [ { "type": "string", @@ -2660,7 +2535,6 @@ "Months" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -2668,7 +2542,7 @@ } } }, - "/v1/transactions": { + "/v3/transactions": { "get": { "description": "Returns a list of transactions", "produces": [ @@ -2678,7 +2552,6 @@ "Transactions" ], "summary": "Get transactions", - "deprecated": true, "parameters": [ { "type": "string", @@ -2752,12 +2625,6 @@ "name": "envelope", "in": "query" }, - { - "type": "boolean", - "description": "DEPRECATED. Filter by reconcilication state", - "name": "reconciled", - "in": "query" - }, { "type": "boolean", "description": "Reconcilication state in source account", @@ -2769,47 +2636,61 @@ "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/controllers.TransactionListResponse" + "$ref": "#/definitions/controllers.TransactionListResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionListResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionListResponseV3" } } } }, "post": { - "description": "Creates a new transaction", + "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 transaction", - "deprecated": true, + "summary": "Create transactions", "parameters": [ { - "description": "Transaction", - "name": "transaction", + "description": "Transactions", + "name": "transactions", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/models.TransactionCreate" + "type": "array", + "items": { + "$ref": "#/definitions/models.TransactionCreate" + } } } ], @@ -2817,25 +2698,25 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/controllers.TransactionResponse" + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" } } } @@ -2846,7 +2727,6 @@ "Transactions" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" @@ -2854,7 +2734,7 @@ } } }, - "/v1/transactions/{id}": { + "/v3/transactions/{id}": { "get": { "description": "Returns a specific transaction", "produces": [ @@ -2864,7 +2744,6 @@ "Transactions" ], "summary": "Get transaction", - "deprecated": true, "parameters": [ { "type": "string", @@ -2878,25 +2757,25 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.TransactionResponse" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } } } @@ -2907,7 +2786,6 @@ "Transactions" ], "summary": "Delete transaction", - "deprecated": true, "parameters": [ { "type": "string", @@ -2947,7 +2825,6 @@ "Transactions" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "parameters": [ { "type": "string", @@ -2993,7 +2870,6 @@ "Transactions" ], "summary": "Update transaction", - "deprecated": true, "parameters": [ { "type": "string", @@ -3016,43 +2892,42 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/controllers.TransactionResponse" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/httperrors.HTTPError" + "$ref": "#/definitions/controllers.TransactionResponseV3" } } } } }, - "/v2": { + "/version": { "get": { - "description": "Returns general information about the v2 API", + "description": "Returns the software version of the API", "tags": [ - "v2" + "General" ], - "summary": "v2 API", - "deprecated": true, + "summary": "API version", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/router.V2Response" + "$ref": "#/definitions/router.VersionResponse" } } } @@ -3060,5397 +2935,97 @@ "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ - "v2" + "General" ], "summary": "Allowed HTTP verbs", - "deprecated": true, "responses": { "204": { "description": "No Content" } } } + } + }, + "definitions": { + "controllers.AccountCreateResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of created Accounts", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.AccountResponseV3" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" + } + } }, - "/v2/accounts": { - "get": { - "description": "Returns a list of accounts", - "produces": [ - "application/json" - ], - "tags": [ - "Accounts" - ], - "summary": "List accounts", - "deprecated": true, - "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 hidden?", - "name": "hidden", - "in": "query" - }, - { - "type": "string", - "description": "Search for this text in name and note", - "name": "search", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.AccountListResponse" - } - }, - "400": { - "description": "Bad Request", - "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", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v2/match-rules": { - "get": { - "description": "Returns a list of matchRules", - "produces": [ - "application/json" - ], - "tags": [ - "MatchRules" - ], - "summary": "Get matchRules", - "deprecated": true, - "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" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.MatchRule" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - } - } - }, - "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", - "deprecated": true, - "parameters": [ - { - "description": "MatchRules", - "name": "matchRules", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.MatchRuleCreate" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - } - } - }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", - "tags": [ - "MatchRules" - ], - "summary": "Allowed HTTP verbs", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v2/match-rules/{id}": { - "get": { - "description": "Returns a specific matchRule", - "produces": [ - "application/json" - ], - "tags": [ - "MatchRules" - ], - "summary": "Get matchRule", - "deprecated": true, - "parameters": [ - { - "type": "string", - "description": "ID formatted as string", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/models.MatchRule" - } - }, - "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 an matchRule", - "tags": [ - "MatchRules" - ], - "summary": "Delete matchRule", - "deprecated": true, - "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", - "deprecated": true, - "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 matchRule. Only values to be updated need to be specified.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "MatchRules" - ], - "summary": "Update matchRule", - "deprecated": true, - "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/models.MatchRuleCreate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/models.MatchRule" - } - }, - "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" - } - } - } - } - }, - "/v2/rename-rules": { - "get": { - "description": "Returns a list of renameRules", - "produces": [ - "application/json" - ], - "tags": [ - "RenameRules" - ], - "summary": "Get renameRules", - "deprecated": true, - "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" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.RenameRuleListResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - } - } - }, - "post": { - "description": "Creates renameRules from the list of submitted renameRule data. The response code is the highest response code number that a single renameRule creation would have caused. If it is not equal to 201, at least one renameRule has an error.", - "produces": [ - "application/json" - ], - "tags": [ - "RenameRules" - ], - "summary": "Create renameRules", - "deprecated": true, - "parameters": [ - { - "description": "RenameRules", - "name": "renameRules", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.MatchRuleCreate" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseMatchRule" - } - } - } - } - }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", - "tags": [ - "RenameRules" - ], - "summary": "Allowed HTTP verbs", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v2/rename-rules/{id}": { - "get": { - "description": "Returns a specific renameRule", - "produces": [ - "application/json" - ], - "tags": [ - "RenameRules" - ], - "summary": "Get renameRule", - "deprecated": true, - "parameters": [ - { - "type": "string", - "description": "ID formatted as string", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.RenameRuleResponse" - } - }, - "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 an renameRule", - "tags": [ - "RenameRules" - ], - "summary": "Delete renameRule", - "deprecated": true, - "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": [ - "RenameRules" - ], - "summary": "Allowed HTTP verbs", - "deprecated": true, - "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 renameRule. Only values to be updated need to be specified.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "RenameRules" - ], - "summary": "Update renameRule", - "deprecated": true, - "parameters": [ - { - "type": "string", - "description": "ID formatted as string", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "RenameRule", - "name": "renameRule", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.MatchRuleCreate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.RenameRuleResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - } - } - } - }, - "/v2/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.", - "produces": [ - "application/json" - ], - "tags": [ - "Transactions" - ], - "summary": "Create transactions", - "deprecated": true, - "parameters": [ - { - "description": "Transactions", - "name": "transactions", - "in": "body", - "required": true, - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.TransactionCreate" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseTransactionV2" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httperrors.HTTPError" - } - }, - "404": { - "description": "Not Found", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseTransactionV2" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/controllers.ResponseTransactionV2" - } - } - } - } - }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", - "tags": [ - "Transactions" - ], - "summary": "Allowed HTTP verbs", - "deprecated": true, - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v3": { - "get": { - "description": "Returns general information about the v3 API", - "tags": [ - "v3" - ], - "summary": "v3 API", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/router.V3Response" - } - } - } - }, - "delete": { - "description": "Permanently deletes all resources", - "tags": [ - "v3" - ], - "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" - } - } - } - }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", - "tags": [ - "v3" - ], - "summary": "Allowed HTTP verbs", - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v3/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/controllers.AccountListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.AccountListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.AccountListResponseV3" - } - } - } - }, - "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/controllers.AccountCreateV3" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.AccountCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.AccountCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.AccountCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.AccountCreateResponseV3" - } - } - } - }, - "options": { - "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", - "tags": [ - "Accounts" - ], - "summary": "Allowed HTTP verbs", - "responses": { - "204": { - "description": "No Content" - } - } - } - }, - "/v3/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/controllers.AccountResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - } - } - }, - "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/controllers.AccountCreateV3" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - } - } - } - }, - "/v3/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/controllers.BudgetListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.BudgetListResponseV3" - } - } - } - }, - "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": { - "$ref": "#/definitions/models.BudgetCreate" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.BudgetCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.BudgetCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.BudgetCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.BudgetResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - } - } - }, - "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/models.BudgetCreate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - } - } - } - }, - "/v3/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 hidden?", - "name": "hidden", - "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/controllers.CategoryListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.CategoryListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.CategoryListResponseV3" - } - } - } - }, - "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/controllers.CategoryCreateV3" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.CategoryCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.CategoryCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.CategoryCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.CategoryCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.CategoryResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - } - } - }, - "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/controllers.CategoryCreateV3" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - } - } - } - }, - "/v3/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/controllers.EnvelopeListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeListResponseV3" - } - } - } - }, - "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/controllers.EnvelopeCreateV3" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.EnvelopeResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - } - } - }, - "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/controllers.EnvelopeCreateV3" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - } - } - } - }, - "/v3/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/controllers.MonthConfigResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - } - } - }, - "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/controllers.MonthConfigCreateV3" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MonthConfigResponseV3" - } - } - } - } - }, - "/v3/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/controllers.GoalListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.GoalListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.GoalListResponseV3" - } - } - } - }, - "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/controllers.GoalV3Editable" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.GoalCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.GoalCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.GoalCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.GoalCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.GoalResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - } - } - }, - "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/controllers.GoalV3Editable" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - } - } - } - }, - "/v3/import": { - "get": { - "description": "Returns general information about the v3 API", - "tags": [ - "Import" - ], - "summary": "Import API overview", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.ImportV3Response" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.ImportPreviewListV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.ImportPreviewListV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.ImportPreviewListV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.ImportPreviewListV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.BudgetResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.MatchRuleListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleListResponseV3" - } - } - } - }, - "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/models.MatchRuleCreate" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.MatchRuleResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - } - } - }, - "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/models.MatchRuleCreate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } - } - } - } - }, - "/v3/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/controllers.MonthResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.MonthResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.MonthResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.MonthResponseV3" - } - } - } - }, - "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/controllers.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" - } - } - } - }, - "/v3/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/controllers.TransactionListResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.TransactionListResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.TransactionListResponseV3" - } - } - } - }, - "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/models.TransactionCreate" - } - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/controllers.TransactionCreateResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.TransactionCreateResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.TransactionCreateResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.TransactionCreateResponseV3" - } - } - } - }, - "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" - } - } - } - }, - "/v3/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/controllers.TransactionResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - } - } - }, - "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/models.TransactionCreate" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/controllers.TransactionResponseV3" - } - } - } - } - }, - "/version": { - "get": { - "description": "Returns the software version of the API", - "tags": [ - "General" - ], - "summary": "API version", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/router.VersionResponse" - } - } - } - }, - "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": { - "controllers.Account": { - "type": "object", - "properties": { - "archived": { - "description": "Is the account archived?", - "type": "boolean", - "default": false, - "example": true - }, - "balance": { - "description": "Balance of the account, including all transactions referencing it", - "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 - }, - "hidden": { - "description": "Is the account archived?", - "type": "boolean", - "default": false, - "example": true - }, - "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, - "initialBalance": { - "description": "Balance of the account before any transactions were recorded", - "type": "number", - "default": 0, - "example": 173.12 - }, - "initialBalanceDate": { - "description": "Date of the initial balance", - "type": "string", - "example": "2017-05-12T00:00:00Z" - }, - "links": { - "type": "object", - "properties": { - "self": { - "description": "The account itself", - "type": "string", - "example": "https://example.com/api/v1/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" - }, - "transactions": { - "description": "Transactions referencing the account", - "type": "string", - "example": "https://example.com/api/v1/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" - } - } - }, - "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": { - "$ref": "#/definitions/models.Envelope" - } - }, - "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" - } - } - }, - "controllers.AccountCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of created Accounts", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.AccountResponseV3" - } - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.AccountCreateV3": { - "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, - "initialBalance": { - "description": "Balance of the account before any transactions were recorded", - "type": "number", - "default": 0, - "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 - } - } - }, - "controllers.AccountListResponse": { - "type": "object", - "properties": { - "data": { - "description": "List of accounts", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Account" - } - } - } - }, - "controllers.AccountListResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of accounts", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.AccountV3" - } - }, - "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/controllers.Pagination" - } - ] - } - } - }, - "controllers.AccountResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the account", - "allOf": [ - { - "$ref": "#/definitions/controllers.Account" - } - ] - } - } - }, - "controllers.AccountResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "Data for the account", - "allOf": [ - { - "$ref": "#/definitions/controllers.AccountV3" - } - ] - }, - "error": { - "description": "The error, if any occurred for this transaction", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.AccountV3": { - "type": "object", - "properties": { - "archived": { - "description": "Is the account archived?", - "type": "boolean", - "default": false, - "example": true - }, - "balance": { - "description": "Balance of the account, including all transactions referencing it", - "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 - }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, - "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, - "initialBalance": { - "description": "Balance of the account before any transactions were recorded", - "type": "number", - "default": 0, - "example": 173.12 - }, - "initialBalanceDate": { - "description": "Date of the initial balance", - "type": "string", - "example": "2017-05-12T00:00:00Z" - }, - "links": { - "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" - } - } - }, - "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" - } - } - }, - "controllers.Allocation": { - "type": "object", - "properties": { - "amount": { - "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", - "type": "number", - "maximum": 1000000000000, - "minimum": 1e-8, - "multipleOf": 1e-8, - "example": 22.01 - }, - "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": "ID of the envelope", - "type": "string", - "example": "a0909e84-e8f9-4cb6-82a5-025dff105ff2" - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "type": "object", - "properties": { - "self": { - "description": "The allocation itself", - "type": "string", - "example": "https://example.com/api/v1/allocations/902cd93c-3724-4e46-8540-d014131282fc" - } - } - }, - "month": { - "description": "Only year and month of this timestamp are used, everything else is ignored. This will always be set to 00:00 UTC on the first of the specified month", - "type": "string", - "example": "2021-12-01T00:00:00.000000Z" - }, - "updatedAt": { - "description": "Last time the resource was updated", - "type": "string", - "example": "2022-04-17T20:14:01.048145Z" - } - } - }, - "controllers.AllocationListResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the allocation", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Allocation" - } - } - } - }, - "controllers.AllocationMode": { - "type": "string", - "enum": [ - "ALLOCATE_LAST_MONTH_BUDGET", - "ALLOCATE_LAST_MONTH_SPEND" - ], - "x-enum-varnames": [ - "AllocateLastMonthBudget", - "AllocateLastMonthSpend" - ] - }, - "controllers.AllocationResponse": { - "type": "object", - "properties": { - "data": { - "description": "List of allocations", - "allOf": [ - { - "$ref": "#/definitions/controllers.Allocation" - } - ] - } - } - }, - "controllers.Budget": { - "type": "object", - "properties": { - "balance": { - "description": "DEPRECATED. Will be removed in API v2, see https://github.com/envelope-zero/backend/issues/526.", - "type": "number", - "example": 3423.42 - }, - "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": { - "type": "object", - "properties": { - "accounts": { - "description": "Accounts for this budget", - "type": "string", - "example": "https://example.com/api/v1/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" - }, - "categories": { - "description": "Categories for this budget", - "type": "string", - "example": "https://example.com/api/v1/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" - }, - "envelopes": { - "description": "Envelopes for this budget", - "type": "string", - "example": "https://example.com/api/v1/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" - }, - "groupedMonth": { - "description": "This uses 'YYYY-MM' for clients to replace with the actual year and month.", - "type": "string", - "example": "https://example.com/api/v1/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf\u0026month=YYYY-MM" - }, - "month": { - "description": "This uses 'YYYY-MM' for clients to replace with the actual year and month.", - "type": "string", - "example": "https://example.com/api/v1/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf/YYYY-MM" - }, - "monthAllocations": { - "description": "This uses 'YYYY-MM' for clients to replace with the actual year and month.", - "type": "string", - "example": "https://example.com/api/v1/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf\u0026month=YYYY-MM" - }, - "self": { - "description": "The budget itself", - "type": "string", - "example": "https://example.com/api/v1/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf" - }, - "transactions": { - "description": "Transactions for this budget", - "type": "string", - "example": "https://example.com/api/v1/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf" - } - } - }, - "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" - } - } - }, - "controllers.BudgetAllocationMode": { - "type": "object", - "properties": { - "mode": { - "description": "Mode to allocate budget with", - "allOf": [ - { - "$ref": "#/definitions/controllers.AllocationMode" - } - ], - "example": "ALLOCATE_LAST_MONTH_SPEND" - } - } - }, - "controllers.BudgetCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of created Budgets", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.BudgetResponseV3" - } - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.BudgetListResponse": { - "type": "object", - "properties": { - "data": { - "description": "List of budgets", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Budget" - } - } - } - }, - "controllers.BudgetListResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of budgets", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.BudgetV3" - } - }, - "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/controllers.Pagination" - } - ] - } - } - }, - "controllers.BudgetMonthResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the budget's month", - "allOf": [ - { - "$ref": "#/definitions/models.BudgetMonth" - } - ] - } - } - }, - "controllers.BudgetResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the budget", - "allOf": [ - { - "$ref": "#/definitions/controllers.Budget" - } - ] - } - } - }, - "controllers.BudgetResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "Data for the budget", - "allOf": [ - { - "$ref": "#/definitions/controllers.BudgetV3" - } - ] - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.BudgetV3": { - "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": { - "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" - } - } - }, - "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" - } - } - }, - "controllers.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": "Envelopes for the category", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Envelope" - } - }, - "hidden": { - "description": "Is the category hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "type": "object", - "properties": { - "envelopes": { - "description": "Envelopes for this category", - "type": "string", - "example": "https://example.com/api/v1/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f" - }, - "self": { - "description": "The category itself", - "type": "string", - "example": "https://example.com/api/v1/categories/3b1ea324-d438-4419-882a-2fc91d71772f" - } - } - }, - "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" - } - } - }, - "controllers.CategoryCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of the created Categories or their respective error", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.CategoryResponseV3" - } - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.CategoryCreateV3": { - "type": "object", - "properties": { - "archived": { - "description": "Is the category hidden?", - "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" - } - } - }, - "controllers.CategoryEnvelopesV3": { - "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/controllers.EnvelopeMonthV3" - } - }, - "hidden": { - "description": "Is the category hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "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" - } - } - }, - "controllers.CategoryListResponse": { - "type": "object", - "properties": { - "data": { - "description": "List of categories", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Category" - } - } - } - }, - "controllers.CategoryListResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of Categories", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.CategoryV3" - } - }, - "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/controllers.Pagination" - } - ] - } - } - }, - "controllers.CategoryResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the category", - "allOf": [ - { - "$ref": "#/definitions/controllers.Category" - } - ] - } - } - }, - "controllers.CategoryResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "Data for the Category", - "allOf": [ - { - "$ref": "#/definitions/controllers.CategoryV3" - } - ] - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.CategoryV3": { - "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": "Envelopes for the category", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.EnvelopeV3" - } - }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "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" - } - } - }, - "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" - } - } - }, - "controllers.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" - }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "description": "Links to related resources", - "type": "object", - "properties": { - "allocations": { - "description": "the envelope's allocations", - "type": "string", - "example": "https://example.com/api/v1/allocations?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166" - }, - "month": { - "description": "Month information endpoint. This will always end in 'YYYY-MM' for clients to use replace with actual numbers.", - "type": "string", - "example": "https://example.com/api/v1/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM" - }, - "self": { - "description": "The envelope itself", - "type": "string", - "example": "https://example.com/api/v1/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166" - }, - "transactions": { - "description": "The envelope's transactions", - "type": "string", - "example": "https://example.com/api/v1/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166" - } - } - }, - "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" - } - } - }, - "controllers.EnvelopeCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "Data for the Envelope", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.EnvelopeResponseV3" - } - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.EnvelopeCreateV3": { - "type": "object", - "properties": { - "archived": { - "description": "Is the envelope hidden?", - "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" - } - } - }, - "controllers.EnvelopeListResponse": { - "type": "object", - "properties": { - "data": { - "description": "List of Envelopes", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Envelope" - } - } - } - }, - "controllers.EnvelopeListResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of Envelopes", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.EnvelopeV3" - } - }, - "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/controllers.Pagination" - } - ] - } - } - }, - "controllers.EnvelopeMonthResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the month for the envelope", - "allOf": [ - { - "$ref": "#/definitions/models.EnvelopeMonth" - } - ] - } - } - }, - "controllers.EnvelopeMonthV3": { - "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" - }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "$ref": "#/definitions/controllers.EnvelopeV3Links" - }, - "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" - } - } - }, - "controllers.EnvelopeResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the Envelope", - "allOf": [ - { - "$ref": "#/definitions/controllers.Envelope" - } - ] - } - } - }, - "controllers.EnvelopeResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "Data for the Envelope", - "allOf": [ - { - "$ref": "#/definitions/controllers.EnvelopeV3" - } - ] - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.EnvelopeV3": { - "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" - }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, - "id": { - "description": "UUID for the resource", - "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "description": "Links to related resources", - "allOf": [ - { - "$ref": "#/definitions/controllers.EnvelopeV3Links" - } - ] - }, - "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" - } - } - }, - "controllers.EnvelopeV3Links": { - "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" - } - } - }, - "controllers.GoalCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of created resources", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.GoalResponseV3" - } - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.GoalListResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of resources", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.GoalV3" - } - }, - "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/controllers.Pagination" - } - ] - } - } - }, - "controllers.GoalResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "The resource", - "allOf": [ - { - "$ref": "#/definitions/controllers.GoalV3" - } - ] - }, - "error": { - "description": "The error, if any occurred", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.GoalV3": { + "controllers.AccountCreateV3": { "type": "object", "properties": { - "amount": { - "description": "How much money should be saved for this goal?", - "type": "number", - "default": 0, - "example": 127 - }, "archived": { - "description": "If this goal is still in use or not", + "description": "Is the account archived?", "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/controllers.GoalV3Links" - }, - "month": { - "description": "The month the balance of the envelope should be the set amount", - "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", + "budgetId": { + "description": "ID of the budget this account belongs to", "type": "string", - "example": "2022-04-17T20:14:01.048145Z" - } - } - }, - "controllers.GoalV3Editable": { - "type": "object", - "properties": { - "amount": { - "description": "How much money should be saved for this goal?", - "type": "number", - "default": 0, - "example": 127 + "example": "550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, - "archived": { - "description": "If this goal is still in use or not", + "external": { + "description": "Does the account belong to the budget owner 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 balance of the envelope should be the set amount", - "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" - } - } - }, - "controllers.GoalV3Links": { - "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" - } - } - }, - "controllers.ImportPreviewList": { - "type": "object", - "properties": { - "data": { - "description": "List of transaction previews", - "type": "array", - "items": { - "$ref": "#/definitions/importer.TransactionPreview" - } - } - } - }, - "controllers.ImportPreviewListV3": { - "type": "object", - "properties": { - "data": { - "description": "List of transaction previews", - "type": "array", - "items": { - "$ref": "#/definitions/importer.TransactionPreviewV3" - } - }, - "error": { - "description": "The error, if any occurred for this Match Rule", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.ImportV3Links": { - "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" - } - } - }, - "controllers.ImportV3Response": { - "type": "object", - "properties": { - "links": { - "description": "Links for the v3 API", - "allOf": [ - { - "$ref": "#/definitions/controllers.ImportV3Links" - } - ] - } - } - }, - "controllers.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" + "example": false }, - "id": { - "description": "UUID for the resource", + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" - }, - "links": { - "type": "object", - "properties": { - "self": { - "description": "The match rule itself", - "type": "string", - "example": "https://example.com/api/v2/match-rules/95685c82-53c6-455d-b235-f49960b73b21" - } - } + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" }, - "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*" + "initialBalance": { + "description": "Balance of the account before any transactions were recorded", + "type": "number", + "default": 0, + "example": 173.12 }, - "priority": { - "description": "The priority of the match rule", - "type": "integer", - "example": 3 + "initialBalanceDate": { + "description": "Date of the initial balance", + "type": "string", + "example": "2017-05-12T00:00:00Z" }, - "updatedAt": { - "description": "Last time the resource was updated", + "name": { + "description": "Name of the account", "type": "string", - "example": "2022-04-17T20:14:01.048145Z" - } - } - }, - "controllers.MatchRuleCreateResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "List of created Match Rules", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.MatchRuleResponseV3" - } + "example": "Cash" }, - "error": { - "description": "The error, if any occurred", + "note": { + "description": "A longer description for the account", "type": "string", - "example": "the specified resource ID is not a valid UUID" + "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 } } }, - "controllers.MatchRuleListResponseV3": { + "controllers.AccountListResponseV3": { "type": "object", "properties": { "data": { - "description": "List of Match Rules", + "description": "List of accounts", "type": "array", "items": { - "$ref": "#/definitions/controllers.MatchRuleV3" + "$ref": "#/definitions/controllers.AccountV3" } }, "error": { @@ -8468,31 +3043,42 @@ } } }, - "controllers.MatchRuleResponseV3": { + "controllers.AccountResponseV3": { "type": "object", "properties": { "data": { - "description": "The Match Rule data, if creation was successful", + "description": "Data for the account", "allOf": [ { - "$ref": "#/definitions/controllers.MatchRuleV3" + "$ref": "#/definitions/controllers.AccountV3" } ] }, "error": { - "description": "The error, if any occurred for this Match Rule", + "description": "The error, if any occurred for this transaction", "type": "string", "example": "the specified resource ID is not a valid UUID" } } }, - "controllers.MatchRuleV3": { + "controllers.AccountV3": { "type": "object", "properties": { - "accountId": { - "description": "The account to map matching transactions to", + "archived": { + "description": "Is the account archived?", + "type": "boolean", + "default": false, + "example": true + }, + "balance": { + "description": "Balance of the account, including all transactions referencing it", + "type": "number", + "example": 2735.17 + }, + "budgetId": { + "description": "ID of the budget this account belongs to", "type": "string", - "example": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" + "example": "550dc009-cea6-4c12-b2a5-03446eb7b7cf" }, "createdAt": { "description": "Time the resource was created", @@ -8504,98 +3090,79 @@ "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 + }, + "hidden": { + "description": "Remove the hidden field", + "type": "boolean" + }, "id": { "description": "UUID for the resource", "type": "string", "example": "65392deb-5e92-4268-b114-297faad6cdce" }, - "links": { - "type": "object", - "properties": { - "self": { - "description": "The match rule itself", - "type": "string", - "example": "https://example.com/api/v3/match-rules/95685c82-53c6-455d-b235-f49960b73b21" - } - } - }, - "match": { - "description": "The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive.", + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", "type": "string", - "example": "Bank*" - }, - "priority": { - "description": "The priority of the match rule", - "type": "integer", - "example": 3 + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" }, - "updatedAt": { - "description": "Last time the resource was updated", - "type": "string", - "example": "2022-04-17T20:14:01.048145Z" - } - } - }, - "controllers.MonthConfig": { - "type": "object", - "properties": { - "allocation": { - "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "initialBalance": { + "description": "Balance of the account before any transactions were recorded", "type": "number", - "maximum": 1000000000000, - "minimum": 1e-8, - "multipleOf": 1e-8, - "example": 22.01 - }, - "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" + "default": 0, + "example": 173.12 }, - "envelopeId": { - "description": "ID of the envelope", + "initialBalanceDate": { + "description": "Date of the initial balance", "type": "string", - "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" + "example": "2017-05-12T00:00:00Z" }, "links": { "type": "object", "properties": { - "envelope": { - "description": "The envelope this config belongs to", + "self": { + "description": "The account itself", "type": "string", - "example": "https://example.com/api/v1/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b" + "example": "https://example.com/api/v3/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" }, - "self": { - "description": "The month config itself", + "transactions": { + "description": "Transactions referencing the account", "type": "string", - "example": "https://example.com/api/v1/month-configs/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" + "example": "https://example.com/api/v3/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2" } } }, - "month": { - "description": "The month. This is always set to 00:00 UTC on the first of the month.", + "name": { + "description": "Name of the account", "type": "string", - "example": "1969-06-01T00:00:00.000000Z" + "example": "Cash" }, "note": { - "description": "A note for the month config", + "description": "A longer description for the account", "type": "string", - "example": "Added 200€ here because we replaced Tim's expensive vase" + "example": "Money in my wallet" }, - "overspendMode": { - "description": "The overspend handling mode to use. Deprecated, will be removed with 4.0.0 release and is not used in API v3 anymore", - "default": "AFFECT_AVAILABLE", - "allOf": [ - { - "$ref": "#/definitions/models.OverspendMode" - } - ], - "example": "AFFECT_ENVELOPE" + "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", @@ -8604,57 +3171,81 @@ } } }, - "controllers.MonthConfigCreateV3": { + "controllers.AllocationMode": { + "type": "string", + "enum": [ + "ALLOCATE_LAST_MONTH_BUDGET", + "ALLOCATE_LAST_MONTH_SPEND" + ], + "x-enum-varnames": [ + "AllocateLastMonthBudget", + "AllocateLastMonthSpend" + ] + }, + "controllers.BudgetAllocationMode": { "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 - }, - "note": { - "description": "A note for the month config", - "type": "string", - "example": "Added 200€ here because we replaced Tim's expensive vase" + "mode": { + "description": "Mode to allocate budget with", + "allOf": [ + { + "$ref": "#/definitions/controllers.AllocationMode" + } + ], + "example": "ALLOCATE_LAST_MONTH_SPEND" } } }, - "controllers.MonthConfigListResponse": { + "controllers.BudgetCreateResponseV3": { "type": "object", "properties": { "data": { - "description": "List of month configs", + "description": "List of created Budgets", "type": "array", "items": { - "$ref": "#/definitions/controllers.MonthConfig" + "$ref": "#/definitions/controllers.BudgetResponseV3" } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" } } }, - "controllers.MonthConfigResponse": { + "controllers.BudgetListResponseV3": { "type": "object", "properties": { "data": { - "description": "Data for the month", + "description": "List of budgets", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.BudgetV3" + } + }, + "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/controllers.MonthConfig" + "$ref": "#/definitions/controllers.Pagination" } ] } } }, - "controllers.MonthConfigResponseV3": { + "controllers.BudgetResponseV3": { "type": "object", "properties": { "data": { - "description": "Config for the month", + "description": "Data for the budget", "allOf": [ { - "$ref": "#/definitions/controllers.MonthConfigV3" + "$ref": "#/definitions/controllers.BudgetV3" } ] }, @@ -8662,63 +3253,76 @@ "description": "The error, if any occurred", "type": "string", "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.MonthConfigV3": { - "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 - }, + } + } + }, + "controllers.BudgetV3": { + "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" }, - "envelopeId": { - "description": "ID of the envelope", + "id": { + "description": "UUID for the resource", "type": "string", - "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" + "example": "65392deb-5e92-4268-b114-297faad6cdce" }, "links": { "type": "object", "properties": { - "envelope": { - "description": "The Envelope this config belongs to", + "accounts": { + "description": "Accounts for this budget", "type": "string", - "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b" + "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 Month Config itself", + "description": "The budget itself", "type": "string", - "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" + "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" } } }, - "month": { - "description": "The month. This is always set to 00:00 UTC on the first of the month.", + "name": { + "description": "Name of the budget", "type": "string", - "example": "1969-06-01T00:00:00.000000Z" + "example": "Morre's Budget" }, "note": { - "description": "A note for the month config", + "description": "A longer description of the budget", "type": "string", - "example": "Added 200€ here because we replaced Tim's expensive vase" - }, - "overspendMode": { - "description": "Ignore this. It is here to override the OverspendMode from models.MonthConfigCreate and will be removed with 4.0.0", - "type": "string" + "example": "My personal expenses" }, "updatedAt": { "description": "Last time the resource was updated", @@ -8727,268 +3331,230 @@ } } }, - "controllers.MonthResponse": { + "controllers.CategoryCreateResponseV3": { "type": "object", "properties": { "data": { - "description": "Data for the month", - "allOf": [ - { - "$ref": "#/definitions/models.Month" - } - ] + "description": "List of the created Categories or their respective error", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.CategoryResponseV3" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" } } }, - "controllers.MonthResponseV3": { + "controllers.CategoryCreateV3": { "type": "object", "properties": { - "data": { - "description": "Data for the month", - "allOf": [ - { - "$ref": "#/definitions/controllers.MonthV3" - } - ] + "archived": { + "description": "Is the category hidden?", + "type": "boolean", + "default": false, + "example": true }, - "error": { - "description": "The error, if any occurred", - "type": "string" + "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" } } }, - "controllers.MonthV3": { + "controllers.CategoryEnvelopesV3": { "type": "object", "properties": { "allocation": { - "description": "The sum of all allocations for this month", + "description": "Sum of allocations for the envelopes", "type": "number", - "example": 1200.5 + "example": 90 }, - "available": { - "description": "The amount available to budget", - "type": "number", - "example": 217.34 + "archived": { + "description": "Is the Category archived?", + "type": "boolean", + "default": false, + "example": true }, "balance": { - "description": "The sum of all envelope balances", + "description": "Sum of the balances of the envelopes", "type": "number", - "example": 5231.37 + "example": -10.13 }, - "categories": { - "description": "A list of envelope month calculations grouped by category", + "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/controllers.CategoryEnvelopesV3" + "$ref": "#/definitions/controllers.EnvelopeMonthV3" } }, + "hidden": { + "description": "Is the category hidden?", + "type": "boolean", + "default": false, + "example": true + }, "id": { - "description": "The ID of the Budget", + "description": "UUID for the resource", "type": "string", - "example": "1e777d24-3f5b-4c43-8000-04f65f895578" - }, - "income": { - "description": "The total income for the month (sum of all incoming transactions without an Envelope)", - "type": "number", - "example": 2317.34 + "example": "65392deb-5e92-4268-b114-297faad6cdce" }, - "month": { - "description": "The month", + "name": { + "description": "Name of the category", "type": "string", - "example": "2006-05-01T00:00:00.000000Z" + "example": "Saving" }, - "name": { - "description": "The name of the Budget", + "note": { + "description": "Notes about the category", "type": "string", - "example": "Zero budget" + "example": "All envelopes for long-term saving" }, "spent": { - "description": "The amount of money spent in this month", + "description": "Sum spent for all envelopes", "type": "number", - "example": 133.7 - } - } - }, - "controllers.Pagination": { - "type": "object", - "properties": { - "count": { - "description": "The amount of records returned in this response", - "type": "integer", - "example": 25 - }, - "limit": { - "description": "The maximum amount of resources to return for this request", - "type": "integer", - "example": 25 - }, - "offset": { - "description": "The offset for the first record returned", - "type": "integer", - "example": 50 + "example": 100.13 }, - "total": { - "description": "The total number of resources matching the query", - "type": "integer", - "example": 827 + "updatedAt": { + "description": "Last time the resource was updated", + "type": "string", + "example": "2022-04-17T20:14:01.048145Z" } } }, - "controllers.RenameRuleListResponse": { + "controllers.CategoryListResponseV3": { "type": "object", "properties": { "data": { - "description": "List of rename rules", + "description": "List of Categories", "type": "array", "items": { - "$ref": "#/definitions/models.MatchRule" + "$ref": "#/definitions/controllers.CategoryV3" } - } - } - }, - "controllers.RenameRuleResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the rename rule", - "allOf": [ - { - "$ref": "#/definitions/models.MatchRule" - } - ] - } - } - }, - "controllers.ResponseMatchRule": { - "type": "object", - "properties": { - "data": { - "description": "This field contains the MatchRule data", + }, + "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/controllers.MatchRule" + "$ref": "#/definitions/controllers.Pagination" } ] - }, - "error": { - "description": "This field contains a human readable error message", - "type": "string", - "example": "A human readable error message" } } }, - "controllers.ResponseTransactionV2": { + "controllers.CategoryResponseV3": { "type": "object", "properties": { "data": { - "description": "This field contains the Transaction data", + "description": "Data for the Category", "allOf": [ { - "$ref": "#/definitions/controllers.TransactionV2" + "$ref": "#/definitions/controllers.CategoryV3" } ] }, "error": { - "description": "This field contains a human readable error message", + "description": "The error, if any occurred", "type": "string", - "example": "A human readable error message" + "example": "the specified resource ID is not a valid UUID" } } }, - "controllers.Transaction": { + "controllers.CategoryV3": { "type": "object", "properties": { - "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 - }, - "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" + "archived": { + "description": "Is the Category archived?", + "type": "boolean", + "default": false, + "example": true }, "budgetId": { - "description": "ID of the budget", + "description": "ID of the budget the category belongs to", "type": "string", - "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" + "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" }, "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" + "envelopes": { + "description": "Envelopes for the category", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.EnvelopeV3" + } }, - "envelopeId": { - "description": "ID of the envelope", - "type": "string", - "example": "2649c965-7999-4873-ae16-89d5d5fa972e" + "hidden": { + "description": "Remove the hidden field", + "type": "boolean" }, "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, "links": { - "description": "Links for the transaction", "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 transaction itself", + "description": "The category itself", "type": "string", - "example": "https://example.com/api/v1/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" + "example": "https://example.com/api/v3/categories/3b1ea324-d438-4419-882a-2fc91d71772f" } } }, - "note": { - "description": "A note", + "name": { + "description": "Name of the category", "type": "string", - "example": "Lunch" - }, - "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", - "type": "boolean", - "default": false, - "example": true - }, - "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 + "example": "Saving" }, - "sourceAccountId": { - "description": "ID of the source account", + "note": { + "description": "Notes about the category", "type": "string", - "example": "fd81dc45-a3a2-468e-a6fa-b2618f30aa45" + "example": "All envelopes for long-term saving" }, "updatedAt": { "description": "Last time the resource was updated", @@ -8997,14 +3563,14 @@ } } }, - "controllers.TransactionCreateResponseV3": { + "controllers.EnvelopeCreateResponseV3": { "type": "object", "properties": { "data": { - "description": "List of created Transactions", + "description": "Data for the Envelope", "type": "array", "items": { - "$ref": "#/definitions/controllers.TransactionResponseV3" + "$ref": "#/definitions/controllers.EnvelopeResponseV3" } }, "error": { @@ -9014,26 +3580,40 @@ } } }, - "controllers.TransactionListResponse": { + "controllers.EnvelopeCreateV3": { "type": "object", "properties": { - "data": { - "description": "List of transactions", - "type": "array", - "items": { - "$ref": "#/definitions/controllers.Transaction" - } + "archived": { + "description": "Is the envelope hidden?", + "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" } } }, - "controllers.TransactionListResponseV3": { + "controllers.EnvelopeListResponseV3": { "type": "object", "properties": { "data": { - "description": "List of transactions", + "description": "List of Envelopes", "type": "array", "items": { - "$ref": "#/definitions/controllers.TransactionV3" + "$ref": "#/definitions/controllers.EnvelopeV3" } }, "error": { @@ -9051,231 +3631,144 @@ } } }, - "controllers.TransactionResponse": { - "type": "object", - "properties": { - "data": { - "description": "Data for the transaction", - "allOf": [ - { - "$ref": "#/definitions/controllers.Transaction" - } - ] - } - } - }, - "controllers.TransactionResponseV3": { - "type": "object", - "properties": { - "data": { - "description": "The Transaction data, if creation was successful", - "allOf": [ - { - "$ref": "#/definitions/controllers.TransactionV3" - } - ] - }, - "error": { - "description": "The error, if any occurred for this transaction", - "type": "string", - "example": "the specified resource ID is not a valid UUID" - } - } - }, - "controllers.TransactionV2": { + "controllers.EnvelopeMonthV3": { "type": "object", "properties": { - "amount": { - "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", + "allocation": { + "description": "The amount of money allocated", "type": "number", - "maximum": 1000000000000, - "minimum": 1e-8, - "multipleOf": 1e-8, - "example": 14.03 + "example": 85.44 }, - "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" + "archived": { + "description": "Is the Envelope archived?", + "type": "boolean", + "default": false, + "example": true }, - "budgetId": { - "description": "ID of the budget", + "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": "55eecbd8-7c46-4b06-ada9-f287802fb05e" + "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" }, "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" + "hidden": { + "description": "Is the envelope hidden?", + "type": "boolean", + "default": false, + "example": true }, "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, "links": { - "description": "Links for the transaction", - "type": "object", - "properties": { - "self": { - "description": "The transaction itself", - "type": "string", - "example": "https://example.com/api/v2/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" - } - } + "$ref": "#/definitions/controllers.EnvelopeV3Links" }, - "note": { - "description": "A note", + "name": { + "description": "Name of the envelope", "type": "string", - "example": "Lunch" - }, - "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", - "type": "boolean", - "default": false, - "example": true - }, - "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 + "example": "Groceries" }, - "sourceAccountId": { - "description": "ID of the source account", + "note": { + "description": "Notes about the envelope", "type": "string", - "example": "fd81dc45-a3a2-468e-a6fa-b2618f30aa45" + "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" + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "controllers.EnvelopeResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "Data for the Envelope", + "allOf": [ + { + "$ref": "#/definitions/controllers.EnvelopeV3" + } + ] + }, + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" } } }, - "controllers.TransactionV3": { + "controllers.EnvelopeV3": { "type": "object", "properties": { - "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 - }, - "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" + "archived": { + "description": "Is the Envelope archived?", + "type": "boolean", + "default": false, + "example": true }, - "budgetId": { - "description": "ID of the budget", + "categoryId": { + "description": "ID of the category the envelope belongs to", "type": "string", - "example": "55eecbd8-7c46-4b06-ada9-f287802fb05e" + "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" }, "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" + "hidden": { + "description": "Remove the hidden field", + "type": "boolean" }, "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", - "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, "links": { - "description": "Links for the transaction", - "type": "object", - "properties": { - "self": { - "description": "The transaction itself", - "type": "string", - "example": "https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" + "description": "Links to related resources", + "allOf": [ + { + "$ref": "#/definitions/controllers.EnvelopeV3Links" } - } + ] }, - "note": { - "description": "A note", + "name": { + "description": "Name of the envelope", "type": "string", - "example": "Lunch" - }, - "reconciled": { - "description": "Remove the reconciled field", - "type": "boolean" - }, - "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 + "example": "Groceries" }, - "sourceAccountId": { - "description": "ID of the source account", + "note": { + "description": "Notes about the envelope", "type": "string", - "example": "fd81dc45-a3a2-468e-a6fa-b2618f30aa45" + "example": "For stuff bought at supermarkets and drugstores" }, "updatedAt": { "description": "Last time the resource was updated", @@ -9284,268 +3777,310 @@ } } }, - "httperrors.HTTPError": { + "controllers.EnvelopeV3Links": { "type": "object", "properties": { - "error": { + "month": { + "description": "The MonthConfig for the envelope", "type": "string", - "example": "An ID specified in the query string was not a valid UUID" + "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" } } }, - "importer.TransactionPreview": { + "controllers.GoalCreateResponseV3": { "type": "object", "properties": { - "destinationAccountName": { - "description": "Name of the destination account from the CSV file", - "type": "string", - "example": "Deutsche Bahn" - }, - "duplicateTransactionIds": { - "description": "IDs of transactions that this transaction duplicates", + "data": { + "description": "List of created resources", "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/controllers.GoalResponseV3" } }, - "matchRuleId": { - "description": "ID of the match rule that was applied to this transaction preview", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "042d101d-f1de-4403-9295-59dc0ea58677" + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "controllers.GoalListResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of resources", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.GoalV3" + } }, - "renameRuleId": { - "description": "ID of the match rule that was applied to this transaction preview. This is kept for backwards compatibility and will be removed with API version 3", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "042d101d-f1de-4403-9295-59dc0ea58677" + "example": "the specified resource ID is not a valid UUID" }, - "sourceAccountName": { - "description": "Name of the source account from the CSV file", - "type": "string", - "example": "Employer" + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/controllers.Pagination" + } + ] + } + } + }, + "controllers.GoalResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "The resource", + "allOf": [ + { + "$ref": "#/definitions/controllers.GoalV3" + } + ] }, - "transaction": { - "$ref": "#/definitions/models.TransactionCreate" + "error": { + "description": "The error, if any occurred", + "type": "string", + "example": "the specified resource ID is not a valid UUID" } } }, - "importer.TransactionPreviewV3": { + "controllers.GoalV3": { "type": "object", "properties": { - "destinationAccountName": { - "description": "Name of the destination account from the CSV file", + "amount": { + "description": "How much money should be saved for this goal?", + "type": "number", + "default": 0, + "example": 127 + }, + "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": "Deutsche Bahn" + "example": "2022-04-02T19:28:44.491514Z" }, - "duplicateTransactionIds": { - "description": "IDs of transactions that this transaction duplicates", - "type": "array", - "items": { - "type": "string" - } + "deletedAt": { + "description": "Time the resource was marked as deleted", + "type": "string", + "example": "2022-04-22T21:01:05.058161Z" }, - "matchRuleId": { - "description": "ID of the match rule that was applied to this transaction preview", + "envelopeId": { + "description": "The ID of the envelope this goal is for", "type": "string", - "example": "042d101d-f1de-4403-9295-59dc0ea58677" + "example": "f81566d9-af4d-4f13-9830-c62c4b5e4c7e" }, - "sourceAccountName": { - "description": "Name of the source account from the CSV file", + "id": { + "description": "UUID for the resource", "type": "string", - "example": "Employer" + "example": "65392deb-5e92-4268-b114-297faad6cdce" }, - "transaction": { - "$ref": "#/definitions/models.TransactionCreate" + "links": { + "$ref": "#/definitions/controllers.GoalV3Links" + }, + "month": { + "description": "The month the balance of the envelope should be the set amount", + "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" } } }, - "models.AccountCreate": { + "controllers.GoalV3Editable": { "type": "object", "properties": { - "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 + "amount": { + "description": "How much money should be saved for this goal?", + "type": "number", + "default": 0, + "example": 127 }, - "hidden": { - "description": "Is the account archived?", + "archived": { + "description": "If this goal is still in use or not", "type": "boolean", "default": false, "example": true }, - "importHash": { - "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", + "envelopeId": { + "description": "The ID of the envelope this goal is for", "type": "string", - "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" - }, - "initialBalance": { - "description": "Balance of the account before any transactions were recorded", - "type": "number", - "default": 0, - "example": 173.12 + "example": "f81566d9-af4d-4f13-9830-c62c4b5e4c7e" }, - "initialBalanceDate": { - "description": "Date of the initial balance", + "month": { + "description": "The month the balance of the envelope should be the set amount", "type": "string", - "example": "2017-05-12T00:00:00Z" + "example": "2024-07-01T00:00:00.000000Z" }, "name": { - "description": "Name of the account", + "description": "Name of the goal", "type": "string", - "example": "Cash" + "example": "New TV" }, "note": { - "description": "A longer description for the account", + "description": "Note about the goal", "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 + "example": "We want to replace the old CRT TV soon-ish" } } }, - "models.AllocationCreate": { + "controllers.GoalV3Links": { "type": "object", "properties": { - "amount": { - "description": "The maximum value is \"999999999999.99999999\", swagger unfortunately rounds this.", - "type": "number", - "maximum": 1000000000000, - "minimum": 1e-8, - "multipleOf": 1e-8, - "example": 22.01 - }, - "envelopeId": { - "description": "ID of the envelope", + "envelope": { + "description": "The Envelope this goal references", "type": "string", - "example": "a0909e84-e8f9-4cb6-82a5-025dff105ff2" + "example": "https://example.com/api/v3/envelopes/c1a96ae4-80e3-4827-8ed0-c7656f224fee" }, - "month": { - "description": "Only year and month of this timestamp are used, everything else is ignored. This will always be set to 00:00 UTC on the first of the specified month", + "self": { + "description": "The Goal itself", "type": "string", - "example": "2021-12-01T00:00:00.000000Z" + "example": "https://example.com/api/v3/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c" } } }, - "models.BudgetCreate": { + "controllers.ImportPreviewListV3": { "type": "object", "properties": { - "currency": { - "description": "The currency for the budget", - "type": "string", - "example": "€" + "data": { + "description": "List of transaction previews", + "type": "array", + "items": { + "$ref": "#/definitions/importer.TransactionPreviewV3" + } }, - "name": { - "description": "Name of the budget", + "error": { + "description": "The error, if any occurred for this Match Rule", "type": "string", - "example": "Morre's Budget" + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "controllers.ImportV3Links": { + "type": "object", + "properties": { + "matchRules": { + "description": "URL of YNAB Import preview endpoint", + "type": "string", + "example": "https://example.com/api/v3/import/ynab-import-preview" }, - "note": { - "description": "A longer description of the budget", + "transactions": { + "description": "URL of YNAB4 import endpoint", "type": "string", - "example": "My personal expenses" + "example": "https://example.com/api/v3/import/ynab4" } } }, - "models.BudgetMonth": { + "controllers.ImportV3Response": { "type": "object", "properties": { - "available": { - "description": "The amount of money still available to budget.", - "type": "number", - "example": 217.34 - }, - "budgeted": { - "description": "Amount of money that has been allocated to envelopes", - "type": "number", - "example": 2100 - }, - "envelopes": { - "description": "The envelopes this budget has, with detailed calculations", + "links": { + "description": "Links for the v3 API", + "allOf": [ + { + "$ref": "#/definitions/controllers.ImportV3Links" + } + ] + } + } + }, + "controllers.MatchRuleCreateResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of created Match Rules", "type": "array", "items": { - "$ref": "#/definitions/models.EnvelopeMonth" + "$ref": "#/definitions/controllers.MatchRuleResponseV3" } }, - "id": { - "description": "The ID of the Budget", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "1e777d24-3f5b-4c43-8000-04f65f895578" - }, - "income": { - "description": "Income. This is all money that is sent from off-budget to on-budget accounts without an envelope set.", - "type": "number", - "example": 2317.34 + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "controllers.MatchRuleListResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of Match Rules", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.MatchRuleV3" + } }, - "month": { - "description": "Month these calculations are made for", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "2006-05-01T00:00:00.000000Z" + "example": "the specified resource ID is not a valid UUID" }, - "name": { - "description": "The name of the Budget", - "type": "string", - "example": "Groceries" + "pagination": { + "description": "Pagination information", + "allOf": [ + { + "$ref": "#/definitions/controllers.Pagination" + } + ] } } }, - "models.CategoryCreate": { + "controllers.MatchRuleResponseV3": { "type": "object", "properties": { - "budgetId": { - "description": "ID of the budget the category belongs to", - "type": "string", - "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" - }, - "hidden": { - "description": "Is the category hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "name": { - "description": "Name of the category", - "type": "string", - "example": "Saving" + "data": { + "description": "The Match Rule data, if creation was successful", + "allOf": [ + { + "$ref": "#/definitions/controllers.MatchRuleV3" + } + ] }, - "note": { - "description": "Notes about the category", + "error": { + "description": "The error, if any occurred for this Match Rule", "type": "string", - "example": "All envelopes for long-term saving" + "example": "the specified resource ID is not a valid UUID" } } }, - "models.CategoryEnvelopes": { + "controllers.MatchRuleV3": { "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", + "accountId": { + "description": "The account to map matching transactions to", "type": "string", - "example": "52d967d3-33f4-4b04-9ba7-772e5ab9d0ce" + "example": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" }, "createdAt": { "description": "Time the resource was created", @@ -9557,59 +4092,84 @@ "type": "string", "example": "2022-04-22T21:01:05.058161Z" }, - "envelopes": { - "description": "Slice of all envelopes", - "type": "array", - "items": { - "$ref": "#/definitions/models.EnvelopeMonth" - } - }, - "hidden": { - "description": "Is the category hidden?", - "type": "boolean", - "default": false, - "example": true - }, "id": { "description": "UUID for the resource", "type": "string", "example": "65392deb-5e92-4268-b114-297faad6cdce" }, - "name": { - "description": "Name of the category", - "type": "string", - "example": "Saving" + "links": { + "type": "object", + "properties": { + "self": { + "description": "The match rule itself", + "type": "string", + "example": "https://example.com/api/v3/match-rules/95685c82-53c6-455d-b235-f49960b73b21" + } + } }, - "note": { - "description": "Notes about the category", + "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": "All envelopes for long-term saving" + "example": "Bank*" }, - "spent": { - "description": "Sum spent for all envelopes", - "type": "number", - "example": 100.13 + "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" + "example": "2022-04-17T20:14:01.048145Z" + } + } + }, + "controllers.MonthConfigCreateV3": { + "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 + }, + "note": { + "description": "A note for the month config", + "type": "string", + "example": "Added 200€ here because we replaced Tim's expensive vase" } } }, - "models.Envelope": { + "controllers.MonthConfigResponseV3": { "type": "object", "properties": { - "archived": { - "description": "Is the Envelope archived?", - "type": "boolean", - "default": false, - "example": true + "data": { + "description": "Config for the month", + "allOf": [ + { + "$ref": "#/definitions/controllers.MonthConfigV3" + } + ] }, - "categoryId": { - "description": "ID of the category the envelope belongs to", + "error": { + "description": "The error, if any occurred", "type": "string", - "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" + "example": "the specified resource ID is not a valid UUID" + } + } + }, + "controllers.MonthConfigV3": { + "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 }, "createdAt": { "description": "Time the resource was created", @@ -9621,26 +4181,39 @@ "type": "string", "example": "2022-04-22T21:01:05.058161Z" }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "id": { - "description": "UUID for the resource", + "envelopeId": { + "description": "ID of the envelope", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" + "example": "10b9705d-3356-459e-9d5a-28d42a6c4547" }, - "name": { - "description": "Name of the envelope", + "links": { + "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" + }, + "self": { + "description": "The Month Config itself", + "type": "string", + "example": "https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10" + } + } + }, + "month": { + "description": "The month. This is always set to 00:00 UTC on the first of the month.", "type": "string", - "example": "Groceries" + "example": "1969-06-01T00:00:00.000000Z" }, "note": { - "description": "Notes about the envelope", + "description": "A note for the month config", "type": "string", - "example": "For stuff bought at supermarkets and drugstores" + "example": "Added 200€ here because we replaced Tim's expensive vase" + }, + "overspendMode": { + "description": "Ignore this. It is here to override the OverspendMode from models.MonthConfigCreate and will be removed with 4.0.0", + "type": "string" }, "updatedAt": { "description": "Last time the resource was updated", @@ -9649,154 +4222,252 @@ } } }, - "models.EnvelopeCreate": { + "controllers.MonthResponseV3": { "type": "object", "properties": { - "categoryId": { - "description": "ID of the category the envelope belongs to", - "type": "string", - "example": "878c831f-af99-4a71-b3ca-80deb7d793c1" - }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true - }, - "name": { - "description": "Name of the envelope", - "type": "string", - "example": "Groceries" + "data": { + "description": "Data for the month", + "allOf": [ + { + "$ref": "#/definitions/controllers.MonthV3" + } + ] }, - "note": { - "description": "Notes about the envelope", - "type": "string", - "example": "For stuff bought at supermarkets and drugstores" + "error": { + "description": "The error, if any occurred", + "type": "string" } } }, - "models.EnvelopeMonth": { + "controllers.MonthV3": { "type": "object", "properties": { "allocation": { - "description": "The amount of money allocated", + "description": "The sum of all allocations for this month", "type": "number", - "example": 85.44 + "example": 1200.5 }, - "archived": { - "description": "Is the Envelope archived?", - "type": "boolean", - "default": false, - "example": true + "available": { + "description": "The amount available to budget", + "type": "number", + "example": 217.34 }, "balance": { - "description": "The balance at the end of the monht", + "description": "The sum of all envelope balances", "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" + "example": 5231.37 }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true + "categories": { + "description": "A list of envelope month calculations grouped by category", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.CategoryEnvelopesV3" + } }, "id": { - "description": "UUID for the resource", + "description": "The ID of the Budget", "type": "string", - "example": "65392deb-5e92-4268-b114-297faad6cdce" + "example": "1e777d24-3f5b-4c43-8000-04f65f895578" }, - "links": { - "description": "Linked resources", - "allOf": [ - { - "$ref": "#/definitions/models.EnvelopeMonthLinks" - } - ] + "income": { + "description": "The total income for the month (sum of all incoming transactions without an Envelope)", + "type": "number", + "example": 2317.34 }, "month": { - "description": "This is always set to 00:00 UTC on the first of the month. **This field is deprecated and will be removed in v2**", + "description": "The month", "type": "string", - "example": "1969-06-01T00:00:00.000000Z" + "example": "2006-05-01T00:00:00.000000Z" }, "name": { - "description": "Name of the envelope", - "type": "string", - "example": "Groceries" - }, - "note": { - "description": "Notes about the envelope", + "description": "The name of the Budget", "type": "string", - "example": "For stuff bought at supermarkets and drugstores" + "example": "Zero budget" }, "spent": { - "description": "The amount spent over the whole month", + "description": "The amount of money spent in this month", "type": "number", - "example": 73.12 + "example": 133.7 + } + } + }, + "controllers.Pagination": { + "type": "object", + "properties": { + "count": { + "description": "The amount of records returned in this response", + "type": "integer", + "example": 25 }, - "updatedAt": { - "description": "Last time the resource was updated", + "limit": { + "description": "The maximum amount of resources to return for this request", + "type": "integer", + "example": 25 + }, + "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 + } + } + }, + "controllers.TransactionCreateResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of created Transactions", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.TransactionResponseV3" + } + }, + "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.EnvelopeMonthLinks": { + "controllers.TransactionListResponseV3": { "type": "object", "properties": { - "allocation": { - "description": "The allocations for this envelope for this month", + "data": { + "description": "List of transactions", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.TransactionV3" + } + }, + "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/controllers.Pagination" + } + ] + } + } + }, + "controllers.TransactionResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "The Transaction data, if creation was successful", + "allOf": [ + { + "$ref": "#/definitions/controllers.TransactionV3" + } + ] + }, + "error": { + "description": "The error, if any occurred for this transaction", "type": "string", - "example": "https://example.com/api/v1/allocations/772d6956-ecba-485b-8a27-46a506c5a2a3" + "example": "the specified resource ID is not a valid UUID" } } }, - "models.MatchRule": { + "controllers.TransactionV3": { "type": "object", "properties": { - "accountId": { - "description": "The account to map matching transactions to", + "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 + }, + "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": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" + "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", "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" }, - "match": { - "description": "The matching applied to the opposite account. This is a glob pattern. Multiple globs are allowed. Globbing is case sensitive.", + "importHash": { + "description": "The SHA256 hash of a unique combination of values to use in duplicate detection", "type": "string", - "example": "Bank*" + "example": "867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70" }, - "priority": { - "description": "The priority of the match rule", - "type": "integer", - "example": 3 + "links": { + "description": "Links for the transaction", + "type": "object", + "properties": { + "self": { + "description": "The transaction itself", + "type": "string", + "example": "https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673" + } + } + }, + "note": { + "description": "A note", + "type": "string", + "example": "Lunch" + }, + "reconciled": { + "description": "Remove the reconciled field", + "type": "boolean" + }, + "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", @@ -9805,100 +4476,82 @@ } } }, - "models.MatchRuleCreate": { + "httperrors.HTTPError": { "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.", + "error": { "type": "string", - "example": "Bank*" - }, - "priority": { - "description": "The priority of the match rule", - "type": "integer", - "example": 3 + "example": "An ID specified in the query string was not a valid UUID" } } }, - "models.Month": { + "importer.TransactionPreviewV3": { "type": "object", "properties": { - "allocation": { - "description": "The sum of all allocations for this month", - "type": "number", - "example": 1200.5 - }, - "available": { - "description": "The amount available to budget", - "type": "number", - "example": 217.34 - }, - "balance": { - "description": "The sum of all envelope balances", - "type": "number", - "example": 5231.37 - }, - "budgeted": { - "description": "The sum of all allocations for the month. **Deprecated, please use the `allocation` field**", - "type": "number", - "example": 2100 + "destinationAccountName": { + "description": "Name of the destination account from the CSV file", + "type": "string", + "example": "Deutsche Bahn" }, - "categories": { - "description": "A list of envelope month calculations grouped by category", + "duplicateTransactionIds": { + "description": "IDs of transactions that this transaction duplicates", "type": "array", "items": { - "$ref": "#/definitions/models.CategoryEnvelopes" + "type": "string" } }, - "id": { - "description": "The ID of the Budget", + "matchRuleId": { + "description": "ID of the match rule that was applied to this transaction preview", "type": "string", - "example": "1e777d24-3f5b-4c43-8000-04f65f895578" + "example": "042d101d-f1de-4403-9295-59dc0ea58677" }, - "income": { - "description": "The total income for the month (sum of all incoming transactions without an Envelope)", - "type": "number", - "example": 2317.34 + "sourceAccountName": { + "description": "Name of the source account from the CSV file", + "type": "string", + "example": "Employer" }, - "month": { - "description": "The month", + "transaction": { + "$ref": "#/definitions/models.TransactionCreate" + } + } + }, + "models.BudgetCreate": { + "type": "object", + "properties": { + "currency": { + "description": "The currency for the budget", "type": "string", - "example": "2006-05-01T00:00:00.000000Z" + "example": "€" }, "name": { - "description": "The name of the Budget", + "description": "Name of the budget", "type": "string", - "example": "Zero budget" + "example": "Morre's Budget" }, - "spent": { - "description": "The amount of money spent in this month", - "type": "number", - "example": 133.7 + "note": { + "description": "A longer description of the budget", + "type": "string", + "example": "My personal expenses" } } }, - "models.MonthConfigCreate": { + "models.MatchRuleCreate": { "type": "object", "properties": { - "note": { - "description": "A note for the month config", + "accountId": { + "description": "The account to map matching transactions to", "type": "string", - "example": "Added 200€ here because we replaced Tim's expensive vase" + "example": "f9e873c2-fb96-4367-bfb6-7ecd9bf4a6b5" }, - "overspendMode": { - "description": "The overspend handling mode to use. Deprecated, will be removed with 4.0.0 release and is not used in API v3 anymore", - "default": "AFFECT_AVAILABLE", - "allOf": [ - { - "$ref": "#/definitions/models.OverspendMode" - } - ], - "example": "AFFECT_ENVELOPE" + "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 } } }, @@ -10002,16 +4655,6 @@ "type": "string", "example": "https://example.com/api/metrics" }, - "v1": { - "description": "List endpoint for all v1 endpoints", - "type": "string", - "example": "https://example.com/api/v1" - }, - "v2": { - "description": "List endpoint for all v2 endpoints", - "type": "string", - "example": "https://example.com/api/v2" - }, "v3": { "description": "List endpoint for all v3 endpoints", "type": "string", @@ -10037,102 +4680,6 @@ } } }, - "router.V1Links": { - "type": "object", - "properties": { - "accounts": { - "description": "URL of account list endpoint", - "type": "string", - "example": "https://example.com/api/v1/accounts" - }, - "allocations": { - "description": "URL of allocation list endpoint", - "type": "string", - "example": "https://example.com/api/v1/allocations" - }, - "budgets": { - "description": "URL of budget list endpoint", - "type": "string", - "example": "https://example.com/api/v1/budgets" - }, - "categories": { - "description": "URL of category list endpoint", - "type": "string", - "example": "https://example.com/api/v1/categories" - }, - "envelopes": { - "description": "URL of envelope list endpoint", - "type": "string", - "example": "https://example.com/api/v1/envelopes" - }, - "import": { - "description": "URL of import list endpoint", - "type": "string", - "example": "https://example.com/api/v1/import" - }, - "months": { - "description": "URL of month list endpoint", - "type": "string", - "example": "https://example.com/api/v1/months" - }, - "transactions": { - "description": "URL of transaction list endpoint", - "type": "string", - "example": "https://example.com/api/v1/transactions" - } - } - }, - "router.V1Response": { - "type": "object", - "properties": { - "links": { - "description": "Links for the v1 API", - "allOf": [ - { - "$ref": "#/definitions/router.V1Links" - } - ] - } - } - }, - "router.V2Links": { - "type": "object", - "properties": { - "accounts": { - "description": "URL of transaction list endpoint", - "type": "string", - "example": "https://example.com/api/v2/accounts" - }, - "match-rules": { - "description": "URL of match-rule list endpoint", - "type": "string", - "example": "https://example.com/api/v2/match-rules" - }, - "rename-rules": { - "description": "URL of rename-rule list endpoint", - "type": "string", - "example": "https://example.com/api/v2/rename-rules" - }, - "transactions": { - "description": "URL of transaction list endpoint", - "type": "string", - "example": "https://example.com/api/v2/transactions" - } - } - }, - "router.V2Response": { - "type": "object", - "properties": { - "links": { - "description": "Links for the v2 API", - "allOf": [ - { - "$ref": "#/definitions/router.V2Links" - } - ] - } - } - }, "router.V3Links": { "type": "object", "properties": { @@ -10187,7 +4734,7 @@ "type": "object", "properties": { "links": { - "description": "Links for the v2 API", + "description": "Links for the v3 API", "allOf": [ { "$ref": "#/definitions/router.V3Links" diff --git a/api/swagger.yaml b/api/swagger.yaml index 09e72587..3f524dd0 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -1,96 +1,4 @@ definitions: - controllers.Account: - properties: - archived: - default: false - description: Is the account archived? - example: true - type: boolean - balance: - description: Balance of the account, including all transactions referencing - it - example: 2735.17 - type: number - 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 - hidden: - default: false - description: Is the account archived? - example: true - 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 - example: 867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70 - type: string - initialBalance: - default: 0 - description: Balance of the account before any transactions were recorded - example: 173.12 - type: number - initialBalanceDate: - description: Date of the initial balance - example: "2017-05-12T00:00:00Z" - type: string - links: - properties: - self: - description: The account itself - example: https://example.com/api/v1/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2 - type: string - transactions: - description: Transactions referencing the account - example: https://example.com/api/v1/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2 - type: string - type: object - 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 - recentEnvelopes: - description: Envelopes recently used with this account - items: - $ref: '#/definitions/models.Envelope' - type: array - reconciledBalance: - description: Balance of the account, including all reconciled transactions - referencing it - example: 2539.57 - type: number - updatedAt: - description: Last time the resource was updated - example: "2022-04-17T20:14:01.048145Z" - type: string - type: object controllers.AccountCreateResponseV3: properties: data: @@ -148,14 +56,6 @@ definitions: example: true type: boolean type: object - controllers.AccountListResponse: - properties: - data: - description: List of accounts - items: - $ref: '#/definitions/controllers.Account' - type: array - type: object controllers.AccountListResponseV3: properties: data: @@ -172,13 +72,6 @@ definitions: - $ref: '#/definitions/controllers.Pagination' description: Pagination information type: object - controllers.AccountResponse: - properties: - data: - allOf: - - $ref: '#/definitions/controllers.Account' - description: Data for the account - type: object controllers.AccountResponseV3: properties: data: @@ -280,58 +173,6 @@ definitions: example: "2022-04-17T20:14:01.048145Z" type: string type: object - controllers.Allocation: - properties: - amount: - description: The maximum value is "999999999999.99999999", swagger unfortunately - rounds this. - example: 22.01 - maximum: 1000000000000 - minimum: 1e-08 - multipleOf: 1e-08 - type: number - 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: ID of the envelope - example: a0909e84-e8f9-4cb6-82a5-025dff105ff2 - type: string - id: - description: UUID for the resource - example: 65392deb-5e92-4268-b114-297faad6cdce - type: string - links: - properties: - self: - description: The allocation itself - example: https://example.com/api/v1/allocations/902cd93c-3724-4e46-8540-d014131282fc - type: string - type: object - month: - description: Only year and month of this timestamp are used, everything else - is ignored. This will always be set to 00:00 UTC on the first of the specified - month - example: "2021-12-01T00:00:00.000000Z" - type: string - updatedAt: - description: Last time the resource was updated - example: "2022-04-17T20:14:01.048145Z" - type: string - type: object - controllers.AllocationListResponse: - properties: - data: - description: Data for the allocation - items: - $ref: '#/definitions/controllers.Allocation' - type: array - type: object controllers.AllocationMode: enum: - ALLOCATE_LAST_MONTH_BUDGET @@ -340,86 +181,6 @@ definitions: x-enum-varnames: - AllocateLastMonthBudget - AllocateLastMonthSpend - controllers.AllocationResponse: - properties: - data: - allOf: - - $ref: '#/definitions/controllers.Allocation' - description: List of allocations - type: object - controllers.Budget: - properties: - balance: - description: DEPRECATED. Will be removed in API v2, see https://github.com/envelope-zero/backend/issues/526. - example: 3423.42 - type: number - 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: - properties: - accounts: - description: Accounts for this budget - example: https://example.com/api/v1/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf - type: string - categories: - description: Categories for this budget - example: https://example.com/api/v1/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf - type: string - envelopes: - description: Envelopes for this budget - example: https://example.com/api/v1/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf - type: string - groupedMonth: - description: This uses 'YYYY-MM' for clients to replace with the actual - year and month. - example: https://example.com/api/v1/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf&month=YYYY-MM - type: string - month: - description: This uses 'YYYY-MM' for clients to replace with the actual - year and month. - example: https://example.com/api/v1/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf/YYYY-MM - type: string - monthAllocations: - description: This uses 'YYYY-MM' for clients to replace with the actual - year and month. - example: https://example.com/api/v1/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf&month=YYYY-MM - type: string - self: - description: The budget itself - example: https://example.com/api/v1/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf - type: string - transactions: - description: Transactions for this budget - example: https://example.com/api/v1/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf - type: string - type: object - 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 controllers.BudgetAllocationMode: properties: mode: @@ -440,14 +201,6 @@ definitions: example: the specified resource ID is not a valid UUID type: string type: object - controllers.BudgetListResponse: - properties: - data: - description: List of budgets - items: - $ref: '#/definitions/controllers.Budget' - type: array - type: object controllers.BudgetListResponseV3: properties: data: @@ -464,20 +217,6 @@ definitions: - $ref: '#/definitions/controllers.Pagination' description: Pagination information type: object - controllers.BudgetMonthResponse: - properties: - data: - allOf: - - $ref: '#/definitions/models.BudgetMonth' - description: Data for the budget's month - type: object - controllers.BudgetResponse: - properties: - data: - allOf: - - $ref: '#/definitions/controllers.Budget' - description: Data for the budget - type: object controllers.BudgetResponseV3: properties: data: @@ -548,93 +287,36 @@ definitions: example: "2022-04-17T20:14:01.048145Z" type: string type: object - controllers.Category: + controllers.CategoryCreateResponseV3: + properties: + data: + description: List of the created Categories or their respective error + items: + $ref: '#/definitions/controllers.CategoryResponseV3' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object + controllers.CategoryCreateV3: properties: archived: default: false - description: Is the Category archived? + description: Is the category hidden? 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" + name: + description: Name of the category + example: Saving type: string - deletedAt: - description: Time the resource was marked as deleted - example: "2022-04-22T21:01:05.058161Z" - type: string - envelopes: - description: Envelopes for the category - items: - $ref: '#/definitions/controllers.Envelope' - type: array - hidden: - default: false - description: Is the category hidden? - example: true - type: boolean - id: - description: UUID for the resource - example: 65392deb-5e92-4268-b114-297faad6cdce - type: string - links: - properties: - envelopes: - description: Envelopes for this category - example: https://example.com/api/v1/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f - type: string - self: - description: The category itself - example: https://example.com/api/v1/categories/3b1ea324-d438-4419-882a-2fc91d71772f - type: string - type: object - 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 - controllers.CategoryCreateResponseV3: - properties: - data: - description: List of the created Categories or their respective error - items: - $ref: '#/definitions/controllers.CategoryResponseV3' - type: array - error: - description: The error, if any occurred - example: the specified resource ID is not a valid UUID - type: string - type: object - controllers.CategoryCreateV3: - properties: - archived: - default: false - description: Is the category hidden? - 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 + note: + description: Notes about the category + example: All envelopes for long-term saving type: string type: object controllers.CategoryEnvelopesV3: @@ -695,14 +377,6 @@ definitions: example: "2022-04-17T20:14:01.048145Z" type: string type: object - controllers.CategoryListResponse: - properties: - data: - description: List of categories - items: - $ref: '#/definitions/controllers.Category' - type: array - type: object controllers.CategoryListResponseV3: properties: data: @@ -719,13 +393,6 @@ definitions: - $ref: '#/definitions/controllers.Pagination' description: Pagination information type: object - controllers.CategoryResponse: - properties: - data: - allOf: - - $ref: '#/definitions/controllers.Category' - description: Data for the category - type: object controllers.CategoryResponseV3: properties: data: @@ -792,68 +459,6 @@ definitions: example: "2022-04-17T20:14:01.048145Z" type: string type: object - controllers.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 - hidden: - default: false - description: Is the envelope hidden? - example: true - type: boolean - id: - description: UUID for the resource - example: 65392deb-5e92-4268-b114-297faad6cdce - type: string - links: - description: Links to related resources - properties: - allocations: - description: the envelope's allocations - example: https://example.com/api/v1/allocations?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166 - type: string - month: - description: Month information endpoint. This will always end in 'YYYY-MM' - for clients to use replace with actual numbers. - example: https://example.com/api/v1/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM - type: string - self: - description: The envelope itself - example: https://example.com/api/v1/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166 - type: string - transactions: - description: The envelope's transactions - example: https://example.com/api/v1/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166 - type: string - type: object - 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 controllers.EnvelopeCreateResponseV3: properties: data: @@ -886,14 +491,6 @@ definitions: example: For stuff bought at supermarkets and drugstores type: string type: object - controllers.EnvelopeListResponse: - properties: - data: - description: List of Envelopes - items: - $ref: '#/definitions/controllers.Envelope' - type: array - type: object controllers.EnvelopeListResponseV3: properties: data: @@ -910,13 +507,6 @@ definitions: - $ref: '#/definitions/controllers.Pagination' description: Pagination information type: object - controllers.EnvelopeMonthResponse: - properties: - data: - allOf: - - $ref: '#/definitions/models.EnvelopeMonth' - description: Data for the month for the envelope - type: object controllers.EnvelopeMonthV3: properties: allocation: @@ -972,13 +562,6 @@ definitions: example: "2022-04-17T20:14:01.048145Z" type: string type: object - controllers.EnvelopeResponse: - properties: - data: - allOf: - - $ref: '#/definitions/controllers.Envelope' - description: Data for the Envelope - type: object controllers.EnvelopeResponseV3: properties: data: @@ -1174,14 +757,6 @@ definitions: example: https://example.com/api/v3/goals/438cc6c0-9baf-49fd-a75a-d76bd5cab19c type: string type: object - controllers.ImportPreviewList: - properties: - data: - description: List of transaction previews - items: - $ref: '#/definitions/importer.TransactionPreview' - type: array - type: object controllers.ImportPreviewListV3: properties: data: @@ -1212,45 +787,6 @@ definitions: - $ref: '#/definitions/controllers.ImportV3Links' description: Links for the v3 API type: object - controllers.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: - properties: - self: - description: The match rule itself - example: https://example.com/api/v2/match-rules/95685c82-53c6-455d-b235-f49960b73b21 - type: string - type: object - 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 controllers.MatchRuleCreateResponseV3: properties: data: @@ -1329,60 +865,6 @@ definitions: example: "2022-04-17T20:14:01.048145Z" type: string type: object - controllers.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 - 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: ID of the envelope - example: 10b9705d-3356-459e-9d5a-28d42a6c4547 - type: string - links: - properties: - envelope: - description: The envelope this config belongs to - example: https://example.com/api/v1/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b - type: string - self: - description: The month config itself - example: https://example.com/api/v1/month-configs/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10 - type: string - type: object - 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 - overspendMode: - allOf: - - $ref: '#/definitions/models.OverspendMode' - default: AFFECT_AVAILABLE - description: The overspend handling mode to use. Deprecated, will be removed - with 4.0.0 release and is not used in API v3 anymore - example: AFFECT_ENVELOPE - updatedAt: - description: Last time the resource was updated - example: "2022-04-17T20:14:01.048145Z" - type: string - type: object controllers.MonthConfigCreateV3: properties: allocation: @@ -1398,21 +880,6 @@ definitions: example: Added 200€ here because we replaced Tim's expensive vase type: string type: object - controllers.MonthConfigListResponse: - properties: - data: - description: List of month configs - items: - $ref: '#/definitions/controllers.MonthConfig' - type: array - type: object - controllers.MonthConfigResponse: - properties: - data: - allOf: - - $ref: '#/definitions/controllers.MonthConfig' - description: Data for the month - type: object controllers.MonthConfigResponseV3: properties: data: @@ -1475,13 +942,6 @@ definitions: example: "2022-04-17T20:14:01.048145Z" type: string type: object - controllers.MonthResponse: - properties: - data: - allOf: - - $ref: '#/definitions/models.Month' - description: Data for the month - type: object controllers.MonthResponseV3: properties: data: @@ -1552,44 +1012,46 @@ definitions: example: 827 type: integer type: object - controllers.RenameRuleListResponse: + controllers.TransactionCreateResponseV3: properties: data: - description: List of rename rules + description: List of created Transactions items: - $ref: '#/definitions/models.MatchRule' + $ref: '#/definitions/controllers.TransactionResponseV3' type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string type: object - controllers.RenameRuleResponse: + controllers.TransactionListResponseV3: properties: data: + description: List of transactions + items: + $ref: '#/definitions/controllers.TransactionV3' + 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/models.MatchRule' - description: Data for the rename rule + - $ref: '#/definitions/controllers.Pagination' + description: Pagination information type: object - controllers.ResponseMatchRule: + controllers.TransactionResponseV3: properties: data: allOf: - - $ref: '#/definitions/controllers.MatchRule' - description: This field contains the MatchRule data + - $ref: '#/definitions/controllers.TransactionV3' + description: The Transaction data, if creation was successful error: - description: This field contains a human readable error message - example: A human readable error message - type: string - type: object - controllers.ResponseTransactionV2: - properties: - data: - allOf: - - $ref: '#/definitions/controllers.TransactionV2' - description: This field contains the Transaction data - error: - description: This field contains a human readable error message - example: A human readable error message + description: The error, if any occurred for this transaction + example: the specified resource ID is not a valid UUID type: string type: object - controllers.Transaction: + controllers.TransactionV3: properties: amount: description: The maximum value is "999999999999.99999999", swagger unfortunately @@ -1643,7 +1105,7 @@ definitions: properties: self: description: The transaction itself - example: https://example.com/api/v1/transactions/d430d7c3-d14c-4712-9336-ee56965a6673 + example: https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673 type: string type: object note: @@ -1651,11 +1113,7 @@ definitions: example: Lunch type: string reconciled: - default: false - description: DEPRECATED. Do not use, this field does not work as intended. - See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource - and reconciledDestination instead. This field will be removed in 4.0.0 - example: true + description: Remove the reconciled field type: boolean reconciledDestination: default: false @@ -1676,61 +1134,74 @@ definitions: example: "2022-04-17T20:14:01.048145Z" type: string type: object - controllers.TransactionCreateResponseV3: + httperrors.HTTPError: properties: - data: - description: List of created Transactions - items: - $ref: '#/definitions/controllers.TransactionResponseV3' - type: array error: - description: The error, if any occurred - example: the specified resource ID is not a valid UUID + example: An ID specified in the query string was not a valid UUID type: string type: object - controllers.TransactionListResponse: - properties: - data: - description: List of transactions - items: - $ref: '#/definitions/controllers.Transaction' - type: array - type: object - controllers.TransactionListResponseV3: + importer.TransactionPreviewV3: properties: - data: - description: List of transactions + 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: - $ref: '#/definitions/controllers.TransactionV3' + type: string type: array - error: - description: The error, if any occurred - example: the specified resource ID is not a valid UUID + matchRuleId: + description: ID of the match rule that was applied to this transaction preview + example: 042d101d-f1de-4403-9295-59dc0ea58677 type: string - pagination: - allOf: - - $ref: '#/definitions/controllers.Pagination' - description: Pagination information + sourceAccountName: + description: Name of the source account from the CSV file + example: Employer + type: string + transaction: + $ref: '#/definitions/models.TransactionCreate' type: object - controllers.TransactionResponse: + models.BudgetCreate: properties: - data: - allOf: - - $ref: '#/definitions/controllers.Transaction' - description: Data for the transaction + 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 - controllers.TransactionResponseV3: + models.MatchRuleCreate: properties: - data: - allOf: - - $ref: '#/definitions/controllers.TransactionV3' - 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 + 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 - controllers.TransactionV2: + models.OverspendMode: + enum: + - AFFECT_AVAILABLE + - AFFECT_ENVELOPE + type: string + x-enum-varnames: + - AffectAvailable + - AffectEnvelope + models.TransactionCreate: properties: amount: description: The maximum value is "999999999999.99999999", swagger unfortunately @@ -1750,18 +1221,10 @@ definitions: description: ID of the budget example: 55eecbd8-7c46-4b06-ada9-f287802fb05e 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 @@ -1770,23 +1233,11 @@ definitions: 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: - description: Links for the transaction - properties: - self: - description: The transaction itself - example: https://example.com/api/v2/transactions/d430d7c3-d14c-4712-9336-ee56965a6673 - type: string - type: object note: description: A note example: Lunch @@ -1812,3238 +1263,112 @@ definitions: 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 - controllers.TransactionV3: + router.RootLinks: 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" + docs: + description: Swagger API documentation + example: https://example.com/api/docs/index.html type: string - budgetId: - description: ID of the budget - example: 55eecbd8-7c46-4b06-ada9-f287802fb05e + healthz: + description: Healthz endpoint + example: https://example.com/api/healtzh type: string - createdAt: - description: Time the resource was created - example: "2022-04-02T19:28:44.491514Z" + metrics: + description: Endpoint returning Prometheus metrics + example: https://example.com/api/metrics type: string - date: - description: Date of the transaction. Time is currently only used for sorting - example: "1815-12-10T18:43:00.271152Z" + v3: + description: List endpoint for all v3 endpoints + example: https://example.com/api/v3 type: string - deletedAt: - description: Time the resource was marked as deleted - example: "2022-04-22T21:01:05.058161Z" + version: + description: Endpoint returning the version of the backend + example: https://example.com/api/version type: string - destinationAccountId: - description: ID of the destination account - example: 8e16b456-a719-48ce-9fec-e115cfa7cbcc + type: object + router.RootResponse: + properties: + links: + allOf: + - $ref: '#/definitions/router.RootLinks' + description: URLs of API endpoints + type: object + router.V3Links: + properties: + accounts: + description: URL of Account collection endpoint + example: https://example.com/api/v3/accounts type: string - envelopeId: - description: ID of the envelope - example: 2649c965-7999-4873-ae16-89d5d5fa972e + budgets: + description: URL of Budget collection endpoint + example: https://example.com/api/v3/budgets type: string - id: - description: UUID for the resource - example: 65392deb-5e92-4268-b114-297faad6cdce + categories: + description: URL of Category collection endpoint + example: https://example.com/api/v3/categories type: string - importHash: - description: The SHA256 hash of a unique combination of values to use in duplicate - detection - example: 867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70 + envelopes: + description: URL of Envelope collection endpoint + example: https://example.com/api/v3/envelopes type: string - links: - description: Links for the transaction - properties: - self: - description: The transaction itself - example: https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673 - type: string - type: object - note: - description: A note - example: Lunch + goals: + description: URL of goal collection endpoint + example: https://example.com/api/v3/goals type: string - reconciled: - description: Remove the reconciled field - type: boolean - 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 + import: + description: URL of import list endpoint + example: https://example.com/api/v3/import type: string - updatedAt: - description: Last time the resource was updated - example: "2022-04-17T20:14:01.048145Z" + matchRules: + description: URL of Match Rule collection endpoint + example: https://example.com/api/v3/match-rules type: string - type: object - httperrors.HTTPError: - properties: - error: - example: An ID specified in the query string was not a valid UUID + months: + description: URL of Month endpoint + example: https://example.com/api/v3/months + type: string + transactions: + description: URL of Transaction collection endpoint + example: https://example.com/api/v3/transactions type: string type: object - importer.TransactionPreview: + router.V3Response: 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 - renameRuleId: - description: ID of the match rule that was applied to this transaction preview. - This is kept for backwards compatibility and will be removed with API version - 3 - 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.TransactionCreate' - type: object - importer.TransactionPreviewV3: - 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.TransactionCreate' - type: object - models.AccountCreate: - properties: - 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 - hidden: - default: false - description: Is the account archived? - example: true - type: boolean - importHash: - description: The SHA256 hash of a unique combination of values to use in duplicate - detection - example: 867e3a26dc0baf73f4bff506f31a97f6c32088917e9e5cf1a5ed6f3f84a6fa70 - type: string - initialBalance: - default: 0 - description: Balance of the account before any transactions were recorded - example: 173.12 - 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 - models.AllocationCreate: - properties: - amount: - 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: a0909e84-e8f9-4cb6-82a5-025dff105ff2 - type: string - month: - description: Only year and month of this timestamp are used, everything else - is ignored. This will always be set to 00:00 UTC on the first of the specified - month - example: "2021-12-01T00:00:00.000000Z" - type: string - type: object - models.BudgetCreate: - 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 - models.BudgetMonth: - properties: - available: - description: The amount of money still available to budget. - example: 217.34 - type: number - budgeted: - description: Amount of money that has been allocated to envelopes - example: 2100 - type: number - envelopes: - description: The envelopes this budget has, with detailed calculations - items: - $ref: '#/definitions/models.EnvelopeMonth' - type: array - id: - description: The ID of the Budget - example: 1e777d24-3f5b-4c43-8000-04f65f895578 - type: string - income: - description: Income. This is all money that is sent from off-budget to on-budget - accounts without an envelope set. - example: 2317.34 - type: number - month: - description: Month these calculations are made for - example: "2006-05-01T00:00:00.000000Z" - type: string - name: - description: The name of the Budget - example: Groceries - type: string - type: object - models.CategoryCreate: - properties: - budgetId: - description: ID of the budget the category belongs to - example: 52d967d3-33f4-4b04-9ba7-772e5ab9d0ce - type: string - hidden: - default: false - description: Is the category hidden? - example: true - type: boolean - 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 - models.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/models.EnvelopeMonth' - type: array - hidden: - default: false - description: Is the category hidden? - example: true - type: boolean - id: - description: UUID for the resource - example: 65392deb-5e92-4268-b114-297faad6cdce - 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 - 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 - models.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 - hidden: - default: false - description: Is the envelope hidden? - example: true - type: boolean - id: - description: UUID for the resource - example: 65392deb-5e92-4268-b114-297faad6cdce - 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 - updatedAt: - description: Last time the resource was updated - example: "2022-04-17T20:14:01.048145Z" - type: string - type: object - models.EnvelopeCreate: - properties: - categoryId: - description: ID of the category the envelope belongs to - example: 878c831f-af99-4a71-b3ca-80deb7d793c1 - type: string - hidden: - default: false - description: Is the envelope hidden? - example: true - type: boolean - 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 - models.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 - hidden: - default: false - description: Is the envelope hidden? - example: true - type: boolean - id: - description: UUID for the resource - example: 65392deb-5e92-4268-b114-297faad6cdce - type: string - links: - allOf: - - $ref: '#/definitions/models.EnvelopeMonthLinks' - description: Linked resources - month: - description: This is always set to 00:00 UTC on the first of the month. **This - field is deprecated and will be removed in v2** - example: "1969-06-01T00:00:00.000000Z" - 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 - 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 - models.EnvelopeMonthLinks: - properties: - allocation: - description: The allocations for this envelope for this month - example: https://example.com/api/v1/allocations/772d6956-ecba-485b-8a27-46a506c5a2a3 - type: string - type: object - models.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 - 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 - models.MatchRuleCreate: - 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 - models.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 - budgeted: - description: The sum of all allocations for the month. **Deprecated, please - use the `allocation` field** - example: 2100 - type: number - categories: - description: A list of envelope month calculations grouped by category - items: - $ref: '#/definitions/models.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 - models.MonthConfigCreate: - properties: - note: - description: A note for the month config - example: Added 200€ here because we replaced Tim's expensive vase - type: string - overspendMode: - allOf: - - $ref: '#/definitions/models.OverspendMode' - default: AFFECT_AVAILABLE - description: The overspend handling mode to use. Deprecated, will be removed - with 4.0.0 release and is not used in API v3 anymore - example: AFFECT_ENVELOPE - type: object - models.OverspendMode: - enum: - - AFFECT_AVAILABLE - - AFFECT_ENVELOPE - type: string - x-enum-varnames: - - AffectAvailable - - AffectEnvelope - models.TransactionCreate: - 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 - budgetId: - description: ID of the budget - example: 55eecbd8-7c46-4b06-ada9-f287802fb05e - 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 - reconciled: - default: false - description: DEPRECATED. Do not use, this field does not work as intended. - See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource - and reconciledDestination instead. This field will be removed in 4.0.0 - example: true - type: boolean - 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 - router.RootLinks: - properties: - docs: - description: Swagger API documentation - example: https://example.com/api/docs/index.html - type: string - healthz: - description: Healthz endpoint - example: https://example.com/api/healtzh - type: string - metrics: - description: Endpoint returning Prometheus metrics - example: https://example.com/api/metrics - type: string - v1: - description: List endpoint for all v1 endpoints - example: https://example.com/api/v1 - type: string - v2: - description: List endpoint for all v2 endpoints - example: https://example.com/api/v2 - type: string - v3: - description: List endpoint for all v3 endpoints - example: https://example.com/api/v3 - type: string - version: - description: Endpoint returning the version of the backend - example: https://example.com/api/version - type: string - type: object - router.RootResponse: - properties: - links: - allOf: - - $ref: '#/definitions/router.RootLinks' - description: URLs of API endpoints - type: object - router.V1Links: - properties: - accounts: - description: URL of account list endpoint - example: https://example.com/api/v1/accounts - type: string - allocations: - description: URL of allocation list endpoint - example: https://example.com/api/v1/allocations - type: string - budgets: - description: URL of budget list endpoint - example: https://example.com/api/v1/budgets - type: string - categories: - description: URL of category list endpoint - example: https://example.com/api/v1/categories - type: string - envelopes: - description: URL of envelope list endpoint - example: https://example.com/api/v1/envelopes - type: string - import: - description: URL of import list endpoint - example: https://example.com/api/v1/import - type: string - months: - description: URL of month list endpoint - example: https://example.com/api/v1/months - type: string - transactions: - description: URL of transaction list endpoint - example: https://example.com/api/v1/transactions - type: string - type: object - router.V1Response: - properties: - links: - allOf: - - $ref: '#/definitions/router.V1Links' - description: Links for the v1 API - type: object - router.V2Links: - properties: - accounts: - description: URL of transaction list endpoint - example: https://example.com/api/v2/accounts - type: string - match-rules: - description: URL of match-rule list endpoint - example: https://example.com/api/v2/match-rules - type: string - rename-rules: - description: URL of rename-rule list endpoint - example: https://example.com/api/v2/rename-rules - type: string - transactions: - description: URL of transaction list endpoint - example: https://example.com/api/v2/transactions - type: string - type: object - router.V2Response: - properties: - links: - allOf: - - $ref: '#/definitions/router.V2Links' - description: Links for the v2 API - type: object - router.V3Links: - properties: - accounts: - description: URL of Account collection endpoint - example: https://example.com/api/v3/accounts - type: string - budgets: - description: URL of Budget collection endpoint - example: https://example.com/api/v3/budgets - type: string - categories: - description: URL of Category collection endpoint - example: https://example.com/api/v3/categories - type: string - envelopes: - description: URL of Envelope collection endpoint - example: https://example.com/api/v3/envelopes - type: string - goals: - description: URL of goal collection endpoint - example: https://example.com/api/v3/goals - type: string - import: - description: URL of import list endpoint - example: https://example.com/api/v3/import - type: string - matchRules: - description: URL of Match Rule collection endpoint - example: https://example.com/api/v3/match-rules - type: string - months: - description: URL of Month endpoint - example: https://example.com/api/v3/months - type: string - transactions: - description: URL of Transaction collection endpoint - example: https://example.com/api/v3/transactions - type: string - type: object - router.V3Response: - properties: - links: - allOf: - - $ref: '#/definitions/router.V3Links' - description: Links for the v2 API - type: object - router.VersionObject: - properties: - version: - description: the running version of the Envelope Zero backend - example: 1.1.0 - type: string - type: object - router.VersionResponse: - properties: - data: - allOf: - - $ref: '#/definitions/router.VersionObject' - 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/router.RootResponse' - 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 - /v1: - delete: - deprecated: true - description: Permanently deletes all resources - responses: - "204": - description: No Content - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Delete everything - tags: - - v1 - get: - deprecated: true - description: Returns general information about the v1 API - responses: - "200": - description: OK - schema: - $ref: '#/definitions/router.V1Response' - summary: v1 API - tags: - - v1 - options: - deprecated: true - 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: - - v1 - /v1/accounts: - get: - deprecated: true - 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 hidden? - in: query - name: hidden - type: boolean - - description: Search for this text in name and note - in: query - name: search - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.AccountListResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: List accounts - tags: - - Accounts - options: - deprecated: true - 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: - deprecated: true - description: Creates a new account - parameters: - - description: Account - in: body - name: account - required: true - schema: - $ref: '#/definitions/models.AccountCreate' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/controllers.AccountResponse' - "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: Create account - tags: - - Accounts - /v1/accounts/{id}: - delete: - deprecated: true - 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: - deprecated: true - 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/controllers.AccountResponse' - "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: Get account - tags: - - Accounts - options: - deprecated: true - 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: - deprecated: true - 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/models.AccountCreate' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.AccountResponse' - "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: Update account - tags: - - Accounts - /v1/allocations: - get: - deprecated: true - description: Returns a list of allocations - parameters: - - description: Filter by month - in: query - name: month - type: string - - description: Filter by amount - in: query - name: amount - type: string - - description: Filter by envelope ID - in: query - name: envelope - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.AllocationListResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Get allocations - tags: - - Allocations - options: - deprecated: true - 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: - - Allocations - post: - deprecated: true - description: Create a new allocation of funds to an envelope for a specific - month - parameters: - - description: Allocation - in: body - name: allocation - required: true - schema: - $ref: '#/definitions/models.AllocationCreate' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/controllers.AllocationResponse' - "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: Create allocations - tags: - - Allocations - /v1/allocations/{id}: - delete: - deprecated: true - description: Deletes an allocation - 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 allocation - tags: - - Allocations - get: - deprecated: true - description: Returns a specific allocation - parameters: - - description: ID formatted as string - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.AllocationResponse' - "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: Get allocation - tags: - - Allocations - options: - deprecated: true - 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: - - Allocations - patch: - consumes: - - application/json - deprecated: true - description: Update an allocation. Only values to be updated need to be specified. - parameters: - - description: ID formatted as string - in: path - name: id - required: true - type: string - - description: Allocation - in: body - name: allocation - required: true - schema: - $ref: '#/definitions/models.AllocationCreate' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.AllocationResponse' - "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: Update allocation - tags: - - Allocations - /v1/budgets: - get: - deprecated: true - 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 - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.BudgetListResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: List budgets - tags: - - Budgets - options: - deprecated: true - 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 - deprecated: true - description: Creates a new budget - parameters: - - description: Budget - in: body - name: budget - required: true - schema: - $ref: '#/definitions/models.BudgetCreate' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/controllers.BudgetResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Create budget - tags: - - Budgets - /v1/budgets/{id}: - delete: - deprecated: true - 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: - deprecated: true - 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/controllers.BudgetResponse' - "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: Get budget - tags: - - Budgets - options: - deprecated: true - 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 - deprecated: true - 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/models.BudgetCreate' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.BudgetResponse' - "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: Update budget - tags: - - Budgets - /v1/budgets/{id}/{month}: - get: - deprecated: true - description: Returns data about a budget for a for a specific month. **Use GET - /month endpoint with month and budgetId query parameters instead.** - parameters: - - description: ID formatted as string - 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/controllers.BudgetMonthResponse' - "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: Get Budget month data - tags: - - Budgets - options: - deprecated: true - description: Returns an empty response with the HTTP Header "allow" set to the - allowed HTTP verbs. **Use OPTIONS /month endpoint with month and budgetId - query parameters instead.** - parameters: - - description: ID formatted as string - 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' - "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 - /v1/budgets/{id}/{month}/allocations: - delete: - deprecated: true - description: Deletes all allocation for the specified month. **Use DELETE /month - endpoint with month and budgetId query parameters instead.** - parameters: - - description: The month in YYYY-MM format - in: path - name: month - required: true - type: string - - description: Budget 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' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Delete allocations for a month - tags: - - Budgets - options: - deprecated: true - description: Returns an empty response with the HTTP Header "allow" set to the - allowed HTTP verbs. **Use OPTIONS /month endpoint with month and budgetId - query parameters instead.** - parameters: - - description: ID formatted as string - 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' - "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 - post: - deprecated: true - description: Sets allocations for a month for all envelopes that do not have - an allocation yet. **Deprecated. Use POST /month endpoint with month and budgetId - query parameters instead.** - parameters: - - description: The month in YYYY-MM format - in: path - name: month - required: true - type: string - - description: Budget ID formatted as string - in: path - name: id - required: true - type: string - - description: Budget - in: body - name: mode - required: true - schema: - $ref: '#/definitions/controllers.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: - - Budgets - /v1/categories: - get: - deprecated: true - 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 hidden? - in: query - name: hidden - type: boolean - - description: Search for this text in name and note - in: query - name: search - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.CategoryListResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Get categories - tags: - - Categories - options: - deprecated: true - 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: - deprecated: true - description: Creates a new category - parameters: - - description: Category - in: body - name: category - required: true - schema: - $ref: '#/definitions/models.CategoryCreate' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/controllers.CategoryResponse' - "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: Create category - tags: - - Categories - /v1/categories/{id}: - delete: - deprecated: true - 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: - deprecated: true - 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/controllers.CategoryResponse' - "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: Get category - tags: - - Categories - options: - deprecated: true - 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 - deprecated: true - 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/models.CategoryCreate' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.CategoryResponse' - "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: Update category - tags: - - Categories - /v1/envelopes: - get: - deprecated: true - 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 hidden? - in: query - name: hidden - type: boolean - - description: Search for this text in name and note - in: query - name: search - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.EnvelopeListResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Get envelopes - tags: - - Envelopes - options: - deprecated: true - 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: - deprecated: true - description: Creates a new envelope - parameters: - - description: Envelope - in: body - name: envelope - required: true - schema: - $ref: '#/definitions/models.EnvelopeCreate' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/controllers.EnvelopeResponse' - "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: Create envelope - tags: - - Envelopes - /v1/envelopes/{id}: - delete: - deprecated: true - 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: - deprecated: true - 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/controllers.EnvelopeResponse' - "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: Get envelope - tags: - - Envelopes - options: - deprecated: true - 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 - deprecated: true - 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/models.EnvelopeCreate' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.EnvelopeResponse' - "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: Update envelope - tags: - - Envelopes - /v1/envelopes/{id}/{month}: - get: - deprecated: true - description: Returns data about an envelope for a for a specific month. **Use - GET /month endpoint with month and budgetId query parameters instead.** - parameters: - - description: ID formatted as string - 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/controllers.EnvelopeMonthResponse' - "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: Get Envelope month data - tags: - - Envelopes - /v1/import: - options: - deprecated: true - description: Returns an empty response with the HTTP Header "allow" set to the - allowed HTTP verbs. **Please use /v1/import/ynab4, which works exactly the - same.** - responses: - "204": - description: No Content - summary: Allowed HTTP verbs - tags: - - Import - post: - consumes: - - multipart/form-data - deprecated: true - description: Imports budgets from YNAB 4. **Please use /v1/import/ynab4, which - works exactly the same.** - 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: - "204": - description: No Content - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Import - tags: - - Import - /v1/import/ynab-import-preview: - options: - deprecated: true - 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 - deprecated: true - 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/controllers.ImportPreviewList' - "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: Transaction Import Preview - tags: - - Import - /v1/import/ynab4: - options: - deprecated: true - 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 - deprecated: true - 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/controllers.BudgetResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Import YNAB 4 budget - tags: - - Import - /v1/month-configs: - get: - deprecated: true - description: Returns a list of MonthConfigs - parameters: - - description: Filter by name - in: query - name: envelope - type: string - - description: Filter by month - in: query - name: month - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.MonthConfigListResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: List MonthConfigs - tags: - - MonthConfigs - options: - deprecated: true - 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: - - MonthConfigs - /v1/month-configs/{id}/{month}: - delete: - deprecated: true - description: Deletes configuration settings 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: - "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 MonthConfig - tags: - - MonthConfigs - get: - deprecated: true - 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/controllers.MonthConfigResponse' - "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: Get MonthConfig - tags: - - MonthConfigs - options: - deprecated: true - 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' - "404": - description: Not Found - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Allowed HTTP verbs - tags: - - MonthConfigs - patch: - deprecated: true - description: Changes settings of an existing MonthConfig - 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/models.MonthConfigCreate' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/controllers.MonthConfigResponse' - "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: Update MonthConfig - tags: - - MonthConfigs - post: - deprecated: true - description: Creates a new MonthConfig - 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/models.MonthConfigCreate' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/controllers.MonthConfigResponse' - "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: Create MonthConfig - tags: - - MonthConfigs - /v1/months: - delete: - deprecated: true - 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: - deprecated: true - 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/controllers.MonthResponse' - "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: Get data about a month - tags: - - Months - options: - deprecated: true - 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: - deprecated: true - 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 - - 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/controllers.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 - /v1/transactions: - get: - deprecated: true - 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: DEPRECATED. Filter by reconcilication state - in: query - name: reconciled - type: boolean - - description: Reconcilication state in source account - in: query - name: reconciledSource - type: boolean - - description: Reconcilication state in destination account - in: query - name: reconciledDestination - type: boolean - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.TransactionListResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Get transactions - tags: - - Transactions - options: - deprecated: true - 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: - - Transactions - post: - deprecated: true - description: Creates a new transaction - parameters: - - description: Transaction - in: body - name: transaction - required: true - schema: - $ref: '#/definitions/models.TransactionCreate' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/controllers.TransactionResponse' - "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: Create transaction - tags: - - Transactions - /v1/transactions/{id}: - delete: - deprecated: true - 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: - deprecated: true - 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/controllers.TransactionResponse' - "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: Get transaction - tags: - - Transactions - options: - deprecated: true - 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 - deprecated: true - 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/models.TransactionCreate' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.TransactionResponse' - "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: Update transaction - tags: - - Transactions - /v2: - get: - deprecated: true - description: Returns general information about the v2 API - responses: - "200": - description: OK - schema: - $ref: '#/definitions/router.V2Response' - summary: v2 API - tags: - - v2 - options: - deprecated: true - 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: - - v2 - /v2/accounts: - get: - deprecated: true - 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 hidden? - in: query - name: hidden - type: boolean - - description: Search for this text in name and note - in: query - name: search - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.AccountListResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: List accounts - tags: - - Accounts - options: - deprecated: true - 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 - /v2/match-rules: - get: - deprecated: true - 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 - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/models.MatchRule' - type: array - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Get matchRules - tags: - - MatchRules - options: - deprecated: true - 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: - deprecated: true - 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/models.MatchRuleCreate' - type: array - produces: - - application/json - responses: - "201": - description: Created - schema: - items: - $ref: '#/definitions/controllers.ResponseMatchRule' - type: array - "400": - description: Bad Request - schema: - items: - $ref: '#/definitions/controllers.ResponseMatchRule' - type: array - "404": - description: Not Found - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - items: - $ref: '#/definitions/controllers.ResponseMatchRule' - type: array - summary: Create matchRules - tags: - - MatchRules - /v2/match-rules/{id}: - delete: - deprecated: true - 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: - deprecated: true - 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/models.MatchRule' - "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: Get matchRule - tags: - - MatchRules - options: - deprecated: true - 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 - deprecated: true - description: Update an matchRule. Only values to be updated need to be specified. - parameters: - - description: ID formatted as string - in: path - name: id - required: true + links: + allOf: + - $ref: '#/definitions/router.V3Links' + description: Links for the v3 API + type: object + router.VersionObject: + properties: + version: + description: the running version of the Envelope Zero backend + example: 1.1.0 type: string - - description: MatchRule - in: body - name: matchRule - required: true - schema: - $ref: '#/definitions/models.MatchRuleCreate' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/models.MatchRule' - "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: Update matchRule - tags: - - MatchRules - /v2/rename-rules: + type: object + router.VersionResponse: + properties: + data: + allOf: + - $ref: '#/definitions/router.VersionObject' + description: Data object for the version endpoint + type: object +info: + contact: {} +paths: + /: get: - deprecated: true - description: Returns a list of renameRules - 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 - produces: - - application/json + description: Entrypoint for the API, listing all endpoints responses: "200": description: OK schema: - $ref: '#/definitions/controllers.RenameRuleListResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/httperrors.HTTPError' - summary: Get renameRules + $ref: '#/definitions/router.RootResponse' + summary: API root tags: - - RenameRules + - General options: - deprecated: true description: Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs responses: @@ -5051,177 +1376,23 @@ paths: description: No Content summary: Allowed HTTP verbs tags: - - RenameRules - post: - deprecated: true - description: Creates renameRules from the list of submitted renameRule data. - The response code is the highest response code number that a single renameRule - creation would have caused. If it is not equal to 201, at least one renameRule - has an error. - parameters: - - description: RenameRules - in: body - name: renameRules - required: true - schema: - items: - $ref: '#/definitions/models.MatchRuleCreate' - type: array - produces: - - application/json - responses: - "201": - description: Created - schema: - items: - $ref: '#/definitions/controllers.ResponseMatchRule' - type: array - "400": - description: Bad Request - schema: - items: - $ref: '#/definitions/controllers.ResponseMatchRule' - type: array - "404": - description: Not Found - schema: - $ref: '#/definitions/httperrors.HTTPError' - "500": - description: Internal Server Error - schema: - items: - $ref: '#/definitions/controllers.ResponseMatchRule' - type: array - summary: Create renameRules - tags: - - RenameRules - /v2/rename-rules/{id}: - delete: - deprecated: true - description: Deletes an renameRule - 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 renameRule - tags: - - RenameRules + - General + /healthz: get: - deprecated: true - description: Returns a specific renameRule - parameters: - - description: ID formatted as string - in: path - name: id - required: true - type: string + description: Returns the application health and, if not healthy, an error produces: - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.RenameRuleResponse' - "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: Get renameRule - tags: - - RenameRules - options: - deprecated: true - 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: - - RenameRules - patch: - consumes: - - application/json - deprecated: true - description: Update an renameRule. Only values to be updated need to be specified. - parameters: - - description: ID formatted as string - in: path - name: id - required: true - type: string - - description: RenameRule - in: body - name: renameRule - required: true - schema: - $ref: '#/definitions/models.MatchRuleCreate' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/controllers.RenameRuleResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "404": - description: Not Found "500": description: Internal Server Error schema: $ref: '#/definitions/httperrors.HTTPError' - summary: Update renameRule + summary: Get health tags: - - RenameRules - /v2/transactions: + - General options: - deprecated: true description: Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs responses: @@ -5229,50 +1400,7 @@ paths: description: No Content summary: Allowed HTTP verbs tags: - - Transactions - post: - deprecated: true - 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/models.TransactionCreate' - type: array - produces: - - application/json - responses: - "201": - description: Created - schema: - items: - $ref: '#/definitions/controllers.ResponseTransactionV2' - type: array - "400": - description: Bad Request - schema: - $ref: '#/definitions/httperrors.HTTPError' - "404": - description: Not Found - schema: - items: - $ref: '#/definitions/controllers.ResponseTransactionV2' - type: array - "500": - description: Internal Server Error - schema: - items: - $ref: '#/definitions/controllers.ResponseTransactionV2' - type: array - summary: Create transactions - tags: - - Transactions + - General /v3: delete: description: Permanently deletes all resources diff --git a/pkg/controllers/account_v1.go b/pkg/controllers/account_v1.go deleted file mode 100644 index 7e709499..00000000 --- a/pkg/controllers/account_v1.go +++ /dev/null @@ -1,368 +0,0 @@ -package controllers - -import ( - "fmt" - "net/http" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/shopspring/decimal" -) - -// Account is the API v1 representation of an Account in EZ. -type Account struct { - models.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 - RecentEnvelopes []models.Envelope `json:"recentEnvelopes"` // Envelopes recently used with this account - - Links struct { - Self string `json:"self" example:"https://example.com/api/v1/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2"` // The account itself - Transactions string `json:"transactions" example:"https://example.com/api/v1/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2"` // Transactions referencing the account - } `json:"links"` -} - -// links generates the HATEOAS links for the Account. -func (a *Account) links(c *gin.Context) { - url := c.GetString(string(database.ContextURL)) - a.Links.Self = fmt.Sprintf("%s/v1/accounts/%s", url, a.ID) - a.Links.Transactions = fmt.Sprintf("%s/v1/transactions?account=%s", url, a.ID) -} - -type AccountListResponse struct { - Data []Account `json:"data"` // List of accounts -} - -type AccountResponse struct { - Data Account `json:"data"` // Data for the account -} - -func (co Controller) getAccount(c *gin.Context, id uuid.UUID) (Account, bool) { - accountModel, ok := getResourceByIDAndHandleErrors[models.Account](c, co, id) - - account := Account{ - Account: accountModel, - } - - if !ok { - return Account{}, false - } - - // Recent Envelopes - envelopeIDs, err := accountModel.RecentEnvelopes(co.DB) - if err != nil { - httperrors.Handler(c, err) - return Account{}, false - } - - envelopes := make([]models.Envelope, 0) - for _, id := range envelopeIDs { - // If the ID is nil, append the zero Envelope - if id == nil { - envelopes = append(envelopes, models.Envelope{}) - continue - } - - envelope, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, *id) - if !ok { - return Account{}, false - } - envelopes = append(envelopes, envelope) - } - - account.RecentEnvelopes = envelopes - - // Balance - balance, _, err := accountModel.GetBalanceMonth(co.DB, types.Month{}) - if err != nil { - httperrors.Handler(c, err) - return Account{}, false - } - account.Balance = balance - - // Reconciled Balance - reconciledBalance, err := accountModel.SumReconciled(co.DB) - if err != nil { - httperrors.Handler(c, err) - return Account{}, false - } - account.ReconciledBalance = reconciledBalance - - // Links - account.links(c) - - return account, true -} - -// RegisterAccountRoutes registers the routes for accounts with -// the RouterGroup that is passed. -func (co Controller) RegisterAccountRoutes(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsAccountList) - r.GET("", co.GetAccounts) - r.POST("", co.CreateAccount) - } - - // Account with ID - { - r.OPTIONS("/:id", co.OptionsAccountDetail) - r.GET("/:id", co.GetAccount) - r.PATCH("/:id", co.UpdateAccount) - r.DELETE("/:id", co.DeleteAccount) - } -} - -// OptionsAccountList returns the allowed HTTP verbs -// -// @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 /v1/accounts [options] -// @Deprecated true -func (co Controller) OptionsAccountList(c *gin.Context) { - httputil.OptionsGetPost(c) -} - -// OptionsAccountDetail returns the allowed HTTP verbs -// -// @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 /v1/accounts/{id} [options] -// @Deprecated true -func (co Controller) OptionsAccountDetail(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - _, ok := co.getAccount(c, id) - if !ok { - return - } - httputil.OptionsGetPatchDelete(c) -} - -// CreateAccount creates a new account -// -// @Summary Create account -// @Description Creates a new account -// @Tags Accounts -// @Produce json -// @Success 201 {object} AccountResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param account body models.AccountCreate true "Account" -// @Router /v1/accounts [post] -// @Deprecated true -func (co Controller) CreateAccount(c *gin.Context) { - var accountCreate models.AccountCreate - - if err := httputil.BindDataHandleErrors(c, &accountCreate); err != nil { - return - } - - account := models.Account{ - AccountCreate: accountCreate, - } - - // Check if the budget that the account shoud belong to exists - _, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, account.BudgetID) - if !ok { - return - } - - if !queryAndHandleErrors(c, co.DB.Create(&account)) { - return - } - - accountObject, ok := co.getAccount(c, account.ID) - if !ok { - return - } - c.JSON(http.StatusCreated, AccountResponse{Data: accountObject}) -} - -// GetAccounts returns a list of all accounts matching the filter parameters -// -// @Summary List accounts -// @Description Returns a list of accounts -// @Tags Accounts -// @Produce json -// @Success 200 {object} AccountListResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Router /v1/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 hidden query bool false "Is the account hidden?" -// @Param search query string false "Search for this text in name and note" -// @Deprecated true -func (co Controller) 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 - create, ok := filter.ToCreate(c) - if !ok { - return - } - - query := co.DB.Where(&models.Account{ - AccountCreate: create, - }, queryFields...) - - query = stringFilters(co.DB, query, setFields, filter.Name, filter.Note, filter.Search) - - var accounts []models.Account - if !queryAndHandleErrors(c, query.Find(&accounts)) { - 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 - accountObjects := make([]Account, 0) - - for _, account := range accounts { - o, ok := co.getAccount(c, account.ID) - if !ok { - return - } - - accountObjects = append(accountObjects, o) - } - - c.JSON(http.StatusOK, AccountListResponse{Data: accountObjects}) -} - -// GetAccount returns data for a specific account -// -// @Summary Get account -// @Description Returns a specific account -// @Tags Accounts -// @Produce json -// @Success 200 {object} AccountResponse -// @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 /v1/accounts/{id} [get] -// @Deprecated true -func (co Controller) GetAccount(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - accountObject, ok := co.getAccount(c, id) - if !ok { - return - } - - c.JSON(http.StatusOK, AccountResponse{Data: accountObject}) -} - -// UpdateAccount updates data for a specific account -// -// @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} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID formatted as string" -// @Param account body models.AccountCreate true "Account" -// @Router /v1/accounts/{id} [patch] -// @Deprecated true -func (co Controller) UpdateAccount(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - account, ok := getResourceByIDAndHandleErrors[models.Account](c, co, id) - if !ok { - return - } - - updateFields, err := httputil.GetBodyFieldsHandleErrors(c, models.AccountCreate{}) - if err != nil { - return - } - - var data models.Account - if err := httputil.BindDataHandleErrors(c, &data.AccountCreate); err != nil { - return - } - - if !queryAndHandleErrors(c, co.DB.Model(&account).Select("", updateFields...).Updates(data)) { - return - } - - accountObject, ok := co.getAccount(c, account.ID) - if !ok { - return - } - c.JSON(http.StatusOK, AccountResponse{Data: accountObject}) -} - -// DeleteAccount deletes an account -// -// @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 /v1/accounts/{id} [delete] -// @Deprecated true -func (co Controller) DeleteAccount(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - account, ok := getResourceByIDAndHandleErrors[models.Account](c, co, id) - - if !ok { - return - } - - if !queryAndHandleErrors(c, co.DB.Delete(&account)) { - return - } - - c.JSON(http.StatusNoContent, gin.H{}) -} diff --git a/pkg/controllers/account_v1_test.go b/pkg/controllers/account_v1_test.go deleted file mode 100644 index 223caa55..00000000 --- a/pkg/controllers/account_v1_test.go +++ /dev/null @@ -1,343 +0,0 @@ -package controllers_test - -import ( - "fmt" - "net/http" - "strings" - "testing" - - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) createTestAccount(c models.AccountCreate, expectedStatus ...int) controllers.AccountResponse { - if c.BudgetID == uuid.Nil { - c.BudgetID = suite.createTestBudget(models.BudgetCreate{Name: "Testing budget"}).Data.ID - } - - // Default to 201 Created as expected status - if len(expectedStatus) == 0 { - expectedStatus = append(expectedStatus, http.StatusCreated) - } - - r := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/accounts", c) - assertHTTPStatus(suite.T(), &r, expectedStatus...) - - var a controllers.AccountResponse - suite.decodeResponse(&r, &a) - - return a -} - -func (suite *TestSuiteStandard) TestAccounts() { - suite.CloseDB() - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusInternalServerError) - assert.Contains(suite.T(), test.DecodeError(suite.T(), recorder.Body.Bytes()), "There is a problem with the database connection") -} - -func (suite *TestSuiteStandard) TestOptionsAccount() { - path := fmt.Sprintf("%s/%s", "http://example.com/v1/accounts", uuid.New()) - recorder := test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNotFound, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, "http://example.com/v1/accounts/NotParseableAsUUID", "") - assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - path = suite.createTestAccount(models.AccountCreate{Name: "TestOptionsAccount"}).Data.Links.Self - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNoContent, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) -} - -func (suite *TestSuiteStandard) TestCreateBrokenAccount() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/accounts", `{ "note": 2 }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestGetAccounts() { - account := suite.createTestAccount(models.AccountCreate{Name: "TestGetAccounts"}) - - var response controllers.AccountListResponse - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - suite.decodeResponse(&recorder, &response) - - assert.Len(suite.T(), response.Data, 1) - assert.Equal(suite.T(), fmt.Sprintf("http://example.com/v1/accounts/%s", account.Data.ID), response.Data[0].Links.Self) - assert.Equal(suite.T(), fmt.Sprintf("http://example.com/v1/transactions?account=%s", account.Data.ID), response.Data[0].Links.Transactions) -} - -func (suite *TestSuiteStandard) TestGetAccountsInvalidQuery() { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts?onBudget=NotABoolean", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts?budget=8593-not-a-uuid", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestGetAccountsFilter() { - b1 := suite.createTestBudget(models.BudgetCreate{}) - b2 := suite.createTestBudget(models.BudgetCreate{}) - - a1 := suite.createTestAccount(models.AccountCreate{ - Name: "Exact Account Match", - Note: "This is a specific note", - BudgetID: b1.Data.ID, - OnBudget: true, - External: false, - }) - - a2 := suite.createTestAccount(models.AccountCreate{ - Name: "External Account Filter", - Note: "This is a specific note", - BudgetID: b2.Data.ID, - OnBudget: true, - External: true, - }) - - a3 := suite.createTestAccount(models.AccountCreate{ - Name: "External Account Filter", - Note: "A different note", - BudgetID: b1.Data.ID, - OnBudget: false, - External: true, - Hidden: true, - }) - - _ = suite.createTestAccount(models.AccountCreate{ - Name: "", - Note: "specific note", - BudgetID: b1.Data.ID, - }) - - _ = suite.createTestAccount(models.AccountCreate{ - Name: "Name only", - Note: "", - BudgetID: b1.Data.ID, - Hidden: true, - }) - - tests := []struct { - name string - query string - len int - }{ - {"Name single", "name=Exact Account Match", 1}, - {"Name multiple", "name=External Account Filter", 2}, - {"Fuzzy name", "name=Account", 3}, - {"Note", "note=A different note", 1}, - {"Fuzzy Note", "note=note", 4}, - {"Empty name with note", "name=¬e=specific", 1}, - {"Empty note with name", "note=&name=Name", 1}, - {"Empty note and name", "note=&name=&onBudget=false", 0}, - {"Budget", fmt.Sprintf("budget=%s", b1.Data.ID), 4}, - {"On budget", "onBudget=true", 1}, - {"Off budget", "onBudget=false", 4}, - {"External", "external=true", 2}, - {"Internal", "external=false", 3}, - {"Not Hidden", "hidden=false", 3}, - {"Hidden", "hidden=true", 2}, - {"Search for 'na", "search=na", 3}, - {"Search for 'fi", "search=fi", 4}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - var re controllers.BudgetListResponse - r := test.Request(suite.controller, t, http.MethodGet, fmt.Sprintf("/v1/accounts?%s", tt.query), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&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")) - }) - } - - for _, r := range []controllers.BudgetResponse{b1, b2} { - test.Request(suite.controller, suite.T(), http.MethodDelete, r.Data.Links.Self, "") - } - - for _, r := range []controllers.AccountResponse{a1, a2, a3} { - test.Request(suite.controller, suite.T(), http.MethodDelete, r.Data.Links.Self, "") - } -} - -func (suite *TestSuiteStandard) TestNoAccountNotFound() { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts/39633f90-3d9f-4b1e-ac24-c341c432a6e3", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestAccountInvalidIDs() { - /* - * GET - */ - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts/-56", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts/notANumber", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts/23", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * PATCH - */ - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/accounts/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/accounts/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * DELETE - */ - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/accounts/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/accounts/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateAccount() { - _ = suite.createTestAccount(models.AccountCreate{Name: "TestCreateAccount"}) -} - -func (suite *TestSuiteStandard) TestCreateAccountNoBudget() { - tests := []struct { - name string - status int - create models.AccountCreate - }{ - { - "No Budget", - http.StatusBadRequest, - models.AccountCreate{}, - }, - { - "Non-existing Budget", - http.StatusNotFound, - models.AccountCreate{BudgetID: uuid.New()}, - }, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - r := test.Request(suite.controller, t, http.MethodPost, "http://example.com/v1/accounts", models.Account{AccountCreate: tt.create}) - assertHTTPStatus(t, &r, tt.status) - }) - } -} - -func (suite *TestSuiteStandard) TestCreateAccountNoBody() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/accounts", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestGetAccount() { - a := suite.createTestAccount(models.AccountCreate{Name: "TestGetAccount"}) - - r := test.Request(suite.controller, suite.T(), http.MethodGet, a.Data.Links.Self, "") - assert.Equal(suite.T(), http.StatusOK, r.Code) -} - -func (suite *TestSuiteStandard) TestGetAccountTransactionsNonExistingAccount() { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts/57372/transactions", "") - assert.Equal(suite.T(), http.StatusNotFound, recorder.Code) -} - -func (suite *TestSuiteStandard) TestUpdateAccount() { - a := suite.createTestAccount(models.AccountCreate{Name: "Original name", OnBudget: true}) - - r := test.Request(suite.controller, suite.T(), http.MethodPatch, a.Data.Links.Self, map[string]any{ - "name": "Updated new account for testing", - "note": "", - "onBudget": false, - }) - assertHTTPStatus(suite.T(), &r, http.StatusOK) - - var u controllers.AccountResponse - suite.decodeResponse(&r, &u) - - assert.Equal(suite.T(), "Updated new account for testing", u.Data.Name) - assert.Equal(suite.T(), "", u.Data.Note) - assert.Equal(suite.T(), false, u.Data.OnBudget) -} - -func (suite *TestSuiteStandard) TestUpdateAccountBrokenJSON() { - a := suite.createTestAccount(models.AccountCreate{ - Name: "TestUpdateAccountBrokenJSON", - Note: "More tests something something", - }) - - r := test.Request(suite.controller, suite.T(), http.MethodPatch, a.Data.Links.Self, `{ "name": 2" }`) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateAccountInvalidType() { - a := suite.createTestAccount(models.AccountCreate{ - Name: "TestUpdateAccountInvalidType", - Note: "More tests something something", - }) - - r := test.Request(suite.controller, suite.T(), http.MethodPatch, a.Data.Links.Self, map[string]any{ - "name": 2, - }) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateAccountInvalidBudgetID() { - a := suite.createTestAccount(models.AccountCreate{ - Name: "TestUpdateAccountInvalidBudgetID", - Note: "More tests something something", - }) - - // Sets the BudgetID to uuid.Nil - r := test.Request(suite.controller, suite.T(), http.MethodPatch, a.Data.Links.Self, models.AccountCreate{}) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateNonExistingAccount() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/accounts/9b81de41-eead-451d-bc6b-31fceedd236c", models.AccountCreate{Name: "This account does not exist"}) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteAccountsAndEmptyList() { - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts", "") - - var l controllers.AccountListResponse - suite.decodeResponse(&r, &l) - - for _, a := range l.Data { - r = test.Request(suite.controller, suite.T(), http.MethodDelete, a.Links.Self, "") - assertHTTPStatus(suite.T(), &r, http.StatusNoContent) - } - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/accounts", "") - suite.decodeResponse(&r, &l) - - // Verify that the account list is an empty list, not null - assert.NotNil(suite.T(), l.Data) - assert.Empty(suite.T(), l.Data) -} - -func (suite *TestSuiteStandard) TestDeleteNonExistingAccount() { - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/accounts/77b70a75-4bb3-4d1d-90cf-5b7a61f452f5", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteAccountWithBody() { - a := suite.createTestAccount(models.AccountCreate{Name: "TestDeleteAccountWithBody"}) - - r := test.Request(suite.controller, suite.T(), http.MethodDelete, a.Data.Links.Self, models.AccountCreate{Name: "Some other account"}) - assertHTTPStatus(suite.T(), &r, http.StatusNoContent) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, a.Data.Links.Self, "") -} diff --git a/pkg/controllers/account_v2.go b/pkg/controllers/account_v2.go deleted file mode 100644 index 620ac79a..00000000 --- a/pkg/controllers/account_v2.go +++ /dev/null @@ -1,157 +0,0 @@ -package controllers - -import ( - "fmt" - "net/http" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/shopspring/decimal" -) - -// AccountV2 is the API v2 representation of an Account in EZ. -type AccountV2 struct { - models.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 - RecentEnvelopes []*uuid.UUID `json:"recentEnvelopes"` // Envelopes recently used with this account - - Links struct { - Self string `json:"self" example:"https://example.com/api/v2/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2"` // The account itself - Transactions string `json:"transactions" example:"https://example.com/api/v2/transactions?account=af892e10-7e0a-4fb8-b1bc-4b6d88401ed2"` // Transactions referencing the account - } `json:"links"` -} - -// links generates the HATEOAS links for the Account. -func (a *AccountV2) links(c *gin.Context) { - url := c.GetString(string(database.ContextURL)) - a.Links.Self = fmt.Sprintf("%s/v2/accounts/%s", url, a.ID) - a.Links.Transactions = fmt.Sprintf("%s/v2/transactions?account=%s", url, a.ID) -} - -func (co Controller) getAccountV2(c *gin.Context, id uuid.UUID) (AccountV2, bool) { - accountModel, ok := getResourceByIDAndHandleErrors[models.Account](c, co, id) - - account := AccountV2{ - Account: accountModel, - } - - if !ok { - return AccountV2{}, false - } - - // Recent Envelopes - ids, err := accountModel.RecentEnvelopes(co.DB) - if err != nil { - httperrors.Handler(c, err) - return AccountV2{}, false - } - - account.RecentEnvelopes = ids - - // Balance - balance, _, err := accountModel.GetBalanceMonth(co.DB, types.Month{}) - if err != nil { - httperrors.Handler(c, err) - return AccountV2{}, false - } - account.Balance = balance - - // Reconciled Balance - reconciledBalance, err := accountModel.SumReconciled(co.DB) - if err != nil { - httperrors.Handler(c, err) - return AccountV2{}, false - } - account.ReconciledBalance = reconciledBalance - - // Links - account.links(c) - - return account, true -} - -// RegisterAccountRoutes registers the routes for accounts with -// the RouterGroup that is passed. -func (co Controller) RegisterAccountRoutesV2(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsAccountListV2) - r.GET("", co.GetAccountsV2) - } -} - -// OptionsAccountList returns the allowed HTTP verbs -// -// @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 /v2/accounts [options] -// @Deprecated true -func (co Controller) OptionsAccountListV2(c *gin.Context) { - httputil.OptionsGet(c) -} - -// GetAccounts returns a list of all accounts matching the filter parameters -// -// @Summary List accounts -// @Description Returns a list of accounts -// @Tags Accounts -// @Produce json -// @Success 200 {object} AccountListResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Router /v2/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 hidden query bool false "Is the account hidden?" -// @Param search query string false "Search for this text in name and note" -// @Deprecated true -func (co Controller) GetAccountsV2(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 - create, ok := filter.ToCreate(c) - if !ok { - return - } - - query := co.DB.Where(&models.Account{ - AccountCreate: create, - }, queryFields...) - - query = stringFilters(co.DB, query, setFields, filter.Name, filter.Note, filter.Search) - - var accounts []models.Account - if !queryAndHandleErrors(c, query.Find(&accounts)) { - 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 - accountObjects := make([]AccountV2, 0) - - for _, account := range accounts { - o, _ := co.getAccountV2(c, account.ID) - accountObjects = append(accountObjects, o) - } - - c.JSON(http.StatusOK, accountObjects) -} diff --git a/pkg/controllers/account_v2_test.go b/pkg/controllers/account_v2_test.go deleted file mode 100644 index ff2c8ab4..00000000 --- a/pkg/controllers/account_v2_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package controllers_test - -import ( - "fmt" - "net/http" - "strings" - "testing" - - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) TestAccountsV2() { - suite.CloseDB() - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v2/accounts", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusInternalServerError) - assert.Contains(suite.T(), test.DecodeError(suite.T(), recorder.Body.Bytes()), "There is a problem with the database connection") -} - -func (suite *TestSuiteStandard) TestGetAccountsV2() { - account := suite.createTestAccount(models.AccountCreate{Name: "TestGetAccounts"}) - - var response []controllers.AccountV2 - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v2/accounts", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - suite.decodeResponse(&recorder, &response) - - assert.Len(suite.T(), response, 1) - assert.Equal(suite.T(), fmt.Sprintf("http://example.com/v2/accounts/%s", account.Data.ID), response[0].Links.Self) - assert.Equal(suite.T(), fmt.Sprintf("http://example.com/v2/transactions?account=%s", account.Data.ID), response[0].Links.Transactions) -} - -func (suite *TestSuiteStandard) TestGetAccountsV2InvalidQuery() { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v2/accounts?onBudget=NotABoolean", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v2/accounts?budget=8593-not-a-uuid", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestGetAccountsV2Filter() { - b1 := suite.createTestBudget(models.BudgetCreate{}) - b2 := suite.createTestBudget(models.BudgetCreate{}) - - a1 := suite.createTestAccount(models.AccountCreate{ - Name: "Exact Account Match", - Note: "This is a specific note", - BudgetID: b1.Data.ID, - OnBudget: true, - External: false, - }) - - a2 := suite.createTestAccount(models.AccountCreate{ - Name: "External Account Filter", - Note: "This is a specific note", - BudgetID: b2.Data.ID, - OnBudget: true, - External: true, - }) - - a3 := suite.createTestAccount(models.AccountCreate{ - Name: "External Account Filter", - Note: "A different note", - BudgetID: b1.Data.ID, - OnBudget: false, - External: true, - Hidden: true, - }) - - _ = suite.createTestAccount(models.AccountCreate{ - Name: "", - Note: "specific note", - BudgetID: b1.Data.ID, - }) - - _ = suite.createTestAccount(models.AccountCreate{ - Name: "Name only", - Note: "", - BudgetID: b1.Data.ID, - Hidden: true, - }) - - tests := []struct { - name string - query string - len int - }{ - {"Name single", "name=Exact Account Match", 1}, - {"Name multiple", "name=External Account Filter", 2}, - {"Fuzzy name", "name=Account", 3}, - {"Note", "note=A different note", 1}, - {"Fuzzy Note", "note=note", 4}, - {"Empty name with note", "name=¬e=specific", 1}, - {"Empty note with name", "note=&name=Name", 1}, - {"Empty note and name", "note=&name=&onBudget=false", 0}, - {"Budget", fmt.Sprintf("budget=%s", b1.Data.ID), 4}, - {"On budget", "onBudget=true", 1}, - {"Off budget", "onBudget=false", 4}, - {"External", "external=true", 2}, - {"Internal", "external=false", 3}, - {"Not Hidden", "hidden=false", 3}, - {"Hidden", "hidden=true", 2}, - {"Search for 'na", "search=na", 3}, - {"Search for 'fi", "search=fi", 4}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - var re []controllers.AccountV2 - r := test.Request(suite.controller, t, http.MethodGet, fmt.Sprintf("/v2/accounts?%s", tt.query), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &re) - - var accountNames []string - for _, d := range re { - accountNames = append(accountNames, d.Name) - } - assert.Equal(t, tt.len, len(re), "Existing accounts: %#v, Request-ID: %s", strings.Join(accountNames, ", "), r.Header().Get("x-request-id")) - }) - } - - for _, r := range []controllers.BudgetResponse{b1, b2} { - test.Request(suite.controller, suite.T(), http.MethodDelete, r.Data.Links.Self, "") - } - - for _, r := range []controllers.AccountResponse{a1, a2, a3} { - test.Request(suite.controller, suite.T(), http.MethodDelete, r.Data.Links.Self, "") - } -} diff --git a/pkg/controllers/account_v3_test.go b/pkg/controllers/account_v3_test.go index 674e8d65..09b7386c 100644 --- a/pkg/controllers/account_v3_test.go +++ b/pkg/controllers/account_v3_test.go @@ -138,8 +138,8 @@ func (suite *TestSuiteStandard) TestAccountsV3GetSingle() { } func (suite *TestSuiteStandard) TestAccountsV3GetFilter() { - b1 := suite.createTestBudget(models.BudgetCreate{}) - b2 := suite.createTestBudget(models.BudgetCreate{}) + b1 := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) + b2 := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) _ = suite.createTestAccountV3(suite.T(), controllers.AccountCreateV3{ Name: "Exact Account Match", diff --git a/pkg/controllers/allocation.go b/pkg/controllers/allocation.go deleted file mode 100644 index e61136a2..00000000 --- a/pkg/controllers/allocation.go +++ /dev/null @@ -1,337 +0,0 @@ -package controllers - -import ( - "fmt" - "net/http" - - "github.com/google/uuid" - "github.com/shopspring/decimal" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" -) - -type Allocation struct { - models.Allocation - Links struct { - Self string `json:"self" example:"https://example.com/api/v1/allocations/902cd93c-3724-4e46-8540-d014131282fc"` // The allocation itself - } `json:"links"` -} - -func (a *Allocation) links(c *gin.Context) { - a.Links.Self = fmt.Sprintf("%s/v1/allocations/%s", c.GetString(string(database.ContextURL)), a.ID) -} - -func (co Controller) getAllocation(c *gin.Context, id uuid.UUID) (Allocation, bool) { - m, ok := getResourceByIDAndHandleErrors[models.Allocation](c, co, id) - if !ok { - return Allocation{}, false - } - - a := Allocation{ - Allocation: m, - } - - a.links(c) - return a, true -} - -type AllocationResponse struct { - Data Allocation `json:"data"` // List of allocations -} - -type AllocationListResponse struct { - Data []Allocation `json:"data"` // Data for the allocation -} - -type AllocationQueryFilter struct { - Month string `form:"month"` // By month - Amount decimal.Decimal `form:"amount"` // By exact amount - EnvelopeID string `form:"envelope"` // By the Envelope ID -} - -func (f AllocationQueryFilter) Parse(c *gin.Context) (models.AllocationCreate, bool) { - envelopeID, ok := httputil.UUIDFromStringHandleErrors(c, f.EnvelopeID) - if !ok { - return models.AllocationCreate{}, false - } - - var month QueryMonth - if err := c.Bind(&month); err != nil { - httperrors.Handler(c, err) - return models.AllocationCreate{}, false - } - - return models.AllocationCreate{ - Month: types.MonthOf(month.Month), - Amount: f.Amount, - EnvelopeID: envelopeID, - }, true -} - -// RegisterAllocationRoutes registers the routes for allocations with -// the RouterGroup that is passed. -func (co Controller) RegisterAllocationRoutes(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsAllocationList) - r.GET("", co.GetAllocations) - r.POST("", co.CreateAllocation) - } - - // Transaction with ID - { - r.OPTIONS("/:id", co.OptionsAllocationDetail) - r.GET("/:id", co.GetAllocation) - r.PATCH("/:id", co.UpdateAllocation) - r.DELETE("/:id", co.DeleteAllocation) - } -} - -// OptionsAllocationList returns the allowed HTTP verbs -// -// @Summary Allowed HTTP verbs -// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs -// @Tags Allocations -// @Success 204 -// @Router /v1/allocations [options] -// @Deprecated true -func (co Controller) OptionsAllocationList(c *gin.Context) { - httputil.OptionsGetPost(c) -} - -// OptionsAllocationDetail returns the allowed HTTP verbs -// -// @Summary Allowed HTTP verbs -// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs -// @Tags Allocations -// @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 /v1/allocations/{id} [options] -// @Deprecated true -func (co Controller) OptionsAllocationDetail(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - _, ok := getResourceByIDAndHandleErrors[models.Allocation](c, co, id) - if !ok { - return - } - httputil.OptionsGetPatchDelete(c) -} - -// CreateAllocation creates a new allocation -// -// @Summary Create allocations -// @Description Create a new allocation of funds to an envelope for a specific month -// @Tags Allocations -// @Produce json -// @Success 201 {object} AllocationResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param allocation body models.AllocationCreate true "Allocation" -// @Router /v1/allocations [post] -// @Deprecated true -func (co Controller) CreateAllocation(c *gin.Context) { - var create models.AllocationCreate - - err := httputil.BindDataHandleErrors(c, &create) - if err != nil { - return - } - - a := models.Allocation{ - AllocationCreate: create, - } - - _, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, a.EnvelopeID) - if !ok { - return - } - - if !queryAndHandleErrors(c, co.DB.Create(&a)) { - return - } - - o, ok := co.getAllocation(c, a.ID) - if !ok { - return - } - - c.JSON(http.StatusCreated, AllocationResponse{Data: o}) -} - -// GetAllocations returns a list of allocations matching the search parameters -// -// @Summary Get allocations -// @Description Returns a list of allocations -// @Tags Allocations -// @Produce json -// @Success 200 {object} AllocationListResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Router /v1/allocations [get] -// @Param month query string false "Filter by month" -// @Param amount query string false "Filter by amount" -// @Param envelope query string false "Filter by envelope ID" -// @Deprecated true -func (co Controller) GetAllocations(c *gin.Context) { - var filter AllocationQueryFilter - if err := c.Bind(&filter); err != nil { - httperrors.InvalidQueryString(c) - return - } - - // Get the parameters set in the query string - queryFields, _ := httputil.GetURLFields(c.Request.URL, filter) - - // Convert the QueryFilter to a Create struct - create, ok := filter.Parse(c) - if !ok { - return - } - - var allocations []models.Allocation - if !queryAndHandleErrors(c, co.DB.Where(&models.Allocation{ - AllocationCreate: create, - }, queryFields...).Find(&allocations)) { - return - } - - s := make([]Allocation, 0) - for _, allocation := range allocations { - a, ok := co.getAllocation(c, allocation.ID) - if !ok { - return - } - - s = append(s, a) - } - - c.JSON(http.StatusOK, AllocationListResponse{Data: s}) -} - -// GetAllocation returns data about a specific allocation -// -// @Summary Get allocation -// @Description Returns a specific allocation -// @Tags Allocations -// @Produce json -// @Success 200 {object} AllocationResponse -// @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 /v1/allocations/{id} [get] -// @Deprecated true -func (co Controller) GetAllocation(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - allocation, ok := getResourceByIDAndHandleErrors[models.Allocation](c, co, id) - if !ok { - return - } - - a, ok := co.getAllocation(c, allocation.ID) - if !ok { - return - } - - c.JSON(http.StatusOK, AllocationResponse{Data: a}) -} - -// UpdateAllocation updates allocation data -// -// @Summary Update allocation -// @Description Update an allocation. Only values to be updated need to be specified. -// @Tags Allocations -// @Accept json -// @Produce json -// @Success 200 {object} AllocationResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID formatted as string" -// @Param allocation body models.AllocationCreate true "Allocation" -// @Router /v1/allocations/{id} [patch] -// @Deprecated true -func (co Controller) UpdateAllocation(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - allocation, ok := getResourceByIDAndHandleErrors[models.Allocation](c, co, id) - if !ok { - return - } - - updateFields, err := httputil.GetBodyFieldsHandleErrors(c, models.AllocationCreate{}) - if err != nil { - return - } - - var data models.Allocation - if err := httputil.BindDataHandleErrors(c, &data.AllocationCreate); err != nil { - return - } - - if !queryAndHandleErrors(c, co.DB.Model(&allocation).Select("", updateFields...).Updates(data)) { - return - } - - a, ok := co.getAllocation(c, allocation.ID) - if !ok { - return - } - - c.JSON(http.StatusOK, AllocationResponse{Data: a}) -} - -// DeleteAllocation deletes an allocation -// -// @Summary Delete allocation -// @Description Deletes an allocation -// @Tags Allocations -// @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 /v1/allocations/{id} [delete] -// @Deprecated true -func (co Controller) DeleteAllocation(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - allocation, ok := getResourceByIDAndHandleErrors[models.Allocation](c, co, id) - if !ok { - return - } - - // Allocations are hard deleted instantly to avoid conflicts for the UNIQUE(id,month) - if !queryAndHandleErrors(c, co.DB.Unscoped().Delete(&allocation)) { - return - } - - c.JSON(http.StatusNoContent, nil) -} diff --git a/pkg/controllers/allocation_test.go b/pkg/controllers/allocation_test.go deleted file mode 100644 index 41a48f84..00000000 --- a/pkg/controllers/allocation_test.go +++ /dev/null @@ -1,334 +0,0 @@ -package controllers_test - -import ( - "fmt" - "math/rand" - "net/http" - "testing" - "time" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/google/uuid" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) createTestAllocation(c models.AllocationCreate, expectedStatus ...int) controllers.AllocationResponse { - if c.EnvelopeID == uuid.Nil { - c.EnvelopeID = suite.createTestEnvelope(models.EnvelopeCreate{Name: "Transaction Test Envelope"}).Data.ID - } - - // If no amount is set, set a random one - if c.Amount.IsZero() { - c.Amount = decimal.NewFromFloat(float64(rand.Intn(100000)) / 100.0) - } - - // Default to 200 OK as expected status - if len(expectedStatus) == 0 { - expectedStatus = append(expectedStatus, http.StatusCreated) - } - - r := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/allocations", c) - assertHTTPStatus(suite.T(), &r, expectedStatus...) - - var a controllers.AllocationResponse - suite.decodeResponse(&r, &a) - - return a -} - -func (suite *TestSuiteStandard) TestAllocations() { - suite.CloseDB() - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/allocations", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusInternalServerError) - assert.Contains(suite.T(), test.DecodeError(suite.T(), recorder.Body.Bytes()), "There is a problem with the database connection") -} - -func (suite *TestSuiteStandard) TestOptionsAllocation() { - path := fmt.Sprintf("%s/%s", "http://example.com/v1/allocations", uuid.New()) - recorder := test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNotFound, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, "http://example.com/v1/allocations/NotParseableAsUUID", "") - assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - path = suite.createTestAllocation(models.AllocationCreate{Month: types.NewMonth(2022, 2)}).Data.Links.Self - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNoContent, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) -} - -func (suite *TestSuiteStandard) TestGetAllocations() { - _ = suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(20.99), - }) - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/allocations", "") - - var response controllers.AllocationListResponse - suite.decodeResponse(&recorder, &response) - - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - assert.Len(suite.T(), response.Data, 1) - assert.Equal(suite.T(), types.NewMonth(2022, 1), response.Data[0].Month) - - if !decimal.NewFromFloat(20.99).Equal(response.Data[0].Amount) { - assert.Fail(suite.T(), "Allocation amount does not equal 20.99", response.Data[0].Amount) - } - - assert.LessOrEqual(suite.T(), time.Since(response.Data[0].CreatedAt), tolerance) - assert.LessOrEqual(suite.T(), time.Since(response.Data[0].UpdatedAt), tolerance) -} - -func (suite *TestSuiteStandard) TestGetAllocationsFilter() { - e1 := suite.createTestEnvelope(models.EnvelopeCreate{}) - e2 := suite.createTestEnvelope(models.EnvelopeCreate{}) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: e1.Data.ID, - Month: types.NewMonth(2018, 9), - Amount: decimal.NewFromFloat(314.1592), - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: e1.Data.ID, - Month: types.NewMonth(2018, 10), - Amount: decimal.NewFromFloat(1371), - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: e2.Data.ID, - Month: types.NewMonth(2018, 9), - Amount: decimal.NewFromFloat(1204), - }) - - tests := []struct { - name string - query string - len int - }{ - {"Envelope 1", fmt.Sprintf("envelope=%s", e1.Data.ID), 2}, - {"Envelope Not Existing", "envelope=f1411c94-0ec6-417a-bb00-9e51d3c1c6e0", 0}, - {"Amount", "amount=1204", 1}, - {"Month", fmt.Sprintf("month=%s", types.NewMonth(2018, 9)), 2}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - var re controllers.AllocationListResponse - r := test.Request(suite.controller, t, http.MethodGet, fmt.Sprintf("/v1/allocations?%s", tt.query), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &re) - - assert.Equal(t, tt.len, len(re.Data), "Request ID: %s", r.Result().Header.Get("x-request-id")) - }) - } -} - -func (suite *TestSuiteStandard) TestNoAllocationNotFound() { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/allocations/f8b93ce2-309f-4e99-8886-6ab960df99c3", "") - - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestGetAllocationsInvalidQuery() { - tests := []string{ - "month=2022 Test Month", - "amount=The cake is a lie", - "envelope=NotAUUID", - } - - for _, tt := range tests { - suite.T().Run(tt, func(t *testing.T) { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v1/allocations?%s", tt), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - }) - } -} - -func (suite *TestSuiteStandard) TestAllocationInvalidIDs() { - /* - * GET - */ - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/allocations/-56", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/allocations/notANumber", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/allocations/23", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * PATCH - */ - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/allocations/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/allocations/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * DELETE - */ - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/allocations/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/allocations/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateAllocation() { - a := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 10), - Amount: decimal.NewFromFloat(15.42), - }) - - if !decimal.NewFromFloat(15.42).Equal(a.Data.Amount) { - assert.Fail(suite.T(), "Allocation amount does not equal 15.42", a.Data.Amount) - } -} - -func (suite *TestSuiteStandard) TestCreateAllocationNoEnvelope() { - r := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/allocations", models.Allocation{}) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateBrokenAllocation() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/allocations", `{ "createdAt": "New Allocation" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateAllocationNonExistingEnvelope() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/allocations", models.AllocationCreate{EnvelopeID: uuid.New()}) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestCreateDuplicateAllocation() { - allocation := suite.createTestAllocation(models.AllocationCreate{Month: types.NewMonth(2022, 2)}) - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/allocations", models.AllocationCreate{ - EnvelopeID: allocation.Data.EnvelopeID, - Month: allocation.Data.Month, - }) - - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateNonDuplicateAllocationSameMonth() { - e1 := suite.createTestEnvelope(models.EnvelopeCreate{}) - e2 := suite.createTestEnvelope(models.EnvelopeCreate{}) - - _ = suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 2), - EnvelopeID: e1.Data.ID, - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 2), - EnvelopeID: e2.Data.ID, - }) -} - -func (suite *TestSuiteStandard) TestCreateAllocationNoBody() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/allocations", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestGetAllocation() { - a := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 8), - }) - - r := test.Request(suite.controller, suite.T(), http.MethodGet, a.Data.Links.Self, "") - assert.Equal(suite.T(), http.StatusOK, r.Code) -} - -func (suite *TestSuiteStandard) TestUpdateAllocation() { - a := suite.createTestAllocation(models.AllocationCreate{Month: types.NewMonth(2100, 6)}) - - r := test.Request(suite.controller, suite.T(), http.MethodPatch, a.Data.Links.Self, map[string]any{ - "month": types.NewMonth(2022, 6), - }) - assertHTTPStatus(suite.T(), &r, http.StatusOK) - - var updatedAllocation controllers.AllocationResponse - suite.decodeResponse(&r, &updatedAllocation) - - assert.Equal(suite.T(), 2022, time.Time(updatedAllocation.Data.Month).Year()) -} - -func (suite *TestSuiteStandard) TestUpdateAllocationZeroValues() { - a := suite.createTestAllocation(models.AllocationCreate{Month: types.NewMonth(2100, 8)}) - - r := test.Request(suite.controller, suite.T(), http.MethodPatch, a.Data.Links.Self, map[string]any{ - "month": types.NewMonth(0, 8), - }) - assertHTTPStatus(suite.T(), &r, http.StatusOK) - - var updatedAllocation controllers.AllocationResponse - suite.decodeResponse(&r, &updatedAllocation) - - assert.Equal(suite.T(), 0, time.Time(updatedAllocation.Data.Month).Year(), "Year is not updated correctly") -} - -func (suite *TestSuiteStandard) TestUpdateAllocationBrokenJSON() { - a := suite.createTestAllocation(models.AllocationCreate{Month: types.NewMonth(2054, 5)}) - - r := test.Request(suite.controller, suite.T(), http.MethodPatch, a.Data.Links.Self, `{ "name": 2" }`) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateAllocationInvalidType() { - a := suite.createTestAllocation(models.AllocationCreate{Month: types.NewMonth(2062, 3)}) - - r := test.Request(suite.controller, suite.T(), http.MethodPatch, a.Data.Links.Self, map[string]any{ - "month": "A long time ago in a galaxy far, far away", - }) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateAllocationInvalidEnvelopeID() { - a := suite.createTestAllocation(models.AllocationCreate{Month: types.NewMonth(2099, 11)}) - - // Sets the EnvelopeID to uuid.Nil by not specifying it - r := test.Request(suite.controller, suite.T(), http.MethodPatch, a.Data.Links.Self, models.AllocationCreate{Month: types.NewMonth(2099, 11)}) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateNonExistingAllocation() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/allocations/df684988-31df-444c-8aaa-b53195d55d6e", models.AllocationCreate{Month: types.NewMonth(2142, 3)}) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteAllocation() { - e := suite.createTestEnvelope(models.EnvelopeCreate{}) - a := suite.createTestAllocation(models.AllocationCreate{Month: types.NewMonth(2058, 7), EnvelopeID: e.Data.ID}) - r := test.Request(suite.controller, suite.T(), http.MethodDelete, a.Data.Links.Self, "") - - assertHTTPStatus(suite.T(), &r, http.StatusNoContent) - - // Regression Test: Verify that allocations are hard deleted instantly to avoid problems - // with the UNIQUE(id,month) - _ = suite.createTestAllocation(models.AllocationCreate{Month: types.NewMonth(2058, 7), EnvelopeID: e.Data.ID}) -} - -func (suite *TestSuiteStandard) TestDeleteNonExistingAllocation() { - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/allocations/34ac51a7-431c-454b-ba29-feaefeae70d5", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteAllocationWithBody() { - a := suite.createTestAllocation(models.AllocationCreate{Month: types.NewMonth(2067, 3)}) - - r := test.Request(suite.controller, suite.T(), http.MethodDelete, a.Data.Links.Self, models.AllocationCreate{Month: types.NewMonth(2067, 3)}) - assertHTTPStatus(suite.T(), &r, http.StatusNoContent) -} - -func (suite *TestSuiteStandard) TestDeleteNullAllocation() { - r := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/allocations/00000000-0000-0000-0000-000000000000", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} diff --git a/pkg/controllers/budget_v1.go b/pkg/controllers/budget_v1.go deleted file mode 100644 index 3bddc663..00000000 --- a/pkg/controllers/budget_v1.go +++ /dev/null @@ -1,682 +0,0 @@ -package controllers - -import ( - "fmt" - "net/http" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/shopspring/decimal" -) - -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 -} - -// Budget is the API v1 representation of a Budget. -type Budget struct { - models.Budget - Balance decimal.Decimal `json:"balance" example:"3423.42"` // DEPRECATED. Will be removed in API v2, see https://github.com/envelope-zero/backend/issues/526. - Links struct { - Self string `json:"self" example:"https://example.com/api/v1/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // The budget itself - Accounts string `json:"accounts" example:"https://example.com/api/v1/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Accounts for this budget - Categories string `json:"categories" example:"https://example.com/api/v1/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Categories for this budget - Envelopes string `json:"envelopes" example:"https://example.com/api/v1/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Envelopes for this budget - Transactions string `json:"transactions" example:"https://example.com/api/v1/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Transactions for this budget - Month string `json:"month" example:"https://example.com/api/v1/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf/YYYY-MM"` // This uses 'YYYY-MM' for clients to replace with the actual year and month. - GroupedMonth string `json:"groupedMonth" example:"https://example.com/api/v1/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf&month=YYYY-MM"` // This uses 'YYYY-MM' for clients to replace with the actual year and month. - MonthAllocations string `json:"monthAllocations" example:"https://example.com/api/v1/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf&month=YYYY-MM"` // This uses 'YYYY-MM' for clients to replace with the actual year and month. - } `json:"links"` -} - -// links sets all links for the Budget. -func (b *Budget) links(c *gin.Context) { - url := c.GetString(string(database.ContextURL)) - - b.Links.Self = fmt.Sprintf("%s/v1/budgets/%s", url, b.ID) - b.Links.Month = b.Links.Self + "/YYYY-MM" - b.Links.Accounts = fmt.Sprintf("%s/v1/accounts?budget=%s", url, b.ID) - b.Links.Categories = fmt.Sprintf("%s/v1/categories?budget=%s", url, b.ID) - b.Links.Envelopes = fmt.Sprintf("%s/v1/envelopes?budget=%s", url, b.ID) - b.Links.Transactions = fmt.Sprintf("%s/v1/transactions?budget=%s", url, b.ID) - b.Links.GroupedMonth = fmt.Sprintf("%s/v1/months?budget=%s&month=YYYY-MM", url, b.ID) - b.Links.MonthAllocations = fmt.Sprintf("%s/v1/months?budget=%s&month=YYYY-MM", url, b.ID) -} - -// getBudget returns a budget with all fields set. -func (co Controller) getBudget(c *gin.Context, id uuid.UUID) (Budget, bool) { - m, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, id) - if !ok { - return Budget{}, false - } - - b := Budget{ - Budget: m, - } - - balance, err := m.Balance(co.DB) - if err != nil { - httperrors.Handler(c, err) - return Budget{}, false - } - b.Balance = balance - b.links(c) - - return b, true -} - -type BudgetListResponse struct { - Data []Budget `json:"data"` // List of budgets -} - -type BudgetResponse struct { - Data Budget `json:"data"` // Data for the budget -} - -type BudgetMonthResponse struct { - Data models.BudgetMonth `json:"data"` // Data for the budget's month -} - -// RegisterBudgetRoutes registers the routes for budgets with -// the RouterGroup that is passed. -func (co Controller) RegisterBudgetRoutes(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsBudgetList) - r.GET("", co.GetBudgets) - r.POST("", co.CreateBudget) - } - - // Budget with ID - { - r.OPTIONS("/:id", co.OptionsBudgetDetail) - r.GET("/:id", co.GetBudget) - r.OPTIONS("/:id/:month", co.OptionsBudgetMonth) - r.GET("/:id/:month", co.GetBudgetMonth) - r.OPTIONS("/:id/:month/allocations", co.OptionsBudgetMonthAllocations) - r.POST("/:id/:month/allocations", co.SetAllocationsMonth) - r.DELETE("/:id/:month/allocations", co.DeleteAllocationsMonth) - r.PATCH("/:id", co.UpdateBudget) - r.DELETE("/:id", co.DeleteBudget) - } -} - -// OptionsBudgetList 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 Budgets -// @Success 204 -// @Router /v1/budgets [options] -// @Deprecated true -func (co Controller) OptionsBudgetList(c *gin.Context) { - httputil.OptionsGetPost(c) -} - -// OptionsBudgetDetail 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 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 /v1/budgets/{id} [options] -// @Deprecated true -func (co Controller) OptionsBudgetDetail(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - _, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, id) - if !ok { - return - } - httputil.OptionsGetPatchDelete(c) -} - -// OptionsBudgetMonth 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. **Use OPTIONS /month endpoint with month and budgetId query parameters instead.** -// @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" -// @Param month path string true "The month in YYYY-MM format" -// @Router /v1/budgets/{id}/{month} [options] -// @Deprecated true -func (co Controller) OptionsBudgetMonth(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - _, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, id) - if !ok { - return - } - httputil.OptionsGet(c) -} - -// OptionsBudgetMonthAllocations 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. **Use OPTIONS /month endpoint with month and budgetId query parameters instead.** -// @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" -// @Param month path string true "The month in YYYY-MM format" -// @Router /v1/budgets/{id}/{month}/allocations [options] -// @Deprecated true -func (co Controller) OptionsBudgetMonthAllocations(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - _, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, id) - if !ok { - return - } - httputil.OptionsDelete(c) -} - -// CreateBudget creates a new budget -// -// @Summary Create budget -// @Description Creates a new budget -// @Tags Budgets -// @Accept json -// @Produce json -// @Success 201 {object} BudgetResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param budget body models.BudgetCreate true "Budget" -// @Router /v1/budgets [post] -// @Deprecated true -func (co Controller) CreateBudget(c *gin.Context) { - var bCreate models.BudgetCreate - - if err := httputil.BindDataHandleErrors(c, &bCreate); err != nil { - return - } - - budget := models.Budget{ - BudgetCreate: bCreate, - } - - if !queryAndHandleErrors(c, co.DB.Create(&budget)) { - return - } - - b, ok := co.getBudget(c, budget.ID) - if !ok { - return - } - - c.JSON(http.StatusCreated, BudgetResponse{Data: b}) -} - -// GetBudgets returns data for all budgets filtered by the query parameters -// -// @Summary List budgets -// @Description Returns a list of budgets -// @Tags Budgets -// @Produce json -// @Success 200 {object} BudgetListResponse -// @Failure 500 {object} httperrors.HTTPError -// @Router /v1/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" -// @Deprecated true -func (co Controller) 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 - - query := co.DB.Where(&models.Budget{ - BudgetCreate: models.BudgetCreate{ - Name: filter.Name, - Note: filter.Note, - Currency: filter.Currency, - }, - }, queryFields...) - - query = stringFilters(co.DB, query, setFields, filter.Name, filter.Note, filter.Search) - - if !queryAndHandleErrors(c, query.Find(&budgets)) { - return - } - - budgetResources := make([]Budget, 0) - for _, budget := range budgets { - r, ok := co.getBudget(c, budget.ID) - if !ok { - return - } - budgetResources = append(budgetResources, r) - } - - c.JSON(http.StatusOK, BudgetListResponse{Data: budgetResources}) -} - -// GetBudget returns data for a single budget -// -// @Summary Get budget -// @Description Returns a specific budget -// @Tags Budgets -// @Produce json -// @Success 200 {object} BudgetResponse -// @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 /v1/budgets/{id} [get] -// @Deprecated true -func (co Controller) GetBudget(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - m, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, id) - if !ok { - return - } - - r, ok := co.getBudget(c, m.ID) - if !ok { - return - } - - c.JSON(http.StatusOK, BudgetResponse{Data: r}) -} - -// GetBudgetMonth returns data for a month for a specific budget -// -// @Summary Get Budget month data -// @Description Returns data about a budget for a for a specific month. **Use GET /month endpoint with month and budgetId query parameters instead.** -// @Tags Budgets -// @Produce json -// @Success 200 {object} BudgetMonthResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID formatted as string" -// @Param month path string true "The month in YYYY-MM format" -// @Router /v1/budgets/{id}/{month} [get] -// @Deprecated true -func (co Controller) GetBudgetMonth(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - budget, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, id) - if !ok { - return - } - - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - if month.Month.IsZero() { - httperrors.New(c, http.StatusBadRequest, "You cannot request data for no month") - return - } - - // Get all categories for the budget - var categories []models.Category - - if !queryAndHandleErrors(c, co.DB.Where(&models.Category{ - CategoryCreate: models.CategoryCreate{ - BudgetID: budget.ID, - }, - }).Find(&categories)) { - return - } - - ids := make([]uuid.UUID, 0) - for _, cat := range categories { - ids = append(ids, cat.ID) - } - - // Get envelopes for all categories - var envelopes []models.Envelope - for _, id := range ids { - var e []models.Envelope - - if !queryAndHandleErrors(c, co.DB.Where(&models.Envelope{ - EnvelopeCreate: models.EnvelopeCreate{ - CategoryID: id, - }, - }).Find(&e)) { - return - } - - envelopes = append(envelopes, e...) - } - - var envelopeMonths []models.EnvelopeMonth - for _, envelope := range envelopes { - envelopeMonth, _, err := envelope.Month(co.DB, types.MonthOf(month.Month)) - if err != nil { - httperrors.Handler(c, err) - return - } - envelopeMonths = append(envelopeMonths, envelopeMonth) - } - - // Get all allocations for all Envelopes for the month - var allocations []models.Allocation - for _, envelope := range envelopes { - var a models.Allocation - - if !queryAndHandleErrors(c, co.DB.Where(&models.Allocation{ - AllocationCreate: models.AllocationCreate{ - EnvelopeID: envelope.ID, - Month: types.MonthOf(month.Month), - }, - }).Find(&a)) { - return - } - - allocations = append(allocations, a) - } - - // Calculate the budgeted sum - var budgeted decimal.Decimal - for _, allocation := range allocations { - budgeted = budgeted.Add(allocation.Amount) - } - - // Calculate the income - income, err := budget.Income(co.DB, types.MonthOf(month.Month)) - if err != nil { - httperrors.Handler(c, err) - return - } - - // Get the available sum for budgeting - bMonth, err := budget.Month(co.DB, types.MonthOf(month.Month)) - if err != nil { - httperrors.Handler(c, err) - return - } - - c.JSON(http.StatusOK, BudgetMonthResponse{Data: models.BudgetMonth{ - ID: budget.ID, - Name: budget.Name, - Month: types.MonthOf(month.Month), - Income: income, - Budgeted: budgeted, - Envelopes: envelopeMonths, - Available: bMonth.Available, - }}) -} - -// UpdateBudget updates data for a budget -// -// @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} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID formatted as string" -// @Param budget body models.BudgetCreate true "Budget" -// @Router /v1/budgets/{id} [patch] -// @Deprecated true -func (co Controller) UpdateBudget(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - budget, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, id) - if !ok { - return - } - - updateFields, err := httputil.GetBodyFieldsHandleErrors(c, models.BudgetCreate{}) - if err != nil { - return - } - - var data models.Budget - if err := httputil.BindDataHandleErrors(c, &data.BudgetCreate); err != nil { - return - } - - if !queryAndHandleErrors(c, co.DB.Model(&budget).Select("", updateFields...).Updates(data)) { - return - } - - r, ok := co.getBudget(c, budget.ID) - if !ok { - return - } - - c.JSON(http.StatusOK, BudgetResponse{Data: r}) -} - -// Do stuff -// -// @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 /v1/budgets/{id} [delete] -// @Deprecated true -func (co Controller) DeleteBudget(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - budget, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, id) - if !ok { - return - } - - if !queryAndHandleErrors(c, co.DB.Delete(&budget)) { - return - } - - c.JSON(http.StatusNoContent, gin.H{}) -} - -// DeleteAllocationsMonth deletes all allocations for a specific month -// -// @Summary Delete allocations for a month -// @Description Deletes all allocation for the specified month. **Use DELETE /month endpoint with month and budgetId query parameters instead.** -// @Tags Budgets -// @Success 204 -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param month path string true "The month in YYYY-MM format" -// @Param id path string true "Budget ID formatted as string" -// @Router /v1/budgets/{id}/{month}/allocations [delete] -// @Deprecated true -func (co Controller) DeleteAllocationsMonth(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - // If the budget does not exist, abort the request - _, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, id) - if !ok { - return - } - - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - // We query for all allocations here - var allocations []models.Allocation - - if !queryAndHandleErrors(c, co.DB. - Joins("JOIN envelopes ON envelopes.id = allocations.envelope_id"). - Joins("JOIN categories ON categories.id = envelopes.category_id"). - Joins("JOIN budgets on budgets.id = categories.budget_id"). - Where(models.Allocation{AllocationCreate: models.AllocationCreate{Month: types.MonthOf(month.Month)}}). - Where("budgets.id = ?", id). - Find(&allocations)) { - return - } - - for _, allocation := range allocations { - if !queryAndHandleErrors(c, co.DB.Unscoped().Delete(&allocation)) { - return - } - } - - c.JSON(http.StatusNoContent, gin.H{}) -} - -// SetAllocationsMonth sets all allocations for a specific month -// -// @Summary Set allocations for a month -// @Description Sets allocations for a month for all envelopes that do not have an allocation yet. **Deprecated. Use POST /month endpoint with month and budgetId query parameters instead.** -// @Tags Budgets -// @Success 204 -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param month path string true "The month in YYYY-MM format" -// @Param id path string true "Budget ID formatted as string" -// @Param mode body BudgetAllocationMode true "Budget" -// @Router /v1/budgets/{id}/{month}/allocations [post] -// @Deprecated true -func (co Controller) SetAllocationsMonth(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - // If the budget does not exist, abort the request - _, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, id) - if !ok { - return - } - - // Verify the month - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - // Get the mode to set new allocations in - var data BudgetAllocationMode - if err := httputil.BindDataHandleErrors(c, &data); err != nil { - return - } - - if data.Mode != AllocateLastMonthBudget && data.Mode != AllocateLastMonthSpend { - httperrors.New(c, http.StatusBadRequest, "The mode must be %s or %s", AllocateLastMonthBudget, AllocateLastMonthSpend) - return - } - - pastMonth := types.MonthOf(month.Month.AddDate(0, -1, 0)) - - queryCurrentMonth := co.DB.Select("id").Table("allocations").Where("allocations.envelope_id = envelopes.id AND allocations.month = ?", month.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 - } - - // Get all envelope IDs and allocation amounts where there is no allocation - // for the request month, but one for the last month - if !queryAndHandleErrors(c, co.DB. - Joins("JOIN allocations ON allocations.envelope_id = envelopes.id AND allocations.month = ? AND NOT EXISTS(?)", pastMonth, queryCurrentMonth). - Select("envelopes.id, allocations.amount"). - Table("envelopes"). - Find(&envelopesAmount)) { - 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(co.DB, pastMonth) - } - - if !queryAndHandleErrors(c, co.DB.Create(&models.Allocation{ - AllocationCreate: models.AllocationCreate{ - EnvelopeID: allocation.EnvelopeID, - Amount: amount, - Month: types.MonthOf(month.Month), - }, - })) { - return - } - } - - c.JSON(http.StatusNoContent, gin.H{}) -} diff --git a/pkg/controllers/budget_v1_test.go b/pkg/controllers/budget_v1_test.go deleted file mode 100644 index 319f35d6..00000000 --- a/pkg/controllers/budget_v1_test.go +++ /dev/null @@ -1,718 +0,0 @@ -package controllers_test - -import ( - "fmt" - "net/http" - "strings" - "testing" - "time" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/google/uuid" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) createTestBudget(c models.BudgetCreate, expectedStatus ...int) controllers.BudgetResponse { - // Default to 200 OK as expected status - if len(expectedStatus) == 0 { - expectedStatus = append(expectedStatus, http.StatusCreated) - } - - r := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets", c) - assertHTTPStatus(suite.T(), &r, expectedStatus...) - - var a controllers.BudgetResponse - suite.decodeResponse(&r, &a) - - return a -} - -func (suite *TestSuiteStandard) TestCreateBudgetNoDB() { - suite.CloseDB() - suite.createTestBudget(models.BudgetCreate{}, http.StatusInternalServerError) -} - -func (suite *TestSuiteStandard) TestBudgets() { - suite.CloseDB() - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/budgets", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusInternalServerError) - assert.Contains(suite.T(), test.DecodeError(suite.T(), recorder.Body.Bytes()), "There is a problem with the database connection") -} - -func (suite *TestSuiteStandard) TestOptionsBudget() { - path := fmt.Sprintf("%s/%s", "http://example.com/v1/budgets", uuid.New()) - recorder := test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNotFound, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, "http://example.com/v1/budgets/NotParseableAsUUID", "") - assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - path = suite.createTestBudget(models.BudgetCreate{}).Data.Links.Self - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNoContent, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - path = suite.createTestBudget(models.BudgetCreate{}).Data.Links.MonthAllocations - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNoContent, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) -} - -func (suite *TestSuiteStandard) TestOptionsBudgetMonth() { - budgetLink := suite.createTestBudget(models.BudgetCreate{}).Data.Links.Month - - recorder := test.Request(suite.controller, suite.T(), http.MethodOptions, strings.Replace(budgetLink, "YYYY-MM", "1970-01", 1), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) - assert.Equal(suite.T(), recorder.Header().Get("allow"), "OPTIONS, GET") - - // Bad Request for invalid UUID - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, "http://example.com/v1/budgets/nouuid/2022-01", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Bad Request for invalid month - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, budgetLink, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Not found for non-existing budget - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, "http://example.com/v1/budgets/5b95e1a9-522d-4a36-9074-32f7c2ff0513/1980-06", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestGetBudget() { - budget := suite.createTestBudget(models.BudgetCreate{}) - - tests := []struct { - name string - id uuid.UUID - response int - }{ - {"Existing budget", budget.Data.ID, http.StatusOK}, - {"ID nil", uuid.Nil, http.StatusBadRequest}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - recorder := test.Request(suite.controller, t, http.MethodGet, fmt.Sprintf("http://example.com/v1/budgets/%s", tt.id), "") - - var response controllers.BudgetResponse - suite.decodeResponse(&recorder, &response) - - assert.Equal(t, tt.response, recorder.Code, "Wrong response code, Request ID: %s, Object: %v", recorder.Result().Header.Get("x-request-id"), response) - }) - } -} - -func (suite *TestSuiteStandard) TestGetBudgets() { - _ = suite.createTestBudget(models.BudgetCreate{}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/budgets", "") - - var response controllers.BudgetListResponse - suite.decodeResponse(&recorder, &response) - - assert.Equal(suite.T(), 200, recorder.Code) - assert.Len(suite.T(), response.Data, 1) - - diff := time.Since(response.Data[0].CreatedAt) - assert.LessOrEqual(suite.T(), diff, tolerance) - - diff = time.Since(response.Data[0].UpdatedAt) - assert.LessOrEqual(suite.T(), diff, tolerance) -} - -func (suite *TestSuiteStandard) TestGetBudgetsFilter() { - _ = suite.createTestBudget(models.BudgetCreate{ - Name: "Exact String Match", - Note: "This is a specific note", - Currency: "", - }) - - _ = suite.createTestBudget(models.BudgetCreate{ - Name: "", - Note: "This is a specific note", - Currency: "$", - }) - - _ = suite.createTestBudget(models.BudgetCreate{ - 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}, - } - - var re controllers.BudgetListResponse - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - r := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v1/budgets?%s", tt.query), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &re) - assert.Equal(t, tt.len, len(re.Data)) - }) - } -} - -func (suite *TestSuiteStandard) TestNoBudgetNotFound() { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/budgets/65064e6f-04b4-46e0-8bbc-88c96c6b21bd", "") - - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestBudgetInvalidIDs() { - /* - * GET - */ - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/budgets/-56", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/budgets/notANumber", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/budgets/23", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/budgets/d19a622f-broken-uuid/2022-01", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * PATCH - */ - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/budgets/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/budgets/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * DELETE - */ - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/budgets/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/budgets/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateBudget() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets", `{ "name": "New Budget", "note": "More tests something something" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) - - var budgetObject, savedObject controllers.BudgetResponse - suite.decodeResponse(&recorder, &budgetObject) - - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, budgetObject.Data.Links.Self, "") - suite.decodeResponse(&recorder, &savedObject) - - assert.Equal(suite.T(), savedObject, budgetObject) -} - -func (suite *TestSuiteStandard) TestCreateBrokenBudget() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets", `{ "note": 2 }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateBudgetNoBody() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -// TestBudgetMonth verifies that the monthly calculations are correct. -func (suite *TestSuiteStandard) TestBudgetMonth() { - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID, Name: "Utilities"}) - account := suite.createTestAccount(models.AccountCreate{BudgetID: budget.Data.ID, OnBudget: true, Name: "TestBudgetMonth Internal"}) - externalAccount := suite.createTestAccount(models.AccountCreate{BudgetID: budget.Data.ID, External: true, Name: "TestBudgetMonth External"}) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelope.Data.ID, - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(20.99), - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelope.Data.ID, - Month: types.NewMonth(2022, 2), - Amount: decimal.NewFromFloat(47.12), - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelope.Data.ID, - Month: types.NewMonth(2022, 3), - Amount: decimal.NewFromFloat(31.17), - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 1, 15, 0, 0, 0, 0, time.UTC), - Amount: decimal.NewFromFloat(10.0), - Note: "Water bill for January", - BudgetID: budget.Data.ID, - SourceAccountID: account.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - EnvelopeID: &envelope.Data.ID, - Reconciled: true, - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 2, 15, 0, 0, 0, 0, time.UTC), - Amount: decimal.NewFromFloat(5.0), - Note: "Water bill for February", - BudgetID: budget.Data.ID, - SourceAccountID: account.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - EnvelopeID: &envelope.Data.ID, - Reconciled: true, - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 3, 15, 0, 0, 0, 0, time.UTC), - Amount: decimal.NewFromFloat(15.0), - Note: "Water bill for March", - BudgetID: budget.Data.ID, - SourceAccountID: account.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - EnvelopeID: &envelope.Data.ID, - Reconciled: true, - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 3, 1, 7, 38, 17, 0, time.UTC), - Amount: decimal.NewFromFloat(1500), - Note: "Income for march", - BudgetID: budget.Data.ID, - SourceAccountID: externalAccount.Data.ID, - DestinationAccountID: account.Data.ID, - EnvelopeID: nil, - }) - - tests := []struct { - path string - response controllers.BudgetMonthResponse - }{ - { - fmt.Sprintf("%s/2022-01", budget.Data.Links.Self), - controllers.BudgetMonthResponse{ - Data: models.BudgetMonth{ - Month: types.NewMonth(2022, 1), - Income: decimal.NewFromFloat(0), - Envelopes: []models.EnvelopeMonth{ - { - Envelope: models.Envelope{ - EnvelopeCreate: models.EnvelopeCreate{ - Name: "Utilities", - }, - }, - Month: types.NewMonth(2022, 1), - Spent: decimal.NewFromFloat(-10), - Balance: decimal.NewFromFloat(10.99), - Allocation: decimal.NewFromFloat(20.99), - }, - }, - }, - }, - }, - { - fmt.Sprintf("%s/2022-02", budget.Data.Links.Self), - controllers.BudgetMonthResponse{ - Data: models.BudgetMonth{ - Month: types.NewMonth(2022, 2), - Income: decimal.NewFromFloat(0), - Envelopes: []models.EnvelopeMonth{ - { - Envelope: models.Envelope{ - EnvelopeCreate: models.EnvelopeCreate{ - Name: "Utilities", - }, - }, - Month: types.NewMonth(2022, 2), - Balance: decimal.NewFromFloat(53.11), - Spent: decimal.NewFromFloat(-5), - Allocation: decimal.NewFromFloat(47.12), - }, - }, - }, - }, - }, - { - fmt.Sprintf("%s/2022-03", budget.Data.Links.Self), - controllers.BudgetMonthResponse{ - Data: models.BudgetMonth{ - Month: types.NewMonth(2022, 3), - Income: decimal.NewFromFloat(1500), - Envelopes: []models.EnvelopeMonth{ - { - Envelope: models.Envelope{ - EnvelopeCreate: models.EnvelopeCreate{ - Name: "Utilities", - }, - }, - Month: types.NewMonth(2022, 3), - Balance: decimal.NewFromFloat(69.28), - Spent: decimal.NewFromFloat(-15), - Allocation: decimal.NewFromFloat(31.17), - }, - }, - }, - }, - }, - } - - var budgetMonth controllers.BudgetMonthResponse - for _, tt := range tests { - r := test.Request(suite.controller, suite.T(), http.MethodGet, tt.path, "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &budgetMonth) - - // Verify income calculation - assert.True(suite.T(), budgetMonth.Data.Income.Equal(tt.response.Data.Income)) - - if !assert.Len(suite.T(), budgetMonth.Data.Envelopes, 1) { - assert.FailNow(suite.T(), "Response length does not match!", "Response does not have exactly 1 item") - } - - expected := tt.response.Data.Envelopes[0] - envelope := budgetMonth.Data.Envelopes[0] - assert.True(suite.T(), envelope.Spent.Equal(expected.Spent), "Monthly spent calculation for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, expected.Spent, envelope.Spent, budgetMonth.Data) - assert.True(suite.T(), envelope.Balance.Equal(expected.Balance), "Monthly balance calculation for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, expected.Balance, envelope.Balance, budgetMonth.Data) - assert.True(suite.T(), envelope.Allocation.Equal(expected.Allocation), "Monthly allocation fetch for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, expected.Allocation, envelope.Allocation, budgetMonth.Data) - } -} - -func (suite *TestSuiteStandard) TestBudgetMonthBudgeted() { - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID, Name: "Utilities"}) - envelopeZero := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID, Name: "Zero"}) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelopeZero.Data.ID, - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(19.01), - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelope.Data.ID, - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(20.99), - }) - - var budgetMonth controllers.BudgetMonthResponse - - r := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("%s/2022-01", budget.Data.Links.Self), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &budgetMonth) - - assert.True(suite.T(), budgetMonth.Data.Budgeted.Equal(decimal.NewFromFloat(40)), "Calculation of budgeted sum for a month is off. Should be 40, is %s", budgetMonth.Data.Budgeted) -} - -// TestBudgetMonthNonExistent verifies that month requests for non-existing budgets return a HTTP 404 Not Found. -func (suite *TestSuiteStandard) TestBudgetMonthNonExistent() { - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/budgets/65064e6f-04b4-46e0-8bbc-88c96c6b21bd/2022-01", "") - assertHTTPStatus(suite.T(), &r, http.StatusNotFound) -} - -// TestBudgetMonthZero tests that we return a HTTP Bad Request when requesting data for the zero timestamp. -func (suite *TestSuiteStandard) TestBudgetMonthZero() { - budget := suite.createTestBudget(models.BudgetCreate{}) - - r := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("%s/0001-01", budget.Data.Links.Self), "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -// TestBudgetMonthInvalid tests that we return a HTTP Bad Request when requesting data for the zero timestamp. -func (suite *TestSuiteStandard) TestBudgetMonthInvalid() { - budget := suite.createTestBudget(models.BudgetCreate{}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("%s/December-2020", budget.Data.Links.Self), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateBudget() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets", `{ "name": "New Budget", "note": "More tests something something" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) - - var budget controllers.BudgetResponse - suite.decodeResponse(&recorder, &budget) - - recorder = test.Request(suite.controller, suite.T(), http.MethodPatch, budget.Data.Links.Self, map[string]any{ - "name": "Updated new budget", - "note": "", - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - - var updatedBudget controllers.BudgetResponse - suite.decodeResponse(&recorder, &updatedBudget) - - assert.Equal(suite.T(), "", updatedBudget.Data.Note) - assert.Equal(suite.T(), "Updated new budget", updatedBudget.Data.Name) -} - -func (suite *TestSuiteStandard) TestUpdateBudgetBrokenJSON() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets", `{ "name": "New Budget", "note": "More tests something something" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) - - var budget controllers.BudgetResponse - suite.decodeResponse(&recorder, &budget) - - recorder = test.Request(suite.controller, suite.T(), http.MethodPatch, budget.Data.Links.Self, `{ "name": 2" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateBudgetInvalidType() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets", `{ "name": "New Budget", "note": "More tests something something" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) - - var budget controllers.BudgetResponse - suite.decodeResponse(&recorder, &budget) - - recorder = test.Request(suite.controller, suite.T(), http.MethodPatch, budget.Data.Links.Self, map[string]any{ - "name": 2, - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateNonExistingBudget() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/budgets/a29bd123-beec-47de-a9cd-b6f7483fe00f", `{ "name": "2" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteBudget() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets", `{ "name": "Delete me now!" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) - - var budget controllers.BudgetResponse - suite.decodeResponse(&recorder, &budget) - - recorder = test.Request(suite.controller, suite.T(), http.MethodDelete, budget.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) -} - -func (suite *TestSuiteStandard) TestDeleteNonExistingBudget() { - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/budgets/c3d34346-609a-4734-9364-98f5b0100150", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteBudgetWithBody() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets", `{ "name": "Delete me now!" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) - - var budget controllers.BudgetResponse - suite.decodeResponse(&recorder, &budget) - - recorder = test.Request(suite.controller, suite.T(), http.MethodDelete, budget.Data.Links.Self, `{ "name": "test name 23" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) -} - -func (suite *TestSuiteStandard) TestDeleteAllocationsMonth() { - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope1 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - envelope2 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - - allocation1 := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(15.42), - EnvelopeID: envelope1.Data.ID, - }) - - allocation2 := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(15.42), - EnvelopeID: envelope2.Data.ID, - }) - - // Clear allocations - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, strings.Replace(budget.Data.Links.MonthAllocations, "YYYY-MM", "2022-01", 1), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) - - // Verify that allocations are deleted - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, allocation1.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) - - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, allocation2.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteAllocationsMonthFailures() { - budgetAllocationsLink := suite.createTestBudget(models.BudgetCreate{}).Data.Links.MonthAllocations - - // Bad Request for invalid UUID - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/budgets/nouuid/2022-01/allocations", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Bad Request for invalid months - recorder = test.Request(suite.controller, suite.T(), http.MethodDelete, budgetAllocationsLink, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Not found for non-existing budget - recorder = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/budgets/059cdead-249f-4f94-8d29-16a80c6b4a09/2032-03/allocations", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestSetAllocationsMonthBudgeted() { - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope1 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - envelope2 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - - allocation1 := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(30), - EnvelopeID: envelope1.Data.ID, - }) - - allocation2 := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(40), - EnvelopeID: envelope2.Data.ID, - }) - - // Update in budgeted mode allocations - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, strings.Replace(budget.Data.Links.MonthAllocations, "YYYY-MM", "2022-02", 1), controllers.BudgetAllocationMode{Mode: controllers.AllocateLastMonthBudget}) - 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.controller, suite.T(), http.MethodGet, requestString, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var envelope1Month controllers.EnvelopeMonthResponse - suite.decodeResponse(&recorder, &envelope1Month) - suite.Assert().True(allocation1.Data.Amount.Equal(envelope1Month.Data.Allocation), "Expected: %s, got %s, Request ID: %s", allocation1.Data.Amount, envelope1Month.Data.Allocation, recorder.Header().Get("x-request-id")) - - // Verify the allocation for the second envelope - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(envelope2.Data.Links.Month, "YYYY-MM", "2022-02", 1), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var envelope2Month controllers.EnvelopeMonthResponse - suite.decodeResponse(&recorder, &envelope2Month) - suite.Assert().True(allocation2.Data.Amount.Equal(envelope2Month.Data.Allocation), "Expected: %s, got %s, Request ID: %s", allocation2.Data.Amount, envelope2Month.Data.Allocation, recorder.Header().Get("x-request-id")) -} - -func (suite *TestSuiteStandard) TestSetAllocationsMonthSpend() { - budget := suite.createTestBudget(models.BudgetCreate{}) - cashAccount := suite.createTestAccount(models.AccountCreate{External: false, OnBudget: true, Name: "TestSetAllocationsMonthSpend Internal"}) - externalAccount := suite.createTestAccount(models.AccountCreate{External: true, Name: "TestSetAllocationsMonthSpend External"}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope1 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - envelope2 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - - _ = suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(30), - EnvelopeID: envelope1.Data.ID, - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(40), - EnvelopeID: envelope2.Data.ID, - }) - - eID := &envelope1.Data.ID - transaction1 := suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 1, 15, 14, 43, 27, 0, time.UTC), - EnvelopeID: eID, - BudgetID: budget.Data.ID, - SourceAccountID: cashAccount.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - Amount: decimal.NewFromFloat(15), - }) - - // Update in budgeted mode allocations - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, strings.Replace(budget.Data.Links.MonthAllocations, "YYYY-MM", "2022-02", 1), controllers.BudgetAllocationMode{Mode: controllers.AllocateLastMonthSpend}) - 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.controller, suite.T(), http.MethodGet, requestString, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var envelope1Month controllers.EnvelopeMonthResponse - suite.decodeResponse(&recorder, &envelope1Month) - 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.controller, suite.T(), http.MethodGet, strings.Replace(envelope2.Data.Links.Month, "YYYY-MM", "2022-02", 1), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var envelope2Month controllers.EnvelopeMonthResponse - suite.decodeResponse(&recorder, &envelope2Month) - 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) TestSetAllocationsMonthFailures() { - budgetAllocationsLink := suite.createTestBudget(models.BudgetCreate{}).Data.Links.MonthAllocations - - // Bad Request for invalid UUID - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets/nouuid/2022-01/allocations", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Bad Request for invalid months - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, budgetAllocationsLink, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Not found for non-existing budget - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/budgets/059cdead-249f-4f94-8d29-16a80c6b4a09/2032-03/allocations", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) - - // Bad Request for invalid json in body - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, strings.Replace(budgetAllocationsLink, "YYYY-MM", "2022-01", 1), `{ "mode": INVALID_JSON" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Bad Request for invalid mode - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, strings.Replace(budgetAllocationsLink, "YYYY-MM", "2022-01", 1), `{ "mode": "UNKNOWN_MODE" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -// TestBudgetBalanceDoubleRegression verifies that the Budget balance is only added once. -func (suite *TestSuiteStandard) TestBudgetBalanceDoubleRegression() { - shouldBalance := decimal.NewFromFloat(1000) - - budget := suite.createTestBudget(models.BudgetCreate{Name: "TestBudgetBalanceDoubleRegression"}) - - internalAccount := suite.createTestAccount(models.AccountCreate{ - BudgetID: budget.Data.ID, - OnBudget: true, - External: false, - Name: "TestBudgetBalanceDoubleRegression Internal", - }) - - externalAccount := suite.createTestAccount(models.AccountCreate{ - BudgetID: budget.Data.ID, - OnBudget: true, - External: true, - Name: "TestBudgetBalanceDoubleRegression External", - }) - - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - - _ = suite.createTestTransaction(models.TransactionCreate{ - BudgetID: budget.Data.ID, - Amount: shouldBalance, - SourceAccountID: externalAccount.Data.ID, - DestinationAccountID: internalAccount.Data.ID, - EnvelopeID: &envelope.Data.ID, - }) - - var budgetResponse controllers.BudgetResponse - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, budget.Data.Links.Self, "") - suite.decodeResponse(&recorder, &budgetResponse) - - assert.True(suite.T(), budgetResponse.Data.Balance.Equal(shouldBalance), "Balance is %s, should be %s", budgetResponse.Data.Balance, shouldBalance) -} diff --git a/pkg/controllers/category_v1.go b/pkg/controllers/category_v1.go deleted file mode 100644 index b857644d..00000000 --- a/pkg/controllers/category_v1.go +++ /dev/null @@ -1,347 +0,0 @@ -package controllers - -import ( - "fmt" - "net/http" - - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type Category struct { - models.Category - Envelopes []Envelope `json:"envelopes"` // Envelopes for the category - Links struct { - Self string `json:"self" example:"https://example.com/api/v1/categories/3b1ea324-d438-4419-882a-2fc91d71772f"` // The category itself - Envelopes string `json:"envelopes" example:"https://example.com/api/v1/envelopes?category=3b1ea324-d438-4419-882a-2fc91d71772f"` // Envelopes for this category - } `json:"links"` -} - -func (c *Category) links(context *gin.Context) { - url := context.GetString(string(database.ContextURL)) - - c.Links.Self = fmt.Sprintf("%s/v1/categories/%s", url, c.ID) - c.Links.Envelopes = fmt.Sprintf("%s/v1/envelopes?category=%s", url, c.ID) -} - -func (co Controller) getCategory(c *gin.Context, id uuid.UUID) (Category, bool) { - m, ok := getResourceByIDAndHandleErrors[models.Category](c, co, id) - if !ok { - return Category{}, false - } - - cat := Category{ - Category: m, - } - - eModels, err := m.Envelopes(co.DB) - if err != nil { - httperrors.Handler(c, err) - return Category{}, false - } - - envelopes := make([]Envelope, 0) - for _, e := range eModels { - o, ok := co.getEnvelope(c, e.ID) - if !ok { - return Category{}, false - } - envelopes = append(envelopes, o) - } - - cat.Envelopes = envelopes - cat.links(c) - - return cat, true -} - -type CategoryListResponse struct { - Data []Category `json:"data"` // List of categories -} - -type CategoryResponse struct { - Data Category `json:"data"` // Data for the category -} - -type CategoryQueryFilter struct { - Name string `form:"name" filterField:"false"` // By name - BudgetID string `form:"budget"` // By ID of the budget - Note string `form:"note" filterField:"false"` // By note - Hidden bool `form:"hidden"` // Is the category archived? - Search string `form:"search" filterField:"false"` // By string in name or note -} - -func (f CategoryQueryFilter) ToCreate(c *gin.Context) (models.CategoryCreate, bool) { - budgetID, ok := httputil.UUIDFromStringHandleErrors(c, f.BudgetID) - if !ok { - return models.CategoryCreate{}, false - } - - return models.CategoryCreate{ - BudgetID: budgetID, - Hidden: f.Hidden, - }, true -} - -// RegisterCategoryRoutes registers the routes for categories with -// the RouterGroup that is passed. -func (co Controller) RegisterCategoryRoutes(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsCategoryList) - r.GET("", co.GetCategories) - r.POST("", co.CreateCategory) - } - - // Category with ID - { - r.OPTIONS("/:id", co.OptionsCategoryDetail) - r.GET("/:id", co.GetCategory) - r.PATCH("/:id", co.UpdateCategory) - r.DELETE("/:id", co.DeleteCategory) - } -} - -// OptionsCategoryList 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 Categories -// @Success 204 -// @Router /v1/categories [options] -// @Deprecated true -func (co Controller) OptionsCategoryList(c *gin.Context) { - httputil.OptionsGetPost(c) -} - -// OptionsCategoryDetail 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 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 /v1/categories/{id} [options] -// @Deprecated true -func (co Controller) OptionsCategoryDetail(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - _, ok := co.getCategory(c, id) - if !ok { - return - } - httputil.OptionsGetPatchDelete(c) -} - -// CreateCategory creates a new category -// -// @Summary Create category -// @Description Creates a new category -// @Tags Categories -// @Produce json -// @Success 201 {object} CategoryResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param category body models.CategoryCreate true "Category" -// @Router /v1/categories [post] -// @Deprecated true -func (co Controller) CreateCategory(c *gin.Context) { - var create models.CategoryCreate - - err := httputil.BindDataHandleErrors(c, &create) - if err != nil { - return - } - - cat := models.Category{ - CategoryCreate: create, - } - - _, ok := getResourceByIDAndHandleErrors[models.Budget](c, co, cat.BudgetID) - if !ok { - return - } - - if !queryAndHandleErrors(c, co.DB.Create(&cat)) { - return - } - - r, ok := co.getCategory(c, cat.ID) - if !ok { - return - } - c.JSON(http.StatusCreated, CategoryResponse{Data: r}) -} - -// GetCategories returns a list of categories filtered by the query parameters -// -// @Summary Get categories -// @Description Returns a list of categories -// @Tags Categories -// @Produce json -// @Success 200 {object} CategoryListResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Router /v1/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 hidden query bool false "Is the category hidden?" -// @Param search query string false "Search for this text in name and note" -// @Deprecated true -func (co Controller) 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 - create, ok := filter.ToCreate(c) - if !ok { - return - } - - query := co.DB.Where(&models.Category{ - CategoryCreate: create, - }, queryFields...) - - query = stringFilters(co.DB, query, setFields, filter.Name, filter.Note, filter.Search) - - var categories []models.Category - if !queryAndHandleErrors(c, query.Find(&categories)) { - return - } - - r := make([]Category, 0) - for _, category := range categories { - o, ok := co.getCategory(c, category.ID) - if !ok { - return - } - r = append(r, o) - } - - c.JSON(http.StatusOK, CategoryListResponse{Data: r}) -} - -// GetCategory returns data for a specific category -// -// @Summary Get category -// @Description Returns a specific category -// @Tags Categories -// @Produce json -// @Success 200 {object} CategoryResponse -// @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 /v1/categories/{id} [get] -// @Deprecated true -func (co Controller) GetCategory(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - r, ok := co.getCategory(c, id) - if !ok { - return - } - - c.JSON(http.StatusOK, CategoryResponse{Data: r}) -} - -// UpdateCategory updates data for a specific category -// -// @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} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID formatted as string" -// @Param category body models.CategoryCreate true "Category" -// @Router /v1/categories/{id} [patch] -// @Deprecated true -func (co Controller) UpdateCategory(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - category, ok := getResourceByIDAndHandleErrors[models.Category](c, co, id) - if !ok { - return - } - - updateFields, err := httputil.GetBodyFieldsHandleErrors(c, models.CategoryCreate{}) - if err != nil { - return - } - - var data models.Category - if err := httputil.BindDataHandleErrors(c, &data.CategoryCreate); err != nil { - return - } - - if !queryAndHandleErrors(c, co.DB.Model(&category).Select("", updateFields...).Updates(data)) { - return - } - - r, ok := co.getCategory(c, category.ID) - if !ok { - return - } - c.JSON(http.StatusOK, CategoryResponse{Data: r}) -} - -// DeleteCategory deletes a specific category -// -// @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 /v1/categories/{id} [delete] -// @Deprecated true -func (co Controller) DeleteCategory(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - category, ok := getResourceByIDAndHandleErrors[models.Category](c, co, id) - if !ok { - return - } - - if !queryAndHandleErrors(c, co.DB.Delete(&category)) { - return - } - - c.JSON(http.StatusNoContent, gin.H{}) -} diff --git a/pkg/controllers/category_v1_test.go b/pkg/controllers/category_v1_test.go deleted file mode 100644 index f516f352..00000000 --- a/pkg/controllers/category_v1_test.go +++ /dev/null @@ -1,313 +0,0 @@ -package controllers_test - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) createTestCategory(c models.CategoryCreate, expectedStatus ...int) controllers.CategoryResponse { - if c.BudgetID == uuid.Nil { - c.BudgetID = suite.createTestBudget(models.BudgetCreate{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) - } - - r := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/categories", c) - assertHTTPStatus(suite.T(), &r, expectedStatus...) - - var category controllers.CategoryResponse - suite.decodeResponse(&r, &category) - - return category -} - -func (suite *TestSuiteStandard) TestCategories() { - suite.CloseDB() - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/categories", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusInternalServerError) - assert.Contains(suite.T(), test.DecodeError(suite.T(), recorder.Body.Bytes()), "There is a problem with the database connection") -} - -func (suite *TestSuiteStandard) TestOptionsCategory() { - path := fmt.Sprintf("%s/%s", "http://example.com/v1/categories", uuid.New()) - recorder := test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNotFound, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, "http://example.com/v1/categories/NotParseableAsUUID", "") - assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - path = suite.createTestCategory(models.CategoryCreate{}).Data.Links.Self - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNoContent, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) -} - -func (suite *TestSuiteStandard) TestGetCategories() { - _ = suite.createTestCategory(models.CategoryCreate{}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/categories", "") - - var response controllers.CategoryListResponse - suite.decodeResponse(&recorder, &response) - - assert.Equal(suite.T(), 200, recorder.Code) - assert.Len(suite.T(), response.Data, 1) - - diff := time.Since(response.Data[0].CreatedAt) - assert.LessOrEqual(suite.T(), diff, tolerance) - - diff = time.Since(response.Data[0].UpdatedAt) - assert.LessOrEqual(suite.T(), diff, tolerance) -} - -func (suite *TestSuiteStandard) TestGetCategoriesEnvelopes() { - category := suite.createTestCategory(models.CategoryCreate{}) - _ = suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - _ = suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/categories", "") - - var response controllers.CategoryListResponse - suite.decodeResponse(&recorder, &response) - - assert.Equal(suite.T(), 200, recorder.Code) - assert.Len(suite.T(), response.Data, 1) - assert.Len(suite.T(), response.Data[0].Envelopes, 2) -} - -func (suite *TestSuiteStandard) TestGetCategoriesNoEnvelopesEmptyArray() { - _ = suite.createTestCategory(models.CategoryCreate{}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/categories", "") - - var response controllers.CategoryListResponse - suite.decodeResponse(&recorder, &response) - - assert.Equal(suite.T(), 200, recorder.Code) - assert.Len(suite.T(), response.Data, 1) - assert.NotNil(suite.T(), response.Data[0].Envelopes, "Envelopes must be an empty array when no envelopes are present, not nil") - assert.Len(suite.T(), response.Data[0].Envelopes, 0) -} - -func (suite *TestSuiteStandard) TestGetCategoriesInvalidQuery() { - tests := []string{ - "budget=NotAUUID", - } - - for _, tt := range tests { - suite.T().Run(tt, func(t *testing.T) { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v1/categories?%s", tt), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - }) - } -} - -func (suite *TestSuiteStandard) TestGetCategoriesFilter() { - b1 := suite.createTestBudget(models.BudgetCreate{}) - b2 := suite.createTestBudget(models.BudgetCreate{}) - - _ = suite.createTestCategory(models.CategoryCreate{ - Name: "Category Name", - Note: "A note for this category", - BudgetID: b1.Data.ID, - Hidden: true, - }) - - _ = suite.createTestCategory(models.CategoryCreate{ - Name: "Groceries", - Note: "For Groceries", - BudgetID: b2.Data.ID, - }) - - _ = suite.createTestCategory(models.CategoryCreate{ - Name: "Daily stuff", - Note: "Groceries, Drug Store, …", - BudgetID: b2.Data.ID, - }) - - tests := []struct { - name string - query string - len int - }{ - {"Budget 1", fmt.Sprintf("budget=%s", b1.Data.ID), 1}, - {"Budget Not Existing", "budget=c9e4ee7a-e702-4f92-b168-11a95b22c7aa", 0}, - {"Empty Note", "note=", 0}, - {"Empty Name", "name=", 0}, - {"Name & Note", "name=Category Name¬e=A note for this category", 1}, - {"Fuzzy name, no note", "name=Category¬e=", 0}, - {"Fuzzy name", "name=t", 2}, - {"Fuzzy note, no name", "name=¬e=Groceries", 0}, - {"Fuzzy note", "note=Groceries", 2}, - {"Not hidden", "hidden=false", 2}, - {"Hidden", "hidden=true", 1}, - {"Search for 'groceries'", "search=groceries", 2}, - {"Search for 'FOR'", "search=FOR", 2}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - var re controllers.CategoryListResponse - r := test.Request(suite.controller, t, http.MethodGet, fmt.Sprintf("/v1/categories?%s", tt.query), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &re) - - assert.Equal(t, tt.len, len(re.Data), "Request ID: %s", r.Result().Header.Get("x-request-id")) - }) - } -} - -func (suite *TestSuiteStandard) TestGetCategory() { - category := suite.createTestCategory(models.CategoryCreate{Name: "Catch me if you can!"}) - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, category.Data.Links.Self, "") - - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) -} - -func (suite *TestSuiteStandard) TestNoCategoryNotFound() { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/categories/4e743e94-6a4b-44d6-aba5-d77c87103ff7", "") - - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestCategoryInvalidIDs() { - /* - * GET - */ - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/categories/-56", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/categories/notANumber", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/categories/23", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * PATCH - */ - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/categories/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/categories/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * DELETE - */ - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/categories/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/categories/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateCategory() { - _ = suite.createTestCategory(models.CategoryCreate{Name: "New Category", Note: "Something to test creation"}) -} - -func (suite *TestSuiteStandard) TestCreateBrokenCategory() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/categories", `{ "createdAt": "New Category", "note": "More tests for categories to ensure less brokenness something" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateBudgetDoesNotExist() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/categories", `{ "budgetId": "f8c74664-9b79-4e15-8d3d-4618f3e3c230" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestCreateCategoryNoBody() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/categories", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateCategoryDuplicateName() { - c := suite.createTestCategory(models.CategoryCreate{ - Name: "Unique Category Name", - }) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/categories", models.CategoryCreate{ - BudgetID: c.Data.BudgetID, - Name: c.Data.Name, - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateCategory() { - category := suite.createTestCategory(models.CategoryCreate{Name: "New category", Note: "Mor(r)e tests"}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, category.Data.Links.Self, map[string]any{ - "name": "Updated new category for testing", - "note": "", - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - - var updatedCategory controllers.CategoryResponse - suite.decodeResponse(&recorder, &updatedCategory) - - assert.Equal(suite.T(), "", updatedCategory.Data.Note) - assert.Equal(suite.T(), "Updated new category for testing", updatedCategory.Data.Name) -} - -func (suite *TestSuiteStandard) TestUpdateCategoryBrokenJSON() { - category := suite.createTestCategory(models.CategoryCreate{Name: "New category", Note: "Mor(r)e tests"}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, category.Data.Links.Self, `{ "name": 2" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateCategoryInvalidType() { - category := suite.createTestCategory(models.CategoryCreate{Name: "New category", Note: "Mor(r)e tests"}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, category.Data.Links.Self, map[string]any{ - "name": 2, - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateCategoryInvalidBudgetID() { - category := suite.createTestCategory(models.CategoryCreate{Name: "New category", Note: "Mor(r)e tests"}) - - // Sets the BudgetID to uuid.Nil - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, category.Data.Links.Self, models.CategoryCreate{}) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateNonExistingCategory() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/categories/f9288848-517e-4b8c-9f14-b3d849ca275b", `{ "name": "2" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteCategory() { - category := suite.createTestCategory(models.CategoryCreate{Name: "Delete me now!"}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, category.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) -} - -func (suite *TestSuiteStandard) TestDeleteNonExistingCategory() { - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/categories/a2aa0569-5ac5-42e1-8563-7c61194cc7d9", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteCategoryWithBody() { - category := suite.createTestCategory(models.CategoryCreate{Name: "Delete me now!"}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, category.Data.Links.Self, `{ "name": "test name 23" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) -} diff --git a/pkg/controllers/category_v3_test.go b/pkg/controllers/category_v3_test.go index f25122db..5795a256 100644 --- a/pkg/controllers/category_v3_test.go +++ b/pkg/controllers/category_v3_test.go @@ -15,7 +15,7 @@ import ( func (suite *TestSuiteStandard) createTestCategoryV3(t *testing.T, c controllers.CategoryCreateV3, expectedStatus ...int) controllers.CategoryResponseV3 { if c.BudgetID == uuid.Nil { - c.BudgetID = suite.createTestBudget(models.BudgetCreate{Name: "Testing budget"}).Data.ID + c.BudgetID = suite.createTestBudgetV3(t, models.BudgetCreate{Name: "Testing budget"}).Data.ID } if c.Name == "" { @@ -138,8 +138,8 @@ func (suite *TestSuiteStandard) TestCategoriesV3GetSingle() { } func (suite *TestSuiteStandard) TestCategoriesV3GetFilter() { - b1 := suite.createTestBudget(models.BudgetCreate{}) - b2 := suite.createTestBudget(models.BudgetCreate{}) + b1 := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) + b2 := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) _ = suite.createTestCategoryV3(suite.T(), controllers.CategoryCreateV3{ Name: "Category Name", diff --git a/pkg/controllers/cleanup.go b/pkg/controllers/cleanup.go deleted file mode 100644 index 136d2f8c..00000000 --- a/pkg/controllers/cleanup.go +++ /dev/null @@ -1,64 +0,0 @@ -package controllers - -import ( - "net/http" - - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" -) - -// DeleteAll permanently deletes all resources in the database -// -// @Summary Delete everything -// @Description Permanently deletes all resources -// @Tags v1 -// @Success 204 -// @Failure 500 {object} httperrors.HTTPError -// @Router /v1 [delete] -// @Deprecated true -func (co Controller) DeleteAll(c *gin.Context) { - err := co.DB.Unscoped().Where("true").Delete(&models.Transaction{}).Error - if err != nil { - httperrors.Handler(c, err) - return - } - - err = co.DB.Unscoped().Where("true").Delete(&models.Allocation{}).Error - if err != nil { - httperrors.Handler(c, err) - return - } - - err = co.DB.Unscoped().Where("true").Delete(&models.Envelope{}).Error - if err != nil { - httperrors.Handler(c, err) - return - } - - err = co.DB.Unscoped().Where("true").Delete(&models.Category{}).Error - if err != nil { - httperrors.Handler(c, err) - return - } - - err = co.DB.Unscoped().Where("true").Delete(&models.Account{}).Error - if err != nil { - httperrors.Handler(c, err) - return - } - - err = co.DB.Unscoped().Where("true").Delete(&models.Budget{}).Error - if err != nil { - httperrors.Handler(c, err) - return - } - - err = co.DB.Unscoped().Where("true").Delete(&models.MonthConfig{}).Error - if err != nil { - httperrors.Handler(c, err) - return - } - - c.JSON(http.StatusNoContent, gin.H{}) -} diff --git a/pkg/controllers/cleanup_test.go b/pkg/controllers/cleanup_test.go deleted file mode 100644 index b4b1e772..00000000 --- a/pkg/controllers/cleanup_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package controllers_test - -import ( - "net/http" - "testing" - "time" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) TestCleanup() { - _ = suite.createTestBudget(models.BudgetCreate{}) - _ = suite.createTestAccount(models.AccountCreate{Name: "TestCleanup"}) - _ = suite.createTestCategory(models.CategoryCreate{}) - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - _ = suite.createTestAllocation(models.AllocationCreate{}) - _ = suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(17.32)}) - _ = suite.createTestMonthConfig(envelope.Data.ID, types.NewMonth(time.Now().Year(), time.Now().Month()), models.MonthConfigCreate{}) - - tests := []string{ - "http://example.com/v1/budgets", - "http://example.com/v1/accounts", - "http://example.com/v1/categories", - "http://example.com/v1/transactions", - "http://example.com/v1/envelopes", - "http://example.com/v1/allocations", - "http://example.com/v1/month-configs", - } - - // Delete - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) - - // Verify - for _, tt := range tests { - suite.T().Run(tt, func(t *testing.T) { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, tt, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - - var response struct { - Data []any `json:"data"` - } - - suite.decodeResponse(&recorder, &response) - assert.Len(t, response.Data, 0, "There are resources left for type %s", tt) - }) - } -} diff --git a/pkg/controllers/cleanup_v3.go b/pkg/controllers/cleanup_v3.go index 020bc5af..64a5f6d4 100644 --- a/pkg/controllers/cleanup_v3.go +++ b/pkg/controllers/cleanup_v3.go @@ -31,8 +31,8 @@ func (co Controller) CleanupV3(c *gin.Context) { // The order is important here since there are foreign keys to consider! models := []models.Model{ - models.Allocation{}, models.MatchRule{}, + models.Goal{}, models.Transaction{}, models.MonthConfig{}, models.Envelope{}, diff --git a/pkg/controllers/cleanup_v3_test.go b/pkg/controllers/cleanup_v3_test.go index 9ebbd8f5..0e692d81 100644 --- a/pkg/controllers/cleanup_v3_test.go +++ b/pkg/controllers/cleanup_v3_test.go @@ -15,24 +15,22 @@ import ( ) func (suite *TestSuiteStandard) TestCleanupV3() { - _ = suite.createTestBudget(models.BudgetCreate{}) + _ = suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) account := suite.createTestAccountV3(suite.T(), controllers.AccountCreateV3{Name: "TestCleanup"}) _ = suite.createTestCategoryV3(suite.T(), controllers.CategoryCreateV3{}) envelope := suite.createTestEnvelopeV3(suite.T(), controllers.EnvelopeCreateV3{}) - _ = suite.createTestAllocation(models.AllocationCreate{}) - _ = suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(17.32)}) - _ = suite.createTestMonthConfig(envelope.Data.ID, types.NewMonth(time.Now().Year(), time.Now().Month()), models.MonthConfigCreate{}) - _ = suite.createTestMatchRule(suite.T(), models.MatchRuleCreate{AccountID: account.Data.ID, Match: "Delete me"}) + _ = suite.createTestTransactionV3(suite.T(), models.TransactionCreate{Amount: decimal.NewFromFloat(17.32)}) + _ = suite.patchTestMonthConfigV3(suite.T(), envelope.Data.ID, types.NewMonth(time.Now().Year(), time.Now().Month()), models.MonthConfigCreate{}) + _ = suite.createTestMatchRuleV3(suite.T(), models.MatchRuleCreate{AccountID: account.Data.ID, Match: "Delete me"}) tests := []string{ + "http://example.com/v3/accounts", "http://example.com/v3/budgets", - "http://example.com/v1/accounts", - "http://example.com/v1/categories", - "http://example.com/v3/transactions", - "http://example.com/v1/envelopes", - "http://example.com/v1/allocations", - "http://example.com/v1/month-configs", + "http://example.com/v3/categories", + "http://example.com/v3/envelopes", + "http://example.com/v3/goals", "http://example.com/v3/match-rules", + "http://example.com/v3/transactions", } // Delete diff --git a/pkg/controllers/database.go b/pkg/controllers/database.go index 11afd064..359ade3a 100644 --- a/pkg/controllers/database.go +++ b/pkg/controllers/database.go @@ -6,21 +6,6 @@ import ( "gorm.io/gorm" ) -// queryAndHandleErrors tries to execute a query. If it fails, it -// tries to reconnect the database and retries the query once. -// -// This function is deprecated. Use `query(tx *gorm.DB) httperrors.Error` and -// perform HTTP responses in the calling method. -func queryAndHandleErrors(c *gin.Context, tx *gorm.DB) bool { - err := tx.Error - if err != nil { - httperrors.Handler(c, err) - return false - } - - return true -} - // 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 { diff --git a/pkg/controllers/envelope_v1.go b/pkg/controllers/envelope_v1.go deleted file mode 100644 index 5c262b15..00000000 --- a/pkg/controllers/envelope_v1.go +++ /dev/null @@ -1,378 +0,0 @@ -package controllers - -import ( - "fmt" - "net/http" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type Envelope struct { - models.Envelope - Links struct { - Self string `json:"self" example:"https://example.com/api/v1/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166"` // The envelope itself - Allocations string `json:"allocations" example:"https://example.com/api/v1/allocations?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166"` // the envelope's allocations - Month string `json:"month" example:"https://example.com/api/v1/envelopes/45b6b5b9-f746-4ae9-b77b-7688b91f8166/YYYY-MM"` // Month information endpoint. This will always end in 'YYYY-MM' for clients to use replace with actual numbers. - Transactions string `json:"transactions" example:"https://example.com/api/v1/transactions?envelope=45b6b5b9-f746-4ae9-b77b-7688b91f8166"` // The envelope's transactions - } `json:"links"` // Links to related resources -} - -func (e *Envelope) links(c *gin.Context) { - url := c.GetString(string(database.ContextURL)) - self := fmt.Sprintf("%s/v1/envelopes/%s", url, e.ID) - - e.Links.Self = self - e.Links.Allocations = self + "/allocations" - e.Links.Month = self + "/YYYY-MM" - e.Links.Transactions = fmt.Sprintf("%s/v1/transactions?envelope=%s", url, e.ID) -} - -func (co Controller) getEnvelope(c *gin.Context, id uuid.UUID) (Envelope, bool) { - m, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, id) - if !ok { - return Envelope{}, false - } - - r := Envelope{ - Envelope: m, - } - - r.links(c) - return r, true -} - -type EnvelopeListResponse struct { - Data []Envelope `json:"data"` // List of Envelopes -} - -type EnvelopeResponse struct { - Data Envelope `json:"data"` // Data for the Envelope -} - -type EnvelopeMonthResponse struct { - Data models.EnvelopeMonth `json:"data"` // Data for the month for the envelope -} - -type EnvelopeQueryFilter struct { - Name string `form:"name" filterField:"false"` // By name - CategoryID string `form:"category"` // By the ID of the category - Note string `form:"note" filterField:"false"` // By the note - Hidden bool `form:"hidden"` // Is the envelope archived? - Search string `form:"search" filterField:"false"` // By string in name or note -} - -func (f EnvelopeQueryFilter) ToCreate(c *gin.Context) (models.EnvelopeCreate, bool) { - categoryID, ok := httputil.UUIDFromStringHandleErrors(c, f.CategoryID) - if !ok { - return models.EnvelopeCreate{}, false - } - - return models.EnvelopeCreate{ - CategoryID: categoryID, - Hidden: f.Hidden, - }, true -} - -// RegisterEnvelopeRoutes registers the routes for envelopes with -// the RouterGroup that is passed. -func (co Controller) RegisterEnvelopeRoutes(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsEnvelopeList) - r.GET("", co.GetEnvelopes) - r.POST("", co.CreateEnvelope) - } - - // Envelope with ID - { - r.OPTIONS("/:id", co.OptionsEnvelopeDetail) - r.GET("/:id", co.GetEnvelope) - r.GET("/:id/:month", co.GetEnvelopeMonth) - r.PATCH("/:id", co.UpdateEnvelope) - r.DELETE("/:id", co.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 /v1/envelopes [options] -// @Deprecated true -func (co Controller) 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 /v1/envelopes/{id} [options] -// @Deprecated true -func (co Controller) OptionsEnvelopeDetail(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - _, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, id) - if !ok { - return - } - - httputil.OptionsGetPatchDelete(c) -} - -// @Summary Create envelope -// @Description Creates a new envelope -// @Tags Envelopes -// @Produce json -// @Success 201 {object} EnvelopeResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param envelope body models.EnvelopeCreate true "Envelope" -// @Router /v1/envelopes [post] -// @Deprecated true -func (co Controller) CreateEnvelope(c *gin.Context) { - var create models.EnvelopeCreate - - err := httputil.BindDataHandleErrors(c, &create) - if err != nil { - return - } - - e := models.Envelope{ - EnvelopeCreate: create, - } - - _, ok := getResourceByIDAndHandleErrors[models.Category](c, co, create.CategoryID) - if !ok { - return - } - - if !queryAndHandleErrors(c, co.DB.Create(&e)) { - return - } - - r, ok := co.getEnvelope(c, e.ID) - if !ok { - return - } - - c.JSON(http.StatusCreated, EnvelopeResponse{Data: r}) -} - -// @Summary Get envelopes -// @Description Returns a list of envelopes -// @Tags Envelopes -// @Produce json -// @Success 200 {object} EnvelopeListResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Router /v1/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 hidden query bool false "Is the envelope hidden?" -// @Param search query string false "Search for this text in name and note" -// @Deprecated true -func (co Controller) 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 - create, ok := filter.ToCreate(c) - if !ok { - return - } - - query := co.DB.Where(&models.Envelope{ - EnvelopeCreate: create, - }, queryFields...) - - query = stringFilters(co.DB, query, setFields, filter.Name, filter.Note, filter.Search) - - var envelopes []models.Envelope - if !queryAndHandleErrors(c, query.Find(&envelopes)) { - return - } - - r := make([]Envelope, 0) - for _, e := range envelopes { - o, ok := co.getEnvelope(c, e.ID) - if !ok { - return - } - - r = append(r, o) - } - - c.JSON(http.StatusOK, EnvelopeListResponse{Data: r}) -} - -// @Summary Get envelope -// @Description Returns a specific envelope -// @Tags Envelopes -// @Produce json -// @Success 200 {object} EnvelopeResponse -// @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 /v1/envelopes/{id} [get] -// @Deprecated true -func (co Controller) GetEnvelope(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - m, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, id) - if !ok { - return - } - - r, ok := co.getEnvelope(c, m.ID) - if !ok { - return - } - - c.JSON(http.StatusOK, EnvelopeResponse{Data: r}) -} - -// @Summary Get Envelope month data -// @Description Returns data about an envelope for a for a specific month. **Use GET /month endpoint with month and budgetId query parameters instead.** -// @Tags Envelopes -// @Produce json -// @Success 200 {object} EnvelopeMonthResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID formatted as string" -// @Param month path string true "The month in YYYY-MM format" -// @Router /v1/envelopes/{id}/{month} [get] -// @Deprecated true -func (co Controller) GetEnvelopeMonth(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - envelope, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, id) - if !ok { - return - } - - if month.Month.IsZero() { - httperrors.New(c, http.StatusBadRequest, "You cannot request data for no month") - return - } - - envelopeMonth, _, err := envelope.Month(co.DB, types.MonthOf(month.Month)) - if err != nil { - httperrors.Handler(c, err) - return - } - - c.JSON(http.StatusOK, EnvelopeMonthResponse{Data: envelopeMonth}) -} - -// @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} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID formatted as string" -// @Param envelope body models.EnvelopeCreate true "Envelope" -// @Router /v1/envelopes/{id} [patch] -// @Deprecated true -func (co Controller) UpdateEnvelope(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - envelope, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, id) - if !ok { - return - } - - updateFields, err := httputil.GetBodyFieldsHandleErrors(c, models.EnvelopeCreate{}) - if err != nil { - return - } - - var data models.Envelope - if err := httputil.BindDataHandleErrors(c, &data.EnvelopeCreate); err != nil { - return - } - - if !queryAndHandleErrors(c, co.DB.Model(&envelope).Select("", updateFields...).Updates(data)) { - return - } - - r, ok := co.getEnvelope(c, envelope.ID) - if !ok { - return - } - - c.JSON(http.StatusOK, EnvelopeResponse{Data: r}) -} - -// @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 /v1/envelopes/{id} [delete] -// @Deprecated true -func (co Controller) DeleteEnvelope(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - envelope, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, id) - if !ok { - return - } - - if !queryAndHandleErrors(c, co.DB.Delete(&envelope)) { - return - } - - c.JSON(http.StatusNoContent, gin.H{}) -} diff --git a/pkg/controllers/envelope_v1_test.go b/pkg/controllers/envelope_v1_test.go deleted file mode 100644 index 6c333e76..00000000 --- a/pkg/controllers/envelope_v1_test.go +++ /dev/null @@ -1,447 +0,0 @@ -package controllers_test - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/google/uuid" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) createTestEnvelope(c models.EnvelopeCreate, expectedStatus ...int) controllers.EnvelopeResponse { - if c.CategoryID == uuid.Nil { - c.CategoryID = suite.createTestCategory(models.CategoryCreate{}).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) - } - - r := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/envelopes", c) - assertHTTPStatus(suite.T(), &r, expectedStatus...) - - var e controllers.EnvelopeResponse - suite.decodeResponse(&r, &e) - - return e -} - -func (suite *TestSuiteStandard) TestEnvelopes() { - suite.CloseDB() - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/envelopes", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusInternalServerError) - assert.Contains(suite.T(), test.DecodeError(suite.T(), recorder.Body.Bytes()), "There is a problem with the database connection") -} - -func (suite *TestSuiteStandard) TestOptionsEnvelope() { - path := fmt.Sprintf("%s/%s", "http://example.com/v1/envelopes", uuid.New()) - recorder := test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNotFound, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, "http://example.com/v1/envelopes/NotParseableAsUUID", "") - assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - path = suite.createTestEnvelope(models.EnvelopeCreate{}).Data.Links.Self - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNoContent, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) -} - -func (suite *TestSuiteStandard) TestGetEnvelopes() { - _ = suite.createTestEnvelope(models.EnvelopeCreate{}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/envelopes", "") - - var response controllers.EnvelopeListResponse - suite.decodeResponse(&recorder, &response) - - assert.Equal(suite.T(), 200, recorder.Code) - assert.Len(suite.T(), response.Data, 1) - - diff := time.Since(response.Data[0].CreatedAt) - assert.LessOrEqual(suite.T(), diff, tolerance) - - diff = time.Since(response.Data[0].UpdatedAt) - assert.LessOrEqual(suite.T(), diff, tolerance) -} - -func (suite *TestSuiteStandard) TestGetEnvelopesInvalidQuery() { - tests := []string{ - "category=DefinitelyACat", - } - - for _, tt := range tests { - suite.T().Run(tt, func(t *testing.T) { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v1/envelopes?%s", tt), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - }) - } -} - -func (suite *TestSuiteStandard) TestGetEnvelopesFilter() { - c1 := suite.createTestCategory(models.CategoryCreate{}) - c2 := suite.createTestCategory(models.CategoryCreate{}) - - _ = suite.createTestEnvelope(models.EnvelopeCreate{ - Name: "Groceries", - Note: "For the stuff bought in supermarkets", - CategoryID: c1.Data.ID, - }) - - _ = suite.createTestEnvelope(models.EnvelopeCreate{ - Name: "Hairdresser", - Note: "Because… Hair!", - CategoryID: c2.Data.ID, - Hidden: true, - }) - - _ = suite.createTestEnvelope(models.EnvelopeCreate{ - 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 - }{ - {"Category 2", fmt.Sprintf("category=%s", c2.Data.ID), 2}, - {"Category Not Existing", "category=e0f9ff7a-9f07-463c-bbd2-0d72d09d3cc6", 0}, - {"Empty Note", "note=", 0}, - {"Empty Name", "name=", 0}, - {"Name & Note", "name=Groceries¬e=For the stuff bought in supermarkets", 1}, - {"Fuzzy name", "name=es", 2}, - {"Fuzzy note", "note=Because", 2}, - {"Not hidden", "hidden=false", 2}, - {"Hidden", "hidden=true", 1}, - {"Search for 'hair'", "search=hair", 2}, - {"Search for 'st'", "search=st", 2}, - {"Search for 'STUFF'", "search=STUFF", 1}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - var re controllers.EnvelopeListResponse - r := test.Request(suite.controller, t, http.MethodGet, fmt.Sprintf("/v1/envelopes?%s", tt.query), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &re) - - assert.Equal(t, tt.len, len(re.Data), "Request ID: %s", r.Result().Header.Get("x-request-id")) - }) - } -} - -func (suite *TestSuiteStandard) TestGetEnvelope() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, envelope.Data.Links.Self, "") - - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) -} - -func (suite *TestSuiteStandard) TestNoEnvelopeNotFound() { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/envelopes/828f2483-dabd-4267-a223-e34b5f171978", "") - - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestEnvelopeInvalidIDs() { - /* - * GET - */ - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/envelopes/-56", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/envelopes/notANumber", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/envelopes/23", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/envelopes/d19a622f-broken-uuid/2017-09", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * PATCH - */ - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/envelopes/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/envelopes/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * DELETE - */ - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/envelopes/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/envelopes/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateEnvelope() { - _ = suite.createTestEnvelope(models.EnvelopeCreate{Name: "New envelope", Note: "More tests something something"}) -} - -func (suite *TestSuiteStandard) TestCreateEnvelopeNoCategory() { - r := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/envelopes", models.Envelope{}) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateBrokenEnvelope() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/envelopes", `{ "createdAt": "New Envelope", "note": "More tests for envelopes to ensure less brokenness something" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateEnvelopeNonExistingCategory() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/envelopes", `{ "categoryId": "5f0cd7b9-9788-4871-96f8-c816c9ae338a" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestCreateEnvelopeNoBody() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/envelopes", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateEnvelopeDuplicateName() { - e := suite.createTestEnvelope(models.EnvelopeCreate{ - Name: "Unique Category Name", - }) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/envelopes", models.EnvelopeCreate{ - CategoryID: e.Data.CategoryID, - Name: e.Data.Name, - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -// TestEnvelopeMonth verifies that the monthly calculations are correct. -func (suite *TestSuiteStandard) TestEnvelopeMonth() { - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID, Name: "Utilities"}) - account := suite.createTestAccount(models.AccountCreate{BudgetID: budget.Data.ID, OnBudget: true, Name: "TestEnvelopeMonth Internal"}) - externalAccount := suite.createTestAccount(models.AccountCreate{BudgetID: budget.Data.ID, External: true, Name: "TestEnvelopeMonth External"}) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelope.Data.ID, - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(20.99), - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelope.Data.ID, - Month: types.NewMonth(2022, 2), - Amount: decimal.NewFromFloat(47.12), - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelope.Data.ID, - Month: types.NewMonth(2022, 3), - Amount: decimal.NewFromFloat(31.17), - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 1, 15, 0, 0, 0, 0, time.UTC), - Amount: decimal.NewFromFloat(10.0), - Note: "Water bill for January", - BudgetID: budget.Data.ID, - SourceAccountID: account.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - EnvelopeID: &envelope.Data.ID, - Reconciled: true, - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 2, 15, 0, 0, 0, 0, time.UTC), - Amount: decimal.NewFromFloat(5.0), - Note: "Water bill for February", - BudgetID: budget.Data.ID, - SourceAccountID: account.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - EnvelopeID: &envelope.Data.ID, - Reconciled: true, - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 3, 15, 0, 0, 0, 0, time.UTC), - Amount: decimal.NewFromFloat(15.0), - Note: "Water bill for March", - BudgetID: budget.Data.ID, - SourceAccountID: account.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - EnvelopeID: &envelope.Data.ID, - Reconciled: true, - }) - - tests := []struct { - path string - envelopeMonth models.EnvelopeMonth - }{ - { - fmt.Sprintf("%s/2022-01", envelope.Data.Links.Self), - models.EnvelopeMonth{ - Envelope: models.Envelope{ - EnvelopeCreate: models.EnvelopeCreate{ - Name: "Utilities", - }, - }, - Month: types.NewMonth(2022, 1), - Spent: decimal.NewFromFloat(-10), - Balance: decimal.NewFromFloat(10.99), - Allocation: decimal.NewFromFloat(20.99), - }, - }, - { - fmt.Sprintf("%s/2022-02", envelope.Data.Links.Self), - models.EnvelopeMonth{ - Envelope: models.Envelope{ - EnvelopeCreate: models.EnvelopeCreate{ - Name: "Utilities", - }, - }, - Month: types.NewMonth(2022, 2), - Balance: decimal.NewFromFloat(53.11), - Spent: decimal.NewFromFloat(-5), - Allocation: decimal.NewFromFloat(47.12), - }, - }, - { - fmt.Sprintf("%s/2022-03", envelope.Data.Links.Self), - models.EnvelopeMonth{ - Envelope: models.Envelope{ - EnvelopeCreate: models.EnvelopeCreate{ - Name: "Utilities", - }, - }, - Month: types.NewMonth(2022, 3), - Balance: decimal.NewFromFloat(69.28), - Spent: decimal.NewFromFloat(-15), - Allocation: decimal.NewFromFloat(31.17), - }, - }, - // This month should be all zeroes, but have otherwise correct settings - { - fmt.Sprintf("%s/1998-10", envelope.Data.Links.Self), - models.EnvelopeMonth{ - Envelope: models.Envelope{ - EnvelopeCreate: models.EnvelopeCreate{ - Name: "Utilities", - }, - }, - Month: types.NewMonth(1998, 10), - Spent: decimal.NewFromFloat(-0), - Balance: decimal.NewFromFloat(0), - Allocation: decimal.NewFromFloat(0), - }, - }, - } - - // Sum alloc: 99.28 - - var envelopeMonth controllers.EnvelopeMonthResponse - for _, tt := range tests { - r := test.Request(suite.controller, suite.T(), http.MethodGet, tt.path, "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - - suite.decodeResponse(&r, &envelopeMonth) - assert.Equal(suite.T(), tt.envelopeMonth.Name, envelopeMonth.Data.Name) - assert.Equal(suite.T(), tt.envelopeMonth.Month, envelopeMonth.Data.Month) - assert.True(suite.T(), envelopeMonth.Data.Spent.Equal(tt.envelopeMonth.Spent), "Monthly spent calculation for %v is wrong: should be %v, but is %v: %#v", envelopeMonth.Data.Month, tt.envelopeMonth.Spent, envelopeMonth.Data.Spent, envelopeMonth.Data) - assert.True(suite.T(), envelopeMonth.Data.Balance.Equal(tt.envelopeMonth.Balance), "Monthly balance calculation for %v is wrong: should be %v, but is %v: %#v", envelopeMonth.Data.Month, tt.envelopeMonth.Balance, envelopeMonth.Data.Balance, envelopeMonth.Data) - assert.True(suite.T(), envelopeMonth.Data.Allocation.Equal(tt.envelopeMonth.Allocation), "Monthly allocation fetch for %v is wrong: should be %v, but is %v: %#v", envelopeMonth.Data.Month, tt.envelopeMonth.Allocation, envelopeMonth.Data.Allocation, envelopeMonth.Data) - } -} - -func (suite *TestSuiteStandard) TestEnvelopeMonthInvalid() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - - // Test that non-parseable requests produce an error - r := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("%s/Stonks!", envelope.Data.Links.Self), "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestEnvelopeMonthNoEnvelope() { - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/envelopes/510ffa95-e445-43cc-8abc-da8e2c20ea5c/2022-04", "") - assertHTTPStatus(suite.T(), &r, http.StatusNotFound) -} - -// TestEnvelopeMonthZero tests that we return a HTTP Bad Request when requesting data for the zero timestamp. -func (suite *TestSuiteStandard) TestEnvelopeMonthZero() { - e := suite.createTestEnvelope(models.EnvelopeCreate{}) - r := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("%s/0001-01", e.Data.Links.Self), "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateEnvelope() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{Name: "New envelope", Note: "Keks is a cuddly cat"}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, envelope.Data.Links.Self, map[string]any{ - "name": "Updated new envelope for testing", - "note": "", - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - - var updatedEnvelope controllers.EnvelopeResponse - suite.decodeResponse(&recorder, &updatedEnvelope) - - assert.Equal(suite.T(), "", updatedEnvelope.Data.Note) - assert.Equal(suite.T(), "Updated new envelope for testing", updatedEnvelope.Data.Name) -} - -func (suite *TestSuiteStandard) TestUpdateEnvelopeBrokenJSON() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{Name: "New envelope", Note: "Keks is a cuddly cat"}) - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, envelope.Data.Links.Self, `{ "name": 2" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateEnvelopeInvalidType() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{Name: "New envelope", Note: "Keks is a cuddly cat"}) - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, envelope.Data.Links.Self, map[string]any{ - "name": 2, - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateEnvelopeInvalidCategoryID() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{Name: "New envelope", Note: "Keks is a cuddly cat"}) - - // Sets the CategoryID to uuid.Nil - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, envelope.Data.Links.Self, models.EnvelopeCreate{}) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateNonExistingEnvelope() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/envelopes/dcf472ba-a64e-4f0f-900e-a789319e432c", `{ "name": "2" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteEnvelope() { - e := suite.createTestEnvelope(models.EnvelopeCreate{Name: "Delete me!"}) - r := test.Request(suite.controller, suite.T(), http.MethodDelete, e.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &r, http.StatusNoContent) -} - -func (suite *TestSuiteStandard) TestDeleteNonExistingEnvelope() { - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/envelopes/21a300da-d8b4-478d-8e85-95cb7982cbca", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteEnvelopeWithBody() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{Name: "Delete this envelope"}) - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, envelope.Data.Links.Self, `{ "name": "test name 23" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) -} diff --git a/pkg/controllers/generics.go b/pkg/controllers/generics.go index 999fbf97..81ad1763 100644 --- a/pkg/controllers/generics.go +++ b/pkg/controllers/generics.go @@ -10,35 +10,6 @@ import ( "github.com/google/uuid" ) -// getResourceByIDAndHandleErrors gets a resources of a specified type by its ID. -// -// When the ID is not specified (which is equal to an all-zeroes UUID), it returns an HTTP 400. -// When no resource exists for the specified ID, an HTTP 404 is returned with an appropriate message. -func getResourceByIDAndHandleErrors[T models.Model](c *gin.Context, co Controller, id uuid.UUID) (resource T, success bool) { - if id == uuid.Nil { - httperrors.New(c, http.StatusBadRequest, "No %s ID specified", resource.Self()) - return - } - - err := query(c, co.DB.Where( - map[string]interface{}{"ID": id}, - ).First(&resource)) - if !err.Nil() { - msg := err.Error() - if err.Status == http.StatusNotFound { - s := fmt.Sprintf("No %s found for the specified ID", resource.Self()) - msg = s - } - - c.JSON(err.Status, httperrors.HTTPError{ - Error: msg, - }) - return resource, false - } - - return resource, true -} - // getResourceByID 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. diff --git a/pkg/controllers/import_v1.go b/pkg/controllers/import_v1.go deleted file mode 100644 index 4aff3960..00000000 --- a/pkg/controllers/import_v1.go +++ /dev/null @@ -1,259 +0,0 @@ -package controllers - -import ( - "errors" - "net/http" - - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/importer" - ynabimport "github.com/envelope-zero/backend/v3/pkg/importer/parser/ynab-import" - "github.com/envelope-zero/backend/v3/pkg/importer/parser/ynab4" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" -) - -type ImportPreviewList struct { - Data []importer.TransactionPreview `json:"data"` // List of transaction previews -} - -// RegisterImportRoutes registers the routes for imports. -func (co Controller) RegisterImportRoutes(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsImport) - r.POST("", co.Import) - - r.OPTIONS("/ynab4", co.OptionsImportYnab4) - r.POST("/ynab4", co.ImportYnab4) - - r.OPTIONS("/ynab-import-preview", co.OptionsImportYnabImportPreview) - r.POST("/ynab-import-preview", co.ImportYnabImportPreview) - } -} - -// OptionsImport 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. **Please use /v1/import/ynab4, which works exactly the same.** -// @Tags Import -// @Success 204 -// @Router /v1/import [options] -// @Deprecated true -func (co Controller) OptionsImport(c *gin.Context) { - httputil.OptionsPost(c) -} - -// OptionsImportYnab4 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 Import -// @Success 204 -// @Router /v1/import/ynab4 [options] -// @Deprecated true -func (co Controller) OptionsImportYnab4(c *gin.Context) { - httputil.OptionsPost(c) -} - -// OptionsImportYnab4 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 Import -// @Success 204 -// @Router /v1/import/ynab-import-preview [options] -// @Deprecated true -func (co Controller) OptionsImportYnabImportPreview(c *gin.Context) { - httputil.OptionsPost(c) -} - -// Import imports a YNAB 4 budget -// -// @Summary Import -// @Description Imports budgets from YNAB 4. **Please use /v1/import/ynab4, which works exactly the same.** -// @Tags Import -// @Accept multipart/form-data -// @Produce json -// @Success 204 -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param file formData file true "File to import" -// @Param budgetName query string false "Name of the Budget to create" -// @Router /v1/import [post] -// @Deprecated true -func (co Controller) Import(c *gin.Context) { - co.ImportYnab4(c) -} - -// ImportYnabImportPreview parses a YNAB import format CSV and returns a preview of transactions -// to be imported into Envelope Zero. -// -// @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} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param file formData file true "File to import" -// @Param accountId query string false "ID of the account to import transactions for" -// @Router /v1/import/ynab-import-preview [post] -// @Deprecated true -func (co Controller) ImportYnabImportPreview(c *gin.Context) { - var query ImportPreviewQuery - if err := c.BindQuery(&query); err != nil { - httperrors.New(c, http.StatusBadRequest, httperrors.ErrAccountIDParameter.Error()) - return - } - - f, e := getUploadedFile(c, ".csv") - if !e.Nil() { - c.JSON(e.Status, httperrors.HTTPError{ - Error: e.Error(), - }) - return - } - - accountID, e := httputil.UUIDFromString(query.AccountID) - if !e.Nil() { - c.JSON(e.Status, httperrors.HTTPError{ - Error: e.Error(), - }) - return - } - - // Verify that the account exists - account, e := getResourceByID[models.Account](c, co, accountID) - if !e.Nil() { - c.JSON(e.Status, httperrors.HTTPError{ - Error: e.Error(), - }) - return - } - - transactions, err := ynabimport.Parse(f, account) - if err != nil { - // ynabimport.Parse parsing returns a usable error already, no parsing necessary - c.JSON(http.StatusBadRequest, httperrors.HTTPError{ - Error: err.Error(), - }) - return - } - - // Get all match rules for the budget that the import target account is part of - var matchRules []models.MatchRule - err = co.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 { - httperrors.Handler(c, err) - 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(co, &transaction, account.BudgetID) - if err != nil { - httperrors.Handler(c, err) - return - } - } - - duplicateTransactions(co, &transaction, account.BudgetID) - - // Recommend an envelope - if transaction.Transaction.DestinationAccountID != uuid.Nil { - err = recommendEnvelope(co, &transaction, transaction.Transaction.DestinationAccountID) - if err != nil { - httperrors.Handler(c, err) - } - } - - transactions[i] = transaction - } - - c.JSON(http.StatusOK, ImportPreviewList{Data: transactions}) -} - -// ImportYnab4 imports a YNAB 4 budget -// -// @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} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param file formData file true "File to import" -// @Param budgetName query string false "Name of the Budget to create" -// @Router /v1/import/ynab4 [post] -// @Deprecated true -func (co Controller) 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 := co.DB.Where(&models.Budget{ - BudgetCreate: models.BudgetCreate{ - 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) { - httperrors.Handler(c, err) - return - } - - f, e := getUploadedFile(c, ".yfull") - if !e.Nil() { - c.JSON(e.Status, httperrors.HTTPError{ - Error: e.Error(), - }) - 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.BudgetCreate.Name = query.BudgetName - - budget, err = importer.Create(co.DB, resources) - if err != nil { - httperrors.Handler(c, err) - return - } - - r, ok := co.getBudget(c, budget.ID) - if !ok { - return - } - - c.JSON(http.StatusCreated, BudgetResponse{Data: r}) -} diff --git a/pkg/controllers/import_v1_test.go b/pkg/controllers/import_v1_test.go deleted file mode 100644 index 2132dc1d..00000000 --- a/pkg/controllers/import_v1_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package controllers_test - -import ( - "bytes" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/google/uuid" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" -) - -// TestYnab4ImportFails tests failing imports for the YNAB 4 budget import endpoint. -func (suite *TestSuiteStandard) TestYnab4ImportFails() { - 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() { - _ = suite.createTestBudget(models.BudgetCreate{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/v1/import/ynab4?budgetName=%s", tt.budgetName) - - var body *bytes.Buffer - var headers map[string]string - var recorder httptest.ResponseRecorder - if tt.file != "" { - body, headers = suite.loadTestFile(tt.file) - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, path, body, headers) - } else { - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, path, "") - } - - assert.Equal(t, tt.status, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - assert.Contains(t, test.DecodeError(t, recorder.Body.Bytes()), tt.expectedError) - }) - } -} - -// TestYnabImportPreviewFails tests failing requests for the YNAB import format preview endpoint. -func (suite *TestSuiteStandard) TestYnabImportPreviewFails() { - accountID := suite.createTestAccount(models.AccountCreate{Name: "TestYnabImportPreviewFails"}).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/v1/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 = suite.loadTestFile(tt.file) - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, path, body, headers) - } else { - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, path, "") - } - - assert.Equal(t, tt.status, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - assert.Contains(t, test.DecodeError(t, recorder.Body.Bytes()), tt.expectedError) - }) - } -} - -func (suite *TestSuiteStandard) TestImport() { - accountID := suite.createTestAccount(models.AccountCreate{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 := suite.loadTestFile(tt.file) - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, fmt.Sprintf("http://example.com/v1/import/%s", tt.path), body, headers) - suite.Assert().Equal(tt.status, recorder.Code, "Request ID %s, response %s", recorder.Header().Get("x-request-id"), recorder.Body.String()) - }) - } -} - -func (suite *TestSuiteStandard) TestYnabImportPreviewDuplicateDetection() { - // Create test account - account := suite.createTestAccount(models.AccountCreate{Name: "TestYnabImportPreviewDuplicateDetection"}) - - // Get the import hash of the first transaction and create one with the same import hash - preview := parseCSV(suite, account.Data.ID, "comdirect-ynap.csv") - - transaction := suite.createTestTransaction(models.TransactionCreate{ - SourceAccountID: account.Data.ID, - ImportHash: preview.Data[0].Transaction.ImportHash, - Amount: decimal.NewFromFloat(1.13), - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - SourceAccountID: suite.createTestAccount(models.AccountCreate{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 = parseCSV(suite, 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) TestYnabImportAvailableFrom() { - // Create test account - account := suite.createTestAccount(models.AccountCreate{Name: "TestYnabImportAvailableFrom"}) - preview := parseCSV(suite, 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 parseCSV(suite *TestSuiteStandard, accountID uuid.UUID, file string) controllers.ImportPreviewList { - path := fmt.Sprintf("ynab-import-preview?accountId=%s", accountID.String()) - - // Parse the test CSV - body, headers := suite.loadTestFile(fmt.Sprintf("importer/ynab-import/%s", file)) - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, fmt.Sprintf("http://example.com/v1/import/%s", path), body, headers) - suite.Assert().Equal(http.StatusOK, recorder.Code, "Request ID %s, response %s", recorder.Header().Get("x-request-id"), recorder.Body.String()) - - // Decode the response - var response controllers.ImportPreviewList - suite.decodeResponse(&recorder, &response) - - return response -} - -func (suite *TestSuiteStandard) TestYnabImportFindAccounts() { - // Create a budget and two existing accounts to use - budget := suite.createTestBudget(models.BudgetCreate{}) - edeka := suite.createTestAccount(models.AccountCreate{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 - _ = suite.createTestAccount(models.AccountCreate{Name: "Edeka"}) - - // Account we import to - internalAccount := suite.createTestAccount(models.AccountCreate{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 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}).Data.ID}) - envelopeID := envelope.Data.ID - _ = suite.createTestTransaction(models.TransactionCreate{BudgetID: budget.Data.ID, 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 := parseCSV(suite, 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) TestMatch() { - // Create a budget and two existing accounts to use - budget := suite.createTestBudget(models.BudgetCreate{}) - edeka := suite.createTestAccount(models.AccountCreate{BudgetID: budget.Data.ID, Name: "Edeka", External: true}) - bahn := suite.createTestAccount(models.AccountCreate{BudgetID: budget.Data.ID, Name: "Deutsche Bahn", External: true}) - - // Account we import to - internalAccount := suite.createTestAccount(models.AccountCreate{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 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}).Data.ID}) - envelopeID := envelope.Data.ID - _ = suite.createTestTransaction(models.TransactionCreate{BudgetID: budget.Data.ID, 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 := suite.createTestMatchRule(t, models.MatchRuleCreate{ - Match: "EDEKA*", - AccountID: edeka.Data.ID, - }) - - return [3]uuid.UUID{edeka.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 := suite.createTestMatchRule(t, models.MatchRuleCreate{ - Match: "EDEKA*", - AccountID: edeka.Data.ID, - }) - - db := suite.createTestMatchRule(t, models.MatchRuleCreate{ - Match: "DB Vertrieb GmbH", - AccountID: bahn.Data.ID, - }) - - return [3]uuid.UUID{edeka.ID, db.ID} - }, - }, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - matchRuleIDs := tt.preTest(t) - preview := parseCSV(suite, 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) - } - - assert.Equal(t, matchRuleIDs[i], transaction.MatchRuleID, "Expected match rule has match '%s', actual match rule has match '%s'", matchRuleIDs[i], transaction.MatchRuleID) - - // This is kept for backwards compatibility and will be removed with API version 3 - // https://github.com/envelope-zero/backend/issues/763 - assert.Equal(t, matchRuleIDs[i], transaction.RenameRuleID, "Expected rename rule has match '%s', actual rename 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 { - suite.controller.DB.Delete(&models.MatchRule{}, id) - } - } - }) - } -} diff --git a/pkg/controllers/match_rule_v2.go b/pkg/controllers/match_rule_v2.go deleted file mode 100644 index b95e9d61..00000000 --- a/pkg/controllers/match_rule_v2.go +++ /dev/null @@ -1,330 +0,0 @@ -package controllers - -import ( - "fmt" - "net/http" - - "github.com/google/uuid" - - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" -) - -// MatchRuleQueryFilter contains the fields that Match Rules can be filtered with. -type MatchRuleQueryFilter struct { - Priority uint `form:"month"` // By priority - Match string `form:"match"` // By match - AccountID string `form:"account"` // By ID of the account they map to -} - -// Parse returns a models.MatchRuleCreate struct that represents the MatchRuleQueryFilter. -func (f MatchRuleQueryFilter) Parse(c *gin.Context) (models.MatchRuleCreate, httperrors.Error) { - envelopeID, err := httputil.UUIDFromString(f.AccountID) - if !err.Nil() { - return models.MatchRuleCreate{}, err - } - - var month QueryMonth - if err := c.Bind(&month); err != nil { - e := httperrors.Parse(c, err) - return models.MatchRuleCreate{}, e - } - - return models.MatchRuleCreate{ - Priority: f.Priority, - Match: f.Match, - AccountID: envelopeID, - }, httperrors.Error{} -} - -type ResponseMatchRule struct { - Error string `json:"error" example:"A human readable error message"` // This field contains a human readable error message - Data MatchRule `json:"data"` // This field contains the MatchRule data -} - -type MatchRule struct { - models.MatchRule - Links struct { - Self string `json:"self" example:"https://example.com/api/v2/match-rules/95685c82-53c6-455d-b235-f49960b73b21"` // The match rule itself - } `json:"links"` -} - -func (r *MatchRule) links(c *gin.Context) { - r.Links.Self = fmt.Sprintf("%s/v2/match-rules/%s", c.GetString(string(database.ContextURL)), r.ID) -} - -func (co Controller) getMatchRule(c *gin.Context, id uuid.UUID) (MatchRule, bool) { - m, ok := getResourceByIDAndHandleErrors[models.MatchRule](c, co, id) - if !ok { - return MatchRule{}, false - } - - r := MatchRule{ - MatchRule: m, - } - - r.links(c) - return r, true -} - -// RegisterMatchRuleRoutes registers the routes for matchRules with -// the RouterGroup that is passed. -func (co Controller) RegisterMatchRuleRoutes(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsMatchRuleList) - r.GET("", co.GetMatchRules) - r.POST("", co.CreateMatchRules) - } - - // MatchRule with ID - { - r.OPTIONS("/:id", co.OptionsMatchRuleDetail) - r.GET("/:id", co.GetMatchRule) - r.PATCH("/:id", co.UpdateMatchRule) - r.DELETE("/:id", co.DeleteMatchRule) - } -} - -// OptionsMatchRuleList returns the allowed HTTP verbs -// -// @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 /v2/match-rules [options] -// @Deprecated true -func (co Controller) OptionsMatchRuleList(c *gin.Context) { - httputil.OptionsGetPost(c) -} - -// OptionsMatchRuleDetail returns the allowed HTTP verbs -// -// @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 /v2/match-rules/{id} [options] -// @Deprecated true -func (co Controller) OptionsMatchRuleDetail(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - _, ok := getResourceByIDAndHandleErrors[models.MatchRule](c, co, id) - if !ok { - return - } - httputil.OptionsGetPatchDelete(c) -} - -// CreateMatchRulesV2 creates matchRules -// -// @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} []ResponseMatchRule -// @Failure 400 {object} []ResponseMatchRule -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} []ResponseMatchRule -// @Param matchRules body []models.MatchRuleCreate true "MatchRules" -// @Router /v2/match-rules [post] -// @Deprecated true -func (co Controller) CreateMatchRules(c *gin.Context) { - var matchRules []models.MatchRuleCreate - - if err := httputil.BindDataHandleErrors(c, &matchRules); err != nil { - return - } - - // The response list has the same length as the request list - r := make([]ResponseMatchRule, 0, len(matchRules)) - - // The final http status. Will be modified when errors occur - status := http.StatusCreated - - for _, create := range matchRules { - m, err := co.createMatchRule(c, create) - - // Append the error or the successfully created transaction to the response list - if !err.Nil() { - r = append(r, ResponseMatchRule{Error: err.Error()}) - - // The final status code is the highest HTTP status code number since this also - // represents the priority we - if err.Status > status { - status = err.Status - } - } else { - o, ok := co.getMatchRule(c, m.ID) - if !ok { - return - } - r = append(r, ResponseMatchRule{Data: o}) - } - } - - c.JSON(status, r) -} - -// GetMatchRules returns a list of matchRules matching the search parameters -// -// @Summary Get matchRules -// @Description Returns a list of matchRules -// @Tags MatchRules -// @Produce json -// @Success 200 {object} []models.MatchRule -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @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" -// @Router /v2/match-rules [get] -// @Deprecated true -func (co Controller) GetMatchRules(c *gin.Context) { - var filter MatchRuleQueryFilter - if err := c.Bind(&filter); err != nil { - httperrors.InvalidQueryString(c) - return - } - - // Get the parameters set in the query string - queryFields, _ := httputil.GetURLFields(c.Request.URL, filter) - - // Convert the QueryFilter to a Create struct - create, err := filter.Parse(c) - if !err.Nil() { - c.JSON(err.Status, httperrors.HTTPError{Error: err.Error()}) - return - } - - var matchRules []models.MatchRule - if !queryAndHandleErrors(c, co.DB.Where(&models.MatchRule{ - MatchRuleCreate: create, - }, queryFields...).Find(&matchRules)) { - 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 - if len(matchRules) == 0 { - matchRules = make([]models.MatchRule, 0) - } - - c.JSON(http.StatusOK, matchRules) -} - -// GetMatchRule returns data about a specific matchRule -// -// @Summary Get matchRule -// @Description Returns a specific matchRule -// @Tags MatchRules -// @Produce json -// @Success 200 {object} models.MatchRule -// @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 /v2/match-rules/{id} [get] -// @Deprecated true -func (co Controller) GetMatchRule(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - matchRuleObject, ok := getResourceByIDAndHandleErrors[models.MatchRule](c, co, id) - if !ok { - return - } - - c.JSON(http.StatusOK, matchRuleObject) -} - -// UpdateMatchRule updates matchRule data -// -// @Summary Update matchRule -// @Description Update an matchRule. Only values to be updated need to be specified. -// @Tags MatchRules -// @Accept json -// @Produce json -// @Success 200 {object} models.MatchRule -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID formatted as string" -// @Param matchRule body models.MatchRuleCreate true "MatchRule" -// @Router /v2/match-rules/{id} [patch] -// @Deprecated true -func (co Controller) UpdateMatchRule(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - matchRule, ok := getResourceByIDAndHandleErrors[models.MatchRule](c, co, id) - if !ok { - return - } - - updateFields, err := httputil.GetBodyFieldsHandleErrors(c, models.MatchRuleCreate{}) - if err != nil { - return - } - - var data models.MatchRule - if err := httputil.BindDataHandleErrors(c, &data.MatchRuleCreate); err != nil { - return - } - - if !queryAndHandleErrors(c, co.DB.Model(&matchRule).Select("", updateFields...).Updates(data)) { - return - } - - c.JSON(http.StatusOK, matchRule) -} - -// DeleteMatchRule deletes an matchRule -// -// @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 /v2/match-rules/{id} [delete] -// @Deprecated true -func (co Controller) DeleteMatchRule(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - matchRule, ok := getResourceByIDAndHandleErrors[models.MatchRule](c, co, id) - if !ok { - return - } - - // MatchRules are hard deleted instantly to avoid conflicts for the UNIQUE(id,month) - if !queryAndHandleErrors(c, co.DB.Unscoped().Delete(&matchRule)) { - return - } - - c.JSON(http.StatusNoContent, gin.H{}) -} diff --git a/pkg/controllers/match_rule_v2_test.go b/pkg/controllers/match_rule_v2_test.go deleted file mode 100644 index 56560678..00000000 --- a/pkg/controllers/match_rule_v2_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package controllers_test - -import ( - "fmt" - "net/http" - "testing" - - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" -) - -// TODO: migrate all createTest* methods to functions with *testing.T as first argument. -func (suite *TestSuiteStandard) createTestMatchRule(t *testing.T, c models.MatchRuleCreate, expectedStatus ...int) controllers.MatchRule { - // Default to 201 Creted as expected status - if len(expectedStatus) == 0 { - expectedStatus = append(expectedStatus, http.StatusCreated) - } - - rules := []models.MatchRuleCreate{c} - - r := test.Request(suite.controller, t, http.MethodPost, "http://example.com/v2/match-rules", rules) - assertHTTPStatus(t, &r, expectedStatus...) - - var responseRules []controllers.ResponseMatchRule - suite.decodeResponse(&r, &responseRules) - - return responseRules[0].Data -} - -func (suite *TestSuiteStandard) TestOptionsMatchRule() { - path := fmt.Sprintf("%s/%s", "http://example.com/v2/match-rules", uuid.New()) - r := test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assertHTTPStatus(suite.T(), &r, http.StatusNotFound) - - r = test.Request(suite.controller, suite.T(), http.MethodOptions, "http://example.com/v2/match-rules/NotParseableAsUUID", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - path = suite.createTestMatchRule(suite.T(), models.MatchRuleCreate{ - AccountID: suite.createTestAccount(models.AccountCreate{}).Data.ID, - }, - ).Links.Self - - r = test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assertHTTPStatus(suite.T(), &r, http.StatusNoContent) -} - -func (suite *TestSuiteStandard) TestMatchRuleCreate() { - a := suite.createTestAccount(models.AccountCreate{Name: "TestMatchRuleCreate"}) - - tests := []struct { - name string - create []models.MatchRuleCreate - expectedErrors []string - expectedStatus int - }{ - { - "All successful", - []models.MatchRuleCreate{ - { - Priority: 10, - Match: "Some Match*", - AccountID: a.Data.ID, - }, - { - Priority: 10, - Match: "Bank*", - AccountID: a.Data.ID, - }, - }, - []string{ - "", - "", - }, - http.StatusCreated, - }, - { - "Second fails", - []models.MatchRuleCreate{ - { - 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(suite.controller, t, http.MethodPost, "http://example.com/v2/match-rules", tt.create) - assertHTTPStatus(t, &r, tt.expectedStatus) - - var tr []controllers.ResponseMatchRule - suite.decodeResponse(&r, &tr) - - for i, r := range tr { - assert.Equal(t, tt.expectedErrors[i], r.Error) - - if tt.expectedErrors[i] == "" { - assert.Equal(t, fmt.Sprintf("http://example.com/v2/match-rules/%s", r.Data.ID), r.Data.Links.Self) - } - } - }) - } -} diff --git a/pkg/controllers/month_config_v1.go b/pkg/controllers/month_config_v1.go deleted file mode 100644 index 8472fb2d..00000000 --- a/pkg/controllers/month_config_v1.go +++ /dev/null @@ -1,408 +0,0 @@ -package controllers - -import ( - "fmt" - "net/http" - "strings" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type MonthConfig struct { - models.MonthConfig - Links struct { - Self string `json:"self" example:"https://example.com/api/v1/month-configs/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10"` // The month config itself - Envelope string `json:"envelope" example:"https://example.com/api/v1/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b"` // The envelope this config belongs to - } `json:"links"` -} - -func (m *MonthConfig) links(c *gin.Context) { - url := c.GetString(string(database.ContextURL)) - - m.Links.Self = fmt.Sprintf("%s/v1/month-configs/%s/%s", url, m.EnvelopeID, m.Month) - m.Links.Envelope = fmt.Sprintf("%s/v1/envelopes/%s", url, m.EnvelopeID) -} - -func (co Controller) getMonthConfig(c *gin.Context, id uuid.UUID, month types.Month) (MonthConfig, bool) { - m, ok := co.getMonthConfigModel(c, id, month) - if !ok { - return MonthConfig{}, false - } - - r := MonthConfig{ - MonthConfig: m, - } - - r.links(c) - return r, true -} - -func (co Controller) getMonthConfigModel(c *gin.Context, id uuid.UUID, month types.Month) (models.MonthConfig, bool) { - var m models.MonthConfig - - err := query(c, co.DB.First(&m, &models.MonthConfig{ - EnvelopeID: id, - Month: month, - })) - - if !err.Nil() { - msg := err.Error() - if err.Status == http.StatusNotFound { - s := "No MonthConfig found for the Envelope and month specified" - msg = s - } - - c.JSON(err.Status, httperrors.HTTPError{ - Error: msg, - }) - - return models.MonthConfig{}, false - } - - return m, true -} - -type MonthConfigResponse struct { - Data MonthConfig `json:"data"` // Data for the month -} - -type MonthConfigListResponse struct { - Data []MonthConfig `json:"data"` // List of month configs -} - -type MonthConfigQueryFilter struct { - EnvelopeID string `form:"envelope"` // By ID of the envelope - Month string `form:"month"` // By month -} - -func (m MonthConfigQueryFilter) Parse(c *gin.Context) (MonthConfigFilter, bool) { - envelopeID, ok := httputil.UUIDFromStringHandleErrors(c, m.EnvelopeID) - if !ok { - return MonthConfigFilter{}, false - } - - var month QueryMonth - if err := c.Bind(&month); err != nil { - httperrors.Handler(c, err) - return MonthConfigFilter{}, false - } - - return MonthConfigFilter{ - EnvelopeID: envelopeID, - Month: types.MonthOf(month.Month), - }, true -} - -// RegisterMonthConfigRoutes registers the routes for transactions with -// the RouterGroup that is passed. -func (co Controller) RegisterMonthConfigRoutes(r *gin.RouterGroup) { - r.OPTIONS("", co.OptionsMonthConfigList) - r.GET("", co.GetMonthConfigs) - - r.OPTIONS("/:id/:month", co.OptionsMonthConfigDetail) - r.GET("/:id/:month", co.GetMonthConfig) - r.POST("/:id/:month", co.CreateMonthConfig) - r.PATCH("/:id/:month", co.UpdateMonthConfig) - r.DELETE("/:id/:month", co.DeleteMonthConfig) -} - -// OptionsMonthConfigList 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 MonthConfigs -// @Success 204 -// @Router /v1/month-configs [options] -// @Deprecated true -func (co Controller) OptionsMonthConfigList(c *gin.Context) { - httputil.OptionsGet(c) -} - -// OptionsMonthConfigDetail 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 MonthConfigs -// @Success 204 -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID of the Envelope" -// @Param month path string true "The month in YYYY-MM format" -// @Router /v1/month-configs/{id}/{month} [options] -// @Deprecated true -func (co Controller) OptionsMonthConfigDetail(c *gin.Context) { - _, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - httputil.OptionsGetPostPatchDelete(c) -} - -// GetMonthConfig returns config for a specific envelope and month -// -// @Summary Get MonthConfig -// @Description Returns configuration for a specific month -// @Tags MonthConfigs -// @Produce json -// @Success 200 {object} MonthConfigResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID of the Envelope" -// @Param month path string true "The month in YYYY-MM format" -// @Router /v1/month-configs/{id}/{month} [get] -// @Deprecated true -func (co Controller) GetMonthConfig(c *gin.Context) { - envelopeID, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - _, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, envelopeID) - if !ok { - return - } - - mConfig, ok := co.getMonthConfig(c, envelopeID, types.MonthOf(month.Month)) - if !ok { - return - } - - c.JSON(http.StatusOK, MonthConfigResponse{Data: mConfig}) -} - -// GetMonthConfigs returns all month configs filtered by the query parameters -// -// @Summary List MonthConfigs -// @Description Returns a list of MonthConfigs -// @Tags MonthConfigs -// @Produce json -// @Success 200 {object} MonthConfigListResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param envelope query string false "Filter by name" -// @Param month query string false "Filter by month" -// @Router /v1/month-configs [get] -// @Deprecated true -func (co Controller) GetMonthConfigs(c *gin.Context) { - var filter MonthConfigQueryFilter - if err := c.Bind(&filter); err != nil { - httperrors.InvalidQueryString(c) - return - } - - // Get the set parameters in the query string - queryFields, _ := httputil.GetURLFields(c.Request.URL, filter) - - // Convert the QueryFilter to a Filter struct - parsed, ok := filter.Parse(c) - if !ok { - return - } - - var mConfigs []models.MonthConfig - if !queryAndHandleErrors(c, co.DB.Where(&models.MonthConfig{ - EnvelopeID: parsed.EnvelopeID, - Month: parsed.Month, - }, queryFields...).Find(&mConfigs)) { - return - } - - r := make([]MonthConfig, 0) - for _, m := range mConfigs { - o, ok := co.getMonthConfig(c, m.EnvelopeID, m.Month) - if !ok { - return - } - r = append(r, o) - } - - c.JSON(http.StatusOK, MonthConfigListResponse{Data: r}) -} - -// CreateMonthConfig creates a new month config -// -// @Summary Create MonthConfig -// @Description Creates a new MonthConfig -// @Tags MonthConfigs -// @Produce json -// @Success 201 {object} MonthConfigResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID of the Envelope" -// @Param month path string true "The month in YYYY-MM format" -// @Param monthConfig body models.MonthConfigCreate true "MonthConfig" -// @Router /v1/month-configs/{id}/{month} [post] -// @Deprecated true -func (co Controller) CreateMonthConfig(c *gin.Context) { - envelopeID, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - var mConfig models.MonthConfig - if err = httputil.BindDataHandleErrors(c, &mConfig.MonthConfigCreate); err != nil { - return - } - - // Set config to path parameters - mConfig.EnvelopeID = envelopeID - mConfig.Month = types.MonthOf(month.Month) - - _, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, mConfig.EnvelopeID) - if !ok { - return - } - - err = co.DB.Create(&mConfig).Error - if err != nil { - if !strings.Contains(err.Error(), "UNIQUE constraint failed") { - httperrors.Handler(c, err) - return - } - - httperrors.New(c, http.StatusBadRequest, "Cannot create MonthConfig for Envelope with ID %s and month %s as it already exists", mConfig.EnvelopeID, mConfig.Month) - return - } - - r, ok := co.getMonthConfig(c, mConfig.EnvelopeID, mConfig.Month) - if !ok { - return - } - - c.JSON(http.StatusCreated, MonthConfigResponse{Data: r}) -} - -// UpdateMonthConfig updates configuration data for a specific envelope and month -// -// @Summary Update MonthConfig -// @Description Changes settings of an existing MonthConfig -// @Tags MonthConfigs -// @Produce json -// @Success 201 {object} MonthConfigResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID of the Envelope" -// @Param month path string true "The month in YYYY-MM format" -// @Param monthConfig body models.MonthConfigCreate true "MonthConfig" -// @Router /v1/month-configs/{id}/{month} [patch] -// @Deprecated true -func (co Controller) UpdateMonthConfig(c *gin.Context) { - envelopeID, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - _, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, envelopeID) - if !ok { - return - } - - m, ok := co.getMonthConfigModel(c, envelopeID, types.MonthOf(month.Month)) - if !ok { - return - } - - updateFields, err := httputil.GetBodyFieldsHandleErrors(c, models.MonthConfigCreate{}) - if err != nil { - return - } - - var data models.MonthConfig - if err = httputil.BindDataHandleErrors(c, &data.MonthConfigCreate); err != nil { - return - } - - if !queryAndHandleErrors(c, co.DB.Model(&m).Select("", updateFields...).Updates(data)) { - return - } - - o, ok := co.getMonthConfig(c, m.EnvelopeID, m.Month) - if !ok { - return - } - - c.JSON(http.StatusOK, MonthConfigResponse{Data: o}) -} - -// DeleteMonthConfig deletes configuration data for a specific envelope and month -// -// @Summary Delete MonthConfig -// @Description Deletes configuration settings for a specific month -// @Tags MonthConfigs -// @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 of the Envelope" -// @Param month path string true "The month in YYYY-MM format" -// @Router /v1/month-configs/{id}/{month} [delete] -// @Deprecated true -func (co Controller) DeleteMonthConfig(c *gin.Context) { - envelopeID, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - var month URIMonth - if err := c.BindUri(&month); err != nil { - httperrors.InvalidMonth(c) - return - } - - _, ok := getResourceByIDAndHandleErrors[models.Envelope](c, co, envelopeID) - if !ok { - return - } - - m, ok := co.getMonthConfigModel(c, envelopeID, types.MonthOf(month.Month)) - if !ok { - return - } - - if !queryAndHandleErrors(c, co.DB.Delete(&m)) { - return - } - - c.JSON(http.StatusNoContent, gin.H{}) -} diff --git a/pkg/controllers/month_config_v1_test.go b/pkg/controllers/month_config_v1_test.go deleted file mode 100644 index 9ddd49c5..00000000 --- a/pkg/controllers/month_config_v1_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package controllers_test - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) createTestMonthConfig(envelopeID uuid.UUID, month types.Month, c models.MonthConfigCreate, expectedStatus ...int) controllers.MonthConfigResponse { - if envelopeID == uuid.Nil { - envelopeID = suite.createTestEnvelope(models.EnvelopeCreate{Name: "Transaction Test Envelope"}).Data.ID - } - - // Default to 201 Created as expected status - if len(expectedStatus) == 0 { - expectedStatus = append(expectedStatus, http.StatusCreated) - } - - path := fmt.Sprintf("http://example.com/v1/month-configs/%s/%s", envelopeID, month.String()) - r := test.Request(suite.controller, suite.T(), http.MethodPost, path, c) - assertHTTPStatus(suite.T(), &r, expectedStatus...) - - var mc controllers.MonthConfigResponse - suite.decodeResponse(&r, &mc) - - return mc -} - -func (suite *TestSuiteStandard) TestMonthConfigsEmptyList() { - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/month-configs", "") - - var l controllers.MonthConfigListResponse - suite.decodeResponse(&r, &l) - - // Verify that the list is an empty list, not null - suite.Assert().NotNil(l.Data) - suite.Assert().Empty(l.Data) -} - -func (suite *TestSuiteStandard) TestMonthConfigsCreate() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - someMonth := types.NewMonth(2020, 3) - - tests := []struct { - name string - envelopeID uuid.UUID - month types.Month - note string - overspendMode models.OverspendMode - status int - }{ - {"Standard create", envelope.Data.ID, someMonth, "test note", models.AffectAvailable, http.StatusCreated}, - {"duplicate config for same envelope and month", envelope.Data.ID, someMonth, "", models.AffectAvailable, http.StatusBadRequest}, - {"No envelope", uuid.New(), someMonth, "", models.AffectAvailable, http.StatusNotFound}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - response := suite.createTestMonthConfig(tt.envelopeID, tt.month, models.MonthConfigCreate{Note: tt.note, OverspendMode: tt.overspendMode}, tt.status) - - // Verify that fields are set correctly - if tt.status == http.StatusCreated { - assert.Equal(t, tt.overspendMode, response.Data.OverspendMode) - assert.Equal(t, tt.note, response.Data.Note) - } - }) - } -} - -func (suite *TestSuiteStandard) TestMonthConfigsCreateInvalid() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - - tests := []struct { - name string - envelopeID string - month string - body string - }{ - {"Invalid Body", envelope.Data.ID.String(), "2022-03", `{"name": "not valid body"`}, - {"Invaid UUID", "not a uuid", "2017-04", ""}, - {"Invalid month", envelope.Data.ID.String(), "September Seventy Seven", ""}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - path := fmt.Sprintf("%s/%s/%s", "http://example.com/v1/month-configs", tt.envelopeID, tt.month) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, path, tt.body) - assert.Equal(t, http.StatusBadRequest, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - }) - } -} - -func (suite *TestSuiteStandard) TestMonthConfigsGet() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - someMonth := types.NewMonth(2020, 3) - - _ = suite.createTestMonthConfig(envelope.Data.ID, someMonth, models.MonthConfigCreate{}) - - tests := []struct { - name string - envelopeID string - month string - status int - }{ - {"Standard get", envelope.Data.ID.String(), someMonth.String(), 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}, - {"No MonthConfig", envelope.Data.ID.String(), "0333-11", http.StatusNotFound}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - path := fmt.Sprintf("%s/%s/%s", "http://example.com/v1/month-configs", tt.envelopeID, tt.month) - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, path, "") - assert.Equal(t, tt.status, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - if tt.status == http.StatusOK { - var mConfig controllers.MonthConfigResponse - suite.decodeResponse(&recorder, &mConfig) - - selfLink := fmt.Sprintf("http://example.com/v1/month-configs/%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/v1/envelopes/%s", tt.envelopeID) - assert.Equal(t, envelopeLink, mConfig.Data.Links.Envelope, "Request ID %s", recorder.Header().Get("x-request-id")) - } - }) - } -} - -func (suite *TestSuiteStandard) TestMonthConfigsCreateDBError() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - suite.CloseDB() - - _ = suite.createTestMonthConfig(envelope.Data.ID, types.NewMonth(2020, 3), models.MonthConfigCreate{}, http.StatusInternalServerError) -} - -func (suite *TestSuiteStandard) TestMonthConfigsOptions() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - _ = suite.createTestMonthConfig( - envelope.Data.ID, - types.NewMonth(2014, 5), - models.MonthConfigCreate{}, - ) - - 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 the specified month"}, - {"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/v1/month-configs", tt.envelope, tt.month) - recorder := test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(t, tt.status, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - if tt.status != http.StatusNoContent { - assert.Contains(t, test.DecodeError(suite.T(), recorder.Body.Bytes()), tt.errMsg) - } - }) - } -} - -func (suite *TestSuiteStandard) TestMonthConfigsGetList() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - _ = suite.createTestMonthConfig( - envelope.Data.ID, - types.NewMonth(2007, 10), - models.MonthConfigCreate{}, - ) - - _ = suite.createTestMonthConfig( - envelope.Data.ID, - types.NewMonth(3017, 10), - models.MonthConfigCreate{}, - ) - - tests := []struct { - name string - query string - status int - length int - }{ - {"No envelope", fmt.Sprintf("envelope=%s&month=%s", uuid.New().String(), "1984-03"), http.StatusOK, 0}, - {"No MonthConfig", fmt.Sprintf("envelope=%s&month=%s", envelope.Data.ID.String(), "1984-03"), http.StatusOK, 0}, - {"Exact MonthConfig", fmt.Sprintf("envelope=%s&month=%s", envelope.Data.ID.String(), "2007-10"), http.StatusOK, 1}, - {"Month only", "month=2007-10", http.StatusOK, 1}, - {"Envelope ID only", fmt.Sprintf("envelope=%s", envelope.Data.ID.String()), http.StatusOK, 2}, - {"Bad Envelope ID", fmt.Sprintf("envelope=%s&month=%s", "Definitely-Not-A-UUID", "1984-03"), http.StatusBadRequest, 0}, - {"Invalid Month", fmt.Sprintf("envelope=%s&month=%s", envelope.Data.ID.String(), "2000-00"), http.StatusBadRequest, 0}, - {"Invalid query string", "envelope=;", http.StatusBadRequest, 0}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - path := fmt.Sprintf("%s?%s", "http://example.com/v1/month-configs", tt.query) - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, path, "") - assert.Equal(t, tt.status, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - var l controllers.MonthConfigListResponse - suite.decodeResponse(&recorder, &l) - assert.Len(t, l.Data, tt.length) - }) - } -} - -func (suite *TestSuiteStandard) TestMonthConfigsGetDBError() { - suite.CloseDB() - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/month-configs", "") - suite.Assert().Equal(http.StatusInternalServerError, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) -} - -func (suite *TestSuiteStandard) TestMonthConfigsDelete() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - someMonth := types.NewMonth(2020, 3) - - _ = suite.createTestMonthConfig(envelope.Data.ID, someMonth, models.MonthConfigCreate{}) - - tests := []struct { - name string - envelopeID string - month string - status int - }{ - {"Standard get", envelope.Data.ID.String(), someMonth.String(), http.StatusNoContent}, - {"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}, - {"No MonthConfig", envelope.Data.ID.String(), "0333-11", http.StatusNotFound}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - path := fmt.Sprintf("%s/%s/%s", "http://example.com/v1/month-configs", tt.envelopeID, tt.month) - - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, path, "") - assertHTTPStatus(t, &recorder, tt.status) - }) - } -} - -func (suite *TestSuiteStandard) TestUpdateMonthConfig() { - mConfig := suite.createTestMonthConfig(uuid.Nil, types.NewMonth(time.Now().Year(), time.Now().Month()), models.MonthConfigCreate{}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, mConfig.Data.Links.Self, models.MonthConfigCreate{ - OverspendMode: "AFFECT_ENVELOPE", - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - - var updatedMonthConfig controllers.MonthConfigResponse - suite.decodeResponse(&recorder, &updatedMonthConfig) - - var mode models.OverspendMode = "AFFECT_ENVELOPE" - assert.Equal(suite.T(), mode, updatedMonthConfig.Data.OverspendMode) -} - -func (suite *TestSuiteStandard) TestMonthConfigsUpdateInvalid() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - mConfig := suite.createTestMonthConfig(envelope.Data.ID, types.NewMonth(2022, 3), models.MonthConfigCreate{}) - - tests := []struct { - name string - envelopeID string - month string - body string - status int - }{ - {"Invalid Body", envelope.Data.ID.String(), mConfig.Data.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(), mConfig.Data.Month.String(), "", http.StatusNotFound}, - {"No month config", envelope.Data.ID.String(), "0137-12", "", http.StatusNotFound}, - {"Broken values", envelope.Data.ID.String(), mConfig.Data.Month.String(), `{"overspendMode": 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/v1/month-configs", tt.envelopeID, tt.month) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, path, tt.body) - assertHTTPStatus(t, &recorder, tt.status) - }) - } -} - -func (suite *TestSuiteStandard) TestUpdateMonthConfigBrokenJSON() { - mConfig := suite.createTestMonthConfig(uuid.Nil, types.NewMonth(time.Now().Year(), time.Now().Month()), models.MonthConfigCreate{}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, mConfig.Data.Links.Self, `{ test`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} diff --git a/pkg/controllers/month_config_v3.go b/pkg/controllers/month_config_v3.go index cb223daf..b097fb96 100644 --- a/pkg/controllers/month_config_v3.go +++ b/pkg/controllers/month_config_v3.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/http" - "slices" "github.com/envelope-zero/backend/v3/internal/types" "github.com/envelope-zero/backend/v3/pkg/database" @@ -14,9 +13,15 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/shopspring/decimal" - "gorm.io/gorm" ) +type MonthConfigV3Editable 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 +} + type MonthConfigV3 struct { models.MonthConfig OverspendMode string `json:"overspendMode,omitempty"` // Ignore this. It is here to override the OverspendMode from models.MonthConfigCreate and will be removed with 4.0.0 @@ -161,31 +166,6 @@ func (co Controller) GetMonthConfigV3(c *gin.Context) { } mConfig.links(c) - // Check if there is an allocation for this MonthConfig. If yes, set the value. - var a models.Allocation - err := co.DB.First(&a, models.Allocation{ - AllocationCreate: models.AllocationCreate{ - Month: types.MonthOf(month.Month), - EnvelopeID: id, - }, - }).Error - - // If there is a database error, return it - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - e := httperrors.Parse(c, err) - s := e.Error() - c.JSON(e.Status, MonthConfigResponseV3{ - Error: &s, - }) - return - } - - // Set the amount if there is an allocation. If not, - // the amount is 0, which is the zero value of decimal.Decimal - if err == nil { - mConfig.Allocation = a.Amount - } - c.JSON(http.StatusOK, MonthConfigResponseV3{Data: &mConfig}) return } @@ -202,13 +182,14 @@ func (co Controller) GetMonthConfigV3(c *gin.Context) { // MonthConfigCreateV3 contains the fields relevant for MonthConfigs in APIv3. type MonthConfigCreateV3 struct { - Note string `json:"note" example:"Added 200€ here because we replaced Tim's expensive vase" default:""` // A note for the month config - 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 + Allocation decimal.Decimal `json:"allocation" gorm:"type:DECIMAL(20,8)" example:"22.01" minimum:"0.00000001" maximum:"999999999999.99999999" multipleOf:"0.00000001"` // The maximum value is "999999999999.99999999", swagger unfortunately rounds this. } // ToCreate is used to transform the API representation into the model representation func (m MonthConfigCreateV3) ToCreate() (create models.MonthConfigCreate) { create.Note = m.Note + create.Allocation = m.Allocation return create } @@ -307,27 +288,6 @@ func (co Controller) UpdateMonthConfigV3(c *gin.Context) { return } - // The Allocation field does not yet exist on the MonthConfig model in the database, create - // or update the corresponding allocation resource in the meantime - if slices.Contains(updateFields, "Allocation") && data.Allocation != decimal.Zero { - var allocation models.Allocation - e := co.DB.Where(models.Allocation{AllocationCreate: models.AllocationCreate{ - Month: types.MonthOf(month.Month), - EnvelopeID: id, - }}).Assign(models.Allocation{AllocationCreate: models.AllocationCreate{ - Amount: data.Allocation, - }}).FirstOrCreate(&allocation).Error - - if e != nil { - err = httperrors.Parse(c, err) - s := err.Error() - c.JSON(err.Status, MonthConfigResponseV3{ - Error: &s, - }) - return - } - } - create := data.ToCreate() err = query(c, co.DB.Model(&m).Select("", updateFields...).Updates(create)) diff --git a/pkg/controllers/month_config_v3_test.go b/pkg/controllers/month_config_v3_test.go index 0b43a9fb..45c2effe 100644 --- a/pkg/controllers/month_config_v3_test.go +++ b/pkg/controllers/month_config_v3_test.go @@ -11,10 +11,29 @@ import ( "github.com/envelope-zero/backend/v3/pkg/models" "github.com/envelope-zero/backend/v3/test" "github.com/google/uuid" - "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" ) +func (suite *TestSuiteStandard) patchTestMonthConfigV3(t *testing.T, envelopeID uuid.UUID, month types.Month, c models.MonthConfigCreate, expectedStatus ...int) controllers.MonthConfigResponseV3 { + if envelopeID == uuid.Nil { + envelopeID = suite.createTestEnvelopeV3(t, controllers.EnvelopeCreateV3{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/v3/envelopes/%s/%s", envelopeID, month.String()) + r := test.Request(suite.controller, suite.T(), http.MethodPatch, path, c) + assertHTTPStatus(suite.T(), &r, expectedStatus...) + + var mc controllers.MonthConfigResponseV3 + suite.decodeResponse(&r, &mc) + + return mc +} + func (suite *TestSuiteStandard) TestMonthConfigsV3GetSingle() { envelope := suite.createTestEnvelopeV3(suite.T(), controllers.EnvelopeCreateV3{}) someMonth := types.NewMonth(2020, 3) @@ -133,31 +152,3 @@ func (suite *TestSuiteStandard) TestMonthConfigsV3UpdateFails() { }) } } - -func (suite *TestSuiteStandard) TestMonthConfigsV3UpdateAllocationCreatesResource() { - envelope := suite.createTestEnvelopeV3(suite.T(), controllers.EnvelopeCreateV3{}) - month := types.NewMonth(time.Now().Year(), time.Now().Month()) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, fmt.Sprintf("http://example.com/v3/envelopes/%s/%s", envelope.Data.ID, month), controllers.MonthConfigCreateV3{ - Note: "This is the updated note", - Allocation: decimal.NewFromFloat(17.32), - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - - var updatedMonthConfig controllers.MonthConfigResponseV3 - suite.decodeResponse(&recorder, &updatedMonthConfig) - assert.Equal(suite.T(), "This is the updated note", updatedMonthConfig.Data.Note) - assert.Equal(suite.T(), decimal.NewFromFloat(17.32), updatedMonthConfig.Data.Allocation) - - // Verify that the allocation is set to the correct value - a := models.Allocation{ - AllocationCreate: models.AllocationCreate{ - Month: month, - EnvelopeID: envelope.Data.ID, - }, - } - - err := suite.controller.DB.First(&a).Error - assert.Nil(suite.T(), err) - assert.Equal(suite.T(), decimal.NewFromFloat(17.32), a.Amount) -} diff --git a/pkg/controllers/month_config_v3_types.go b/pkg/controllers/month_config_v3_types.go new file mode 100644 index 00000000..ee3b9a58 --- /dev/null +++ b/pkg/controllers/month_config_v3_types.go @@ -0,0 +1,13 @@ +package controllers + +// 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 +} diff --git a/pkg/controllers/month_v1.go b/pkg/controllers/month_v1.go deleted file mode 100644 index 888b84a5..00000000 --- a/pkg/controllers/month_v1.go +++ /dev/null @@ -1,211 +0,0 @@ -package controllers - -import ( - "net/http" - - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/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 MonthResponse struct { - Data models.Month `json:"data"` // Data for the month -} - -// RegisterMonthRoutes registers the routes for months with -// the RouterGroup that is passed. -func (co Controller) RegisterMonthRoutes(r *gin.RouterGroup) { - { - r.OPTIONS("", co.OptionsMonth) - r.GET("", co.GetMonth) - r.POST("", co.SetAllocations) - r.DELETE("", co.DeleteAllocations) - } -} - -// OptionsMonth 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 Months -// @Success 204 -// @Router /v1/months [options] -// @Deprecated true -func (co Controller) OptionsMonth(c *gin.Context) { - httputil.OptionsGetPostDelete(c) -} - -// GetMonth returns data for a specific budget and month -// -// @Summary Get data about a month -// @Description Returns data about a specific month. -// @Tags Months -// @Produce json -// @Success 200 {object} MonthResponse -// @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 /v1/months [get] -// @Deprecated true -func (co Controller) GetMonth(c *gin.Context) { - qMonth, budget, e := co.parseMonthQuery(c) - if !e.Nil() { - c.JSON(e.Status, httperrors.HTTPError{ - Error: e.Error(), - }) - return - } - - month, err := budget.Month(co.DB, qMonth) - if err != nil { - e = httperrors.Parse(c, err) - c.JSON(e.Status, httperrors.HTTPError{ - Error: e.Error(), - }) - return - } - - c.JSON(http.StatusOK, MonthResponse{Data: month}) -} - -// DeleteAllocations deletes all allocations for a month -// -// @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 /v1/months [delete] -// @Deprecated true -func (co Controller) DeleteAllocations(c *gin.Context) { - month, budget, err := co.parseMonthQuery(c) - if !err.Nil() { - c.JSON(err.Status, httperrors.HTTPError{ - Error: err.Error(), - }) - return - } - - // We query for all allocations here - var allocations []models.Allocation - - if !queryAndHandleErrors(c, co.DB. - Joins("JOIN envelopes ON envelopes.id = allocations.envelope_id"). - Joins("JOIN categories ON categories.id = envelopes.category_id"). - Joins("JOIN budgets on budgets.id = categories.budget_id"). - Where(models.Allocation{AllocationCreate: models.AllocationCreate{Month: month}}). - Where("budgets.id = ?", budget.ID). - Find(&allocations)) { - return - } - - for _, allocation := range allocations { - if !queryAndHandleErrors(c, co.DB.Unscoped().Delete(&allocation)) { - return - } - } - - c.JSON(http.StatusNoContent, gin.H{}) -} - -// SetAllocations sets all allocations for a month -// -// @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 /v1/months [post] -// @Deprecated true -func (co Controller) SetAllocations(c *gin.Context) { - month, _, err := co.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 - if err := httputil.BindDataHandleErrors(c, &data); err != nil { - return - } - - if data.Mode != AllocateLastMonthBudget && data.Mode != AllocateLastMonthSpend { - httperrors.New(c, http.StatusBadRequest, "The mode must be %s or %s", AllocateLastMonthBudget, AllocateLastMonthSpend) - return - } - - pastMonth := month.AddDate(0, -1) - queryCurrentMonth := co.DB.Select("id").Table("allocations").Where("allocations.envelope_id = envelopes.id AND allocations.month = ?", 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 - } - - // Get all envelope IDs and allocation amounts where there is no allocation - // for the request month, but one for the last month - if !queryAndHandleErrors(c, co.DB. - Joins("JOIN allocations ON allocations.envelope_id = envelopes.id AND envelopes.hidden IS FALSE AND allocations.month = ? AND NOT EXISTS(?)", pastMonth, queryCurrentMonth). - Select("envelopes.id, allocations.amount"). - Table("envelopes"). - Find(&envelopesAmount)) { - 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(co.DB, pastMonth).Neg() - } - - // Do not create allocations for an amount of 0 - if amount.IsZero() { - continue - } - - if !queryAndHandleErrors(c, co.DB.Create(&models.Allocation{ - AllocationCreate: models.AllocationCreate{ - EnvelopeID: allocation.EnvelopeID, - Amount: amount, - Month: month, - }, - })) { - return - } - } - - c.JSON(http.StatusNoContent, gin.H{}) -} diff --git a/pkg/controllers/month_v1_test.go b/pkg/controllers/month_v1_test.go deleted file mode 100644 index 33210f3f..00000000 --- a/pkg/controllers/month_v1_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package controllers_test - -import ( - "net/http" - "strings" - "time" - - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/shopspring/decimal" -) - -func (suite *TestSuiteStandard) TestOptionsMonth() { - recorder := test.Request(suite.controller, suite.T(), http.MethodOptions, "http://example.com/v1/months", "") - suite.Assert().Equal(http.StatusNoContent, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - suite.Assert().Equal(recorder.Header().Get("allow"), "OPTIONS, GET, POST, DELETE") -} - -// TestBudgetMonth verifies that the monthly calculations are correct. -func (suite *TestSuiteStandard) TestMonth() { - budget := suite.createTestBudget(models.BudgetCreate{}) - - r := test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.GroupedMonth, "YYYY-MM", "2022-01", -1), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) -} - -// TestEnvelopeNoAllocationLink verifies that for an Envelope with no allocation for a specific month, -// the allocation collection endpoint is set as link. -func (suite *TestSuiteStandard) TestEnvelopeNoAllocationLink() { - var month controllers.MonthResponse - - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - _ = suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - - r := test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.GroupedMonth, "YYYY-MM", "2022-01", 1), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &month) - suite.Assert().NotEmpty(month.Data.Categories[0].Envelopes) - suite.Assert().Equal("http://example.com/v1/allocations", month.Data.Categories[0].Envelopes[0].Links.Allocation) -} - -func (suite *TestSuiteStandard) TestEnvelopeAllocationLink() { - var month controllers.MonthResponse - - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - allocation := suite.createTestAllocation(models.AllocationCreate{Amount: decimal.New(1, 1), EnvelopeID: envelope.Data.ID, Month: types.NewMonth(2022, 1)}) - - r := test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.GroupedMonth, "YYYY-MM", "2022-01", 1), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &month) - suite.Assert().NotEmpty(month.Data.Categories[0].Envelopes) - suite.Assert().Equal(allocation.Data.Links.Self, month.Data.Categories[0].Envelopes[0].Links.Allocation) -} - -func (suite *TestSuiteStandard) TestMonthNotNil() { - var month controllers.MonthResponse - - // Verify that the categories list is empty, not nil - budget := suite.createTestBudget(models.BudgetCreate{}) - - r := test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.GroupedMonth, "YYYY-MM", "2022-01", 1), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&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 - _ = suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.GroupedMonth, "YYYY-MM", "2022-01", 1), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &month) - suite.Assert().NotNil(month.Data.Categories[0].Envelopes) - suite.Assert().Empty(month.Data.Categories[0].Envelopes) -} - -func (suite *TestSuiteStandard) TestMonthInvalidRequest() { - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/months?month=-56", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/months?budget=noUUID", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - budget := suite.createTestBudget(models.BudgetCreate{}) - r = test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.GroupedMonth, "YYYY-MM", "0001-01", 1), "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - suite.Assert().Equal("the month query parameter must be set", test.DecodeError(suite.T(), r.Body.Bytes())) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/months?budget=6a463cc8-1938-474a-8aeb-0482b82ffb6f&month=2000-12", "") - assertHTTPStatus(suite.T(), &r, http.StatusNotFound) - suite.Assert().Equal("there is no Budget with this ID", test.DecodeError(suite.T(), r.Body.Bytes())) -} - -func (suite *TestSuiteStandard) TestMonthDBFail() { - budget := suite.createTestBudget(models.BudgetCreate{}) - - suite.CloseDB() - - r := test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.GroupedMonth, "YYYY-MM", "2022-01", 1), "") - assertHTTPStatus(suite.T(), &r, http.StatusInternalServerError) -} - -func (suite *TestSuiteStandard) TestDeleteMonth() { - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope1 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - envelope2 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - - allocation1 := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(15.42), - EnvelopeID: envelope1.Data.ID, - }) - - allocation2 := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(15.42), - EnvelopeID: envelope2.Data.ID, - }) - - // Clear allocations - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, strings.Replace(budget.Data.Links.MonthAllocations, "YYYY-MM", "2022-01", 1), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) - - // Verify that allocations are deleted - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, allocation1.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) - - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, allocation2.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteMonthFailures() { - budgetAllocationsLink := suite.createTestBudget(models.BudgetCreate{}).Data.Links.MonthAllocations - - // Bad Request for invalid UUID - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/months?budget=nouuid&month=2022-01", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Bad Request for invalid months - recorder = test.Request(suite.controller, suite.T(), http.MethodDelete, budgetAllocationsLink, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Not found for non-existing budget - recorder = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/months?budget=059cdead-249f-4f94-8d29-16a80c6b4a09&month=2032-03", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestSetMonthBudgeted() { - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope1 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - envelope2 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - archivedEnvelope := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID, Hidden: true}) - - allocation1 := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(30), - EnvelopeID: envelope1.Data.ID, - }) - - allocation2 := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(40), - EnvelopeID: envelope2.Data.ID, - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(50), - EnvelopeID: archivedEnvelope.Data.ID, - }) - - // Update in budgeted mode allocations - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, strings.Replace(budget.Data.Links.MonthAllocations, "YYYY-MM", "2022-02", 1), controllers.BudgetAllocationMode{Mode: controllers.AllocateLastMonthBudget}) - 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.controller, suite.T(), http.MethodGet, requestString, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var envelope1Month controllers.EnvelopeMonthResponse - suite.decodeResponse(&recorder, &envelope1Month) - suite.Assert().True(allocation1.Data.Amount.Equal(envelope1Month.Data.Allocation), "Expected: %s, got %s, Request ID: %s", allocation1.Data.Amount, envelope1Month.Data.Allocation, recorder.Header().Get("x-request-id")) - - // Verify the allocation for the second envelope - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(envelope2.Data.Links.Month, "YYYY-MM", "2022-02", 1), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var envelope2Month controllers.EnvelopeMonthResponse - suite.decodeResponse(&recorder, &envelope2Month) - suite.Assert().True(allocation2.Data.Amount.Equal(envelope2Month.Data.Allocation), "Expected: %s, got %s, Request ID: %s", allocation2.Data.Amount, envelope2Month.Data.Allocation, recorder.Header().Get("x-request-id")) - - // Verify the allocation for the archived envelope - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(archivedEnvelope.Data.Links.Month, "YYYY-MM", "2022-02", 1), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var archivedEnvelopeMonth controllers.EnvelopeMonthResponse - suite.decodeResponse(&recorder, &archivedEnvelopeMonth) - 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) TestSetMonthSpend() { - budget := suite.createTestBudget(models.BudgetCreate{}) - cashAccount := suite.createTestAccount(models.AccountCreate{External: false, OnBudget: true, Name: "TestSetMonthSpend Cash"}) - externalAccount := suite.createTestAccount(models.AccountCreate{External: true, Name: "TestSetMonthSpend External"}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope1 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - envelope2 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - - _ = suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(30), - EnvelopeID: envelope1.Data.ID, - }) - - _ = suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(40), - EnvelopeID: envelope2.Data.ID, - }) - - eID := &envelope1.Data.ID - transaction1 := suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 1, 15, 14, 43, 27, 0, time.UTC), - EnvelopeID: eID, - BudgetID: budget.Data.ID, - SourceAccountID: cashAccount.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - Amount: decimal.NewFromFloat(15), - }) - - // Update in budgeted mode allocations - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, strings.Replace(budget.Data.Links.MonthAllocations, "YYYY-MM", "2022-02", 1), controllers.BudgetAllocationMode{Mode: controllers.AllocateLastMonthSpend}) - 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.controller, suite.T(), http.MethodGet, requestString, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var envelope1Month controllers.EnvelopeMonthResponse - suite.decodeResponse(&recorder, &envelope1Month) - 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.controller, suite.T(), http.MethodGet, strings.Replace(envelope2.Data.Links.Month, "YYYY-MM", "2022-02", 1), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var envelope2Month controllers.EnvelopeMonthResponse - suite.decodeResponse(&recorder, &envelope2Month) - 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) TestSetMonthFailures() { - budgetAllocationsLink := suite.createTestBudget(models.BudgetCreate{}).Data.Links.MonthAllocations - - // Bad Request for invalid UUID - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/months?budget=nouuid&month=2022-01", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Bad Request for invalid months - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, budgetAllocationsLink, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Not found for non-existing budget - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/months?budget=059cdead-249f-4f94-8d29-16a80c6b4a09&month=2032-03", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) - - // Bad Request for invalid json in body - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, strings.Replace(budgetAllocationsLink, "YYYY-MM", "2022-01", 1), `{ "mode": INVALID_JSON" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - - // Bad Request for invalid mode - recorder = test.Request(suite.controller, suite.T(), http.MethodPost, strings.Replace(budgetAllocationsLink, "YYYY-MM", "2022-01", 1), `{ "mode": "UNKNOWN_MODE" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} diff --git a/pkg/controllers/month_v3.go b/pkg/controllers/month_v3.go index ebed4be2..cf3ea488 100644 --- a/pkg/controllers/month_v3.go +++ b/pkg/controllers/month_v3.go @@ -235,16 +235,17 @@ func (co Controller) DeleteAllocationsV3(c *gin.Context) { }) return } - // We query for all allocations here - var allocations []models.Allocation + + var monthConfigs []models.MonthConfig err = query(c, co.DB. - Joins("JOIN envelopes ON envelopes.id = allocations.envelope_id"). + 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.Allocation{AllocationCreate: models.AllocationCreate{Month: month}}). + Where(models.MonthConfig{Month: month}). Where("budgets.id = ?", budget.ID). - Find(&allocations)) + Where("month_configs.allocation > 0"). + Find(&monthConfigs)) if !err.Nil() { c.JSON(err.Status, httperrors.HTTPError{ @@ -253,8 +254,9 @@ func (co Controller) DeleteAllocationsV3(c *gin.Context) { return } - for _, allocation := range allocations { - err = query(c, co.DB.Unscoped().Delete(&allocation)) + for _, monthConfig := range monthConfigs { + monthConfig.Allocation = decimal.Zero + err = query(c, co.DB.Updates(&monthConfig)) if !err.Nil() { c.JSON(err.Status, httperrors.HTTPError{ Error: err.Error(), @@ -305,20 +307,20 @@ func (co Controller) SetAllocationsV3(c *gin.Context) { } pastMonth := month.AddDate(0, -1) - queryCurrentMonth := co.DB.Select("id").Table("allocations").Where("allocations.envelope_id = envelopes.id AND allocations.month = ?", month) + queryCurrentMonth := co.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 + 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, co.DB. - Joins("JOIN allocations ON allocations.envelope_id = envelopes.id AND envelopes.hidden IS FALSE AND allocations.month = ? AND NOT EXISTS(?)", pastMonth, queryCurrentMonth). - Select("envelopes.id, allocations.amount"). + Joins("JOIN month_configs ON month_configs.envelope_id = envelopes.id AND envelopes.hidden IS FALSE AND month_configs.month = ? AND NOT EXISTS(?)", pastMonth, queryCurrentMonth). + Select("envelopes.id, month_configs.allocation"). Table("envelopes"). Find(&envelopesAmount)) if !err.Nil() { @@ -336,18 +338,14 @@ func (co Controller) SetAllocationsV3(c *gin.Context) { amount = models.Envelope{DefaultModel: models.DefaultModel{ID: allocation.EnvelopeID}}.Spent(co.DB, pastMonth).Neg() } - // Do not create allocations for an amount of 0 - if amount.IsZero() { - continue - } - - err = query(c, co.DB.Create(&models.Allocation{ - AllocationCreate: models.AllocationCreate{ - EnvelopeID: allocation.EnvelopeID, - Amount: amount, - Month: month, - }, - })) + // Find and update the correct MonthConfig. + // If it does not exist, create it + err = query(c, co.DB.Where(models.MonthConfig{ + Month: month, + EnvelopeID: allocation.EnvelopeID, + }).Assign(models.MonthConfig{MonthConfigCreate: models.MonthConfigCreate{ + Allocation: amount, + }}).FirstOrCreate(&models.MonthConfig{})) if !err.Nil() { c.JSON(err.Status, httperrors.HTTPError{ Error: err.Error(), @@ -370,12 +368,10 @@ func envelopeMonthV3(c *gin.Context, db *gorm.DB, e models.Envelope, month types Allocation: decimal.NewFromFloat(0), } - var allocation models.Allocation - err := db.First(&allocation, &models.Allocation{ - AllocationCreate: models.AllocationCreate{ - EnvelopeID: e.ID, - Month: month, - }, + var monthConfig models.MonthConfig + err := db.First(&monthConfig, &models.MonthConfig{ + EnvelopeID: e.ID, + Month: month, }).Error // If an unexpected error occurs, return @@ -388,7 +384,7 @@ func envelopeMonthV3(c *gin.Context, db *gorm.DB, e models.Envelope, month types return EnvelopeMonthV3{}, err } - envelopeMonth.Allocation = allocation.Amount + envelopeMonth.Allocation = monthConfig.Allocation // Set the links envelopeMonth.Links.links(c, e) diff --git a/pkg/controllers/month_v3_test.go b/pkg/controllers/month_v3_test.go index b559727c..54ffdbdf 100644 --- a/pkg/controllers/month_v3_test.go +++ b/pkg/controllers/month_v3_test.go @@ -12,6 +12,7 @@ import ( "github.com/envelope-zero/backend/v3/pkg/controllers" "github.com/envelope-zero/backend/v3/pkg/models" "github.com/envelope-zero/backend/v3/test" + "github.com/google/uuid" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" ) @@ -29,7 +30,13 @@ func (suite *TestSuiteStandard) TestMonthsGetV3EnvelopeAllocationLink() { budget := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) category := suite.createTestCategoryV3(suite.T(), controllers.CategoryCreateV3{BudgetID: budget.Data.ID}) envelope := suite.createTestEnvelopeV3(suite.T(), controllers.EnvelopeCreateV3{CategoryID: category.Data.ID}) - _ = suite.createTestAllocation(models.AllocationCreate{Amount: decimal.NewFromFloat(10), EnvelopeID: envelope.Data.ID, Month: types.NewMonth(2022, 1)}) + + _ = suite.patchTestMonthConfigV3(suite.T(), + envelope.Data.ID, + types.NewMonth(2022, 1), + models.MonthConfigCreate{ + Allocation: decimal.NewFromFloat(10), + }) r := test.Request(suite.controller, suite.T(), http.MethodGet, strings.Replace(budget.Data.Links.Month, "YYYY-MM", "2022-01", 1), "") assertHTTPStatus(suite.T(), &r, http.StatusOK) @@ -108,28 +115,33 @@ func (suite *TestSuiteStandard) TestMonthsGetV3Delete() { envelope1 := suite.createTestEnvelopeV3(suite.T(), controllers.EnvelopeCreateV3{CategoryID: category.Data.ID}) envelope2 := suite.createTestEnvelopeV3(suite.T(), controllers.EnvelopeCreateV3{CategoryID: category.Data.ID}) - allocation1 := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(15.42), - EnvelopeID: envelope1.Data.ID, - }) + monthConfig1 := suite.patchTestMonthConfigV3(suite.T(), + envelope1.Data.ID, + types.NewMonth(2022, 1), + models.MonthConfigCreate{Allocation: decimal.NewFromFloat(15.42)}, + ) - allocation2 := suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(15.42), - EnvelopeID: envelope2.Data.ID, - }) + monthConfig2 := suite.patchTestMonthConfigV3(suite.T(), + envelope2.Data.ID, + types.NewMonth(2022, 1), + models.MonthConfigCreate{Allocation: decimal.NewFromFloat(15.42)}, + ) // Clear allocations recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, strings.Replace(budget.Data.Links.Month, "YYYY-MM", "2022-01", 1), "") assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) // Verify that allocations are deleted - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, allocation1.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) + recorder = test.Request(suite.controller, suite.T(), http.MethodGet, monthConfig1.Data.Links.Self, "") + assertHTTPStatus(suite.T(), &recorder, http.StatusOK) + var response controllers.MonthConfigResponseV3 + suite.decodeResponse(&recorder, &response) + assert.True(suite.T(), response.Data.Allocation.IsZero(), "Allocation is not zero after deletion") - recorder = test.Request(suite.controller, suite.T(), http.MethodGet, allocation2.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) + recorder = test.Request(suite.controller, suite.T(), http.MethodGet, monthConfig2.Data.Links.Self, "") + assertHTTPStatus(suite.T(), &recorder, http.StatusOK) + suite.decodeResponse(&recorder, &response) + assert.True(suite.T(), response.Data.Allocation.IsZero(), "Allocation is not zero after deletion") } func (suite *TestSuiteStandard) TestMonthsV3DeleteFail() { @@ -163,25 +175,20 @@ func (suite *TestSuiteStandard) TestMonthsV3AllocateBudgeted() { february := january.AddDate(0, 1) // Allocate funds to the months - // TODO: Replace this with createTestMonthConfigV3 once Allocations are integrated and not transparently created by API v3 allocations := []struct { - envelopeMonth string - month types.Month - amount decimal.Decimal + envelopeID uuid.UUID + month types.Month + amount decimal.Decimal }{ - {envelope1.Data.Links.Month, january, e1Amount}, - {envelope2.Data.Links.Month, january, e2Amount}, - {archivedEnvelope.Data.Links.Month, january, eArchivedAmount}, + {envelope1.Data.ID, january, e1Amount}, + {envelope2.Data.ID, january, e2Amount}, + {archivedEnvelope.Data.ID, january, eArchivedAmount}, } for _, allocation := range allocations { - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, strings.Replace(allocation.envelopeMonth, "YYYY-MM", january.String(), 1), map[string]string{ - "allocation": allocation.amount.String(), + suite.patchTestMonthConfigV3(suite.T(), allocation.envelopeID, allocation.month, models.MonthConfigCreate{ + Allocation: allocation.amount, }) - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var a controllers.MonthConfigResponseV3 - suite.decodeResponse(&recorder, &a) - assert.True(suite.T(), allocation.amount.Equal(a.Data.Allocation)) } // Update in budgeted mode allocations @@ -220,17 +227,17 @@ func (suite *TestSuiteStandard) TestMonthsV3AllocateSpend() { envelope1 := suite.createTestEnvelopeV3(suite.T(), controllers.EnvelopeCreateV3{CategoryID: category.Data.ID}) envelope2 := suite.createTestEnvelopeV3(suite.T(), controllers.EnvelopeCreateV3{CategoryID: category.Data.ID}) - _ = suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(30), - EnvelopeID: envelope1.Data.ID, - }) + _ = suite.patchTestMonthConfigV3(suite.T(), + envelope1.Data.ID, + types.NewMonth(2022, 1), + models.MonthConfigCreate{Allocation: decimal.NewFromFloat(30)}, + ) - _ = suite.createTestAllocation(models.AllocationCreate{ - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(40), - EnvelopeID: envelope2.Data.ID, - }) + _ = suite.patchTestMonthConfigV3(suite.T(), + envelope2.Data.ID, + types.NewMonth(2022, 1), + models.MonthConfigCreate{Allocation: decimal.NewFromFloat(40)}, + ) eID := &envelope1.Data.ID transaction1 := suite.createTestTransactionV3(suite.T(), models.TransactionCreate{ @@ -299,7 +306,6 @@ func (suite *TestSuiteStandard) TestMonthsV3() { externalAccount := suite.createTestAccountV3(suite.T(), controllers.AccountCreateV3{BudgetID: budget.Data.ID, External: true}) // Allocate funds to the months - // TODO: Replace this with createTestMonthConfigV3 once Allocations are integrated and not transparently created by API v3 allocations := []struct { month types.Month amount decimal.Decimal @@ -310,13 +316,9 @@ func (suite *TestSuiteStandard) TestMonthsV3() { } for _, allocation := range allocations { - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, strings.Replace(envelope.Data.Links.Month, "YYYY-MM", allocation.month.String(), 1), map[string]string{ - "allocation": allocation.amount.String(), + suite.patchTestMonthConfigV3(suite.T(), envelope.Data.ID, allocation.month, models.MonthConfigCreate{ + Allocation: allocation.amount, }) - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - var a controllers.MonthConfigResponseV3 - suite.decodeResponse(&recorder, &a) - assert.True(suite.T(), allocation.amount.Equal(a.Data.Allocation)) } _ = suite.createTestTransactionV3(suite.T(), models.TransactionCreate{ diff --git a/pkg/controllers/rename_rule.go b/pkg/controllers/rename_rule.go deleted file mode 100644 index abee05ed..00000000 --- a/pkg/controllers/rename_rule.go +++ /dev/null @@ -1,320 +0,0 @@ -package controllers - -import ( - "net/http" - - "github.com/google/uuid" - - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" -) - -type RenameRuleResponse struct { - Data models.MatchRule `json:"data"` // Data for the rename rule -} - -type RenameRuleListResponse struct { - Data []models.MatchRule `json:"data"` // List of rename rules -} - -type RenameRuleQueryFilter struct { - Priority uint `form:"month"` // By priority - Match string `form:"match"` // By match - AccountID string `form:"account"` // By ID of the account they map to -} - -func (f RenameRuleQueryFilter) Parse(c *gin.Context) (models.MatchRuleCreate, bool) { - envelopeID, ok := httputil.UUIDFromStringHandleErrors(c, f.AccountID) - if !ok { - return models.MatchRuleCreate{}, false - } - - var month QueryMonth - if err := c.Bind(&month); err != nil { - httperrors.Handler(c, err) - return models.MatchRuleCreate{}, false - } - - return models.MatchRuleCreate{ - Priority: f.Priority, - Match: f.Match, - AccountID: envelopeID, - }, true -} - -// RegisterRenameRuleRoutes registers the routes for renameRules with -// the RouterGroup that is passed. -func (co Controller) RegisterRenameRuleRoutes(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsRenameRuleList) - r.GET("", co.GetRenameRules) - r.POST("", co.CreateRenameRules) - } - - // RenameRule with ID - { - r.OPTIONS("/:id", co.OptionsRenameRuleDetail) - r.GET("/:id", co.GetRenameRule) - r.PATCH("/:id", co.UpdateRenameRule) - r.DELETE("/:id", co.DeleteRenameRule) - } -} - -// OptionsRenameRuleList returns the allowed HTTP verbs -// -// @Summary Allowed HTTP verbs -// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs -// @Tags RenameRules -// @Success 204 -// @Router /v2/rename-rules [options] -// @Deprecated true -func (co Controller) OptionsRenameRuleList(c *gin.Context) { - httputil.OptionsGetPost(c) -} - -// OptionsRenameRuleDetail returns the allowed HTTP verbs -// -// @Summary Allowed HTTP verbs -// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs -// @Tags RenameRules -// @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 /v2/rename-rules/{id} [options] -// @Deprecated true -func (co Controller) OptionsRenameRuleDetail(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - _, ok := getResourceByIDAndHandleErrors[models.MatchRule](c, co, id) - if !ok { - return - } - httputil.OptionsGetPatchDelete(c) -} - -// CreateRenameRulesV2 creates renameRules -// -// @Summary Create renameRules -// @Description Creates renameRules from the list of submitted renameRule data. The response code is the highest response code number that a single renameRule creation would have caused. If it is not equal to 201, at least one renameRule has an error. -// @Tags RenameRules -// @Produce json -// @Success 201 {object} []ResponseMatchRule -// @Failure 400 {object} []ResponseMatchRule -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} []ResponseMatchRule -// @Param renameRules body []models.MatchRuleCreate true "RenameRules" -// @Router /v2/rename-rules [post] -// @Deprecated true -func (co Controller) CreateRenameRules(c *gin.Context) { - var renameRules []models.MatchRule - - if err := httputil.BindDataHandleErrors(c, &renameRules); err != nil { - return - } - - // The response list has the same length as the request list - r := make([]ResponseMatchRule, 0, len(renameRules)) - - // The final http status. Will be modified when errors occur - status := http.StatusCreated - - for _, create := range renameRules { - m, err := co.createRenameRule(c, create) - - // Append the error or the successfully created transaction to the response list - if !err.Nil() { - r = append(r, ResponseMatchRule{Error: err.Error()}) - - // The final status code is the highest HTTP status code number since this also - // represents the priority we - if err.Status > status { - status = err.Status - } - } else { - o, ok := co.getMatchRule(c, m.ID) - if !ok { - return - } - r = append(r, ResponseMatchRule{Data: o}) - } - } - - c.JSON(status, r) -} - -// GetRenameRules returns a list of renameRules matching the search parameters -// -// @Summary Get renameRules -// @Description Returns a list of renameRules -// @Tags RenameRules -// @Produce json -// @Success 200 {object} RenameRuleListResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @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" -// @Router /v2/rename-rules [get] -// @Deprecated true -func (co Controller) GetRenameRules(c *gin.Context) { - var filter RenameRuleQueryFilter - if err := c.Bind(&filter); err != nil { - httperrors.InvalidQueryString(c) - return - } - - // Get the parameters set in the query string - queryFields, _ := httputil.GetURLFields(c.Request.URL, filter) - - // Convert the QueryFilter to a Create struct - create, ok := filter.Parse(c) - if !ok { - return - } - - var renameRules []models.MatchRule - if !queryAndHandleErrors(c, co.DB.Where(&models.MatchRule{ - MatchRuleCreate: create, - }, queryFields...).Find(&renameRules)) { - 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 - if len(renameRules) == 0 { - renameRules = make([]models.MatchRule, 0) - } - - c.JSON(http.StatusOK, RenameRuleListResponse{Data: renameRules}) -} - -// GetRenameRule returns data about a specific renameRule -// -// @Summary Get renameRule -// @Description Returns a specific renameRule -// @Tags RenameRules -// @Produce json -// @Success 200 {object} RenameRuleResponse -// @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 /v2/rename-rules/{id} [get] -// @Deprecated true -func (co Controller) GetRenameRule(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - renameRuleObject, ok := getResourceByIDAndHandleErrors[models.MatchRule](c, co, id) - if !ok { - return - } - - c.JSON(http.StatusOK, RenameRuleResponse{Data: renameRuleObject}) -} - -// UpdateRenameRule updates renameRule data -// -// @Summary Update renameRule -// @Description Update an renameRule. Only values to be updated need to be specified. -// @Tags RenameRules -// @Accept json -// @Produce json -// @Success 200 {object} RenameRuleResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID formatted as string" -// @Param renameRule body models.MatchRuleCreate true "RenameRule" -// @Router /v2/rename-rules/{id} [patch] -// @Deprecated true -func (co Controller) UpdateRenameRule(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - renameRule, ok := getResourceByIDAndHandleErrors[models.MatchRule](c, co, id) - if !ok { - return - } - - updateFields, err := httputil.GetBodyFieldsHandleErrors(c, models.MatchRuleCreate{}) - if err != nil { - return - } - - var data models.MatchRule - if err := httputil.BindDataHandleErrors(c, &data); err != nil { - return - } - - if !queryAndHandleErrors(c, co.DB.Model(&renameRule).Select("", updateFields...).Updates(data)) { - return - } - - c.JSON(http.StatusOK, RenameRuleResponse{Data: renameRule}) -} - -// DeleteRenameRule deletes an renameRule -// -// @Summary Delete renameRule -// @Description Deletes an renameRule -// @Tags RenameRules -// @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 /v2/rename-rules/{id} [delete] -// @Deprecated true -func (co Controller) DeleteRenameRule(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - renameRule, ok := getResourceByIDAndHandleErrors[models.MatchRule](c, co, id) - if !ok { - return - } - - // RenameRules are hard deleted instantly to avoid conflicts for the UNIQUE(id,month) - if !queryAndHandleErrors(c, co.DB.Unscoped().Delete(&renameRule)) { - return - } - - c.JSON(http.StatusNoContent, gin.H{}) -} - -// createRenameRule creates a single renameRule after verifying it is a valid renameRule. -func (co Controller) createRenameRule(c *gin.Context, r models.MatchRule) (models.MatchRule, httperrors.Error) { - // Check that the referenced account exists - _, err := getResourceByID[models.Account](c, co, r.AccountID) - if !err.Nil() { - return r, err - } - - // Create the resource - dbErr := co.DB.Create(&r).Error - if dbErr != nil { - return models.MatchRule{}, httperrors.GenericDBError[models.MatchRule](r, c, dbErr) - } - - return r, httperrors.Error{} -} diff --git a/pkg/controllers/test_create_test.go b/pkg/controllers/test_create_test.go index d05f787b..ca7c6cea 100644 --- a/pkg/controllers/test_create_test.go +++ b/pkg/controllers/test_create_test.go @@ -1,25 +1,26 @@ package controllers_test import ( + "github.com/envelope-zero/backend/v3/pkg/controllers" "github.com/envelope-zero/backend/v3/pkg/models" "github.com/google/uuid" ) func (suite *TestSuiteStandard) defaultTransactionCreate(c models.TransactionCreate) models.TransactionCreate { if c.BudgetID == uuid.Nil { - c.BudgetID = suite.createTestBudget(models.BudgetCreate{Name: "Testing budget"}).Data.ID + c.BudgetID = suite.createTestBudgetV3(suite.T(), models.BudgetCreate{Name: "Testing budget"}).Data.ID } if c.SourceAccountID == uuid.Nil { - c.SourceAccountID = suite.createTestAccount(models.AccountCreate{Name: "Source Account"}).Data.ID + c.SourceAccountID = suite.createTestAccountV3(suite.T(), controllers.AccountCreateV3{Name: "Source Account"}).Data.ID } if c.DestinationAccountID == uuid.Nil { - c.DestinationAccountID = suite.createTestAccount(models.AccountCreate{Name: "Destination Account"}).Data.ID + c.DestinationAccountID = suite.createTestAccountV3(suite.T(), controllers.AccountCreateV3{Name: "Destination Account"}).Data.ID } if c.EnvelopeID == &uuid.Nil { - *c.EnvelopeID = suite.createTestEnvelope(models.EnvelopeCreate{Name: "Transaction Test Envelope"}).Data.ID + *c.EnvelopeID = suite.createTestEnvelopeV3(suite.T(), controllers.EnvelopeCreateV3{Name: "Transaction Test Envelope"}).Data.ID } return c diff --git a/pkg/controllers/test_helpers_test.go b/pkg/controllers/test_helpers_test.go index b1c5f3ee..387ccb24 100644 --- a/pkg/controllers/test_helpers_test.go +++ b/pkg/controllers/test_helpers_test.go @@ -5,18 +5,10 @@ import ( "net/http/httptest" "reflect" "testing" - "time" "github.com/stretchr/testify/assert" ) -// TOLERANCE is the number of seconds that a CreatedAt or UpdatedAt time.Time -// is allowed to differ from the time at which it is checked. -// -// As CreatedAt and UpdatedAt are automatically set by gorm, we need a tolerance here. -// This is in nanoseconds, so we multiply by 1000000000 for seconds. -const tolerance time.Duration = 1000000000 * 60 - func assertHTTPStatus(t *testing.T, r *httptest.ResponseRecorder, expectedStatus ...int) { assert.Contains(t, expectedStatus, r.Code, "HTTP status is wrong. Request ID: '%s' Response body: %s", r.Result().Header.Get("x-request-id"), r.Body.String()) } diff --git a/pkg/controllers/test_list_test.go b/pkg/controllers/test_list_test.go index 15a13c39..161bca87 100644 --- a/pkg/controllers/test_list_test.go +++ b/pkg/controllers/test_list_test.go @@ -12,9 +12,9 @@ var methodNotAllowedTests = []struct { }{ {"/", http.MethodPost}, {"/", http.MethodDelete}, - {"http://example.com/v1", http.MethodPost}, - {"http://example.com/v1/budgets", http.MethodHead}, - {"http://example.com/v1/budgets", http.MethodPut}, + {"http://example.com/v3", http.MethodPost}, + {"http://example.com/v3/budgets", http.MethodHead}, + {"http://example.com/v3/budgets", http.MethodPut}, } func (suite *TestSuiteStandard) TestMethodNotAllowed() { diff --git a/pkg/controllers/test_options_test.go b/pkg/controllers/test_options_test.go index d2395be0..8d7cfd9a 100644 --- a/pkg/controllers/test_options_test.go +++ b/pkg/controllers/test_options_test.go @@ -14,19 +14,6 @@ func (suite *TestSuiteStandard) TestOptionsHeaderResources() { response string }{ {"http://example.com/healthz", "OPTIONS, GET"}, - {"http://example.com/v1/accounts", "OPTIONS, GET, POST"}, - {"http://example.com/v1/allocations", "OPTIONS, GET, POST"}, - {"http://example.com/v1/budgets", "OPTIONS, GET, POST"}, - {"http://example.com/v1/categories", "OPTIONS, GET, POST"}, - {"http://example.com/v1/envelopes", "OPTIONS, GET, POST"}, - {"http://example.com/v1/import", "OPTIONS, POST"}, - {"http://example.com/v1/import/ynab-import-preview", "OPTIONS, POST"}, - {"http://example.com/v1/import/ynab4", "OPTIONS, POST"}, - {"http://example.com/v1/month-configs", "OPTIONS, GET"}, - {"http://example.com/v1/transactions", "OPTIONS, GET, POST"}, - {"http://example.com/v2/accounts", "OPTIONS, GET"}, - {"http://example.com/v2/match-rules", "OPTIONS, GET, POST"}, - {"http://example.com/v2/transactions", "OPTIONS, POST"}, {"http://example.com/v3/accounts", "OPTIONS, GET, POST"}, {"http://example.com/v3/budgets", "OPTIONS, GET, POST"}, {"http://example.com/v3/categories", "OPTIONS, GET, POST"}, diff --git a/pkg/controllers/transaction.go b/pkg/controllers/transaction.go index e264d831..e1bb36c9 100644 --- a/pkg/controllers/transaction.go +++ b/pkg/controllers/transaction.go @@ -49,42 +49,6 @@ func (co Controller) createTransaction(c *gin.Context, create models.Transaction return t, httperrors.Error{} } -// checkTransactionAndHandleErrors 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 -// -// It returns true if the transaction is valid, false in all -// other cases. -// -// Deprecated. -func (co Controller) checkTransactionAndHandleErrors(c *gin.Context, transaction models.Transaction, source, destination models.Account) (ok bool) { - ok = true - - if !decimal.Decimal.IsPositive(transaction.Amount) { - httperrors.New(c, http.StatusBadRequest, "The transaction amount must be positive") - return false - } - - if source.External && destination.External { - httperrors.New(c, http.StatusBadRequest, "A transaction between two external accounts is not possible.") - return false - } - - // Check envelope being set for transfer between on-budget accounts - if transaction.EnvelopeID != nil { - if source.OnBudget && destination.OnBudget { - httperrors.New(c, http.StatusBadRequest, "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") - return false - } - _, ok = getResourceByIDAndHandleErrors[models.Envelope](c, co, *transaction.EnvelopeID) - } - - return -} - // checkTransaction verifies that the transaction is correct // // It checks that @@ -113,49 +77,6 @@ func (co Controller) checkTransaction(c *gin.Context, transaction models.Transac return httperrors.Error{} } -// ToCreateHandleErrors parses the query string and returns a TransactionCreate struct for -// the database request. -// -// This method is deprecated, use ToCreate() and handle errors in the calling method. -func (f TransactionQueryFilterV1) ToCreateHandleErrors(c *gin.Context) (models.TransactionCreate, bool) { - budgetID, ok := httputil.UUIDFromStringHandleErrors(c, f.BudgetID) - if !ok { - return models.TransactionCreate{}, false - } - - sourceAccountID, ok := httputil.UUIDFromStringHandleErrors(c, f.SourceAccountID) - if !ok { - return models.TransactionCreate{}, false - } - - destinationAccountID, ok := httputil.UUIDFromStringHandleErrors(c, f.DestinationAccountID) - if !ok { - return models.TransactionCreate{}, false - } - - envelopeID, ok := httputil.UUIDFromStringHandleErrors(c, f.EnvelopeID) - if !ok { - return models.TransactionCreate{}, false - } - - // If the envelopeID is nil, use an actual nil, not uuid.Nil - var eID *uuid.UUID - if envelopeID != uuid.Nil { - eID = &envelopeID - } - - return models.TransactionCreate{ - Amount: f.Amount, - BudgetID: budgetID, - SourceAccountID: sourceAccountID, - DestinationAccountID: destinationAccountID, - EnvelopeID: eID, - Reconciled: f.Reconciled, - ReconciledSource: f.ReconciledSource, - ReconciledDestination: f.ReconciledDestination, - }, true -} - // ToCreate parses the query string and returns a TransactionCreate struct for // the database request. On error, it returns httperrors.ErrorStatus struct with. func (f TransactionQueryFilterV3) ToCreate() (models.TransactionCreate, httperrors.Error) { diff --git a/pkg/controllers/transaction_v1.go b/pkg/controllers/transaction_v1.go deleted file mode 100644 index 34e3d7d5..00000000 --- a/pkg/controllers/transaction_v1.go +++ /dev/null @@ -1,425 +0,0 @@ -package controllers - -import ( - "fmt" - "net/http" - "time" - - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/shopspring/decimal" - "golang.org/x/exp/slices" - "gorm.io/gorm" -) - -// Transaction is the API v1 representation of a Transaction in EZ. -type Transaction struct { - models.Transaction - Links struct { - Self string `json:"self" example:"https://example.com/api/v1/transactions/d430d7c3-d14c-4712-9336-ee56965a6673"` // The transaction itself - } `json:"links"` // Links for the transaction -} - -// links generates HATEOAS links for the transaction. -func (t *Transaction) links(c *gin.Context) { - // Set links - t.Links.Self = fmt.Sprintf("%s/v1/transactions/%s", c.GetString(string(database.ContextURL)), t.ID) -} - -type TransactionListResponse struct { - Data []Transaction `json:"data"` // List of transactions -} - -type TransactionResponse struct { - Data Transaction `json:"data"` // Data for the transaction -} - -type TransactionQueryFilterV1 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"` // 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. - Reconciled bool `form:"reconciled"` // DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. -} - -func (co Controller) getTransaction(c *gin.Context, id uuid.UUID) (Transaction, bool) { - transactionModel, ok := getResourceByIDAndHandleErrors[models.Transaction](c, co, id) - if !ok { - return Transaction{}, false - } - - transaction := Transaction{ - Transaction: transactionModel, - } - - transaction.links(c) - return transaction, true -} - -// RegisterTransactionRoutes registers the routes for transactions with -// the RouterGroup that is passed. -func (co Controller) RegisterTransactionRoutes(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsTransactionList) - r.GET("", co.GetTransactions) - r.POST("", co.CreateTransaction) - } - - // Transaction with ID - { - r.OPTIONS("/:id", co.OptionsTransactionDetail) - r.GET("/:id", co.GetTransaction) - r.PATCH("/:id", co.UpdateTransaction) - r.DELETE("/:id", co.DeleteTransaction) - } -} - -// OptionsTransactionList 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 Transactions -// @Success 204 -// @Router /v1/transactions [options] -// @Deprecated true -func (co Controller) OptionsTransactionList(c *gin.Context) { - httputil.OptionsGetPost(c) -} - -// OptionsTransactionDetail 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 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 /v1/transactions/{id} [options] -// @Deprecated true -func (co Controller) OptionsTransactionDetail(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - var t models.Transaction - err = co.DB.First(&t, id).Error - if err != nil { - httperrors.Handler(c, err) - return - } - - httputil.OptionsGetPatchDelete(c) -} - -// CreateTransaction creates a new transaction -// -// @Summary Create transaction -// @Description Creates a new transaction -// @Tags Transactions -// @Produce json -// @Success 201 {object} TransactionResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param transaction body models.TransactionCreate true "Transaction" -// @Router /v1/transactions [post] -// @Deprecated true -func (co Controller) CreateTransaction(c *gin.Context) { - var transactionCreate models.TransactionCreate - - if err := httputil.BindDataHandleErrors(c, &transactionCreate); err != nil { - return - } - - transaction, err := co.createTransaction(c, transactionCreate) - if !err.Nil() { - c.JSON(err.Status, gin.H{"error": err.Error()}) - return - } - - transactionObject, ok := co.getTransaction(c, transaction.ID) - if !ok { - return - } - - c.JSON(http.StatusCreated, TransactionResponse{Data: transactionObject}) -} - -// GetTransactions returns transactions filtered by the query parameters -// -// @Summary Get transactions -// @Description Returns a list of transactions -// @Tags Transactions -// @Produce json -// @Success 200 {object} TransactionListResponse -// @Failure 400 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Router /v1/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 reconciled query bool false "DEPRECATED. Filter by reconcilication state" -// @Param reconciledSource query bool false "Reconcilication state in source account" -// @Param reconciledDestination query bool false "Reconcilication state in destination account" -// @Deprecated true -func (co Controller) GetTransactions(c *gin.Context) { - var filter TransactionQueryFilterV1 - if err := c.Bind(&filter); err != nil { - httperrors.InvalidQueryString(c) - return - } - - // Get the fields set in the filter - queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) - - // Convert the QueryFilter to a Create struct - create, ok := filter.ToCreateHandleErrors(c) - if !ok { - return - } - - var query *gorm.DB - query = co.DB.Order("datetime(date) DESC, datetime(created_at) DESC").Where(&models.Transaction{ - TransactionCreate: create, - }, 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) - query = query.Where("transactions.date >= date(?)", date).Where("transactions.date < date(?)", date.AddDate(0, 0, 1)) - } - - if !filter.FromDate.IsZero() { - query = query.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() { - query = query.Where("transactions.date < date(?)", time.Date(filter.UntilDate.Year(), filter.UntilDate.Month(), filter.UntilDate.Day()+1, 0, 0, 0, 0, time.UTC)) - } - - if filter.AccountID != "" { - accountID, ok := httputil.UUIDFromStringHandleErrors(c, filter.AccountID) - if !ok { - return - } - - query = query.Where(co.DB.Where(&models.Transaction{ - TransactionCreate: models.TransactionCreate{ - SourceAccountID: accountID, - }, - }).Or(&models.Transaction{ - TransactionCreate: models.TransactionCreate{ - DestinationAccountID: accountID, - }, - })) - } - - if !filter.AmountLessOrEqual.IsZero() { - query = query.Where("transactions.amount <= ?", filter.AmountLessOrEqual) - } - - if !filter.AmountMoreOrEqual.IsZero() { - query = query.Where("transactions.amount >= ?", filter.AmountMoreOrEqual) - } - - if filter.Note != "" { - query = query.Where("note LIKE ?", fmt.Sprintf("%%%s%%", filter.Note)) - } else if slices.Contains(setFields, "Note") { - query = query.Where("note = ''") - } - - var transactions []models.Transaction - if !queryAndHandleErrors(c, query.Find(&transactions)) { - return - } - - transactionObjects := make([]Transaction, 0) - for _, t := range transactions { - transactionObject, ok := co.getTransaction(c, t.ID) - if !ok { - return - } - - transactionObjects = append(transactionObjects, transactionObject) - } - - c.JSON(http.StatusOK, TransactionListResponse{Data: transactionObjects}) -} - -// GetTransaction returns a specific transaction -// -// @Summary Get transaction -// @Description Returns a specific transaction -// @Tags Transactions -// @Produce json -// @Success 200 {object} TransactionResponse -// @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 /v1/transactions/{id} [get] -// @Deprecated true -func (co Controller) GetTransaction(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - var t models.Transaction - err = co.DB.First(&t, id).Error - if err != nil { - httperrors.Handler(c, err) - return - } - - tObject, ok := co.getTransaction(c, t.ID) - if !ok { - return - } - - c.JSON(http.StatusOK, TransactionResponse{Data: tObject}) -} - -// UpdateTransaction updates a specific transaction -// -// @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} httperrors.HTTPError -// @Failure 404 {object} httperrors.HTTPError -// @Failure 500 {object} httperrors.HTTPError -// @Param id path string true "ID formatted as string" -// @Param transaction body models.TransactionCreate true "Transaction" -// @Router /v1/transactions/{id} [patch] -// @Deprecated true -func (co Controller) UpdateTransaction(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - transaction, ok := getResourceByIDAndHandleErrors[models.Transaction](c, co, id) - if !ok { - return - } - - updateFields, err := httputil.GetBodyFieldsHandleErrors(c, models.TransactionCreate{}) - if err != nil { - return - } - - var data models.Transaction - if err := httputil.BindDataHandleErrors(c, &data.TransactionCreate); err != nil { - return - } - - // If the amount set via the API request is not existent or - // is 0, we use the old amount - if data.Amount.IsZero() { - data.Amount = transaction.Amount - } - - // Check the source account - sourceAccountID := transaction.SourceAccountID - if data.SourceAccountID != uuid.Nil { - sourceAccountID = data.SourceAccountID - } - sourceAccount, ok := getResourceByIDAndHandleErrors[models.Account](c, co, sourceAccountID) - - if !ok { - return - } - - // Check the destination account - destinationAccountID := transaction.DestinationAccountID - if data.DestinationAccountID != uuid.Nil { - destinationAccountID = data.DestinationAccountID - } - destinationAccount, ok := getResourceByIDAndHandleErrors[models.Account](c, co, destinationAccountID) - - if !ok { - return - } - - // Check the transaction that is set - if !co.checkTransactionAndHandleErrors(c, data, sourceAccount, destinationAccount) { - return - } - - if !queryAndHandleErrors(c, co.DB.Model(&transaction).Select("", updateFields...).Updates(data)) { - return - } - - transactionObject, ok := co.getTransaction(c, transaction.ID) - if !ok { - return - } - - c.JSON(http.StatusOK, TransactionResponse{Data: transactionObject}) -} - -// DeleteTransaction deletes a specific transaction -// -// @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 /v1/transactions/{id} [delete] -// @Deprecated true -func (co Controller) DeleteTransaction(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - httperrors.InvalidUUID(c) - return - } - - transaction, ok := getResourceByIDAndHandleErrors[models.Transaction](c, co, id) - if !ok { - return - } - - if !queryAndHandleErrors(c, co.DB.Delete(&transaction)) { - return - } - - c.JSON(http.StatusNoContent, gin.H{}) -} diff --git a/pkg/controllers/transaction_v1_test.go b/pkg/controllers/transaction_v1_test.go deleted file mode 100644 index c03050f0..00000000 --- a/pkg/controllers/transaction_v1_test.go +++ /dev/null @@ -1,711 +0,0 @@ -package controllers_test - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/google/uuid" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) createTestTransaction(c models.TransactionCreate, expectedStatus ...int) controllers.TransactionResponse { - c = suite.defaultTransactionCreate(c) - - // Default to 201 Created as expected status - if len(expectedStatus) == 0 { - expectedStatus = append(expectedStatus, http.StatusCreated) - } - - r := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/transactions", c) - assertHTTPStatus(suite.T(), &r, expectedStatus...) - - var tr controllers.TransactionResponse - suite.decodeResponse(&r, &tr) - - return tr -} - -func (suite *TestSuiteStandard) TestTransactionsCreate() { - budget := suite.createTestBudget(models.BudgetCreate{}) - internalAccount := suite.createTestAccount(models.AccountCreate{External: false, BudgetID: budget.Data.ID, Name: "TestTransactionsCreate Internal"}) - externalAccount := suite.createTestAccount(models.AccountCreate{External: true, BudgetID: budget.Data.ID, Name: "TestTransactionsCreate External"}) - - tests := []struct { - name string - transaction models.TransactionCreate - expectedStatus int - expectedError string - }{ - { - "Fail", - models.TransactionCreate{ - BudgetID: uuid.New(), - Amount: decimal.NewFromFloat(17.23), - Note: "v2 non-existing budget ID", - }, - http.StatusNotFound, - "there is no Budget with this ID", - }, - { - "Success", - models.TransactionCreate{ - BudgetID: budget.Data.ID, - SourceAccountID: internalAccount.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - Amount: decimal.NewFromFloat(17.23), - }, - http.StatusCreated, - "", - }, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - r := test.Request(suite.controller, t, http.MethodPost, "http://example.com/v1/transactions", tt.transaction) - assertHTTPStatus(t, &r, tt.expectedStatus) - - if tt.expectedStatus == http.StatusCreated { - var tr controllers.TransactionResponse - suite.decodeResponse(&r, &tr) - } else { - var tr httperrors.HTTPError - suite.decodeResponse(&r, &tr) - assert.Equal(t, tt.expectedError, tr.Error) - } - }) - } -} - -func (suite *TestSuiteStandard) TestTransactions() { - suite.CloseDB() - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/transactions", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusInternalServerError) - assert.Contains(suite.T(), test.DecodeError(suite.T(), recorder.Body.Bytes()), "There is a problem with the database connection") -} - -func (suite *TestSuiteStandard) TestOptionsTransaction() { - path := fmt.Sprintf("%s/%s", "http://example.com/v1/transactions", uuid.New()) - recorder := test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNotFound, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, "http://example.com/v1/transactions/NotParseableAsUUID", "") - assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) - - path = suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(31)}).Data.Links.Self - recorder = test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") - assert.Equal(suite.T(), http.StatusNoContent, recorder.Code, "Request ID %s", recorder.Header().Get("x-request-id")) -} - -// TestGetTransactions 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) TestGetTransactions() { - t1 := suite.createTestTransaction(models.TransactionCreate{ - Amount: decimal.NewFromFloat(17.23), - Date: time.Date(2023, 11, 10, 10, 11, 12, 0, time.UTC), - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - 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 := suite.createTestTransaction(models.TransactionCreate{ - Amount: decimal.NewFromFloat(44.05), - Date: time.Date(2023, 11, 10, 10, 11, 12, 0, time.UTC), - }) - - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/transactions", "") - - var response controllers.TransactionListResponse - suite.decodeResponse(&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) -} - -func (suite *TestSuiteStandard) TestGetTransactionsInvalidQuery() { - tests := []string{ - "budget=DefinitelyACat", - "source=MaybeADog", - "destination=OrARat?", - "envelope=NopeDefinitelyAMole", - "date=A long time ago", - "amount=Seventeen Cents", - "reconciled=I don't think so", - "account=ItIsAHippo!", - } - - for _, tt := range tests { - suite.T().Run(tt, func(t *testing.T) { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v1/transactions?%s", tt), "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - }) - } -} - -func (suite *TestSuiteStandard) TestGetTransactionsFilter() { - b := suite.createTestBudget(models.BudgetCreate{}) - - a1 := suite.createTestAccount(models.AccountCreate{BudgetID: b.Data.ID, Name: "TestGetTransactionsFilter 1"}) - a2 := suite.createTestAccount(models.AccountCreate{BudgetID: b.Data.ID, Name: "TestGetTransactionsFilter 2"}) - a3 := suite.createTestAccount(models.AccountCreate{BudgetID: b.Data.ID, Name: "TestGetTransactionsFilter 3"}) - - c := suite.createTestCategory(models.CategoryCreate{BudgetID: b.Data.ID}) - - e1 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: c.Data.ID}) - e2 := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: c.Data.ID}) - - e1ID := &e1.Data.ID - e2ID := &e2.Data.ID - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2018, 9, 5, 17, 13, 29, 45256, time.UTC), - Amount: decimal.NewFromFloat(2.718), - Note: "This was an important expense", - BudgetID: b.Data.ID, - EnvelopeID: e1ID, - SourceAccountID: a1.Data.ID, - DestinationAccountID: a2.Data.ID, - Reconciled: false, - ReconciledSource: true, - ReconciledDestination: false, - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2016, 5, 1, 14, 13, 25, 584575, time.UTC), - Amount: decimal.NewFromFloat(11235.813), - Note: "Not important", - BudgetID: b.Data.ID, - EnvelopeID: e2ID, - SourceAccountID: a2.Data.ID, - DestinationAccountID: a1.Data.ID, - Reconciled: false, - ReconciledSource: true, - ReconciledDestination: true, - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2021, 2, 6, 5, 1, 0, 585, time.UTC), - Amount: decimal.NewFromFloat(2.718), - Note: "", - BudgetID: b.Data.ID, - EnvelopeID: e1ID, - SourceAccountID: a3.Data.ID, - DestinationAccountID: a2.Data.ID, - Reconciled: true, - ReconciledSource: false, - ReconciledDestination: true, - }) - - tests := []struct { - name string - query string - len int - }{ - {"Exact Time", fmt.Sprintf("date=%s", time.Date(2021, 2, 6, 5, 1, 0, 585, time.UTC).Format(time.RFC3339Nano)), 1}, - {"Same date", fmt.Sprintf("date=%s", time.Date(2021, 2, 6, 7, 0, 0, 700, time.UTC).Format(time.RFC3339Nano)), 1}, - {"After date", fmt.Sprintf("fromDate=%s", time.Date(2017, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 2}, - {"Before date", fmt.Sprintf("untilDate=%s", time.Date(2017, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 1}, - {"After all dates", fmt.Sprintf("fromDate=%s", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 0}, - {"Before all dates", fmt.Sprintf("untilDate=%s", time.Date(2010, 8, 17, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 0}, - {"Regression #749", fmt.Sprintf("untilDate=%s", time.Date(2021, 2, 6, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 3}, - {"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}, - {"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}, - {"Exact Amount", fmt.Sprintf("amount=%s", decimal.NewFromFloat(2.718).String()), 2}, - {"Note", "note=Not important", 1}, - {"No note", "note=", 1}, - {"Fuzzy note", "note=important", 2}, - {"Budget Match", fmt.Sprintf("budget=%s", b.Data.ID), 3}, - {"Envelope 2", fmt.Sprintf("envelope=%s", e2.Data.ID), 1}, - {"Non-existing Source Account", "source=3340a084-acf8-4cb4-8f86-9e7f88a86190", 0}, - {"Destination Account", fmt.Sprintf("destination=%s", a2.Data.ID), 2}, - {"Not reconciled in source account", "reconciledSource=false", 1}, - {"Not reconciled in destination account", "reconciledDestination=false", 1}, - {"Reconciled in source account", "reconciledSource=true", 2}, - {"Reconciled in destination account", "reconciledDestination=true", 2}, - {"Non-existing Account", "account=534a3562-c5e8-46d1-a2e2-e96c00e7efec", 0}, - {"Existing Account 2", fmt.Sprintf("account=%s", a2.Data.ID), 3}, - {"Existing Account 1", fmt.Sprintf("account=%s", a1.Data.ID), 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 2.718", "amountMoreOrEqual=2.718", 3}, - {"Amount more or equal to 11235.813", "amountMoreOrEqual=11235.813", 1}, - {"Amount more or equal to 99999", "amountMoreOrEqual=99999", 0}, - {"Amount more or equal to 100", "amountMoreOrEqual=100", 1}, - {"Amount more or equal to 100 and less than 10", "amountMoreOrEqual=100&amountLessOrEqual=10", 0}, - {"Amount more or equal to 1 and less than 3", "amountMoreOrEqual=1&amountLessOrEqual=3", 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}, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - var re controllers.TransactionListResponse - r := test.Request(suite.controller, t, http.MethodGet, fmt.Sprintf("/v1/transactions?%s", tt.query), "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - suite.decodeResponse(&r, &re) - - assert.Equal(t, tt.len, len(re.Data), "Request ID: %s", r.Result().Header.Get("x-request-id")) - }) - } -} - -func (suite *TestSuiteStandard) TestNoTransactionNotFound() { - recorder := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/transactions/048b061f-3b6b-45ab-b0e9-0f38d2fff0c8", "") - - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestTransactionInvalidIDs() { - /* - * GET - */ - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/transactions/-56", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/transactions/notANumber", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/transactions/23", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * PATCH - */ - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/transactions/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/transactions/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - /* - * DELETE - */ - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/transactions/-274", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - r = test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/transactions/stringRandom", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateTransaction() { - _ = suite.createTestTransaction(models.TransactionCreate{Note: "More tests something something", Amount: decimal.NewFromFloat(1253.17)}) -} - -// TestTransactionEnvelopeNilUUID is a regression test to ensure that when the API receives a -// nil UUID "00000000-0000-0000-0000-000000000000" for the envelope, we do not check for the -// envelopes existence in checkTransaction() -// -// If we did, it would always error. -func (suite *TestSuiteStandard) TestCreateTransactionCheckTransactionEnvelopeNilUUID() { - eID := uuid.Nil - _ = suite.createTestTransaction(models.TransactionCreate{Note: "More tests something something", Amount: decimal.NewFromFloat(1253.17), EnvelopeID: &eID}) -} - -func (suite *TestSuiteStandard) TestTransactionSorting() { - tFebrurary := suite.createTestTransaction(models.TransactionCreate{Note: "Should be second in the list", Amount: decimal.NewFromFloat(1253.17), Date: time.Date(2022, 2, 15, 0, 0, 0, 0, time.UTC)}) - - tMarch := suite.createTestTransaction(models.TransactionCreate{Note: "Should be first in the list", Amount: decimal.NewFromFloat(1253.17), Date: time.Date(2022, 3, 15, 0, 0, 0, 0, time.UTC)}) - - tJanuary := suite.createTestTransaction(models.TransactionCreate{Note: "Should be third in the list", Amount: decimal.NewFromFloat(1253.17), Date: time.Date(2022, 1, 15, 0, 0, 0, 0, time.UTC)}) - - r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v1/transactions", "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - - var transactions controllers.TransactionListResponse - suite.decodeResponse(&r, &transactions) - - if !assert.Len(suite.T(), transactions.Data, 3, "There are not exactly three transactions") { - assert.FailNow(suite.T(), "Number of transactions is wrong, aborting") - } - assert.Equal(suite.T(), tMarch.Data.Date, transactions.Data[0].Date, "The first transaction is not the March transaction") - assert.Equal(suite.T(), tFebrurary.Data.Date, transactions.Data[1].Date, "The second transaction is not the February transaction") - assert.Equal(suite.T(), tJanuary.Data.Date, transactions.Data[2].Date, "The third transaction is not the January transaction") -} - -func (suite *TestSuiteStandard) TestCreateTransactionMissingData() { - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - account := suite.createTestAccount(models.AccountCreate{BudgetID: budget.Data.ID, Name: "TestCreateTransactionMissingData"}) - - tests := []struct { - name string - status int - create models.TransactionCreate - }{ - { - "Missing Budget", - http.StatusBadRequest, - models.TransactionCreate{ - SourceAccountID: account.Data.ID, - DestinationAccountID: account.Data.ID, - EnvelopeID: &envelope.Data.ID, - }, - }, - { - "Missing Envelope", - http.StatusBadRequest, - models.TransactionCreate{ - BudgetID: budget.Data.ID, - SourceAccountID: account.Data.ID, - DestinationAccountID: account.Data.ID, - }, - }, - { - "Missing Source Account", - http.StatusBadRequest, - models.TransactionCreate{ - BudgetID: budget.Data.ID, - DestinationAccountID: account.Data.ID, - EnvelopeID: &envelope.Data.ID, - }, - }, - { - "Missing Destination Account", - http.StatusBadRequest, - models.TransactionCreate{ - BudgetID: budget.Data.ID, - SourceAccountID: account.Data.ID, - EnvelopeID: &envelope.Data.ID, - }, - }, - { - "Missing Amount", - http.StatusBadRequest, - models.TransactionCreate{ - BudgetID: budget.Data.ID, - SourceAccountID: account.Data.ID, - DestinationAccountID: account.Data.ID, - EnvelopeID: &envelope.Data.ID, - }, - }, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - r := test.Request(suite.controller, t, http.MethodPost, "http://example.com/v1/transactions", models.Transaction{TransactionCreate: tt.create}) - assertHTTPStatus(t, &r, tt.status) - }) - } -} - -func (suite *TestSuiteStandard) TestCreateBrokenTransaction() { - tests := []string{ - "v1", - "v2", - } - - for _, tt := range tests { - suite.T().Run(tt, func(t *testing.T) { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, fmt.Sprintf("http://example.com/%s/transactions", tt), `{ "createdAt": "New Transaction", "note": "More tests for transactions to ensure less brokenness something" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) - }) - } -} - -func (suite *TestSuiteStandard) TestCreateNegativeAmountTransaction() { - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.Data.ID}) - envelope := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.Data.ID}) - account := suite.createTestAccount(models.AccountCreate{BudgetID: budget.Data.ID, Name: "TestCreateNegativeAmountTransaction"}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/transactions", models.TransactionCreate{ - BudgetID: budget.Data.ID, - SourceAccountID: account.Data.ID, - DestinationAccountID: account.Data.ID, - EnvelopeID: &envelope.Data.ID, - Amount: decimal.NewFromFloat(-17.12), - Note: "Negative amounts are not allowed, this must fail", - }) - - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateNonExistingBudgetTransaction() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/transactions", `{ "budgetId": "978e95a0-90f2-4dee-91fd-ee708c30301c", "amount": 32.12, "note": "The budget with this id must exist, so this must fail" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestCreateNoEnvelopeTransactionTransfer() { - c := models.TransactionCreate{ - BudgetID: suite.createTestBudget(models.BudgetCreate{Name: "Testing budget for transfer"}).Data.ID, - SourceAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal Source Account", External: false}).Data.ID, - DestinationAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal destination account", External: false}).Data.ID, - Amount: decimal.NewFromFloat(500), - } - - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/transactions", c) - assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) -} - -func (suite *TestSuiteStandard) TestCreateNoEnvelopeTransactionOutgoing() { - c := models.TransactionCreate{ - BudgetID: suite.createTestBudget(models.BudgetCreate{Name: "Testing budget for transfer"}).Data.ID, - SourceAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal Source Account", External: false}).Data.ID, - DestinationAccountID: suite.createTestAccount(models.AccountCreate{Name: "External destination account", External: true}).Data.ID, - Amount: decimal.NewFromFloat(350), - } - - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/transactions", c) - assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) -} - -func (suite *TestSuiteStandard) TestCreateTransferOnBudgetWithEnvelope() { - eID := suite.createTestEnvelope(models.EnvelopeCreate{}).Data.ID - c := models.TransactionCreate{ - BudgetID: suite.createTestBudget(models.BudgetCreate{Name: "Testing budget for transfer"}).Data.ID, - SourceAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal On-Budget Source Account", External: false, OnBudget: true}).Data.ID, - DestinationAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal On-Budget destination account", External: false, OnBudget: true}).Data.ID, - Amount: decimal.NewFromFloat(1337), - EnvelopeID: &eID, - } - - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/transactions", c) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateTransferOnBudgetWithEnvelope() { - eID := suite.createTestEnvelope(models.EnvelopeCreate{}).Data.ID - c := models.TransactionCreate{ - BudgetID: suite.createTestBudget(models.BudgetCreate{Name: "Testing budget for transfer"}).Data.ID, - SourceAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal On-Budget Source Account", External: false, OnBudget: true}).Data.ID, - DestinationAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal On-Budget destination account", External: false, OnBudget: true}).Data.ID, - Amount: decimal.NewFromFloat(1337), - } - - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/transactions", c) - assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) - - var transaction controllers.TransactionResponse - suite.decodeResponse(&recorder, &transaction) - - c.EnvelopeID = &eID - recorder = test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, c) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestCreateNonExistingEnvelopeTransactionTransfer() { - id := uuid.New() - - c := models.TransactionCreate{ - BudgetID: suite.createTestBudget(models.BudgetCreate{Name: "Testing budget for transfer"}).Data.ID, - SourceAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal Source Account", External: false}).Data.ID, - DestinationAccountID: suite.createTestAccount(models.AccountCreate{Name: "External destination account", External: true}).Data.ID, - Amount: decimal.NewFromFloat(350), - EnvelopeID: &id, - } - - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/transactions", c) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestCreateTransactionNoBody() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/transactions", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestGetTransaction() { - tr := suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(13.71)}) - - r := test.Request(suite.controller, suite.T(), http.MethodGet, tr.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &r, http.StatusOK) - - var response controllers.TransactionResponse - suite.decodeResponse(&r, &response) - - assert.Equal(suite.T(), fmt.Sprintf("http://example.com/v1/transactions/%s", response.Data.ID), response.Data.Links.Self) -} - -func (suite *TestSuiteStandard) TestUpdateTransaction() { - transaction := suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(584.42), Note: "Test note for transaction"}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, map[string]any{ - "note": "", - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) - - var updatedTransaction controllers.TransactionResponse - suite.decodeResponse(&recorder, &updatedTransaction) - - assert.Equal(suite.T(), "", updatedTransaction.Data.Note) -} - -func (suite *TestSuiteStandard) TestUpdateTransactionSourceDestinationEqual() { - transaction := suite.createTestTransaction(models.TransactionCreate{Note: "More tests something something", Amount: decimal.NewFromFloat(1253.17)}) - - r := test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, map[string]any{ - "destinationAccountId": transaction.Data.SourceAccountID, - }) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateTransactionBrokenJSON() { - transaction := suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(5883.53)}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, `{ "amount": 2" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateTransactionInvalidType() { - transaction := suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(5883.53)}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, map[string]any{ - "amount": false, - }) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateTransactionInvalidBudgetID() { - transaction := suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(5883.53)}) - - // Sets the BudgetID to uuid.Nil - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, models.TransactionCreate{}) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateTransactionNegativeAmount() { - transaction := suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(382.18)}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, `{ "amount": -58.23 }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestUpdateTransactionEmptySourceDestinationAccount() { - transaction := suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(382.18)}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, models.TransactionCreate{SourceAccountID: uuid.New()}) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) - - recorder = test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, models.TransactionCreate{DestinationAccountID: uuid.New()}) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestUpdateNoEnvelopeTransactionOutgoing() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - - c := models.TransactionCreate{ - BudgetID: suite.createTestBudget(models.BudgetCreate{Name: "Testing budget for updating of outgoing transfer"}).Data.ID, - SourceAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal Source Account", External: false}).Data.ID, - DestinationAccountID: suite.createTestAccount(models.AccountCreate{Name: "External destination account", External: true}).Data.ID, - EnvelopeID: &envelope.Data.ID, - Amount: decimal.NewFromFloat(984.13), - } - - transaction := suite.createTestTransaction(c) - - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, `{ "envelopeId": null }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) -} - -func (suite *TestSuiteStandard) TestUpdateEnvelopeTransactionOutgoing() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - - c := models.TransactionCreate{ - BudgetID: suite.createTestBudget(models.BudgetCreate{Name: "Testing budget for updating of outgoing transfer"}).Data.ID, - SourceAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal Source Account", External: false}).Data.ID, - DestinationAccountID: suite.createTestAccount(models.AccountCreate{Name: "External destination account", External: true}).Data.ID, - EnvelopeID: &envelope.Data.ID, - Amount: decimal.NewFromFloat(984.13), - } - - transaction := suite.createTestTransaction(c) - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, fmt.Sprintf("{ \"envelopeId\": \"%s\" }", &envelope.Data.ID)) - assertHTTPStatus(suite.T(), &recorder, http.StatusOK) -} - -func (suite *TestSuiteStandard) TestUpdateNonExistingEnvelopeTransactionOutgoing() { - envelope := suite.createTestEnvelope(models.EnvelopeCreate{}) - - c := models.TransactionCreate{ - BudgetID: suite.createTestBudget(models.BudgetCreate{Name: "Testing budget for updating of outgoing transfer"}).Data.ID, - SourceAccountID: suite.createTestAccount(models.AccountCreate{Name: "Internal Source Account", External: false}).Data.ID, - DestinationAccountID: suite.createTestAccount(models.AccountCreate{Name: "External destination account", External: true}).Data.ID, - EnvelopeID: &envelope.Data.ID, - Amount: decimal.NewFromFloat(984.13), - } - - transaction := suite.createTestTransaction(c) - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, `{ "envelopeId": "e6fa8eb5-5f2c-4292-8ef9-02f0c2af1ce4" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestUpdateNonExistingTransaction() { - recorder := test.Request(suite.controller, suite.T(), http.MethodPatch, "http://example.com/v1/transactions/6ae3312c-23cf-4225-9a81-4f218ba41b00", `{ "note": "2" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteTransaction() { - transaction := suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(123.12)}) - - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, transaction.Data.Links.Self, "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) -} - -func (suite *TestSuiteStandard) TestDeleteNonExistingTransaction() { - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/transactions/4bcb6d09-ced1-41e8-a3fe-bf4f16c5e501", "") - assertHTTPStatus(suite.T(), &recorder, http.StatusNotFound) -} - -func (suite *TestSuiteStandard) TestDeleteTransactionWithBody() { - transaction := suite.createTestTransaction(models.TransactionCreate{Amount: decimal.NewFromFloat(17.21)}) - recorder := test.Request(suite.controller, suite.T(), http.MethodDelete, transaction.Data.Links.Self, `{ "amount": "23.91" }`) - assertHTTPStatus(suite.T(), &recorder, http.StatusNoContent) -} - -func (suite *TestSuiteStandard) TestDeleteNullTransaction() { - r := test.Request(suite.controller, suite.T(), http.MethodDelete, "http://example.com/v1/transactions/00000000-0000-0000-0000-000000000000", "") - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) -} - -func (suite *TestSuiteStandard) TestTransactionSourceDestinationExternal() { - // Test create - r := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v1/transactions", models.Transaction{ - TransactionCreate: models.TransactionCreate{ - BudgetID: suite.createTestBudget(models.BudgetCreate{}).Data.ID, - SourceAccountID: suite.createTestAccount(models.AccountCreate{External: true, Name: "SourceDestinationExternal Source"}).Data.ID, - DestinationAccountID: suite.createTestAccount(models.AccountCreate{External: true, Name: "TestTransactionSourceDestinationExternal Destination"}).Data.ID, - Amount: decimal.NewFromFloat(12), - }, - }) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - // Check the error - err := test.DecodeError(suite.T(), r.Body.Bytes()) - suite.Assert().Contains(err, "transaction between two external accounts is not possible") - - // Test update - transaction := suite.createTestTransaction(models.TransactionCreate{ - Amount: decimal.NewFromFloat(11), - }) - r = test.Request(suite.controller, suite.T(), http.MethodPatch, transaction.Data.Links.Self, map[string]any{ - "sourceAccountId": suite.createTestAccount(models.AccountCreate{External: true, Name: "TestTransactionSourceDestinationExternal Inline Source"}).Data.ID, - "destinationAccountId": suite.createTestAccount(models.AccountCreate{External: true, Name: "TestTransactionSourceDestinationExternal Inline Destination"}).Data.ID, - }) - assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) - - // Check the error - err = test.DecodeError(suite.T(), r.Body.Bytes()) - suite.Assert().Contains(err, "transaction between two external accounts is not possible") -} diff --git a/pkg/controllers/transaction_v2.go b/pkg/controllers/transaction_v2.go deleted file mode 100644 index bfbedded..00000000 --- a/pkg/controllers/transaction_v2.go +++ /dev/null @@ -1,116 +0,0 @@ -package controllers - -import ( - "fmt" - "net/http" - - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type ResponseTransactionV2 struct { - Error string `json:"error" example:"A human readable error message"` // This field contains a human readable error message - Data TransactionV2 `json:"data"` // This field contains the Transaction data -} - -type TransactionV2 struct { - models.Transaction - Links struct { - Self string `json:"self" example:"https://example.com/api/v2/transactions/d430d7c3-d14c-4712-9336-ee56965a6673"` // The transaction itself - } `json:"links"` // Links for the transaction -} - -// links generates HATEOAS links for the transaction. -func (t *TransactionV2) links(c *gin.Context) { - // Set links - t.Links.Self = fmt.Sprintf("%s/v2/transactions/%s", c.GetString(string(database.ContextURL)), t.ID) -} - -func (co Controller) getTransactionV2(c *gin.Context, id uuid.UUID) (TransactionV2, bool) { - transactionModel, ok := getResourceByIDAndHandleErrors[models.Transaction](c, co, id) - if !ok { - return TransactionV2{}, false - } - - transaction := TransactionV2{ - Transaction: transactionModel, - } - - transaction.links(c) - return transaction, true -} - -// RegisterTransactionRoutesV2 registers the routes for transactions with -// the RouterGroup that is passed. -func (co Controller) RegisterTransactionRoutesV2(r *gin.RouterGroup) { - // Root group - { - r.OPTIONS("", co.OptionsTransactionsV2) - r.POST("", co.CreateTransactionsV2) - } -} - -// OptionsTransactionsV2 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 Transactions -// @Success 204 -// @Router /v2/transactions [options] -// @Deprecated true -func (co Controller) OptionsTransactionsV2(c *gin.Context) { - httputil.OptionsPost(c) -} - -// CreateTransactionsV2 creates transactions -// -// @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} []ResponseTransactionV2 -// @Failure 400 {object} httperrors.HTTPError -// @Failure 404 {object} []ResponseTransactionV2 -// @Failure 500 {object} []ResponseTransactionV2 -// @Param transactions body []models.TransactionCreate true "Transactions" -// @Router /v2/transactions [post] -// @Deprecated true -func (co Controller) CreateTransactionsV2(c *gin.Context) { - var transactions []models.Transaction - - if err := httputil.BindDataHandleErrors(c, &transactions); err != nil { - return - } - - // The response list has the same length as the request list - r := make([]ResponseTransactionV2, 0, len(transactions)) - - // The final http status. Will be modified when errors occur - status := http.StatusCreated - - for _, t := range transactions { - t, err := co.createTransaction(c, t.TransactionCreate) - - // Append the error or the successfully created transaction to the response list - if !err.Nil() { - r = append(r, ResponseTransactionV2{Error: err.Error()}) - - // The final status code is the highest HTTP status code number since this also - // represents the priority we - if err.Status > status { - status = err.Status - } - } else { - tObject, ok := co.getTransactionV2(c, t.ID) - if !ok { - return - } - r = append(r, ResponseTransactionV2{Data: tObject}) - } - } - - c.JSON(status, r) -} diff --git a/pkg/controllers/transaction_v2_test.go b/pkg/controllers/transaction_v2_test.go deleted file mode 100644 index c925d10b..00000000 --- a/pkg/controllers/transaction_v2_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package controllers_test - -import ( - "fmt" - "net/http" - "testing" - - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" - "github.com/google/uuid" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) TestTransactionsCreateV2() { - budget := suite.createTestBudget(models.BudgetCreate{}) - internalAccount := suite.createTestAccount(models.AccountCreate{External: false, BudgetID: budget.Data.ID, Name: "TestTransactionsCreate Internal"}) - externalAccount := suite.createTestAccount(models.AccountCreate{External: true, BudgetID: budget.Data.ID, Name: "TestTransactionsCreate External"}) - - tests := []struct { - name string - transactions []models.TransactionCreate - expectedStatus int - expectedErrors []string - }{ - { - "One success, one fail", - []models.TransactionCreate{ - { - BudgetID: uuid.New(), - Amount: decimal.NewFromFloat(17.23), - Note: "v2 non-existing budget ID", - }, - { - BudgetID: budget.Data.ID, - SourceAccountID: internalAccount.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - Amount: decimal.NewFromFloat(57.01), - }, - }, - http.StatusNotFound, - []string{ - "there is no Budget with this ID", - "", - }, - }, - { - "Both succeed", - []models.TransactionCreate{ - { - BudgetID: budget.Data.ID, - SourceAccountID: internalAccount.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - Amount: decimal.NewFromFloat(17.23), - }, - { - BudgetID: budget.Data.ID, - SourceAccountID: internalAccount.Data.ID, - DestinationAccountID: externalAccount.Data.ID, - Amount: decimal.NewFromFloat(57.01), - }, - }, - http.StatusCreated, - []string{ - "", - "", - }, - }, - } - - for _, tt := range tests { - suite.T().Run(tt.name, func(t *testing.T) { - r := test.Request(suite.controller, t, http.MethodPost, "http://example.com/v2/transactions", tt.transactions) - assertHTTPStatus(t, &r, tt.expectedStatus) - - var tr []controllers.ResponseTransactionV2 - suite.decodeResponse(&r, &tr) - - for i, transaction := range tr { - assert.Equal(t, tt.expectedErrors[i], transaction.Error) - - if tt.expectedErrors[i] == "" { - assert.Equal(t, fmt.Sprintf("http://example.com/v2/transactions/%s", transaction.Data.ID), transaction.Data.Links.Self) - } - } - }) - } -} diff --git a/pkg/importer/creator.go b/pkg/importer/creator.go index 5f671b49..312f1936 100644 --- a/pkg/importer/creator.go +++ b/pkg/importer/creator.go @@ -108,27 +108,15 @@ func Create(db *gorm.DB, resources ParsedResources) (models.Budget, error) { } } - // Create allocations - for _, a := range resources.Allocations { - allocation := a.Model - allocation.AllocationCreate.EnvelopeID = resources.Categories[a.Category].Envelopes[a.Envelope].Model.ID - - err := tx.Create(&allocation).Error - if err != nil { - tx.Rollback() - return models.Budget{}, err - } - } - // Create MonthConfigs - for _, m := range resources.MonthConfigs { + for i, m := range resources.MonthConfigs { mConfig := m.Model mConfig.EnvelopeID = resources.Categories[m.Category].Envelopes[m.Envelope].Model.ID err := tx.Create(&mConfig).Error if err != nil { tx.Rollback() - return models.Budget{}, err + return models.Budget{}, fmt.Errorf("error on creation of month config %d: %w", i, err) } } diff --git a/pkg/importer/parser/ynab4/parse.go b/pkg/importer/parser/ynab4/parse.go index 171c4540..cbfbb018 100644 --- a/pkg/importer/parser/ynab4/parse.go +++ b/pkg/importer/parser/ynab4/parse.go @@ -480,26 +480,28 @@ func parseMonthlyBudgets(resources *importer.ParsedResources, monthlyBudgets []M for _, subCategoryBudget := range monthBudget.MonthlySubCategoryBudgets { // If the budget allocation is deleted, we don't need to do anything. // This is the case when a category that has budgeted amounts gets deleted. - if subCategoryBudget.Deleted { + // + // We also don't need to do anything when nothing is budgeted and the overspend handling + // is the default + if subCategoryBudget.Deleted || (subCategoryBudget.Budgeted.IsZero() && (subCategoryBudget.OverspendingHandling == "AffectsBuffer" || subCategoryBudget.OverspendingHandling == "")) { continue } - // If something is budgeted, create an allocation for it + monthConfig := importer.MonthConfig{ + Model: models.MonthConfig{ + Month: month, + }, + Category: envelopeIDNames[subCategoryBudget.CategoryID].Category, + Envelope: envelopeIDNames[subCategoryBudget.CategoryID].Envelope, + } + + // If something is budgeted, set the amount if !subCategoryBudget.Budgeted.IsZero() { - resources.Allocations = append(resources.Allocations, importer.Allocation{ - Model: models.Allocation{ - AllocationCreate: models.AllocationCreate{ - Month: month, - Amount: subCategoryBudget.Budgeted, - }, - }, - Category: envelopeIDNames[subCategoryBudget.CategoryID].Category, - Envelope: envelopeIDNames[subCategoryBudget.CategoryID].Envelope, - }) + monthConfig.Model.Allocation = subCategoryBudget.Budgeted } - // If the overspendHandling is configured, work with it - if !(subCategoryBudget.OverspendingHandling == "") { + // If the overspendHandling is confined, work with it + if subCategoryBudget.OverspendingHandling == "Confined" { // All occurrences of PreYNABDebt configurations that I could find are set for // months before there is any budget data. // Configuration for months before any data exists is not needed and therefore skipped @@ -509,22 +511,10 @@ func parseMonthlyBudgets(resources *importer.ParsedResources, monthlyBudgets []M continue } - var mode models.OverspendMode = "AFFECT_AVAILABLE" - if subCategoryBudget.OverspendingHandling == "Confined" { - mode = "AFFECT_ENVELOPE" - } - - resources.MonthConfigs = append(resources.MonthConfigs, importer.MonthConfig{ - Model: models.MonthConfig{ - MonthConfigCreate: models.MonthConfigCreate{ - OverspendMode: mode, - }, - Month: month, - }, - Category: envelopeIDNames[subCategoryBudget.CategoryID].Category, - Envelope: envelopeIDNames[subCategoryBudget.CategoryID].Envelope, - }) + monthConfig.Model.OverspendMode = "AFFECT_ENVELOPE" } + + resources.MonthConfigs = append(resources.MonthConfigs, monthConfig) } } diff --git a/pkg/importer/parser/ynab4/parse_test.go b/pkg/importer/parser/ynab4/parse_test.go index 38f5216c..89bbdcfe 100644 --- a/pkg/importer/parser/ynab4/parse_test.go +++ b/pkg/importer/parser/ynab4/parse_test.go @@ -137,34 +137,6 @@ func TestParse(t *testing.T) { t.Run("transactions", func(t *testing.T) { testTransactions(t, accounts, envelopes, transactions) }) - - // 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 { - m, err := b.Month(db, tt.month) - assert.Nil(t, err) - - assert.True(t, decimal.NewFromFloat32(tt.available).Equal(m.Available), "Available for %s is wrong, should be %s but is %s", tt.month, decimal.NewFromFloat32(tt.available), m.Available) - assert.True(t, decimal.NewFromFloat32(tt.balance).Equal(m.Balance), "Balance for %s is wrong, should be %s but is %s", tt.month, decimal.NewFromFloat32(tt.balance), m.Balance) - assert.True(t, decimal.NewFromFloat32(tt.spent).Equal(m.Spent), "Spent for %s is wrong, should be %s but is %s", tt.month, decimal.NewFromFloat32(tt.spent), m.Spent) - assert.True(t, decimal.NewFromFloat32(tt.budgeted).Equal(m.Budgeted), "Budgeted for %s is wrong, should be %s but is %s", tt.month, decimal.NewFromFloat32(tt.budgeted), m.Budgeted) - assert.True(t, decimal.NewFromFloat32(tt.income).Equal(m.Income), "Income for %s is wrong, should be %s but is %s", tt.month, decimal.NewFromFloat32(tt.income), m.Income) - } } // testAccount tests all account resources. diff --git a/pkg/importer/types.go b/pkg/importer/types.go index b80da3a6..49c0e739 100644 --- a/pkg/importer/types.go +++ b/pkg/importer/types.go @@ -12,7 +12,6 @@ type ParsedResources struct { Budget models.Budget Accounts []models.Account Categories map[string]Category - Allocations []Allocation Transactions []Transaction MonthConfigs []MonthConfig MatchRules []MatchRule @@ -33,12 +32,6 @@ type MatchRule struct { Account string } -type Allocation struct { - Model models.Allocation - Category string // There is a category here since an envelope with the same name can exist for multiple categories - Envelope string -} - type MonthConfig struct { Model models.MonthConfig Category string // There is a category here since an envelope with the same name can exist for multiple categories diff --git a/pkg/models/allocation.go b/pkg/models/allocation.go deleted file mode 100644 index 55cd0aec..00000000 --- a/pkg/models/allocation.go +++ /dev/null @@ -1,36 +0,0 @@ -package models - -import ( - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/google/uuid" - "github.com/shopspring/decimal" - "gorm.io/gorm" -) - -// Allocation represents the allocation of money to an Envelope for a specific month. -type Allocation struct { - DefaultModel - AllocationCreate - Envelope Envelope `json:"-"` -} - -type AllocationCreate struct { - Month types.Month `json:"month" gorm:"uniqueIndex:allocation_month_envelope" example:"2021-12-01T00:00:00.000000Z"` // Only year and month of this timestamp are used, everything else is ignored. This will always be set to 00:00 UTC on the first of the specified month - Amount decimal.Decimal `json:"amount" gorm:"type:DECIMAL(20,8)" example:"22.01" minimum:"0.00000001" maximum:"999999999999.99999999" multipleOf:"0.00000001"` // The maximum value is "999999999999.99999999", swagger unfortunately rounds this. - EnvelopeID uuid.UUID `json:"envelopeId" gorm:"uniqueIndex:allocation_month_envelope" example:"a0909e84-e8f9-4cb6-82a5-025dff105ff2"` // ID of the envelope -} - -func (a Allocation) Self() string { - return "Allocation" -} - -// BeforeSave verifies that the amount is non-zero. -// To remove an allocation, it has to be deleted instead of -// set to 0. -func (a *Allocation) BeforeSave(_ *gorm.DB) (err error) { - if a.Amount.IsZero() { - return ErrAllocationZero - } - - return -} diff --git a/pkg/models/allocation_test.go b/pkg/models/allocation_test.go deleted file mode 100644 index 363064d8..00000000 --- a/pkg/models/allocation_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package models_test - -import ( - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" -) - -func (suite *TestSuiteStandard) TestAllocationSelf() { - assert.Equal(suite.T(), "Allocation", models.Allocation{}.Self()) -} - -func (suite *TestSuiteStandard) TestAllocationZero() { - a := models.Allocation{ - AllocationCreate: models.AllocationCreate{ - Amount: decimal.Zero, - }, - } - - err := a.BeforeSave(suite.db) - assert.NotNil(suite.T(), err) - assert.Equal(suite.T(), "allocation amounts must be non-zero. Instead of setting to zero, delete the Allocation", err.Error()) -} diff --git a/pkg/models/budget.go b/pkg/models/budget.go index 59d06524..4a0c95e5 100644 --- a/pkg/models/budget.go +++ b/pkg/models/budget.go @@ -1,12 +1,9 @@ package models import ( - "fmt" "strings" "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/google/uuid" "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -93,129 +90,23 @@ func (b Budget) Income(db *gorm.DB, month types.Month) (income decimal.Decimal, // Allocated calculates the sum that has been budgeted for a specific month. func (b Budget) Allocated(db *gorm.DB, month types.Month) (allocated decimal.Decimal, err error) { - var allocations []Allocation + var monthConfigs []MonthConfig err = db. - Joins("JOIN envelopes ON allocations.envelope_id = envelopes.id AND envelopes.deleted_at IS NULL"). + Joins("JOIN envelopes ON month_configs.envelope_id = envelopes.id AND envelopes.deleted_at IS NULL"). Joins("JOIN categories ON envelopes.category_id = categories.id AND categories.deleted_at IS NULL"). Joins("JOIN budgets ON categories.budget_id = budgets.id AND budgets.deleted_at IS NULL"). Where("budgets.id = ?", b.ID). - Where("allocations.month >= date(?)", month). - Where("allocations.month < date(?)", month.AddDate(0, 1)). - Find(&allocations). + Where("month_configs.month >= date(?)", month). + Where("month_configs.month < date(?)", month.AddDate(0, 1)). + Find(&monthConfigs). Error if err != nil { return decimal.Zero, err } - for _, a := range allocations { - allocated = allocated.Add(a.Amount) + for _, a := range monthConfigs { + allocated = allocated.Add(a.Allocation) } return } - -// Month calculates the month overview for this month. -func (b Budget) Month(db *gorm.DB, month types.Month) (Month, error) { - result := Month{ - ID: b.ID, - Name: b.Name, - Month: month, - } - - // Add budgeted sum to response - budgeted, err := b.Allocated(db, result.Month) - if err != nil { - return Month{}, err - } - result.Budgeted = budgeted - result.Allocation = budgeted - - // Add income to response - income, err := b.Income(db, result.Month) - if err != nil { - return Month{}, err - } - result.Income = income - - // Get all categories for the budget - var categories []Category - err = db.Where(&Category{CategoryCreate: CategoryCreate{BudgetID: b.ID}}).Find(&categories).Error - if err != nil { - return Month{}, err - } - - 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 - categoryEnvelopes.Category = category - categoryEnvelopes.Envelopes = make([]EnvelopeMonth, 0) - - var envelopes []Envelope - - err = db.Where(&Envelope{ - EnvelopeCreate: EnvelopeCreate{ - CategoryID: category.ID, - }, - }).Find(&envelopes).Error - if err != nil { - return Month{}, err - } - - for _, envelope := range envelopes { - envelopeMonth, allocationID, err := envelope.Month(db, result.Month) - if err != nil { - return Month{}, err - } - - // 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) - - // TODO: The remove this with the integration of allocations into MonthConfigs. - url := db.Statement.Context.Value(database.ContextURL) - - // Set the allocation link. If there is no allocation, we send the collection endpoint. - // With this, any client will be able to see that the "Budgeted" amount is 0 and therefore - // send a HTTP POST for creation instead of a patch. - envelopeMonth.Links.Allocation = fmt.Sprintf("%s/v1/allocations", url) - if allocationID != uuid.Nil { - envelopeMonth.Links.Allocation = fmt.Sprintf("%s/%s", envelopeMonth.Links.Allocation, allocationID) - } - - 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 []Account - err = db.Where(&Account{AccountCreate: AccountCreate{BudgetID: b.ID, OnBudget: true}}).Find(&accounts).Error - if err != nil { - return Month{}, err - } - - // Add all on-balance accounts to the available sum - for _, a := range accounts { - _, available, err := a.GetBalanceMonth(db, month) - if err != nil { - return Month{}, err - } - result.Available = result.Available.Add(available) - } - - return result, nil -} diff --git a/pkg/models/budget_test.go b/pkg/models/budget_test.go index 0ff9fb88..98080639 100644 --- a/pkg/models/budget_test.go +++ b/pkg/models/budget_test.go @@ -1,9 +1,7 @@ package models_test import ( - "fmt" "strings" - "testing" "time" "github.com/envelope-zero/backend/v3/internal/types" @@ -81,28 +79,36 @@ func (suite *TestSuiteStandard) TestBudgetCalculations() { CategoryID: category.ID, }) - _ = suite.createTestAllocation(models.AllocationCreate{ + _ = suite.createTestMonthConfig(models.MonthConfig{ EnvelopeID: envelope.ID, - Amount: decimal.NewFromFloat(17.42), Month: marchTwentyTwentyTwo.AddDate(0, -2), + MonthConfigCreate: models.MonthConfigCreate{ + Allocation: decimal.NewFromFloat(17.42), + }, }) - _ = suite.createTestAllocation(models.AllocationCreate{ + _ = suite.createTestMonthConfig(models.MonthConfig{ EnvelopeID: envelope.ID, - Amount: decimal.NewFromFloat(24.58), Month: marchTwentyTwentyTwo.AddDate(0, -1), + MonthConfigCreate: models.MonthConfigCreate{ + Allocation: decimal.NewFromFloat(24.58), + }, }) - _ = suite.createTestAllocation(models.AllocationCreate{ + _ = suite.createTestMonthConfig(models.MonthConfig{ EnvelopeID: envelope.ID, - Amount: decimal.NewFromFloat(25), Month: marchTwentyTwentyTwo, + MonthConfigCreate: models.MonthConfigCreate{ + Allocation: decimal.NewFromFloat(25), + }, }) - _ = suite.createTestAllocation(models.AllocationCreate{ + _ = suite.createTestMonthConfig(models.MonthConfig{ EnvelopeID: envelope.ID, - Amount: decimal.NewFromFloat(24.58), Month: types.NewMonth(2170, 2), + MonthConfigCreate: models.MonthConfigCreate{ + Allocation: decimal.NewFromFloat(24.58), + }, }) _ = suite.createTestTransaction(models.TransactionCreate{ @@ -222,223 +228,6 @@ func (suite *TestSuiteStandard) TestBudgetBudgetedDBFail() { suite.Assert().Equal("sql: database is closed", err.Error()) } -// TestBudgetMonth verifies that the monthly calculations are correct. -func (suite *TestSuiteStandard) TestMonth() { - budget := suite.createTestBudget(models.BudgetCreate{}) - category := suite.createTestCategory(models.CategoryCreate{BudgetID: budget.ID, Name: "Upkeep"}) - envelope := suite.createTestEnvelope(models.EnvelopeCreate{CategoryID: category.ID, Name: "Utilities"}) - account := suite.createTestAccount(models.AccountCreate{BudgetID: budget.ID, OnBudget: true, Name: "TestMonth"}) - externalAccount := suite.createTestAccount(models.AccountCreate{BudgetID: budget.ID, External: true}) - - allocationJanuary := suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelope.ID, - Month: types.NewMonth(2022, 1), - Amount: decimal.NewFromFloat(20.99), - }) - - allocationFebruary := suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelope.ID, - Month: types.NewMonth(2022, 2), - Amount: decimal.NewFromFloat(47.12), - }) - - allocationMarch := suite.createTestAllocation(models.AllocationCreate{ - EnvelopeID: envelope.ID, - Month: types.NewMonth(2022, 3), - Amount: decimal.NewFromFloat(31.17), - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 1, 15, 0, 0, 0, 0, time.UTC), - Amount: decimal.NewFromFloat(10.0), - Note: "Water bill for January", - BudgetID: budget.ID, - SourceAccountID: account.ID, - DestinationAccountID: externalAccount.ID, - EnvelopeID: &envelope.ID, - Reconciled: true, - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 2, 15, 0, 0, 0, 0, time.UTC), - Amount: decimal.NewFromFloat(5.0), - Note: "Water bill for February", - BudgetID: budget.ID, - SourceAccountID: account.ID, - DestinationAccountID: externalAccount.ID, - EnvelopeID: &envelope.ID, - Reconciled: true, - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 3, 15, 0, 0, 0, 0, time.UTC), - Amount: decimal.NewFromFloat(15.0), - Note: "Water bill for March", - BudgetID: budget.ID, - SourceAccountID: account.ID, - DestinationAccountID: externalAccount.ID, - EnvelopeID: &envelope.ID, - Reconciled: true, - }) - - _ = suite.createTestTransaction(models.TransactionCreate{ - Date: time.Date(2022, 3, 1, 7, 38, 17, 0, time.UTC), - Amount: decimal.NewFromFloat(1500), - Note: "Income for march", - BudgetID: budget.ID, - SourceAccountID: externalAccount.ID, - DestinationAccountID: account.ID, - EnvelopeID: nil, - }) - - tests := []struct { - month types.Month - result models.Month - }{ - { - types.NewMonth(2022, 1), - models.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: []models.CategoryEnvelopes{ - { - Category: category, - Balance: decimal.NewFromFloat(10.99), - Spent: decimal.NewFromFloat(-10), - Allocation: decimal.NewFromFloat(20.99), - Envelopes: []models.EnvelopeMonth{ - { - Envelope: envelope, - Month: types.NewMonth(2022, 1), - Spent: decimal.NewFromFloat(-10), - Balance: decimal.NewFromFloat(10.99), - Allocation: decimal.NewFromFloat(20.99), - Links: models.EnvelopeMonthLinks{ - Allocation: fmt.Sprintf("https://example.com/v1/allocations/%s", allocationJanuary.ID), - }, - }, - }, - }, - }, - }, - }, - { - types.NewMonth(2022, 2), - models.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: []models.CategoryEnvelopes{ - { - Category: category, - Balance: decimal.NewFromFloat(53.11), - Spent: decimal.NewFromFloat(-5), - Allocation: decimal.NewFromFloat(47.12), - Envelopes: []models.EnvelopeMonth{ - { - Envelope: envelope, - Month: types.NewMonth(2022, 2), - Balance: decimal.NewFromFloat(53.11), - Spent: decimal.NewFromFloat(-5), - Allocation: decimal.NewFromFloat(47.12), - Links: models.EnvelopeMonthLinks{ - Allocation: fmt.Sprintf("https://example.com/v1/allocations/%s", allocationFebruary.ID), - }, - }, - }, - }, - }, - }, - }, - { - types.NewMonth(2022, 3), - models.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: []models.CategoryEnvelopes{ - { - Category: category, - Balance: decimal.NewFromFloat(69.28), - Spent: decimal.NewFromFloat(-15), - Allocation: decimal.NewFromFloat(31.17), - Envelopes: []models.EnvelopeMonth{ - { - Envelope: envelope, - Month: types.NewMonth(2022, 3), - Balance: decimal.NewFromFloat(69.28), - Spent: decimal.NewFromFloat(-15), - Allocation: decimal.NewFromFloat(31.17), - Links: models.EnvelopeMonthLinks{ - Allocation: fmt.Sprintf("https://example.com/v1/allocations/%s", allocationMarch.ID), - }, - }, - }, - }, - }, - }, - }, - } - - for _, tt := range tests { - suite.T().Run(tt.month.String(), func(t *testing.T) { - month, err := budget.Month(suite.db, tt.month) - assert.Nil(t, err) - - // 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)) - } - - // 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) - - suite.Assert().Equal(expectedEnvelope.Links.Allocation, envelope.Links.Allocation) - }) - } -} - func (suite *TestSuiteStandard) TestBudgetSelf() { assert.Equal(suite.T(), "Budget", models.Budget{}.Self()) } diff --git a/pkg/models/database.go b/pkg/models/database.go index 7aadba98..402117c4 100644 --- a/pkg/models/database.go +++ b/pkg/models/database.go @@ -3,7 +3,9 @@ package models import ( "fmt" + "github.com/envelope-zero/backend/v3/internal/types" "github.com/google/uuid" + "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -30,11 +32,20 @@ func Migrate(db *gorm.DB) (err error) { } } - err = db.AutoMigrate(Budget{}, Account{}, Category{}, Envelope{}, Transaction{}, Allocation{}, MonthConfig{}, MatchRule{}, Goal{}) + err = db.AutoMigrate(Budget{}, Account{}, Category{}, Envelope{}, Transaction{}, MonthConfig{}, MatchRule{}, Goal{}) if err != nil { return fmt.Errorf("error during DB migration: %w", err) } + // https://github.com/envelope-zero/backend/issues/440 + // Remove with 5.0.0 + if db.Migrator().HasTable("allocations") { + err = migrateAllocationToMonthConfig(db) + if err != nil { + return fmt.Errorf("error during migrateAllocationToMonthConfig: %w", err) + } + } + // Migration for https://github.com/envelope-zero/backend/issues/613 // Remove with 4.0.0 err = unsetEnvelopes(db) @@ -114,3 +125,49 @@ func unsetEnvelopes(db *gorm.DB) (err error) { return } + +func migrateAllocationToMonthConfig(db *gorm.DB) (err error) { + type allocation struct { + EnvelopeID string `gorm:"column:envelope_id"` + Month types.Month `gorm:"column:month"` + Amount decimal.Decimal `gorm:"column:amount"` + } + + var allocations []allocation + err = db.Raw("select envelope_id, month, amount from allocations").Scan(&allocations).Error + if err != nil { + return err + } + + // Execute all updates in a transaction + tx := db.Begin() + + // For each allocation, read the values and update the MonthConfig with it + for _, allocation := range allocations { + id, err := uuid.Parse(allocation.EnvelopeID) + if err != nil { + tx.Rollback() + return err + } + + err = tx.Where(MonthConfig{ + Month: allocation.Month, + EnvelopeID: id, + }).Assign(MonthConfig{MonthConfigCreate: MonthConfigCreate{ + Allocation: allocation.Amount, + }}).FirstOrCreate(&MonthConfig{}).Error + + if err != nil { + tx.Rollback() + return err + } + } + + err = tx.Raw("DROP TABLE allocations").Scan(nil).Error + if err != nil { + return err + } + + tx.Commit() + return nil +} diff --git a/pkg/models/database_test.go b/pkg/models/database_test.go index 0bea720b..475fe16b 100644 --- a/pkg/models/database_test.go +++ b/pkg/models/database_test.go @@ -1,6 +1,7 @@ package models_test import ( + "github.com/envelope-zero/backend/v3/internal/types" "github.com/envelope-zero/backend/v3/pkg/models" "github.com/shopspring/decimal" ) @@ -92,3 +93,31 @@ func (suite *TestSuiteStandard) TestUnsetEnvelope() { // Test thet the envelope has been set to nil by the migration suite.Assert().Nil(checkTransaction.EnvelopeID) } + +func (suite *TestSuiteStandard) TestMigrateAllocation() { + err := suite.db.Raw("CREATE TABLE allocations (`id` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`month` date,`amount` DECIMAL(20,8),`envelope_id` text,PRIMARY KEY (`id`))").Scan(nil).Error + suite.Assert().Nil(err) + + err = suite.db.Raw("INSERT INTO allocations (id, envelope_id, month, amount) VALUES ('3afd1b7f-6bae-4256-aa78-89ef5dac7775', '41efaa99-1737-4dc6-818b-5d5f2ac65138', '2023-12-01 00:00:00+00:00', '10')").Scan(nil).Error + suite.Assert().Nil(err) + + err = models.Migrate(suite.db) + suite.Assert().Nil(err) + + type monthConfig struct { + EnvelopeID string `gorm:"column:envelope_id"` + Month types.Month `gorm:"column:month"` + Allocation decimal.Decimal `gorm:"column:allocation"` + } + + var monthConfigs []monthConfig + err = suite.db.Raw("SELECT envelope_id, month, allocation FROM month_configs WHERE envelope_id = '41efaa99-1737-4dc6-818b-5d5f2ac65138'").Scan(&monthConfigs).Error + suite.Assert().Nil(err) + suite.Assert().Len(monthConfigs, 1) + suite.Assert().True(monthConfigs[0].Allocation.Equal(decimal.NewFromFloat(10))) + + var count int + err = suite.db.Raw("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='allocations'").Scan(&count).Error + suite.Assert().Nil(err) + suite.Assert().Equal(0, count) +} diff --git a/pkg/models/envelope.go b/pkg/models/envelope.go index cbfbf974..c7abfe10 100644 --- a/pkg/models/envelope.go +++ b/pkg/models/envelope.go @@ -128,24 +128,6 @@ func (e Envelope) Balance(db *gorm.DB, month types.Month) (decimal.Decimal, erro monthTransactions[tDate] = append(monthTransactions[tDate], transaction) } - // Get allocations - var rawAllocations []Allocation - err = db. - Table("allocations"). - Where("allocations.month < date(?)", month.AddDate(0, 1)). - Where("allocations.envelope_id = ?", e.ID). - Where("allocations.deleted_at IS NULL"). - Find(&rawAllocations).Error - if err != nil { - return decimal.Zero, nil - } - - // Sort allocations by month - allocationMonths := make(map[types.Month]Allocation) - for _, allocation := range rawAllocations { - allocationMonths[allocation.Month] = allocation - } - // Get MonthConfigs var rawConfigs []MonthConfig err = db. @@ -168,15 +150,9 @@ func (e Envelope) Balance(db *gorm.DB, month types.Month) (decimal.Decimal, erro // monthKeys slice monthsWithData := make(map[types.Month]bool) - // Create a slice of the months that have Allocation - // data to have a sorted list we can iterate over + // Create a slice of the months that have MonthConfigs + // to have a sorted list we can iterate over monthKeys := make([]types.Month, 0) - for k := range allocationMonths { - monthKeys = append(monthKeys, k) - monthsWithData[k] = true - } - - // Add the months that have MonthConfigs for k := range configMonths { if _, ok := monthsWithData[k]; !ok { monthKeys = append(monthKeys, k) @@ -204,7 +180,6 @@ func (e Envelope) Balance(db *gorm.DB, month types.Month) (decimal.Decimal, erro loopMonth := monthKeys[0] for i := 0; i < len(monthKeys); i++ { currentMonthTransactions, transactionsOk := monthTransactions[loopMonth] - currentMonthAllocation, allocationOk := allocationMonths[loopMonth] currentMonthConfig, configOk := configMonths[loopMonth] // We always go forward one month until we @@ -216,7 +191,7 @@ func (e Envelope) Balance(db *gorm.DB, month types.Month) (decimal.Decimal, erro // // We also reset the balance to 0 if it is negative // since with no MonthConfig, the balance starts from 0 again - if !transactionsOk && !allocationOk && !configOk { + if !transactionsOk && !configOk { i-- if sum.IsNegative() { sum = decimal.Zero @@ -239,7 +214,7 @@ func (e Envelope) Balance(db *gorm.DB, month types.Month) (decimal.Decimal, erro // The zero value for a decimal is Zero, so we don't need to check // if there is an allocation - monthSum = monthSum.Add(currentMonthAllocation.Amount) + monthSum = monthSum.Add(currentMonthConfig.Allocation) // If the value is not negative, we're done here. if !monthSum.IsNegative() { @@ -295,7 +270,7 @@ type EnvelopeMonth struct { } // Month calculates the month specific values for an envelope and returns an EnvelopeMonth and allocation ID for them. -func (e Envelope) Month(db *gorm.DB, month types.Month) (EnvelopeMonth, uuid.UUID, error) { +func (e Envelope) Month(db *gorm.DB, month types.Month) (EnvelopeMonth, error) { spent := e.Spent(db, month) envelopeMonth := EnvelopeMonth{ Envelope: e, @@ -305,24 +280,22 @@ func (e Envelope) Month(db *gorm.DB, month types.Month) (EnvelopeMonth, uuid.UUI Allocation: decimal.NewFromFloat(0), } - var allocation Allocation - err := db.First(&allocation, &Allocation{ - AllocationCreate: AllocationCreate{ - EnvelopeID: e.ID, - Month: month, - }, - }).Error + var monthConfig MonthConfig + err := db.Where(&MonthConfig{ + EnvelopeID: e.ID, + Month: month, + }).Find(&monthConfig).Error // If an unexpected error occurs, return if err != nil && err != gorm.ErrRecordNotFound { - return EnvelopeMonth{}, uuid.Nil, err + return EnvelopeMonth{}, err } envelopeMonth.Balance, err = e.Balance(db, month) if err != nil { - return EnvelopeMonth{}, uuid.Nil, err + return EnvelopeMonth{}, err } - envelopeMonth.Allocation = allocation.Amount - return envelopeMonth, allocation.ID, nil + envelopeMonth.Allocation = monthConfig.Allocation + return envelopeMonth, nil } diff --git a/pkg/models/envelope_test.go b/pkg/models/envelope_test.go index 996ca567..5c4f6d58 100644 --- a/pkg/models/envelope_test.go +++ b/pkg/models/envelope_test.go @@ -73,11 +73,11 @@ func (suite *TestSuiteStandard) TestEnvelopeMonthSum() { Date: time.Time(january.AddDate(0, 1)), }) - envelopeMonth, _, err := envelope.Month(suite.db, january) + envelopeMonth, err := envelope.Month(suite.db, january) assert.Nil(suite.T(), err) assert.True(suite.T(), envelopeMonth.Spent.Equal(spent.Neg()), "Month calculation for 2022-01 is wrong: should be %v, but is %v", spent.Neg(), envelopeMonth.Spent) - envelopeMonth, _, err = envelope.Month(suite.db, january.AddDate(0, 1)) + envelopeMonth, err = envelope.Month(suite.db, january.AddDate(0, 1)) assert.Nil(suite.T(), err) assert.True(suite.T(), envelopeMonth.Spent.Equal(spent), "Month calculation for 2022-02 is wrong: should be %v, but is %v", spent, envelopeMonth.Spent) @@ -86,7 +86,7 @@ func (suite *TestSuiteStandard) TestEnvelopeMonthSum() { suite.Assert().Fail("Resource could not be deleted", err) } - envelopeMonth, _, err = envelope.Month(suite.db, january) + envelopeMonth, err = envelope.Month(suite.db, january) assert.Nil(suite.T(), err) assert.True(suite.T(), envelopeMonth.Spent.Equal(decimal.NewFromFloat(0)), "Month calculation for 2022-01 is wrong: should be %v, but is %v", decimal.NewFromFloat(0), envelopeMonth.Spent) } @@ -154,17 +154,21 @@ func (suite *TestSuiteStandard) TestEnvelopeMonthBalance() { january := types.NewMonth(2022, 1) // Allocation in January - _ = suite.createTestAllocation(models.AllocationCreate{ + _ = suite.createTestMonthConfig(models.MonthConfig{ EnvelopeID: envelope.ID, Month: january, - Amount: decimal.NewFromFloat(50), + MonthConfigCreate: models.MonthConfigCreate{ + Allocation: decimal.NewFromFloat(50), + }, }) // Allocation in February - _ = suite.createTestAllocation(models.AllocationCreate{ + _ = suite.createTestMonthConfig(models.MonthConfig{ EnvelopeID: envelope.ID, Month: january.AddDate(0, 1), - Amount: decimal.NewFromFloat(40), + MonthConfigCreate: models.MonthConfigCreate{ + Allocation: decimal.NewFromFloat(40), + }, }) // Transaction in January @@ -212,7 +216,7 @@ func (suite *TestSuiteStandard) TestEnvelopeMonthBalance() { for _, tt := range tests { suite.T().Run(fmt.Sprintf("%s-%s", tt.envelope.Name, tt.month.String()), func(t *testing.T) { should := decimal.NewFromFloat(float64(tt.balance)) - eMonth, _, err := tt.envelope.Month(suite.db, tt.month) + eMonth, err := tt.envelope.Month(suite.db, tt.month) assert.Nil(t, err) assert.True(t, eMonth.Balance.Equal(should), "Balance calculation for 2022-01 is wrong: should be %v, but is %v", should, eMonth.Balance) }) diff --git a/pkg/models/month_config.go b/pkg/models/month_config.go index 9d0f5831..213feb0a 100644 --- a/pkg/models/month_config.go +++ b/pkg/models/month_config.go @@ -1,7 +1,6 @@ package models import ( - "errors" "strings" "github.com/envelope-zero/backend/v3/internal/types" @@ -21,14 +20,14 @@ const ( type MonthConfig struct { Timestamps MonthConfigCreate - 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. + 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. } type MonthConfigCreate struct { - OverspendMode OverspendMode `json:"overspendMode" example:"AFFECT_ENVELOPE" default:"AFFECT_AVAILABLE"` // The overspend handling mode to use. Deprecated, will be removed with 4.0.0 release and is not used in API v3 anymore - Note string `json:"note" example:"Added 200€ here because we replaced Tim's expensive vase" default:""` // A note for the month config + Allocation decimal.Decimal `json:"allocation" gorm:"type:DECIMAL(20,8)" example:"22.01" minimum:"0.00000001" maximum:"999999999999.99999999" multipleOf:"0.00000001"` // The maximum value is "999999999999.99999999", swagger unfortunately rounds this. + OverspendMode OverspendMode `json:"overspendMode" example:"AFFECT_ENVELOPE" default:"AFFECT_AVAILABLE"` // The overspend handling mode to use. Deprecated, will be removed with 4.0.0 release and is not used in API v3 anymore + Note string `json:"note" example:"Added 200€ here because we replaced Tim's expensive vase" default:""` // A note for the month config } func (m MonthConfig) Self() string { @@ -37,31 +36,5 @@ func (m MonthConfig) Self() string { func (m *MonthConfig) BeforeSave(_ *gorm.DB) error { m.Note = strings.TrimSpace(m.Note) - - return nil -} - -func (m *MonthConfig) AfterFind(tx *gorm.DB) error { - // Check if there is an allocation for this MonthConfig. If yes, set the value. - // This transparently makes use of the Allocation model - var a Allocation - err := tx.First(&a, Allocation{ - AllocationCreate: AllocationCreate{ - Month: m.Month, - EnvelopeID: m.EnvelopeID, - }, - }).Error - - // If there is a database error, return it - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - // Set the amount if there is an allocation. If not, - // the amount is 0, which is the zero value of decimal.Decimal - if err == nil { - m.Allocation = a.Amount - } - return nil } diff --git a/pkg/models/test_suite_test.go b/pkg/models/test_suite_test.go index ed6f24d4..7ab53bb7 100644 --- a/pkg/models/test_suite_test.go +++ b/pkg/models/test_suite_test.go @@ -117,18 +117,6 @@ func (suite *TestSuiteStandard) createTestAccount(c models.AccountCreate) models return account } -func (suite *TestSuiteStandard) createTestAllocation(c models.AllocationCreate) models.Allocation { - allocation := models.Allocation{ - AllocationCreate: c, - } - err := suite.db.Save(&allocation).Error - if err != nil { - suite.Assert().FailNow("Allocation could not be saved", "Error: %s, Allocation: %#v", err, allocation) - } - - return allocation -} - func (suite *TestSuiteStandard) createTestTransaction(c models.TransactionCreate) models.Transaction { transaction := models.Transaction{ TransactionCreate: c, diff --git a/pkg/router/router.go b/pkg/router/router.go index 9d0f95a0..1cdeb8dc 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -121,36 +121,6 @@ func AttachRoutes(co controllers.Controller, group *gin.RouterGroup) { group.GET("/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) co.RegisterHealthzRoutes(group.Group("/healthz")) - // API v1 setup - v1 := group.Group("/v1") - { - v1.GET("", GetV1) - v1.DELETE("", co.DeleteAll) - v1.OPTIONS("", OptionsV1) - } - - co.RegisterBudgetRoutes(v1.Group("/budgets")) - co.RegisterAccountRoutes(v1.Group("/accounts")) - co.RegisterTransactionRoutes(v1.Group("/transactions")) - co.RegisterCategoryRoutes(v1.Group("/categories")) - co.RegisterEnvelopeRoutes(v1.Group("/envelopes")) - co.RegisterAllocationRoutes(v1.Group("/allocations")) - co.RegisterMonthRoutes(v1.Group("/months")) - co.RegisterImportRoutes(v1.Group("/import")) - co.RegisterMonthConfigRoutes(v1.Group("/month-configs")) - - // API v2 setup - v2 := group.Group("/v2") - { - v2.GET("", GetV2) - v2.OPTIONS("", OptionsV2) - } - - co.RegisterAccountRoutesV2(v2.Group("/accounts")) - co.RegisterTransactionRoutesV2(v2.Group("/transactions")) - co.RegisterRenameRuleRoutes(v2.Group("/rename-rules")) - co.RegisterMatchRuleRoutes(v2.Group("/match-rules")) - // API v3 setup v3 := group.Group("/v3") { @@ -180,8 +150,6 @@ type RootLinks struct { Healthz string `json:"healthz" example:"https://example.com/api/healtzh"` // Healthz endpoint Version string `json:"version" example:"https://example.com/api/version"` // Endpoint returning the version of the backend Metrics string `json:"metrics" example:"https://example.com/api/metrics"` // Endpoint returning Prometheus metrics - V1 string `json:"v1" example:"https://example.com/api/v1"` // List endpoint for all v1 endpoints - V2 string `json:"v2" example:"https://example.com/api/v2"` // List endpoint for all v2 endpoints V3 string `json:"v3" example:"https://example.com/api/v3"` // List endpoint for all v3 endpoints } @@ -199,8 +167,6 @@ func GetRoot(c *gin.Context) { Healthz: c.GetString(string(database.ContextURL)) + "/healthz", Version: c.GetString(string(database.ContextURL)) + "/version", Metrics: c.GetString(string(database.ContextURL)) + "/metrics", - V1: c.GetString(string(database.ContextURL)) + "/v1", - V2: c.GetString(string(database.ContextURL)) + "/v2", V3: c.GetString(string(database.ContextURL)) + "/v3", }, }) @@ -250,100 +216,8 @@ func OptionsVersion(c *gin.Context) { httputil.OptionsGet(c) } -type V1Response struct { - Links V1Links `json:"links"` // Links for the v1 API -} - -type V1Links struct { - Budgets string `json:"budgets" example:"https://example.com/api/v1/budgets"` // URL of budget list endpoint - Accounts string `json:"accounts" example:"https://example.com/api/v1/accounts"` // URL of account list endpoint - Categories string `json:"categories" example:"https://example.com/api/v1/categories"` // URL of category list endpoint - Transactions string `json:"transactions" example:"https://example.com/api/v1/transactions"` // URL of transaction list endpoint - Envelopes string `json:"envelopes" example:"https://example.com/api/v1/envelopes"` // URL of envelope list endpoint - Allocations string `json:"allocations" example:"https://example.com/api/v1/allocations"` // URL of allocation list endpoint - Months string `json:"months" example:"https://example.com/api/v1/months"` // URL of month list endpoint - Import string `json:"import" example:"https://example.com/api/v1/import"` // URL of import list endpoint -} - -// GetV1 returns the link list for v1 -// -// @Summary v1 API -// @Description Returns general information about the v1 API -// @Tags v1 -// @Success 200 {object} V1Response -// @Router /v1 [get] -// @Deprecated true -func GetV1(c *gin.Context) { - c.JSON(http.StatusOK, V1Response{ - Links: V1Links{ - Budgets: c.GetString(string(database.ContextURL)) + "/v1/budgets", - Accounts: c.GetString(string(database.ContextURL)) + "/v1/accounts", - Categories: c.GetString(string(database.ContextURL)) + "/v1/categories", - Transactions: c.GetString(string(database.ContextURL)) + "/v1/transactions", - Envelopes: c.GetString(string(database.ContextURL)) + "/v1/envelopes", - Allocations: c.GetString(string(database.ContextURL)) + "/v1/allocations", - Months: c.GetString(string(database.ContextURL)) + "/v1/months", - Import: c.GetString(string(database.ContextURL)) + "/v1/import", - }, - }) -} - -// OptionsV1 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 v1 -// @Success 204 -// @Router /v1 [options] -// @Deprecated true -func OptionsV1(c *gin.Context) { - httputil.OptionsGetDelete(c) -} - -type V2Response struct { - Links V2Links `json:"links"` // Links for the v2 API -} - -type V2Links struct { - Accounts string `json:"accounts" example:"https://example.com/api/v2/accounts"` // URL of transaction list endpoint - Transactions string `json:"transactions" example:"https://example.com/api/v2/transactions"` // URL of transaction list endpoint - RenameRules string `json:"rename-rules" example:"https://example.com/api/v2/rename-rules"` // URL of rename-rule list endpoint - MatchRules string `json:"match-rules" example:"https://example.com/api/v2/match-rules"` // URL of match-rule list endpoint -} - -// GetV2 returns the link list for v2 -// -// @Summary v2 API -// @Description Returns general information about the v2 API -// @Tags v2 -// @Success 200 {object} V2Response -// @Router /v2 [get] -// @Deprecated true -func GetV2(c *gin.Context) { - c.JSON(http.StatusOK, V2Response{ - Links: V2Links{ - Accounts: c.GetString(string(database.ContextURL)) + "/v2/accounts", - Transactions: c.GetString(string(database.ContextURL)) + "/v2/transactions", - RenameRules: c.GetString(string(database.ContextURL)) + "/v2/rename-rules", - MatchRules: c.GetString(string(database.ContextURL)) + "/v2/match-rules", - }, - }) -} - -// OptionsV2 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 v2 -// @Success 204 -// @Router /v2 [options] -// @Deprecated true -func OptionsV2(c *gin.Context) { - httputil.OptionsGet(c) -} - type V3Response struct { - Links V3Links `json:"links"` // Links for the v2 API + Links V3Links `json:"links"` // Links for the v3 API } type V3Links struct { diff --git a/pkg/router/router_test.go b/pkg/router/router_test.go deleted file mode 100644 index ecae1589..00000000 --- a/pkg/router/router_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package router_test - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "os" - "reflect" - "testing" - - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/router" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" -) - -// decodeResponse decodes an HTTP response into a target struct. -func decodeResponse(t *testing.T, r *httptest.ResponseRecorder, target interface{}) { - err := json.NewDecoder(r.Body).Decode(target) - if err != nil { - assert.FailNow(t, "Parsing error", "Unable to parse response from server %q into %v, '%v', Request ID: %s", r.Body, reflect.TypeOf(target), err, r.Result().Header.Get("x-request-id")) - } -} - -func TestGinMode(t *testing.T) { - os.Setenv("GIN_MODE", "debug") - url, _ := url.Parse("http://example.com") - - r, teardown, err := router.Config(url) - defer teardown() - - assert.Nil(t, err, "Error on router initialization") - - db, err := database.Connect(":memory:?_pragma=foreign_keys(1)") - assert.Nil(t, err, "Error on database connection") - - router.AttachRoutes(controllers.Controller{DB: db}, r.Group("/")) - - assert.Nil(t, err, "%T: %v", err, err) - assert.True(t, gin.IsDebugging()) - - os.Unsetenv("GIN_MODE") -} - -func TestPprofOff(t *testing.T) { - os.Setenv("ENABLE_PPROF", "false") - url, _ := url.Parse("http://example.com") - - r, teardown, err := router.Config(url) - defer teardown() - - assert.Nil(t, err, "Error on router initialization") - - db, err := database.Connect(":memory:?_pragma=foreign_keys(1)") - assert.Nil(t, err, "Error on database connection") - - router.AttachRoutes(controllers.Controller{DB: db}, r.Group("/")) - - for _, r := range r.Routes() { - assert.NotContains(t, r.Path, "pprof", "pprof routes are registered erroneously! Route: %s", r) - } - - os.Unsetenv("ENABLE_PPROF") -} - -// TestCorsSetting checks that setting of CORS works. -// It does not check the actual headers as this is already done in testing of the module. -func TestCorsSetting(t *testing.T) { - os.Setenv("CORS_ALLOW_ORIGINS", "http://localhost:3000 https://example.com") - url, _ := url.Parse("http://example.com") - - _, teardown, err := router.Config(url) - defer teardown() - - assert.Nil(t, err) - os.Unsetenv("CORS_ALLOW_ORIGINS") -} - -func TestGetRoot(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - c, r := gin.CreateTestContext(w) - - r.GET("/", func(ctx *gin.Context) { - router.GetRoot(c) - }) - - // Test contexts cannot be injected any middleware, therefore - // this only tests the path, not the host - l := router.RootResponse{ - Links: router.RootLinks{ - Docs: "/docs/index.html", - Healthz: "/healthz", - Version: "/version", - Metrics: "/metrics", - V1: "/v1", - V2: "/v2", - V3: "/v3", - }, - } - - var lr router.RootResponse - - c.Request, _ = http.NewRequest(http.MethodGet, "https://example.com/", nil) - r.ServeHTTP(w, c.Request) - - decodeResponse(t, w, &lr) - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, l, lr) -} - -func TestGetV1(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - c, r := gin.CreateTestContext(w) - - r.GET("/v1", func(ctx *gin.Context) { - router.GetV1(c) - }) - - // Test contexts cannot be injected any middleware, therefore - // this only tests the path, not the host - l := router.V1Response{ - Links: router.V1Links{ - Budgets: "/v1/budgets", - Accounts: "/v1/accounts", - Transactions: "/v1/transactions", - Categories: "/v1/categories", - Envelopes: "/v1/envelopes", - Allocations: "/v1/allocations", - Months: "/v1/months", - Import: "/v1/import", - }, - } - - var lr router.V1Response - - c.Request, _ = http.NewRequest(http.MethodGet, "http://example.com/v1", nil) - r.ServeHTTP(w, c.Request) - - decodeResponse(t, w, &lr) - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, l, lr) -} - -func TestGetV2(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - c, r := gin.CreateTestContext(w) - - r.GET("/v2", func(ctx *gin.Context) { - router.GetV2(c) - }) - - // Test contexts cannot be injected any middleware, therefore - // this only tests the path, not the host - l := router.V2Response{ - Links: router.V2Links{ - Accounts: "/v2/accounts", - Transactions: "/v2/transactions", - RenameRules: "/v2/rename-rules", - MatchRules: "/v2/match-rules", - }, - } - - var lr router.V2Response - - c.Request, _ = http.NewRequest(http.MethodGet, "http://example.com/v2", nil) - r.ServeHTTP(w, c.Request) - - decodeResponse(t, w, &lr) - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, l, lr) -} - -func TestGetV3(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - c, r := gin.CreateTestContext(w) - - r.GET("/v3", func(ctx *gin.Context) { - router.GetV3(c) - }) - - // Test contexts cannot be injected any middleware, therefore - // this only tests the path, not the host - l := router.V3Response{ - Links: router.V3Links{ - Accounts: "/v3/accounts", - Budgets: "/v3/budgets", - Categories: "/v3/categories", - Envelopes: "/v3/envelopes", - Goals: "/v3/goals", - Import: "/v3/import", - MatchRules: "/v3/match-rules", - Months: "/v3/months", - Transactions: "/v3/transactions", - }, - } - - var lr router.V3Response - - c.Request, _ = http.NewRequest(http.MethodGet, "http://example.com/v3", nil) - r.ServeHTTP(w, c.Request) - - decodeResponse(t, w, &lr) - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, l, lr) -} - -func TestGetVersion(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - c, r := gin.CreateTestContext(w) - - r.GET("/version", func(ctx *gin.Context) { - router.GetVersion(c) - }) - - l := router.VersionResponse{ - Data: router.VersionObject{ - Version: "0.0.0", - }, - } - - var lr router.VersionResponse - - c.Request, _ = http.NewRequest(http.MethodGet, "https://example.com/version", nil) - r.ServeHTTP(w, c.Request) - - decodeResponse(t, w, &lr) - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, l, lr) -} - -func TestOptions(t *testing.T) { - t.Parallel() - - tests := []struct { - path string - f func(*gin.Context) - expected string - }{ - {"/", router.OptionsRoot, "OPTIONS, GET"}, - {"/version", router.OptionsVersion, "OPTIONS, GET"}, - {"/v1", router.OptionsV1, "OPTIONS, GET, DELETE"}, - {"/v2", router.OptionsV2, "OPTIONS, GET"}, - {"/v3", router.OptionsV3, "OPTIONS, GET, DELETE"}, - } - - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - w := httptest.NewRecorder() - c, r := gin.CreateTestContext(w) - - r.OPTIONS(tt.path, func(ctx *gin.Context) { - tt.f(c) - }) - - url := fmt.Sprintf("http://example.com%s", tt.path) - c.Request, _ = http.NewRequest(http.MethodOptions, url, nil) - r.ServeHTTP(w, c.Request) - - assert.Equal(t, http.StatusNoContent, w.Code) - assert.Equal(t, tt.expected, w.Header().Get("allow")) - }) - } -} From ca0c35292e28c061618a047a7b7cad81012ef1a3 Mon Sep 17 00:00:00 2001 From: Morre Date: Sun, 31 Dec 2023 15:56:06 +0100 Subject: [PATCH 02/15] chore!: Bump module version to v4 BREAKING CHANGE: bumps the module version to v4 --- go.mod | 2 +- internal/types/month_test.go | 2 +- main.go | 8 ++++---- pkg/controllers/account.go | 4 ++-- pkg/controllers/account_v3.go | 10 +++++----- pkg/controllers/account_v3_test.go | 6 +++--- pkg/controllers/budget_v3.go | 8 ++++---- pkg/controllers/budget_v3_test.go | 6 +++--- pkg/controllers/category_v3.go | 8 ++++---- pkg/controllers/category_v3_test.go | 6 +++--- pkg/controllers/cleanup_v3.go | 4 ++-- pkg/controllers/cleanup_v3_test.go | 8 ++++---- pkg/controllers/database.go | 2 +- pkg/controllers/envelope_v3.go | 8 ++++---- pkg/controllers/envelope_v3_test.go | 4 ++-- pkg/controllers/generics.go | 4 ++-- pkg/controllers/goals_v3.go | 8 ++++---- pkg/controllers/goals_v3_test.go | 10 +++++----- pkg/controllers/goals_v3_types.go | 10 +++++----- pkg/controllers/healthz.go | 4 ++-- pkg/controllers/healthz_test.go | 2 +- pkg/controllers/import.go | 6 +++--- pkg/controllers/import_v3.go | 14 +++++++------- pkg/controllers/import_v3_test.go | 8 ++++---- pkg/controllers/match_rule.go | 4 ++-- pkg/controllers/match_rule_v3.go | 8 ++++---- pkg/controllers/match_rule_v3_test.go | 8 ++++---- pkg/controllers/month.go | 6 +++--- pkg/controllers/month_config.go | 2 +- pkg/controllers/month_config_v3.go | 10 +++++----- pkg/controllers/month_config_v3_test.go | 8 ++++---- pkg/controllers/month_v3.go | 8 ++++---- pkg/controllers/month_v3_test.go | 8 ++++---- pkg/controllers/test_create_test.go | 4 ++-- pkg/controllers/test_list_test.go | 2 +- pkg/controllers/test_options_test.go | 2 +- pkg/controllers/test_suite_test.go | 6 +++--- pkg/controllers/transaction.go | 6 +++--- pkg/controllers/transaction_v3.go | 8 ++++---- pkg/controllers/transaction_v3_test.go | 8 ++++---- pkg/httperrors/errors.go | 2 +- pkg/httperrors/errors_test.go | 6 +++--- pkg/httperrors/types_test.go | 2 +- pkg/httputil/options_test.go | 2 +- pkg/httputil/query.go | 2 +- pkg/httputil/query_test.go | 6 +++--- pkg/httputil/request.go | 2 +- pkg/httputil/request_test.go | 6 +++--- pkg/importer/creator.go | 2 +- pkg/importer/helpers/sha256_test.go | 2 +- pkg/importer/parser/ynab-import/parse.go | 8 ++++---- pkg/importer/parser/ynab-import/parse_test.go | 2 +- pkg/importer/parser/ynab4/parse.go | 8 ++++---- pkg/importer/parser/ynab4/parse_test.go | 10 +++++----- pkg/importer/types.go | 2 +- pkg/models/account.go | 2 +- pkg/models/account_test.go | 4 ++-- pkg/models/budget.go | 2 +- pkg/models/budget_month.go | 2 +- pkg/models/budget_test.go | 4 ++-- pkg/models/category_test.go | 2 +- pkg/models/database.go | 2 +- pkg/models/database_test.go | 4 ++-- pkg/models/envelope.go | 2 +- pkg/models/envelope_test.go | 4 ++-- pkg/models/goal.go | 2 +- pkg/models/goal_test.go | 2 +- pkg/models/match_rule_test.go | 2 +- pkg/models/model_test.go | 2 +- pkg/models/month_config.go | 2 +- pkg/models/month_config_test.go | 2 +- pkg/models/test_suite_test.go | 4 ++-- pkg/models/transaction.go | 2 +- pkg/models/transaction_test.go | 4 ++-- pkg/router/middleware.go | 2 +- pkg/router/middleware_test.go | 4 ++-- pkg/router/router.go | 10 +++++----- test/helpers.go | 6 +++--- 78 files changed, 192 insertions(+), 192 deletions(-) diff --git a/go.mod b/go.mod index 5215067e..97bab48e 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/envelope-zero/backend/v3 +module github.com/envelope-zero/backend/v4 go 1.20 diff --git a/internal/types/month_test.go b/internal/types/month_test.go index 34d9f1f6..7ce10b55 100644 --- a/internal/types/month_test.go +++ b/internal/types/month_test.go @@ -4,7 +4,7 @@ import ( "encoding/json" "testing" - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/stretchr/testify/assert" ) diff --git a/main.go b/main.go index 18232aeb..c7ef9f3a 100644 --- a/main.go +++ b/main.go @@ -10,10 +10,10 @@ import ( "syscall" "time" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/pkg/router" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/database" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/router" "github.com/gin-gonic/gin" "github.com/rs/zerolog" "github.com/rs/zerolog/log" diff --git a/pkg/controllers/account.go b/pkg/controllers/account.go index 7883a67d..fe447633 100644 --- a/pkg/controllers/account.go +++ b/pkg/controllers/account.go @@ -1,8 +1,8 @@ package controllers import ( - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/gin-gonic/gin" ) diff --git a/pkg/controllers/account_v3.go b/pkg/controllers/account_v3.go index 7c78d60d..79942d40 100644 --- a/pkg/controllers/account_v3.go +++ b/pkg/controllers/account_v3.go @@ -5,11 +5,11 @@ import ( "net/http" "time" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/database" + "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" diff --git a/pkg/controllers/account_v3_test.go b/pkg/controllers/account_v3_test.go index 09b7386c..6d256644 100644 --- a/pkg/controllers/account_v3_test.go +++ b/pkg/controllers/account_v3_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "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" diff --git a/pkg/controllers/budget_v3.go b/pkg/controllers/budget_v3.go index 490266d6..cb83c5e8 100644 --- a/pkg/controllers/budget_v3.go +++ b/pkg/controllers/budget_v3.go @@ -4,10 +4,10 @@ import ( "fmt" "net/http" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/database" + "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" "golang.org/x/exp/slices" diff --git a/pkg/controllers/budget_v3_test.go b/pkg/controllers/budget_v3_test.go index 2d0ddd47..b7bab502 100644 --- a/pkg/controllers/budget_v3_test.go +++ b/pkg/controllers/budget_v3_test.go @@ -6,9 +6,9 @@ import ( "net/http/httptest" "testing" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/envelope-zero/backend/v4/test" "github.com/google/uuid" "github.com/stretchr/testify/assert" ) diff --git a/pkg/controllers/category_v3.go b/pkg/controllers/category_v3.go index a6cb34b6..2ff0b48c 100644 --- a/pkg/controllers/category_v3.go +++ b/pkg/controllers/category_v3.go @@ -4,10 +4,10 @@ import ( "fmt" "net/http" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/database" + "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" "golang.org/x/exp/slices" diff --git a/pkg/controllers/category_v3_test.go b/pkg/controllers/category_v3_test.go index 5795a256..1b4b2039 100644 --- a/pkg/controllers/category_v3_test.go +++ b/pkg/controllers/category_v3_test.go @@ -6,9 +6,9 @@ import ( "net/http/httptest" "testing" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/envelope-zero/backend/v4/test" "github.com/google/uuid" "github.com/stretchr/testify/assert" ) diff --git a/pkg/controllers/cleanup_v3.go b/pkg/controllers/cleanup_v3.go index 64a5f6d4..a4240fdb 100644 --- a/pkg/controllers/cleanup_v3.go +++ b/pkg/controllers/cleanup_v3.go @@ -3,8 +3,8 @@ package controllers import ( "net/http" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/gin-gonic/gin" ) diff --git a/pkg/controllers/cleanup_v3_test.go b/pkg/controllers/cleanup_v3_test.go index 0e692d81..20a85634 100644 --- a/pkg/controllers/cleanup_v3_test.go +++ b/pkg/controllers/cleanup_v3_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/envelope-zero/backend/v4/test" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" ) diff --git a/pkg/controllers/database.go b/pkg/controllers/database.go index 359ade3a..4fe35eb9 100644 --- a/pkg/controllers/database.go +++ b/pkg/controllers/database.go @@ -1,7 +1,7 @@ package controllers import ( - "github.com/envelope-zero/backend/v3/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httperrors" "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/pkg/controllers/envelope_v3.go b/pkg/controllers/envelope_v3.go index ac057477..d2c8edc9 100644 --- a/pkg/controllers/envelope_v3.go +++ b/pkg/controllers/envelope_v3.go @@ -4,10 +4,10 @@ import ( "fmt" "net/http" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/database" + "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" "golang.org/x/exp/slices" diff --git a/pkg/controllers/envelope_v3_test.go b/pkg/controllers/envelope_v3_test.go index 2aad0b32..f898d96f 100644 --- a/pkg/controllers/envelope_v3_test.go +++ b/pkg/controllers/envelope_v3_test.go @@ -6,8 +6,8 @@ import ( "net/http/httptest" "testing" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/test" "github.com/google/uuid" "github.com/stretchr/testify/assert" ) diff --git a/pkg/controllers/generics.go b/pkg/controllers/generics.go index 81ad1763..eb551df8 100644 --- a/pkg/controllers/generics.go +++ b/pkg/controllers/generics.go @@ -4,8 +4,8 @@ import ( "fmt" "net/http" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/models" + "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" ) diff --git a/pkg/controllers/goals_v3.go b/pkg/controllers/goals_v3.go index 1e26010a..6cd31c89 100644 --- a/pkg/controllers/goals_v3.go +++ b/pkg/controllers/goals_v3.go @@ -3,10 +3,10 @@ package controllers import ( "net/http" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "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" ) diff --git a/pkg/controllers/goals_v3_test.go b/pkg/controllers/goals_v3_test.go index 3f746270..e6d7e653 100644 --- a/pkg/controllers/goals_v3_test.go +++ b/pkg/controllers/goals_v3_test.go @@ -5,11 +5,11 @@ import ( "net/http" "testing" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "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" diff --git a/pkg/controllers/goals_v3_types.go b/pkg/controllers/goals_v3_types.go index e1b899ec..10f6c8f0 100644 --- a/pkg/controllers/goals_v3_types.go +++ b/pkg/controllers/goals_v3_types.go @@ -4,11 +4,11 @@ import ( "fmt" "net/http" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/database" + "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" diff --git a/pkg/controllers/healthz.go b/pkg/controllers/healthz.go index d7e25dbe..fb09aa8a 100644 --- a/pkg/controllers/healthz.go +++ b/pkg/controllers/healthz.go @@ -3,8 +3,8 @@ package controllers import ( "net/http" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" "github.com/gin-gonic/gin" ) diff --git a/pkg/controllers/healthz_test.go b/pkg/controllers/healthz_test.go index 8d5fa005..f3168e59 100644 --- a/pkg/controllers/healthz_test.go +++ b/pkg/controllers/healthz_test.go @@ -3,7 +3,7 @@ package controllers_test import ( "net/http" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/test" "github.com/stretchr/testify/assert" ) diff --git a/pkg/controllers/import.go b/pkg/controllers/import.go index 36f3382f..0c7bd40a 100644 --- a/pkg/controllers/import.go +++ b/pkg/controllers/import.go @@ -7,9 +7,9 @@ import ( "net/http" "strings" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/importer" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/importer" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/ryanuber/go-glob" diff --git a/pkg/controllers/import_v3.go b/pkg/controllers/import_v3.go index a48dd2de..c3109003 100644 --- a/pkg/controllers/import_v3.go +++ b/pkg/controllers/import_v3.go @@ -4,13 +4,13 @@ import ( "errors" "net/http" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/importer" - ynabimport "github.com/envelope-zero/backend/v3/pkg/importer/parser/ynab-import" - "github.com/envelope-zero/backend/v3/pkg/importer/parser/ynab4" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/database" + "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" "gorm.io/gorm" diff --git a/pkg/controllers/import_v3_test.go b/pkg/controllers/import_v3_test.go index 89d6c27d..c7663d48 100644 --- a/pkg/controllers/import_v3_test.go +++ b/pkg/controllers/import_v3_test.go @@ -7,10 +7,10 @@ import ( "net/http/httptest" "testing" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "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" diff --git a/pkg/controllers/match_rule.go b/pkg/controllers/match_rule.go index ea393175..e8e1f256 100644 --- a/pkg/controllers/match_rule.go +++ b/pkg/controllers/match_rule.go @@ -1,8 +1,8 @@ package controllers import ( - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/gin-gonic/gin" ) diff --git a/pkg/controllers/match_rule_v3.go b/pkg/controllers/match_rule_v3.go index bbb16168..3b0c799a 100644 --- a/pkg/controllers/match_rule_v3.go +++ b/pkg/controllers/match_rule_v3.go @@ -7,10 +7,10 @@ import ( "github.com/google/uuid" "golang.org/x/exp/slices" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/database" + "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" ) diff --git a/pkg/controllers/match_rule_v3_test.go b/pkg/controllers/match_rule_v3_test.go index 6f22fe6f..758b9db9 100644 --- a/pkg/controllers/match_rule_v3_test.go +++ b/pkg/controllers/match_rule_v3_test.go @@ -5,10 +5,10 @@ import ( "net/http" "testing" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "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" ) diff --git a/pkg/controllers/month.go b/pkg/controllers/month.go index 896158a0..79946002 100644 --- a/pkg/controllers/month.go +++ b/pkg/controllers/month.go @@ -3,9 +3,9 @@ package controllers import ( "net/http" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/models" + "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" ) diff --git a/pkg/controllers/month_config.go b/pkg/controllers/month_config.go index 40b21052..82a1b386 100644 --- a/pkg/controllers/month_config.go +++ b/pkg/controllers/month_config.go @@ -1,7 +1,7 @@ package controllers import ( - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/google/uuid" ) diff --git a/pkg/controllers/month_config_v3.go b/pkg/controllers/month_config_v3.go index b097fb96..05627052 100644 --- a/pkg/controllers/month_config_v3.go +++ b/pkg/controllers/month_config_v3.go @@ -5,11 +5,11 @@ import ( "fmt" "net/http" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/database" + "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" diff --git a/pkg/controllers/month_config_v3_test.go b/pkg/controllers/month_config_v3_test.go index 45c2effe..ffab9f52 100644 --- a/pkg/controllers/month_config_v3_test.go +++ b/pkg/controllers/month_config_v3_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/models" + "github.com/envelope-zero/backend/v4/test" "github.com/google/uuid" "github.com/stretchr/testify/assert" ) diff --git a/pkg/controllers/month_v3.go b/pkg/controllers/month_v3.go index cf3ea488..1d48e2e4 100644 --- a/pkg/controllers/month_v3.go +++ b/pkg/controllers/month_v3.go @@ -4,10 +4,10 @@ import ( "fmt" "net/http" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "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" diff --git a/pkg/controllers/month_v3_test.go b/pkg/controllers/month_v3_test.go index 54ffdbdf..cdbc9f5e 100644 --- a/pkg/controllers/month_v3_test.go +++ b/pkg/controllers/month_v3_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "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" diff --git a/pkg/controllers/test_create_test.go b/pkg/controllers/test_create_test.go index ca7c6cea..cdd1ef78 100644 --- a/pkg/controllers/test_create_test.go +++ b/pkg/controllers/test_create_test.go @@ -1,8 +1,8 @@ package controllers_test import ( - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/google/uuid" ) diff --git a/pkg/controllers/test_list_test.go b/pkg/controllers/test_list_test.go index 161bca87..a3bd8952 100644 --- a/pkg/controllers/test_list_test.go +++ b/pkg/controllers/test_list_test.go @@ -3,7 +3,7 @@ package controllers_test import ( "net/http" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/test" ) var methodNotAllowedTests = []struct { diff --git a/pkg/controllers/test_options_test.go b/pkg/controllers/test_options_test.go index 8d7cfd9a..517b9a53 100644 --- a/pkg/controllers/test_options_test.go +++ b/pkg/controllers/test_options_test.go @@ -4,7 +4,7 @@ import ( "net/http" "testing" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/test" "github.com/stretchr/testify/assert" ) diff --git a/pkg/controllers/test_suite_test.go b/pkg/controllers/test_suite_test.go index 5bf23f66..5b3962b5 100644 --- a/pkg/controllers/test_suite_test.go +++ b/pkg/controllers/test_suite_test.go @@ -7,9 +7,9 @@ import ( "os" "testing" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/database" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/stretchr/testify/suite" ) diff --git a/pkg/controllers/transaction.go b/pkg/controllers/transaction.go index e1bb36c9..4d1a563f 100644 --- a/pkg/controllers/transaction.go +++ b/pkg/controllers/transaction.go @@ -4,9 +4,9 @@ import ( "errors" "net/http" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "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" diff --git a/pkg/controllers/transaction_v3.go b/pkg/controllers/transaction_v3.go index ebea4412..8cb28b1f 100644 --- a/pkg/controllers/transaction_v3.go +++ b/pkg/controllers/transaction_v3.go @@ -5,10 +5,10 @@ import ( "net/http" "time" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/database" + "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" diff --git a/pkg/controllers/transaction_v3_test.go b/pkg/controllers/transaction_v3_test.go index d3321db4..372d9752 100644 --- a/pkg/controllers/transaction_v3_test.go +++ b/pkg/controllers/transaction_v3_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "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" diff --git a/pkg/httperrors/errors.go b/pkg/httperrors/errors.go index 95c3ecad..9cf20d38 100644 --- a/pkg/httperrors/errors.go +++ b/pkg/httperrors/errors.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/gin-contrib/requestid" "github.com/gin-gonic/gin" "github.com/glebarez/go-sqlite" diff --git a/pkg/httperrors/errors_test.go b/pkg/httperrors/errors_test.go index 3694dda8..81369d02 100644 --- a/pkg/httperrors/errors_test.go +++ b/pkg/httperrors/errors_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/models" - "github.com/envelope-zero/backend/v3/test" + "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/gin-gonic/gin" "github.com/glebarez/go-sqlite" "github.com/shopspring/decimal" diff --git a/pkg/httperrors/types_test.go b/pkg/httperrors/types_test.go index cc67c175..d37cd076 100644 --- a/pkg/httperrors/types_test.go +++ b/pkg/httperrors/types_test.go @@ -4,7 +4,7 @@ import ( "net/http" "testing" - "github.com/envelope-zero/backend/v3/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httperrors" "github.com/stretchr/testify/assert" ) diff --git a/pkg/httputil/options_test.go b/pkg/httputil/options_test.go index 93e9c781..172f3caa 100644 --- a/pkg/httputil/options_test.go +++ b/pkg/httputil/options_test.go @@ -5,7 +5,7 @@ import ( "net/http/httptest" "testing" - "github.com/envelope-zero/backend/v3/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/httputil" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) diff --git a/pkg/httputil/query.go b/pkg/httputil/query.go index f4a88eab..20a05ef2 100644 --- a/pkg/httputil/query.go +++ b/pkg/httputil/query.go @@ -10,7 +10,7 @@ import ( "net/url" "reflect" - "github.com/envelope-zero/backend/v3/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httperrors" "github.com/gin-contrib/requestid" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" diff --git a/pkg/httputil/query_test.go b/pkg/httputil/query_test.go index b075f55e..721ce79a 100644 --- a/pkg/httputil/query_test.go +++ b/pkg/httputil/query_test.go @@ -7,9 +7,9 @@ import ( "net/url" "testing" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) diff --git a/pkg/httputil/request.go b/pkg/httputil/request.go index a28abbdd..748f81a1 100644 --- a/pkg/httputil/request.go +++ b/pkg/httputil/request.go @@ -6,7 +6,7 @@ import ( "io" "net/http" - "github.com/envelope-zero/backend/v3/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httperrors" "github.com/gin-contrib/requestid" "github.com/gin-gonic/gin" "github.com/google/uuid" diff --git a/pkg/httputil/request_test.go b/pkg/httputil/request_test.go index 35d22f2c..202e74ad 100644 --- a/pkg/httputil/request_test.go +++ b/pkg/httputil/request_test.go @@ -6,9 +6,9 @@ import ( "net/http/httptest" "testing" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" - "github.com/envelope-zero/backend/v3/test" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" + "github.com/envelope-zero/backend/v4/test" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) diff --git a/pkg/importer/creator.go b/pkg/importer/creator.go index 312f1936..853b1e6b 100644 --- a/pkg/importer/creator.go +++ b/pkg/importer/creator.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/google/uuid" "golang.org/x/exp/slices" "gorm.io/gorm" diff --git a/pkg/importer/helpers/sha256_test.go b/pkg/importer/helpers/sha256_test.go index 4966cb18..0cfb4e7b 100644 --- a/pkg/importer/helpers/sha256_test.go +++ b/pkg/importer/helpers/sha256_test.go @@ -3,7 +3,7 @@ package helpers_test import ( "testing" - "github.com/envelope-zero/backend/v3/pkg/importer/helpers" + "github.com/envelope-zero/backend/v4/pkg/importer/helpers" "github.com/stretchr/testify/assert" ) diff --git a/pkg/importer/parser/ynab-import/parse.go b/pkg/importer/parser/ynab-import/parse.go index ed005941..f054a6dc 100644 --- a/pkg/importer/parser/ynab-import/parse.go +++ b/pkg/importer/parser/ynab-import/parse.go @@ -8,10 +8,10 @@ import ( "strings" "time" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/importer" - "github.com/envelope-zero/backend/v3/pkg/importer/helpers" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/importer" + "github.com/envelope-zero/backend/v4/pkg/importer/helpers" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/shopspring/decimal" ) diff --git a/pkg/importer/parser/ynab-import/parse_test.go b/pkg/importer/parser/ynab-import/parse_test.go index b278a4d2..9ae8109e 100644 --- a/pkg/importer/parser/ynab-import/parse_test.go +++ b/pkg/importer/parser/ynab-import/parse_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/stretchr/testify/assert" ) diff --git a/pkg/importer/parser/ynab4/parse.go b/pkg/importer/parser/ynab4/parse.go index cbfbb018..0a338cbe 100644 --- a/pkg/importer/parser/ynab4/parse.go +++ b/pkg/importer/parser/ynab4/parse.go @@ -10,12 +10,12 @@ import ( "strings" "time" - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/google/uuid" - "github.com/envelope-zero/backend/v3/pkg/importer" - "github.com/envelope-zero/backend/v3/pkg/importer/helpers" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/importer" + "github.com/envelope-zero/backend/v4/pkg/importer/helpers" + "github.com/envelope-zero/backend/v4/pkg/models" "golang.org/x/exp/maps" "golang.org/x/exp/slices" "golang.org/x/text/currency" diff --git a/pkg/importer/parser/ynab4/parse_test.go b/pkg/importer/parser/ynab4/parse_test.go index 89bbdcfe..71816484 100644 --- a/pkg/importer/parser/ynab4/parse_test.go +++ b/pkg/importer/parser/ynab4/parse_test.go @@ -11,11 +11,11 @@ import ( "testing/iotest" "time" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/importer" - "github.com/envelope-zero/backend/v3/pkg/importer/parser/ynab4" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/database" + "github.com/envelope-zero/backend/v4/pkg/importer" + "github.com/envelope-zero/backend/v4/pkg/importer/parser/ynab4" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/importer/types.go b/pkg/importer/types.go index 49c0e739..7e2876ae 100644 --- a/pkg/importer/types.go +++ b/pkg/importer/types.go @@ -1,7 +1,7 @@ package importer import ( - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/google/uuid" ) diff --git a/pkg/models/account.go b/pkg/models/account.go index c7800832..5e868396 100644 --- a/pkg/models/account.go +++ b/pkg/models/account.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/google/uuid" "github.com/shopspring/decimal" "gorm.io/gorm" diff --git a/pkg/models/account_test.go b/pkg/models/account_test.go index 10274e16..cfed72f8 100644 --- a/pkg/models/account_test.go +++ b/pkg/models/account_test.go @@ -5,8 +5,8 @@ import ( "strings" "time" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/google/uuid" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" diff --git a/pkg/models/budget.go b/pkg/models/budget.go index 4a0c95e5..d0074898 100644 --- a/pkg/models/budget.go +++ b/pkg/models/budget.go @@ -3,7 +3,7 @@ package models import ( "strings" - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/shopspring/decimal" "gorm.io/gorm" ) diff --git a/pkg/models/budget_month.go b/pkg/models/budget_month.go index 1eb60668..c3417a93 100644 --- a/pkg/models/budget_month.go +++ b/pkg/models/budget_month.go @@ -1,7 +1,7 @@ package models import ( - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/google/uuid" "github.com/shopspring/decimal" ) diff --git a/pkg/models/budget_test.go b/pkg/models/budget_test.go index 98080639..a4bc67e5 100644 --- a/pkg/models/budget_test.go +++ b/pkg/models/budget_test.go @@ -4,8 +4,8 @@ import ( "strings" "time" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" ) diff --git a/pkg/models/category_test.go b/pkg/models/category_test.go index 7464335b..046a13e3 100644 --- a/pkg/models/category_test.go +++ b/pkg/models/category_test.go @@ -3,7 +3,7 @@ package models_test import ( "strings" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/stretchr/testify/assert" ) diff --git a/pkg/models/database.go b/pkg/models/database.go index 402117c4..f5e6e319 100644 --- a/pkg/models/database.go +++ b/pkg/models/database.go @@ -3,7 +3,7 @@ package models import ( "fmt" - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/google/uuid" "github.com/shopspring/decimal" "gorm.io/gorm" diff --git a/pkg/models/database_test.go b/pkg/models/database_test.go index 475fe16b..52c76b13 100644 --- a/pkg/models/database_test.go +++ b/pkg/models/database_test.go @@ -1,8 +1,8 @@ package models_test import ( - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/shopspring/decimal" ) diff --git a/pkg/models/envelope.go b/pkg/models/envelope.go index c7abfe10..33046dbc 100644 --- a/pkg/models/envelope.go +++ b/pkg/models/envelope.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/google/uuid" "github.com/shopspring/decimal" "gorm.io/gorm" diff --git a/pkg/models/envelope_test.go b/pkg/models/envelope_test.go index 5c4f6d58..8137ea03 100644 --- a/pkg/models/envelope_test.go +++ b/pkg/models/envelope_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" ) diff --git a/pkg/models/goal.go b/pkg/models/goal.go index f5f1af85..69db9ec1 100644 --- a/pkg/models/goal.go +++ b/pkg/models/goal.go @@ -3,7 +3,7 @@ package models import ( "strings" - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/google/uuid" "github.com/shopspring/decimal" "gorm.io/gorm" diff --git a/pkg/models/goal_test.go b/pkg/models/goal_test.go index cb2b2dd6..8136f764 100644 --- a/pkg/models/goal_test.go +++ b/pkg/models/goal_test.go @@ -3,7 +3,7 @@ package models_test import ( "strings" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "gorm.io/gorm" diff --git a/pkg/models/match_rule_test.go b/pkg/models/match_rule_test.go index 8f5704c7..211db202 100644 --- a/pkg/models/match_rule_test.go +++ b/pkg/models/match_rule_test.go @@ -1,7 +1,7 @@ package models_test import ( - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/stretchr/testify/assert" ) diff --git a/pkg/models/model_test.go b/pkg/models/model_test.go index ebc3493e..f3c8dcb4 100644 --- a/pkg/models/model_test.go +++ b/pkg/models/model_test.go @@ -3,7 +3,7 @@ package models_test import ( "time" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/stretchr/testify/assert" "gorm.io/gorm" ) diff --git a/pkg/models/month_config.go b/pkg/models/month_config.go index 213feb0a..b9fdabee 100644 --- a/pkg/models/month_config.go +++ b/pkg/models/month_config.go @@ -3,7 +3,7 @@ package models import ( "strings" - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/google/uuid" "github.com/shopspring/decimal" "gorm.io/gorm" diff --git a/pkg/models/month_config_test.go b/pkg/models/month_config_test.go index 48a79cde..be8b8d5c 100644 --- a/pkg/models/month_config_test.go +++ b/pkg/models/month_config_test.go @@ -3,7 +3,7 @@ package models_test import ( "strings" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/stretchr/testify/assert" ) diff --git a/pkg/models/test_suite_test.go b/pkg/models/test_suite_test.go index 7ab53bb7..7793cddb 100644 --- a/pkg/models/test_suite_test.go +++ b/pkg/models/test_suite_test.go @@ -7,8 +7,8 @@ import ( "os" "testing" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/pkg/database" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/google/uuid" "github.com/stretchr/testify/suite" "gorm.io/gorm" diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go index 08c27185..efb4e885 100644 --- a/pkg/models/transaction.go +++ b/pkg/models/transaction.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/envelope-zero/backend/v3/internal/types" + "github.com/envelope-zero/backend/v4/internal/types" "github.com/google/uuid" "github.com/shopspring/decimal" "gorm.io/gorm" diff --git a/pkg/models/transaction_test.go b/pkg/models/transaction_test.go index e326ceb7..70a2013e 100644 --- a/pkg/models/transaction_test.go +++ b/pkg/models/transaction_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/envelope-zero/backend/v3/internal/types" - "github.com/envelope-zero/backend/v3/pkg/models" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/models" "github.com/google/uuid" "github.com/stretchr/testify/assert" ) diff --git a/pkg/router/middleware.go b/pkg/router/middleware.go index 65bfd7b5..44af4c1d 100644 --- a/pkg/router/middleware.go +++ b/pkg/router/middleware.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/envelope-zero/backend/v3/pkg/database" + "github.com/envelope-zero/backend/v4/pkg/database" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus" ) diff --git a/pkg/router/middleware_test.go b/pkg/router/middleware_test.go index 407b87c0..f3eb4485 100644 --- a/pkg/router/middleware_test.go +++ b/pkg/router/middleware_test.go @@ -6,8 +6,8 @@ import ( "net/url" "testing" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/router" + "github.com/envelope-zero/backend/v4/pkg/database" + "github.com/envelope-zero/backend/v4/pkg/router" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) diff --git a/pkg/router/router.go b/pkg/router/router.go index 1cdeb8dc..08cbae62 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -6,11 +6,11 @@ import ( "os" "strings" - docs "github.com/envelope-zero/backend/v3/api" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/database" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/httputil" + docs "github.com/envelope-zero/backend/v4/api" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/database" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/httputil" "github.com/gin-contrib/cors" "github.com/gin-contrib/logger" "github.com/gin-contrib/pprof" diff --git a/test/helpers.go b/test/helpers.go index b0d7982e..a3daf9ad 100644 --- a/test/helpers.go +++ b/test/helpers.go @@ -10,9 +10,9 @@ import ( "reflect" "testing" - "github.com/envelope-zero/backend/v3/pkg/controllers" - "github.com/envelope-zero/backend/v3/pkg/httperrors" - "github.com/envelope-zero/backend/v3/pkg/router" + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/httperrors" + "github.com/envelope-zero/backend/v4/pkg/router" "github.com/stretchr/testify/assert" ) From c61f4f27acca560bab64bb482992d28267e0916d Mon Sep 17 00:00:00 2001 From: Morre Date: Sun, 31 Dec 2023 16:28:19 +0100 Subject: [PATCH 03/15] chore!: remove overspend handling This removes overspend handling since it is a hack against clean budgeting. Existing overspend is migrated by subtracting possible overspend from next month's allocation. BREAKING CHANGE: Overspend handling is removed since it is a hack against clean budgeting. --- .golangci.yml | 2 +- api/docs.go | 15 ----- api/swagger.json | 15 ----- api/swagger.yaml | 12 ---- pkg/controllers/month_config_v3.go | 3 +- pkg/importer/creator.go | 44 +++++++++++++ pkg/importer/parser/ynab4/parse.go | 102 ++--------------------------- pkg/importer/types.go | 30 +++++++-- pkg/models/database.go | 75 +++++++++++++++++++++ pkg/models/envelope.go | 11 +--- pkg/models/month_config.go | 13 +--- 11 files changed, 154 insertions(+), 168 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 3929a12c..216d8f00 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -33,4 +33,4 @@ linters-settings: gofumpt: extra-rules: true gocyclo: - min-complexity: 20 + min-complexity: 25 diff --git a/api/docs.go b/api/docs.go index 35e84d8c..cb88593e 100644 --- a/api/docs.go +++ b/api/docs.go @@ -4222,10 +4222,6 @@ const docTemplate = `{ "type": "string", "example": "Added 200€ here because we replaced Tim's expensive vase" }, - "overspendMode": { - "description": "Ignore this. It is here to override the OverspendMode from models.MonthConfigCreate and will be removed with 4.0.0", - "type": "string" - }, "updatedAt": { "description": "Last time the resource was updated", "type": "string", @@ -4566,17 +4562,6 @@ const docTemplate = `{ } } }, - "models.OverspendMode": { - "type": "string", - "enum": [ - "AFFECT_AVAILABLE", - "AFFECT_ENVELOPE" - ], - "x-enum-varnames": [ - "AffectAvailable", - "AffectEnvelope" - ] - }, "models.TransactionCreate": { "type": "object", "properties": { diff --git a/api/swagger.json b/api/swagger.json index 1eb0632d..f3b3ea9a 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -4211,10 +4211,6 @@ "type": "string", "example": "Added 200€ here because we replaced Tim's expensive vase" }, - "overspendMode": { - "description": "Ignore this. It is here to override the OverspendMode from models.MonthConfigCreate and will be removed with 4.0.0", - "type": "string" - }, "updatedAt": { "description": "Last time the resource was updated", "type": "string", @@ -4555,17 +4551,6 @@ } } }, - "models.OverspendMode": { - "type": "string", - "enum": [ - "AFFECT_AVAILABLE", - "AFFECT_ENVELOPE" - ], - "x-enum-varnames": [ - "AffectAvailable", - "AffectEnvelope" - ] - }, "models.TransactionCreate": { "type": "object", "properties": { diff --git a/api/swagger.yaml b/api/swagger.yaml index 3f524dd0..39b23980 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -933,10 +933,6 @@ definitions: description: A note for the month config example: Added 200€ here because we replaced Tim's expensive vase type: string - overspendMode: - description: Ignore this. It is here to override the OverspendMode from models.MonthConfigCreate - and will be removed with 4.0.0 - type: string updatedAt: description: Last time the resource was updated example: "2022-04-17T20:14:01.048145Z" @@ -1193,14 +1189,6 @@ definitions: example: 3 type: integer type: object - models.OverspendMode: - enum: - - AFFECT_AVAILABLE - - AFFECT_ENVELOPE - type: string - x-enum-varnames: - - AffectAvailable - - AffectEnvelope models.TransactionCreate: properties: amount: diff --git a/pkg/controllers/month_config_v3.go b/pkg/controllers/month_config_v3.go index 05627052..c684457b 100644 --- a/pkg/controllers/month_config_v3.go +++ b/pkg/controllers/month_config_v3.go @@ -24,8 +24,7 @@ type MonthConfigV3Editable struct { type MonthConfigV3 struct { models.MonthConfig - OverspendMode string `json:"overspendMode,omitempty"` // Ignore this. It is here to override the OverspendMode from models.MonthConfigCreate and will be removed with 4.0.0 - Links struct { + Links struct { Self string `json:"self" example:"https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b/2017-10"` // The Month Config itself Envelope string `json:"envelope" example:"https://example.com/api/v3/envelopes/61027ebb-ab75-4a49-9e23-a104ddd9ba6b"` // The Envelope this config belongs to } `json:"links"` diff --git a/pkg/importer/creator.go b/pkg/importer/creator.go index 853b1e6b..937bc627 100644 --- a/pkg/importer/creator.go +++ b/pkg/importer/creator.go @@ -6,6 +6,7 @@ import ( "github.com/envelope-zero/backend/v4/pkg/models" "github.com/google/uuid" + "github.com/shopspring/decimal" "golang.org/x/exp/slices" "gorm.io/gorm" ) @@ -120,6 +121,49 @@ func Create(db *gorm.DB, resources ParsedResources) (models.Budget, error) { } } + for _, f := range resources.OverspendFixes { + envelopeID := resources.Categories[f.Category].Envelopes[f.Envelope].Model.ID + + var envelope models.Envelope + err := tx.First(&envelope, envelopeID).Error + if err != nil { + tx.Rollback() + return models.Budget{}, fmt.Errorf("could not find envelope to fix overspend on: %w", err) + } + + balance, err := envelope.Balance(tx, f.Month) + if err != nil { + tx.Rollback() + return models.Budget{}, fmt.Errorf("error on balance calculation for envelope to fix overspend on: %w", err) + } + + // If the envelope is not overspent (i.e. balance is >= 0), we don't need to do anything + if balance.GreaterThanOrEqual(decimal.Zero) { + continue + } + + // We need to add(!) the envelope balance to the allocation for the next month. + // To do so, we find the MonthConfig or create it + var monthConfig models.MonthConfig + err = tx.Where(models.MonthConfig{ + Month: f.Month.AddDate(0, 1), + EnvelopeID: envelopeID, + }).FirstOrCreate(&monthConfig).Error + if err != nil { + tx.Rollback() + return models.Budget{}, fmt.Errorf("error on reading/creating the month config for overspend fixing: %w", err) + } + + // Add the balance + // We need to subtract the overspent amount, since the balance is negative the overspent amount, we add it + monthConfig.Allocation.Add(balance) + err = tx.Save(&monthConfig).Error + if err != nil { + tx.Rollback() + return models.Budget{}, fmt.Errorf("error on updating the month config for overspend fixing: %w", err) + } + } + // No errors happened, commit the transaction tx.Commit() return budget, nil diff --git a/pkg/importer/parser/ynab4/parse.go b/pkg/importer/parser/ynab4/parse.go index 0a338cbe..5606f45d 100644 --- a/pkg/importer/parser/ynab4/parse.go +++ b/pkg/importer/parser/ynab4/parse.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "regexp" - "sort" "strings" "time" @@ -66,9 +65,6 @@ func Parse(f io.Reader) (importer.ParsedResources, error) { return importer.ParsedResources{}, fmt.Errorf("error parsing budget allocations: %w", err) } - // Translate YNAB overspend handling behaviour to EZ overspending handling behaviour - fixOverspendHandling(&resources) - // Fix duplicate account names fixDuplicateAccountNames(&resources) @@ -511,7 +507,11 @@ func parseMonthlyBudgets(resources *importer.ParsedResources, monthlyBudgets []M continue } - monthConfig.Model.OverspendMode = "AFFECT_ENVELOPE" + resources.OverspendFixes = append(resources.OverspendFixes, importer.OverspendFix{ + Category: envelopeIDNames[subCategoryBudget.CategoryID].Category, + Envelope: envelopeIDNames[subCategoryBudget.CategoryID].Envelope, + Month: month, + }) } resources.MonthConfigs = append(resources.MonthConfigs, monthConfig) @@ -521,98 +521,6 @@ func parseMonthlyBudgets(resources *importer.ParsedResources, monthlyBudgets []M return nil } -// fixOverspendHandling translates the overspend handling behaviour of YNAB 4 into -// the overspend handling of EZ. In YNAB 4, when the overspendHandling is set to "Confined", -// it affects all months until it is explicitly set back to "AffectsBuffer". -// -// EZ on the other hand uses AFFECT_AVAILABLE as default (as does YNAB 4 with "AffectsBuffer") -// but only changes to AFFECT_ENVELOPE (= "Confined" on YNAB 4) when explicitly configured for -// that month. -func fixOverspendHandling(resources *importer.ParsedResources) { - // sorter is a map of category names to a map of envelope names to the month configs - sorter := make(map[string]map[string][]importer.MonthConfig, 0) - - // Sort by envelope - for _, monthConfig := range resources.MonthConfigs { - _, ok := sorter[monthConfig.Category] - if !ok { - sorter[monthConfig.Category] = make(map[string][]importer.MonthConfig, 0) - } - - _, ok = sorter[monthConfig.Category][monthConfig.Envelope] - if !ok { - sorter[monthConfig.Category][monthConfig.Envelope] = make([]importer.MonthConfig, 0) - } - - sorter[monthConfig.Category][monthConfig.Envelope] = append(sorter[monthConfig.Category][monthConfig.Envelope], monthConfig) - } - - // New slice for final MonthConfigs - var monthConfigs []importer.MonthConfig - - // Fix handling for all envelopes - for _, category := range sorter { - for _, monthConfig := range category { - // Sort by time so that earlier months are first - sort.Slice(monthConfig, func(i, j int) bool { - return monthConfig[i].Model.Month.Before(monthConfig[j].Model.Month) - }) - - for i, mConfig := range monthConfig { - // If we are switching back to "Available for budget", we don't need to do anything - if mConfig.Model.OverspendMode == "AFFECT_AVAILABLE" || mConfig.Model.OverspendMode == "" { - continue - } - - monthConfigs = append(monthConfigs, mConfig) - - // Start with the next month since we already appended the current one - checkMonth := mConfig.Model.Month.AddDate(0, 1) - - // If this is the last month, we set all months including the one of today to "AFFECT_ENVELOPE" - // to preserve the YNAB 4 behaviour up to the switch to EZ - if i+1 == len(monthConfig) { - for ok := true; ok; ok = !checkMonth.AfterTime(time.Now()) { - monthConfigs = append(monthConfigs, importer.MonthConfig{ - Model: models.MonthConfig{ - Month: checkMonth, - MonthConfigCreate: models.MonthConfigCreate{ - OverspendMode: models.AffectEnvelope, - }, - }, - Category: mConfig.Category, - Envelope: mConfig.Envelope, - }) - - checkMonth = checkMonth.AddDate(0, 1) - } - - continue - } - - // Set all months up to the next one with a configuration to "AFFECT_ENVELOPE" - for ok := !checkMonth.Equal(monthConfig[i+1].Model.Month); ok; ok = !checkMonth.Equal(monthConfig[i+1].Model.Month) { - monthConfigs = append(monthConfigs, importer.MonthConfig{ - Model: models.MonthConfig{ - Month: checkMonth, - MonthConfigCreate: models.MonthConfigCreate{ - OverspendMode: "AFFECT_ENVELOPE", - }, - }, - Category: mConfig.Category, - Envelope: mConfig.Envelope, - }) - - checkMonth = checkMonth.AddDate(0, 1) - } - } - } - } - - // Overwrite the original MonthConfigs with the fixed ones - resources.MonthConfigs = monthConfigs -} - // fixDuplicateAccountNames detects if an account name is the same for an internal and // external account (which is allowed in YNAB for accounts and Payees) and adds // " (External)" to the external (payee) account. diff --git a/pkg/importer/types.go b/pkg/importer/types.go index 7e2876ae..87a7290e 100644 --- a/pkg/importer/types.go +++ b/pkg/importer/types.go @@ -1,6 +1,7 @@ package importer import ( + "github.com/envelope-zero/backend/v4/internal/types" "github.com/envelope-zero/backend/v4/pkg/models" "github.com/google/uuid" ) @@ -9,12 +10,29 @@ import ( // Named resources are in maps with their names as keys to enable easy deduplication // and iteration through them. type ParsedResources struct { - Budget models.Budget - Accounts []models.Account - Categories map[string]Category - Transactions []Transaction - MonthConfigs []MonthConfig - MatchRules []MatchRule + Budget models.Budget + Accounts []models.Account + Categories map[string]Category + Transactions []Transaction + MonthConfigs []MonthConfig + MatchRules []MatchRule + OverspendFixes []OverspendFix +} + +// OverspendFix supports the import of budgeting apps that allow overspending +// for an envelope to affect that envelope's balance in the next month. +// It is used by the creator to subtract the overspent amount from the allocation +// of the next month for the specific envelope +// +// OverspendFixes have to be added by the budget parsers since these are responsible +// for detecting situations where overspend is configured to affect the envelope. +// +// However, the calculation of the balance for the envelope and possible subtraction +// of overspend is handled by the creator +type OverspendFix struct { + Category string // There is a category here since an envelope with the same name can exist for multiple categories + Envelope string + Month types.Month } type Category struct { diff --git a/pkg/models/database.go b/pkg/models/database.go index f5e6e319..ef866a0c 100644 --- a/pkg/models/database.go +++ b/pkg/models/database.go @@ -37,6 +37,15 @@ func Migrate(db *gorm.DB) (err error) { return fmt.Errorf("error during DB migration: %w", err) } + // https://github.com/envelope-zero/backend/issues/856 + // Remove with 5.0.0 + if db.Migrator().HasColumn(&MonthConfig{}, "OverspendMode") { + err = migrateOverspendHandling(db) + if err != nil { + return fmt.Errorf("error during overspend handling migration: %w", err) + } + } + // https://github.com/envelope-zero/backend/issues/440 // Remove with 5.0.0 if db.Migrator().HasTable("allocations") { @@ -171,3 +180,69 @@ func migrateAllocationToMonthConfig(db *gorm.DB) (err error) { tx.Commit() return nil } + +func migrateOverspendHandling(db *gorm.DB) (err error) { + type overspend struct { + EnvelopeID string // `gorm:"column:envelope_id"` + Month types.Month + OverspendMode string + } + + var overspends []overspend + err = db.Raw("select envelope_id, month, overspend_mode from month_configs WHERE overspend_mode != ''").Scan(&overspends).Error + if err != nil { + return err + } + + // Execute all updates in a transaction + tx := db.Begin() + + // For each overspend configuration, migrate the config as needed + for _, overspend := range overspends { + envelopeID, err := uuid.Parse(overspend.EnvelopeID) + if err != nil { + tx.Rollback() + return err + } + + var envelope Envelope + err = tx.First(&envelope, envelopeID).Error + if err != nil { + tx.Rollback() + return err + } + + balance, err := envelope.Balance(tx, overspend.Month) + if err != nil { + tx.Rollback() + return err + } + + // If the envelope is not overspent (i.e. balance is >= 0), we don't need to do anything + if balance.GreaterThanOrEqual(decimal.Zero) { + continue + } + + var monthConfig MonthConfig + err = tx.Where(MonthConfig{ + Month: overspend.Month.AddDate(0, 1), + EnvelopeID: envelopeID, + }).FirstOrCreate(&monthConfig).Error + if err != nil { + tx.Rollback() + return err + } + + // Add the balance + // We need to subtract the overspent amount, since the balance is negative the overspent amount, we add it + monthConfig.Allocation.Add(balance) + err = tx.Save(&monthConfig).Error + if err != nil { + tx.Rollback() + return err + } + } + tx.Commit() + + return db.Migrator().DropColumn(&MonthConfig{}, "OverspendMode") +} diff --git a/pkg/models/envelope.go b/pkg/models/envelope.go index 33046dbc..8b2d374e 100644 --- a/pkg/models/envelope.go +++ b/pkg/models/envelope.go @@ -222,16 +222,9 @@ func (e Envelope) Balance(db *gorm.DB, month types.Month) (decimal.Decimal, erro continue } - // If there is overspend and the overspend should affect the envelope, - // the sum for the month is subtracted (using decimal.Add since the - // number is negative) - if monthSum.IsNegative() && configOk && currentMonthConfig.OverspendMode == AffectEnvelope { + // If this is the last month, the sum is the monthSum + if monthSum.IsNegative() && loopMonth.After(month) { sum = monthSum - // If this is the last month, the sum is the monthSum - } else if monthSum.IsNegative() && loopMonth.After(month) { - sum = monthSum - // In all other cases, the overspend affects Available to Budget, - // not the envelope balance } else if monthSum.IsNegative() { sum = decimal.Zero } diff --git a/pkg/models/month_config.go b/pkg/models/month_config.go index b9fdabee..3c9793a6 100644 --- a/pkg/models/month_config.go +++ b/pkg/models/month_config.go @@ -9,14 +9,6 @@ import ( "gorm.io/gorm" ) -// swagger:enum OverspendMode -type OverspendMode string - -const ( - AffectAvailable OverspendMode = "AFFECT_AVAILABLE" - AffectEnvelope OverspendMode = "AFFECT_ENVELOPE" -) - type MonthConfig struct { Timestamps MonthConfigCreate @@ -25,9 +17,8 @@ type MonthConfig struct { } type MonthConfigCreate struct { - Allocation decimal.Decimal `json:"allocation" gorm:"type:DECIMAL(20,8)" example:"22.01" minimum:"0.00000001" maximum:"999999999999.99999999" multipleOf:"0.00000001"` // The maximum value is "999999999999.99999999", swagger unfortunately rounds this. - OverspendMode OverspendMode `json:"overspendMode" example:"AFFECT_ENVELOPE" default:"AFFECT_AVAILABLE"` // The overspend handling mode to use. Deprecated, will be removed with 4.0.0 release and is not used in API v3 anymore - Note string `json:"note" example:"Added 200€ here because we replaced Tim's expensive vase" default:""` // A note for the month config + Allocation decimal.Decimal `json:"allocation" gorm:"type:DECIMAL(20,8)" 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 (m MonthConfig) Self() string { From f52ae06b4c3adbd4aa4cb148e628b79b0a86622a Mon Sep 17 00:00:00 2001 From: Morre Date: Sun, 31 Dec 2023 16:51:18 +0100 Subject: [PATCH 04/15] chore!: Remove v3 database migrations BREAKING CHANGES: Removes database migrations for v3 --- pkg/models/database.go | 98 ------------------------------------------ 1 file changed, 98 deletions(-) diff --git a/pkg/models/database.go b/pkg/models/database.go index ef866a0c..b1138657 100644 --- a/pkg/models/database.go +++ b/pkg/models/database.go @@ -11,27 +11,6 @@ import ( // Migrate migrates all models to the schema defined in the code. func Migrate(db *gorm.DB) (err error) { - // Migration for https://github.com/envelope-zero/backend/issues/684 - // Remove with 4.0.0 - // This migration is done before the AutoMigrate since AutoMigrate will introduce - // new unique constraints that this migration ensures are fulfilled for existing - // transactions - if db.Migrator().HasTable(&Account{}) { - err = migrateDuplicateAccountNames(db) - if err != nil { - return fmt.Errorf("error during migrateDuplicateAccountNames: %w", err) - } - } - - // https://github.com/envelope-zero/backend/issues/763 - // Remove with 4.0.0 - if db.Migrator().HasTable("rename_rules") { - err := db.Migrator().RenameTable("rename_rules", "match_rules") - if err != nil { - return fmt.Errorf("error during rename_rules -> match_rules migration: %w", err) - } - } - err = db.AutoMigrate(Budget{}, Account{}, Category{}, Envelope{}, Transaction{}, MonthConfig{}, MatchRule{}, Goal{}) if err != nil { return fmt.Errorf("error during DB migration: %w", err) @@ -55,86 +34,9 @@ func Migrate(db *gorm.DB) (err error) { } } - // Migration for https://github.com/envelope-zero/backend/issues/613 - // Remove with 4.0.0 - err = unsetEnvelopes(db) - if err != nil { - return fmt.Errorf("error during unsetEnvelopes: %w", err) - } - return nil } -// migrateDuplicateAccountNames migrates duplicate account names to be unique. -func migrateDuplicateAccountNames(db *gorm.DB) (err error) { - type Duplicate struct { - BudgetID uuid.UUID - Name string - } - - // Get a list of budget ID and account name for all budgets that have duplicate account names - var duplicates []Duplicate - err = db.Raw("select budget_id, name, COUNT(*) from accounts GROUP BY budget_id, name having count(*) > 1").Scan(&duplicates).Error - if err != nil { - return - } - - for _, d := range duplicates { - var accounts []Account - - // Find all accounts that have a duplicate name - err = db.Unscoped().Where(Account{AccountCreate: AccountCreate{ - BudgetID: d.BudgetID, - Name: d.Name, - }}).Find(&accounts).Error - if err != nil { - return - } - - for i, a := range accounts { - a.Name = fmt.Sprintf("%s (%d)", a.Name, i+1) - err = db.Save(&a).Error - if err != nil { - return - } - } - } - - return nil -} - -// unsetEnvelopes removes the envelopes from transfers between -// accounts that are on budget. -func unsetEnvelopes(db *gorm.DB) (err error) { - var accounts []Account - err = db.Where(&Account{AccountCreate: AccountCreate{ - OnBudget: true, - }}).Find(&accounts).Error - if err != nil { - return - } - - for _, a := range accounts { - var transactions []Transaction - err = db.Model(&Transaction{}). - Joins("JOIN accounts ON transactions.source_account_id = accounts.id"). - Where("destination_account_id = ? AND accounts.on_budget AND envelope_id not null", a.ID). - Find(&transactions).Error - if err != nil { - return - } - - for _, t := range transactions { - err = db.Model(&t).Select("EnvelopeID").Updates(map[string]interface{}{"EnvelopeID": nil}).Error - if err != nil { - return - } - } - } - - return -} - func migrateAllocationToMonthConfig(db *gorm.DB) (err error) { type allocation struct { EnvelopeID string `gorm:"column:envelope_id"` From fe593120acb5a1f107d1b37c6eb93c13e54eca3d Mon Sep 17 00:00:00 2001 From: Morre Date: Sun, 31 Dec 2023 16:58:53 +0100 Subject: [PATCH 05/15] chore!: remove reconciled field This field never worked as intended and is removed now. This also fixes a bug where the reconciled sum was not calculated correctly when using the reconciledSource and reconciledDestination fields. BREAKING CHANGE: "reconciled" field is removed from transactions --- api/docs.go | 10 ---- api/swagger.json | 10 ---- api/swagger.yaml | 10 ---- pkg/controllers/month_v3_test.go | 3 -- pkg/controllers/transaction_v3.go | 3 +- pkg/controllers/transaction_v3_test.go | 3 -- pkg/models/account.go | 4 +- pkg/models/account_test.go | 12 ++--- pkg/models/budget_test.go | 3 -- pkg/models/database.go | 9 ++++ pkg/models/database_test.go | 71 -------------------------- pkg/models/transaction.go | 1 - 12 files changed, 18 insertions(+), 121 deletions(-) diff --git a/api/docs.go b/api/docs.go index cb88593e..8b5d575c 100644 --- a/api/docs.go +++ b/api/docs.go @@ -4455,10 +4455,6 @@ const docTemplate = `{ "type": "string", "example": "Lunch" }, - "reconciled": { - "description": "Remove the reconciled field", - "type": "boolean" - }, "reconciledDestination": { "description": "Is the transaction reconciled in the destination account?", "type": "boolean", @@ -4608,12 +4604,6 @@ const docTemplate = `{ "type": "string", "example": "Lunch" }, - "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", - "type": "boolean", - "default": false, - "example": true - }, "reconciledDestination": { "description": "Is the transaction reconciled in the destination account?", "type": "boolean", diff --git a/api/swagger.json b/api/swagger.json index f3b3ea9a..519e499b 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -4444,10 +4444,6 @@ "type": "string", "example": "Lunch" }, - "reconciled": { - "description": "Remove the reconciled field", - "type": "boolean" - }, "reconciledDestination": { "description": "Is the transaction reconciled in the destination account?", "type": "boolean", @@ -4597,12 +4593,6 @@ "type": "string", "example": "Lunch" }, - "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", - "type": "boolean", - "default": false, - "example": true - }, "reconciledDestination": { "description": "Is the transaction reconciled in the destination account?", "type": "boolean", diff --git a/api/swagger.yaml b/api/swagger.yaml index 39b23980..a3a21f14 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -1108,9 +1108,6 @@ definitions: description: A note example: Lunch type: string - reconciled: - description: Remove the reconciled field - type: boolean reconciledDestination: default: false description: Is the transaction reconciled in the destination account? @@ -1230,13 +1227,6 @@ definitions: description: A note example: Lunch type: string - reconciled: - default: false - description: DEPRECATED. Do not use, this field does not work as intended. - See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource - and reconciledDestination instead. This field will be removed in 4.0.0 - example: true - type: boolean reconciledDestination: default: false description: Is the transaction reconciled in the destination account? diff --git a/pkg/controllers/month_v3_test.go b/pkg/controllers/month_v3_test.go index cdbc9f5e..3d8930fd 100644 --- a/pkg/controllers/month_v3_test.go +++ b/pkg/controllers/month_v3_test.go @@ -329,7 +329,6 @@ func (suite *TestSuiteStandard) TestMonthsV3() { SourceAccountID: account.Data.ID, DestinationAccountID: externalAccount.Data.ID, EnvelopeID: &envelope.Data.ID, - Reconciled: true, }) _ = suite.createTestTransactionV3(suite.T(), models.TransactionCreate{ @@ -340,7 +339,6 @@ func (suite *TestSuiteStandard) TestMonthsV3() { SourceAccountID: account.Data.ID, DestinationAccountID: externalAccount.Data.ID, EnvelopeID: &envelope.Data.ID, - Reconciled: true, }) _ = suite.createTestTransactionV3(suite.T(), models.TransactionCreate{ @@ -351,7 +349,6 @@ func (suite *TestSuiteStandard) TestMonthsV3() { SourceAccountID: account.Data.ID, DestinationAccountID: externalAccount.Data.ID, EnvelopeID: &envelope.Data.ID, - Reconciled: true, }) _ = suite.createTestTransactionV3(suite.T(), models.TransactionCreate{ diff --git a/pkg/controllers/transaction_v3.go b/pkg/controllers/transaction_v3.go index 8cb28b1f..c5e722d0 100644 --- a/pkg/controllers/transaction_v3.go +++ b/pkg/controllers/transaction_v3.go @@ -47,8 +47,7 @@ type TransactionResponseV3 struct { // TransactionV3 is the representation of a Transaction in API v3. type TransactionV3 struct { models.Transaction - Reconciled bool `json:"reconciled,omitempty"` // Remove the reconciled field - Links struct { + Links struct { Self string `json:"self" example:"https://example.com/api/v3/transactions/d430d7c3-d14c-4712-9336-ee56965a6673"` // The transaction itself } `json:"links"` // Links for the transaction } diff --git a/pkg/controllers/transaction_v3_test.go b/pkg/controllers/transaction_v3_test.go index 372d9752..6b34d1e4 100644 --- a/pkg/controllers/transaction_v3_test.go +++ b/pkg/controllers/transaction_v3_test.go @@ -176,7 +176,6 @@ func (suite *TestSuiteStandard) TestTransactionsV3GetFilter() { EnvelopeID: e1ID, SourceAccountID: a1.Data.ID, DestinationAccountID: a2.Data.ID, - Reconciled: false, ReconciledSource: true, ReconciledDestination: false, }) @@ -189,7 +188,6 @@ func (suite *TestSuiteStandard) TestTransactionsV3GetFilter() { EnvelopeID: e2ID, SourceAccountID: a2.Data.ID, DestinationAccountID: a1.Data.ID, - Reconciled: false, ReconciledSource: true, ReconciledDestination: true, }) @@ -202,7 +200,6 @@ func (suite *TestSuiteStandard) TestTransactionsV3GetFilter() { EnvelopeID: e1ID, SourceAccountID: a3.Data.ID, DestinationAccountID: a2.Data.ID, - Reconciled: true, ReconciledSource: false, ReconciledDestination: true, }) diff --git a/pkg/models/account.go b/pkg/models/account.go index 5e868396..c2d6c7c3 100644 --- a/pkg/models/account.go +++ b/pkg/models/account.go @@ -107,8 +107,8 @@ func (a Account) SumReconciled(db *gorm.DB) (balance decimal.Decimal, err error) Preload("DestinationAccount"). Preload("SourceAccount"). Where( - db.Where(Transaction{TransactionCreate: TransactionCreate{DestinationAccountID: a.ID, Reconciled: true}}). - Or(db.Where(Transaction{TransactionCreate: TransactionCreate{SourceAccountID: a.ID, Reconciled: true}}))). + db.Where(Transaction{TransactionCreate: TransactionCreate{DestinationAccountID: a.ID, ReconciledDestination: true}}). + Or(db.Where(Transaction{TransactionCreate: TransactionCreate{SourceAccountID: a.ID, ReconciledSource: true}}))). Find(&transactions).Error if err != nil { diff --git a/pkg/models/account_test.go b/pkg/models/account_test.go index cfed72f8..a6d4308c 100644 --- a/pkg/models/account_test.go +++ b/pkg/models/account_test.go @@ -56,12 +56,12 @@ func (suite *TestSuiteStandard) TestAccountCalculations() { }) incomingTransaction := suite.createTestTransaction(models.TransactionCreate{ - BudgetID: budget.ID, - EnvelopeID: &envelope.ID, - SourceAccountID: externalAccount.ID, - DestinationAccountID: account.ID, - Reconciled: true, - Amount: decimal.NewFromFloat(32.17), + BudgetID: budget.ID, + EnvelopeID: &envelope.ID, + SourceAccountID: externalAccount.ID, + DestinationAccountID: account.ID, + ReconciledDestination: true, + Amount: decimal.NewFromFloat(32.17), }) outgoingTransaction := suite.createTestTransaction(models.TransactionCreate{ diff --git a/pkg/models/budget_test.go b/pkg/models/budget_test.go index a4bc67e5..e1be2771 100644 --- a/pkg/models/budget_test.go +++ b/pkg/models/budget_test.go @@ -117,7 +117,6 @@ func (suite *TestSuiteStandard) TestBudgetCalculations() { EnvelopeID: nil, SourceAccountID: employerAccount.ID, DestinationAccountID: bankAccount.ID, - Reconciled: true, Amount: decimal.NewFromFloat(1800), }) @@ -127,7 +126,6 @@ func (suite *TestSuiteStandard) TestBudgetCalculations() { EnvelopeID: nil, SourceAccountID: employerAccount.ID, DestinationAccountID: bankAccount.ID, - Reconciled: true, Amount: decimal.NewFromFloat(2800), }) @@ -137,7 +135,6 @@ func (suite *TestSuiteStandard) TestBudgetCalculations() { EnvelopeID: nil, SourceAccountID: employerAccount.ID, DestinationAccountID: bankAccount.ID, - Reconciled: true, Amount: decimal.NewFromFloat(2800), }) diff --git a/pkg/models/database.go b/pkg/models/database.go index b1138657..ec07c20e 100644 --- a/pkg/models/database.go +++ b/pkg/models/database.go @@ -25,6 +25,15 @@ func Migrate(db *gorm.DB) (err error) { } } + // https://github.com/envelope-zero/backend/issues/359 + // Remove with 5.0.0 + if db.Migrator().HasColumn(&Transaction{}, "Reconciled") { + err = db.Migrator().DropColumn(&Transaction{}, "Reconciled") + if err != nil { + return fmt.Errorf("error when dropping reconciled column for transactions: %w", err) + } + } + // https://github.com/envelope-zero/backend/issues/440 // Remove with 5.0.0 if db.Migrator().HasTable("allocations") { diff --git a/pkg/models/database_test.go b/pkg/models/database_test.go index 52c76b13..179e5e9c 100644 --- a/pkg/models/database_test.go +++ b/pkg/models/database_test.go @@ -23,77 +23,6 @@ func (suite *TestSuiteStandard) TestMigrateWithExistingDB() { suite.Assert().Nil(err) } -func (suite *TestSuiteStandard) TestMigrateDuplicateAccountNames() { - // Initialize the database to have all tables - err := suite.db.AutoMigrate() - suite.Assert().Nil(err, err) - - // Drop the unique constraint so that we can add non-unique account names - err = suite.db.Migrator().DropConstraint(&models.Account{}, "account_name_budget_id") - suite.Assert().Nil(err, err) - - name := "Non-unique name" - budget := suite.createTestBudget(models.BudgetCreate{}) - _ = suite.createTestAccount(models.AccountCreate{ - BudgetID: budget.ID, - Name: name, - }) - - _ = suite.createTestAccount(models.AccountCreate{ - BudgetID: budget.ID, - Name: name, - }) - - // Execute the migration again - err = models.Migrate(suite.db) - suite.Assert().Nil(err) -} - -func (suite *TestSuiteStandard) TestUnsetEnvelope() { - // Initialize the database to have all tables - err := suite.db.AutoMigrate() - suite.Assert().Nil(err, err) - - budget := suite.createTestBudget(models.BudgetCreate{}) - sourceAccount := suite.createTestAccount(models.AccountCreate{ - BudgetID: budget.ID, - Name: "TestUnsetEnvelope: Source", - OnBudget: true, - }) - - destinationAccount := suite.createTestAccount(models.AccountCreate{ - BudgetID: budget.ID, - Name: "TestUnsetEnvelope: Destination", - OnBudget: true, - }) - - envelope := suite.createTestEnvelope(models.EnvelopeCreate{ - CategoryID: suite.createTestCategory(models.CategoryCreate{BudgetID: budget.ID}).ID, - Name: "TestUnsetEnvelope: Envelope", - }) - - transaction := suite.createTestTransaction(models.TransactionCreate{ - BudgetID: budget.ID, - SourceAccountID: sourceAccount.ID, - DestinationAccountID: destinationAccount.ID, - Amount: decimal.NewFromFloat(17.36), - Note: "This can only be created for this test - the controllers prevent creating this already", - EnvelopeID: &envelope.ID, - }) - - // Execute the migration again - err = models.Migrate(suite.db) - suite.Assert().Nil(err) - - // Reload the transaction - var checkTransaction models.Transaction - err = suite.db.First(&checkTransaction, transaction.ID).Error - suite.Assert().Nil(err) - - // Test thet the envelope has been set to nil by the migration - suite.Assert().Nil(checkTransaction.EnvelopeID) -} - func (suite *TestSuiteStandard) TestMigrateAllocation() { err := suite.db.Raw("CREATE TABLE allocations (`id` text,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime,`month` date,`amount` DECIMAL(20,8),`envelope_id` text,PRIMARY KEY (`id`))").Scan(nil).Error suite.Assert().Nil(err) diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go index efb4e885..dd7ea7c2 100644 --- a/pkg/models/transaction.go +++ b/pkg/models/transaction.go @@ -30,7 +30,6 @@ type TransactionCreate struct { SourceAccountID uuid.UUID `json:"sourceAccountId" gorm:"check:source_destination_different,source_account_id != destination_account_id" 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 - Reconciled bool `json:"reconciled" example:"true" default:"false"` // DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0 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? From dcbafb3b45f73998081523f31205f6dede894a46 Mon Sep 17 00:00:00 2001 From: Morre Date: Sun, 31 Dec 2023 17:04:54 +0100 Subject: [PATCH 06/15] chore!: removed deprecated fields BREAKING CHANGE: Removes the renameRuleId field from transaction previews and the month field from envelope month resources --- pkg/controllers/import.go | 8 -------- pkg/importer/types.go | 10 ++++------ pkg/models/envelope.go | 10 ++++------ 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/pkg/controllers/import.go b/pkg/controllers/import.go index 0c7bd40a..958495c9 100644 --- a/pkg/controllers/import.go +++ b/pkg/controllers/import.go @@ -137,18 +137,10 @@ func match(transaction *importer.TransactionPreview, rules []models.MatchRule) { if transaction.SourceAccountName != "" { transaction.Transaction.SourceAccountID, transaction.MatchRuleID = replace(transaction.SourceAccountName) - - // This is kept for backwards compatibility and will be removed with API version 3 - // https://github.com/envelope-zero/backend/issues/763 - transaction.RenameRuleID = transaction.MatchRuleID } if transaction.DestinationAccountName != "" { transaction.Transaction.DestinationAccountID, transaction.MatchRuleID = replace(transaction.DestinationAccountName) - - // This is kept for backwards compatibility and will be removed with API version 3 - // https://github.com/envelope-zero/backend/issues/763 - transaction.RenameRuleID = transaction.MatchRuleID } } diff --git a/pkg/importer/types.go b/pkg/importer/types.go index 87a7290e..e08db4db 100644 --- a/pkg/importer/types.go +++ b/pkg/importer/types.go @@ -67,12 +67,10 @@ type Transaction struct { // TransactionPreview is used to preview transactions that will be imported to allow for editing. type TransactionPreview struct { Transaction models.TransactionCreate `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 - RenameRuleID uuid.UUID `json:"renameRuleId" example:"042d101d-f1de-4403-9295-59dc0ea58677"` // ID of the match rule that was applied to this transaction preview. This is kept for backwards compatibility and will be removed with API version 3 - - MatchRuleID uuid.UUID `json:"matchRuleId" example:"042d101d-f1de-4403-9295-59dc0ea58677"` // ID of the match rule that was applied to this transaction preview + 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 } // transformV3 transforms a TransactionPreview to a TransactionPreviewV3. diff --git a/pkg/models/envelope.go b/pkg/models/envelope.go index 8b2d374e..980a6b79 100644 --- a/pkg/models/envelope.go +++ b/pkg/models/envelope.go @@ -255,11 +255,10 @@ type EnvelopeMonthLinks struct { // EnvelopeMonth contains data about an Envelope for a specific month. type EnvelopeMonth struct { Envelope - Month types.Month `json:"month" example:"1969-06-01T00:00:00.000000Z" hidden:"deprecated"` // This is always set to 00:00 UTC on the first of the month. **This field is deprecated and will be removed in v2** - 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 - Links EnvelopeMonthLinks `json:"links"` // Linked resources + 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 + Links EnvelopeMonthLinks `json:"links"` // Linked resources } // Month calculates the month specific values for an envelope and returns an EnvelopeMonth and allocation ID for them. @@ -267,7 +266,6 @@ func (e Envelope) Month(db *gorm.DB, month types.Month) (EnvelopeMonth, error) { spent := e.Spent(db, month) envelopeMonth := EnvelopeMonth{ Envelope: e, - Month: month, Spent: spent, Balance: decimal.NewFromFloat(0), Allocation: decimal.NewFromFloat(0), From 8153fa7f3a32e2a846581bc32075fa7f7922e3d9 Mon Sep 17 00:00:00 2001 From: Morre Date: Sun, 31 Dec 2023 17:07:17 +0100 Subject: [PATCH 07/15] docs: add upgrading notes --- docs/upgrading.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/upgrading.md b/docs/upgrading.md index a0e01a05..63ff6920 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -1,9 +1,16 @@ # Upgrading -:warning: You cannot skip major versions on upgrades. You have to upgrade to at least one release of each major version. +:warning: You cannot skip major versions on upgrades. You have to upgrade to the latest release of each major version before updating to the next major version. If upgrades between versions require manual actions, those are described here. +# to v4.0.0 + +1. Upgrade to v3.22.1 before upgrading to v4.0.0 +2. Upgrade to v4.0.0 + +For breaking changes, see the release notes + # to v3.0.0 v3.0.0 does not require manual steps. With v3.0.0, account names must be unique per budget. From 0fa423a9ffb6f2987cdc8e284d6d25ac95ede9d1 Mon Sep 17 00:00:00 2001 From: Morre Date: Sun, 31 Dec 2023 17:37:26 +0100 Subject: [PATCH 08/15] refactor: rename hidden field to archived for all resources Resolves #871. --- api/docs.go | 40 ++++--------------- api/swagger.json | 40 ++++--------------- api/swagger.yaml | 35 ++++------------- pkg/controllers/account.go | 4 +- pkg/controllers/account_v3.go | 37 ++++++------------ pkg/controllers/category_v3.go | 51 +++++++++---------------- pkg/controllers/envelope_v3.go | 37 ++++++------------ pkg/controllers/import.go | 4 +- pkg/controllers/month_v3.go | 2 +- pkg/importer/parser/ynab4/parse.go | 26 ++++++------- pkg/importer/parser/ynab4/parse_test.go | 20 ++++++---- pkg/importer/parser/ynab4/types.go | 2 +- pkg/models/account.go | 12 +----- pkg/models/category.go | 16 ++------ pkg/models/category_test.go | 16 ++++---- pkg/models/database.go | 27 +++++++++++++ pkg/models/envelope.go | 16 ++------ pkg/models/envelope_test.go | 10 ++--- 18 files changed, 145 insertions(+), 250 deletions(-) diff --git a/api/docs.go b/api/docs.go index 8b5d575c..b1a3b6be 100644 --- a/api/docs.go +++ b/api/docs.go @@ -805,8 +805,8 @@ const docTemplate = `{ }, { "type": "boolean", - "description": "Is the category hidden?", - "name": "hidden", + "description": "Is the category archived?", + "name": "archived", "in": "query" }, { @@ -3107,10 +3107,6 @@ const docTemplate = `{ "default": false, "example": false }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, "id": { "description": "UUID for the resource", "type": "string", @@ -3363,7 +3359,7 @@ const docTemplate = `{ "type": "object", "properties": { "archived": { - "description": "Is the category hidden?", + "description": "Is the category archived?", "type": "boolean", "default": false, "example": true @@ -3394,7 +3390,7 @@ const docTemplate = `{ "example": 90 }, "archived": { - "description": "Is the Category archived?", + "description": "Is the category archived?", "type": "boolean", "default": false, "example": true @@ -3426,12 +3422,6 @@ const docTemplate = `{ "$ref": "#/definitions/controllers.EnvelopeMonthV3" } }, - "hidden": { - "description": "Is the category hidden?", - "type": "boolean", - "default": false, - "example": true - }, "id": { "description": "UUID for the resource", "type": "string", @@ -3506,7 +3496,7 @@ const docTemplate = `{ "type": "object", "properties": { "archived": { - "description": "Is the Category archived?", + "description": "Is the category archived?", "type": "boolean", "default": false, "example": true @@ -3533,10 +3523,6 @@ const docTemplate = `{ "$ref": "#/definitions/controllers.EnvelopeV3" } }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, "id": { "description": "UUID for the resource", "type": "string", @@ -3595,7 +3581,7 @@ const docTemplate = `{ "type": "object", "properties": { "archived": { - "description": "Is the envelope hidden?", + "description": "Is the envelope archived?", "type": "boolean", "default": false, "example": true @@ -3651,7 +3637,7 @@ const docTemplate = `{ "example": 85.44 }, "archived": { - "description": "Is the Envelope archived?", + "description": "Is the envelope archived?", "type": "boolean", "default": false, "example": true @@ -3676,12 +3662,6 @@ const docTemplate = `{ "type": "string", "example": "2022-04-22T21:01:05.058161Z" }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true - }, "id": { "description": "UUID for the resource", "type": "string", @@ -3734,7 +3714,7 @@ const docTemplate = `{ "type": "object", "properties": { "archived": { - "description": "Is the Envelope archived?", + "description": "Is the envelope archived?", "type": "boolean", "default": false, "example": true @@ -3754,10 +3734,6 @@ const docTemplate = `{ "type": "string", "example": "2022-04-22T21:01:05.058161Z" }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, "id": { "description": "UUID for the resource", "type": "string", diff --git a/api/swagger.json b/api/swagger.json index 519e499b..b966a0e3 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -794,8 +794,8 @@ }, { "type": "boolean", - "description": "Is the category hidden?", - "name": "hidden", + "description": "Is the category archived?", + "name": "archived", "in": "query" }, { @@ -3096,10 +3096,6 @@ "default": false, "example": false }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, "id": { "description": "UUID for the resource", "type": "string", @@ -3352,7 +3348,7 @@ "type": "object", "properties": { "archived": { - "description": "Is the category hidden?", + "description": "Is the category archived?", "type": "boolean", "default": false, "example": true @@ -3383,7 +3379,7 @@ "example": 90 }, "archived": { - "description": "Is the Category archived?", + "description": "Is the category archived?", "type": "boolean", "default": false, "example": true @@ -3415,12 +3411,6 @@ "$ref": "#/definitions/controllers.EnvelopeMonthV3" } }, - "hidden": { - "description": "Is the category hidden?", - "type": "boolean", - "default": false, - "example": true - }, "id": { "description": "UUID for the resource", "type": "string", @@ -3495,7 +3485,7 @@ "type": "object", "properties": { "archived": { - "description": "Is the Category archived?", + "description": "Is the category archived?", "type": "boolean", "default": false, "example": true @@ -3522,10 +3512,6 @@ "$ref": "#/definitions/controllers.EnvelopeV3" } }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, "id": { "description": "UUID for the resource", "type": "string", @@ -3584,7 +3570,7 @@ "type": "object", "properties": { "archived": { - "description": "Is the envelope hidden?", + "description": "Is the envelope archived?", "type": "boolean", "default": false, "example": true @@ -3640,7 +3626,7 @@ "example": 85.44 }, "archived": { - "description": "Is the Envelope archived?", + "description": "Is the envelope archived?", "type": "boolean", "default": false, "example": true @@ -3665,12 +3651,6 @@ "type": "string", "example": "2022-04-22T21:01:05.058161Z" }, - "hidden": { - "description": "Is the envelope hidden?", - "type": "boolean", - "default": false, - "example": true - }, "id": { "description": "UUID for the resource", "type": "string", @@ -3723,7 +3703,7 @@ "type": "object", "properties": { "archived": { - "description": "Is the Envelope archived?", + "description": "Is the envelope archived?", "type": "boolean", "default": false, "example": true @@ -3743,10 +3723,6 @@ "type": "string", "example": "2022-04-22T21:01:05.058161Z" }, - "hidden": { - "description": "Remove the hidden field", - "type": "boolean" - }, "id": { "description": "UUID for the resource", "type": "string", diff --git a/api/swagger.yaml b/api/swagger.yaml index a3a21f14..2a3e1e94 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -112,9 +112,6 @@ definitions: description: Does the account belong to the budget owner or not? example: false type: boolean - hidden: - description: Remove the hidden field - type: boolean id: description: UUID for the resource example: 65392deb-5e92-4268-b114-297faad6cdce @@ -303,7 +300,7 @@ definitions: properties: archived: default: false - description: Is the category hidden? + description: Is the category archived? example: true type: boolean budgetId: @@ -327,7 +324,7 @@ definitions: type: number archived: default: false - description: Is the Category archived? + description: Is the category archived? example: true type: boolean balance: @@ -351,11 +348,6 @@ definitions: items: $ref: '#/definitions/controllers.EnvelopeMonthV3' type: array - hidden: - default: false - description: Is the category hidden? - example: true - type: boolean id: description: UUID for the resource example: 65392deb-5e92-4268-b114-297faad6cdce @@ -408,7 +400,7 @@ definitions: properties: archived: default: false - description: Is the Category archived? + description: Is the category archived? example: true type: boolean budgetId: @@ -428,9 +420,6 @@ definitions: items: $ref: '#/definitions/controllers.EnvelopeV3' type: array - hidden: - description: Remove the hidden field - type: boolean id: description: UUID for the resource example: 65392deb-5e92-4268-b114-297faad6cdce @@ -475,7 +464,7 @@ definitions: properties: archived: default: false - description: Is the envelope hidden? + description: Is the envelope archived? example: true type: boolean categoryId: @@ -515,7 +504,7 @@ definitions: type: number archived: default: false - description: Is the Envelope archived? + description: Is the envelope archived? example: true type: boolean balance: @@ -534,11 +523,6 @@ definitions: description: Time the resource was marked as deleted example: "2022-04-22T21:01:05.058161Z" type: string - hidden: - default: false - description: Is the envelope hidden? - example: true - type: boolean id: description: UUID for the resource example: 65392deb-5e92-4268-b114-297faad6cdce @@ -577,7 +561,7 @@ definitions: properties: archived: default: false - description: Is the Envelope archived? + description: Is the envelope archived? example: true type: boolean categoryId: @@ -592,9 +576,6 @@ definitions: description: Time the resource was marked as deleted example: "2022-04-22T21:01:05.058161Z" type: string - hidden: - description: Remove the hidden field - type: boolean id: description: UUID for the resource example: 65392deb-5e92-4268-b114-297faad6cdce @@ -1861,9 +1842,9 @@ paths: in: query name: budget type: string - - description: Is the category hidden? + - description: Is the category archived? in: query - name: hidden + name: archived type: boolean - description: Search for this text in name and note in: query diff --git a/pkg/controllers/account.go b/pkg/controllers/account.go index fe447633..92f4b3de 100644 --- a/pkg/controllers/account.go +++ b/pkg/controllers/account.go @@ -12,7 +12,7 @@ type AccountQueryFilter struct { BudgetID string `form:"budget"` // By budget ID OnBudget bool `form:"onBudget"` // Is the account on-budget? External bool `form:"external"` // Is the account external? - Hidden bool `form:"hidden"` // Is the account hidden? + Archived bool `form:"archived"` // Is the account archived? Search string `form:"search" filterField:"false"` // By string in name or note } @@ -26,6 +26,6 @@ func (f AccountQueryFilter) ToCreate(c *gin.Context) (models.AccountCreate, bool BudgetID: budgetID, OnBudget: f.OnBudget, External: f.External, - Hidden: f.Hidden, + Archived: f.Archived, }, true } diff --git a/pkg/controllers/account_v3.go b/pkg/controllers/account_v3.go index 79942d40..af2fb2c8 100644 --- a/pkg/controllers/account_v3.go +++ b/pkg/controllers/account_v3.go @@ -39,7 +39,7 @@ func (a AccountCreateV3) ToCreate() models.AccountCreate { External: a.External, InitialBalance: a.InitialBalance, InitialBalanceDate: a.InitialBalanceDate, - Hidden: a.Archived, + Archived: a.Archived, ImportHash: a.ImportHash, } } @@ -78,7 +78,6 @@ type AccountV3 struct { 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 RecentEnvelopes []*uuid.UUID `json:"recentEnvelopes"` // Envelopes recently used with this account - Hidden bool `json:"hidden,omitempty"` // Remove the hidden field Links struct { Self string `json:"self" example:"https://example.com/api/v3/accounts/af892e10-7e0a-4fb8-b1bc-4b6d88401ed2"` // The account itself @@ -94,15 +93,15 @@ func (a *AccountV3) links(c *gin.Context) { } type AccountQueryFilterV3 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" filterField:"false"` // Is the account hidden? - 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. + 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 AccountQueryFilterV3) ToCreate() (models.AccountCreate, httperrors.Error) { @@ -115,7 +114,7 @@ func (f AccountQueryFilterV3) ToCreate() (models.AccountCreate, httperrors.Error BudgetID: budgetID, OnBudget: f.OnBudget, External: f.External, - Hidden: f.Archived, + Archived: f.Archived, }, httperrors.Error{} } @@ -301,13 +300,6 @@ func (co Controller) GetAccountsV3(c *gin.Context) { // Get the set parameters in the query string queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) - // If the archived parameter is set, add "Hidden" to the query fields - // This is done since in v3, we're using the name "Archived", but the - // field is not yet updated in the database, which will happen later - if slices.Contains(setFields, "Archived") { - queryFields = append(queryFields, "Hidden") - } - // Convert the QueryFilter to a Create struct create, err := filter.ToCreate() if !err.Nil() { @@ -469,13 +461,6 @@ func (co Controller) UpdateAccountV3(c *gin.Context) { AccountCreate: data.ToCreate(), } - // If the archived parameter is set, add "Hidden" to the update fields - // This is done since in v3, we're using the name "Archived", but the - // field is not yet updated in the database, which will happen later - if slices.Contains(updateFields, "Archived") { - updateFields = append(updateFields, "Hidden") - } - err = query(c, co.DB.Model(&account).Select("", updateFields...).Updates(a)) if !err.Nil() { s := err.Error() diff --git a/pkg/controllers/category_v3.go b/pkg/controllers/category_v3.go index 2ff0b48c..a9dec1a2 100644 --- a/pkg/controllers/category_v3.go +++ b/pkg/controllers/category_v3.go @@ -18,7 +18,7 @@ type CategoryCreateV3 struct { Name string `json:"name" gorm:"uniqueIndex:category_budget_name" example:"Saving" default:""` // Name of the category BudgetID uuid.UUID `json:"budgetId" gorm:"uniqueIndex:category_budget_name" 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 hidden? + Archived bool `json:"archived" example:"true" default:"false"` // Is the category archived? } // ToCreate transforms the API representation into the model representation @@ -27,14 +27,13 @@ func (c CategoryCreateV3) ToCreate() models.CategoryCreate { Name: c.Name, BudgetID: c.BudgetID, Note: c.Note, - Hidden: c.Archived, + Archived: c.Archived, } } type CategoryV3 struct { models.Category - Envelopes []EnvelopeV3 `json:"envelopes"` // Envelopes for the category - Hidden bool `json:"hidden,omitempty"` // Remove the hidden field + Envelopes []EnvelopeV3 `json:"envelopes"` // Envelopes for the category Links struct { Self string `json:"self" example:"https://example.com/api/v3/categories/3b1ea324-d438-4419-882a-2fc91d71772f"` // The category itself @@ -108,13 +107,13 @@ type CategoryResponseV3 struct { } type CategoryQueryFilterV3 struct { - Name string `form:"name" filterField:"false"` // By name - BudgetID string `form:"budget"` // By ID of the Budget - Note string `form:"note" filterField:"false"` // By note - Archived bool `form:"archived" filterField:"false"` // 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. + Name string `form:"name" filterField:"false"` // By name + BudgetID string `form:"budget"` // By ID of the Budget + 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 CategoryQueryFilterV3) ToCreate() (models.CategoryCreate, httperrors.Error) { @@ -125,7 +124,7 @@ func (f CategoryQueryFilterV3) ToCreate() (models.CategoryCreate, httperrors.Err return models.CategoryCreate{ BudgetID: budgetID, - Hidden: f.Archived, + Archived: f.Archived, }, httperrors.Error{} } @@ -252,13 +251,13 @@ func (co Controller) CreateCategoriesV3(c *gin.Context) { // @Failure 400 {object} CategoryListResponseV3 // @Failure 500 {object} CategoryListResponseV3 // @Router /v3/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 hidden query bool false "Is the category hidden?" -// @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." +// @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 (co Controller) GetCategoriesV3(c *gin.Context) { var filter CategoryQueryFilterV3 @@ -268,13 +267,6 @@ func (co Controller) GetCategoriesV3(c *gin.Context) { // Get the fields that we are filtering for queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) - // If the archived parameter is set, add "Hidden" to the query fields - // This is done since in v3, we're using the name "Archived", but the - // field is not yet updated in the database, which will happen later - if slices.Contains(setFields, "Archived") { - queryFields = append(queryFields, "Hidden") - } - // Convert the QueryFilter to a Create struct create, err := filter.ToCreate() if !err.Nil() { @@ -434,13 +426,6 @@ func (co Controller) UpdateCategoryV3(c *gin.Context) { CategoryCreate: data.ToCreate(), } - // If the archived parameter is set, add "Hidden" to the update fields - // This is done since in v3, we're using the name "Archived", but the - // field is not yet updated in the database, which will happen later - if slices.Contains(updateFields, "Archived") { - updateFields = append(updateFields, "Hidden") - } - err = query(c, co.DB.Model(&category).Select("", updateFields...).Updates(cat)) if !err.Nil() { s := err.Error() diff --git a/pkg/controllers/envelope_v3.go b/pkg/controllers/envelope_v3.go index d2c8edc9..efeb81cb 100644 --- a/pkg/controllers/envelope_v3.go +++ b/pkg/controllers/envelope_v3.go @@ -18,7 +18,7 @@ type EnvelopeCreateV3 struct { Name string `json:"name" gorm:"uniqueIndex:envelope_category_name" example:"Groceries" default:""` // Name of the envelope CategoryID uuid.UUID `json:"categoryId" gorm:"uniqueIndex:envelope_category_name" 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 hidden? + Archived bool `json:"archived" example:"true" default:"false"` // Is the envelope archived? } // ToCreate transforms the API representation into the model representation @@ -27,7 +27,7 @@ func (e EnvelopeCreateV3) ToCreate() models.EnvelopeCreate { Name: e.Name, CategoryID: e.CategoryID, Note: e.Note, - Hidden: e.Archived, + Archived: e.Archived, } } @@ -48,8 +48,7 @@ func (l *EnvelopeV3Links) links(c *gin.Context, e models.Envelope) { type EnvelopeV3 struct { models.Envelope - Links EnvelopeV3Links `json:"links"` // Links to related resources - Hidden bool `json:"hidden,omitempty"` // Remove the hidden field + Links EnvelopeV3Links `json:"links"` // Links to related resources } func (co Controller) getEnvelopeV3(c *gin.Context, id uuid.UUID) (EnvelopeV3, httperrors.Error) { @@ -101,13 +100,13 @@ type EnvelopeMonthResponseV3 struct { } type EnvelopeQueryFilterV3 struct { - Name string `form:"name" filterField:"false"` // By name - CategoryID string `form:"category"` // By the ID of the category - Note string `form:"note" filterField:"false"` // By the note - Archived bool `form:"archived" filterField:"false"` // 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. + Name string `form:"name" filterField:"false"` // By name + CategoryID string `form:"category"` // By the ID of the category + 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 EnvelopeQueryFilterV3) ToCreate() (models.EnvelopeCreate, httperrors.Error) { @@ -118,7 +117,7 @@ func (f EnvelopeQueryFilterV3) ToCreate() (models.EnvelopeCreate, httperrors.Err return models.EnvelopeCreate{ CategoryID: categoryID, - Hidden: f.Archived, + Archived: f.Archived, }, httperrors.Error{} } @@ -261,13 +260,6 @@ func (co Controller) GetEnvelopesV3(c *gin.Context) { queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) - // If the archived parameter is set, add "Hidden" to the query fields - // This is done since in v3, we're using the name "Archived", but the - // field is not yet updated in the database, which will happen later - if slices.Contains(setFields, "Archived") { - queryFields = append(queryFields, "Hidden") - } - // Convert the QueryFilter to a Create struct create, err := filter.ToCreate() if !err.Nil() { @@ -438,13 +430,6 @@ func (co Controller) UpdateEnvelopeV3(c *gin.Context) { EnvelopeCreate: data.ToCreate(), } - // If the archived parameter is set, add "Hidden" to the update fields - // This is done since in v3, we're using the name "Archived", but the - // field is not yet updated in the database, which will happen later - if slices.Contains(updateFields, "Archived") { - updateFields = append(updateFields, "Hidden") - } - err = query(c, co.DB.Model(&envelope).Select("", updateFields...).Updates(e)) if !err.Nil() { s := err.Error() diff --git a/pkg/controllers/import.go b/pkg/controllers/import.go index 958495c9..a621412e 100644 --- a/pkg/controllers/import.go +++ b/pkg/controllers/import.go @@ -96,11 +96,11 @@ func findAccounts(co Controller, transaction *importer.TransactionPreview, budge AccountCreate: models.AccountCreate{ Name: name, BudgetID: budgetID, - Hidden: false, + Archived: false, }, }, // Account Names are unique, therefore only one can match - "Name", "BudgetID", "Hidden").First(&account).Error + "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 diff --git a/pkg/controllers/month_v3.go b/pkg/controllers/month_v3.go index 1d48e2e4..50f7180c 100644 --- a/pkg/controllers/month_v3.go +++ b/pkg/controllers/month_v3.go @@ -319,7 +319,7 @@ func (co Controller) SetAllocationsV3(c *gin.Context) { // 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, co.DB. - Joins("JOIN month_configs ON month_configs.envelope_id = envelopes.id AND envelopes.hidden IS FALSE AND month_configs.month = ? AND NOT EXISTS(?)", pastMonth, queryCurrentMonth). + 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)) diff --git a/pkg/importer/parser/ynab4/parse.go b/pkg/importer/parser/ynab4/parse.go index 5606f45d..8b89180a 100644 --- a/pkg/importer/parser/ynab4/parse.go +++ b/pkg/importer/parser/ynab4/parse.go @@ -71,14 +71,14 @@ func Parse(f io.Reader) (importer.ParsedResources, error) { return resources, nil } -func parseHiddenCategoryName(f string) (category, envelope string, err error) { - // The format of hidden category strings is shown in the next line. Square brackets denote field names +func parseArchivedCategoryName(f string) (category, envelope string, err error) { + // The format of archived category strings is shown in the next line. Square brackets denote field names // [Master Category Name] ` [Category Name] ` [Archival Number] match := regexp.MustCompile("(.*) ` (.*) `").FindStringSubmatch(f) // len needs to be 3 as the whole regex match is in match[0] if len(match) != 3 { - return "", "", fmt.Errorf("incorrect hidden category format: match length is %d", len(match)) + return "", "", fmt.Errorf("incorrect archived category format: match length is %d", len(match)) } category = match[1] @@ -97,7 +97,7 @@ func parseAccounts(resources *importer.ParsedResources, accounts []Account) IDTo Name: account.Name, Note: account.Note, OnBudget: account.OnBudget, - Hidden: account.Hidden, + Archived: account.Archived, ImportHash: helpers.Sha256String(account.EntityID), }, }) @@ -195,9 +195,9 @@ func parseCategories(resources *importer.ParsedResources, categories []Category) CategoryCreate: models.CategoryCreate{ Name: category.Name, Note: category.Note, - // we use category.Deleted here since the original data format does not have a hidden field. If the category is not referenced anywhere, + // we use category.Deleted here since the original data format does not have an "archived" field. If the category is not referenced anywhere, // it will not be imported anyway - Hidden: category.Deleted, + Archived: category.Deleted, }, }, Envelopes: make(map[string]importer.Envelope), @@ -215,16 +215,16 @@ func parseCategories(resources *importer.ParsedResources, categories []Category) Category: category.Name, } - // For hidden categories, we need to extract the actual name - var hidden bool + // For archived categories, we need to extract the actual name + var archived bool if category.Name == "Hidden Categories" { var err error - mapping.Category, mapping.Envelope, err = parseHiddenCategoryName(mapping.Envelope) + mapping.Category, mapping.Envelope, err = parseArchivedCategoryName(mapping.Envelope) if err != nil { return IDToEnvelopes{}, fmt.Errorf("hidden category could not be parsed, your Budget.yfull file seems to be corrupted: %w", err) } - hidden = true + archived = true } idToEnvelope[envelope.EntityID] = mapping @@ -233,9 +233,9 @@ func parseCategories(resources *importer.ParsedResources, categories []Category) importer.Envelope{ Model: models.Envelope{ EnvelopeCreate: models.EnvelopeCreate{ - Name: mapping.Envelope, - Note: envelope.Note, - Hidden: hidden, + Name: mapping.Envelope, + Note: envelope.Note, + Archived: archived, }, }, }, diff --git a/pkg/importer/parser/ynab4/parse_test.go b/pkg/importer/parser/ynab4/parse_test.go index 71816484..702dc831 100644 --- a/pkg/importer/parser/ynab4/parse_test.go +++ b/pkg/importer/parser/ynab4/parse_test.go @@ -162,7 +162,7 @@ func testAccounts(t *testing.T, accounts []models.Account) { initialBalance float32 initialBalanceDate time.Time onBudget bool - hidden bool + archived bool note string }{ {"Checking", 100, time.Date(2022, 10, 15, 0, 0, 0, 0, time.UTC), true, false, ""}, @@ -183,7 +183,7 @@ func testAccounts(t *testing.T, accounts []models.Account) { assert.True(t, a.InitialBalance.Equal(decimal.NewFromFloat32(tt.initialBalance)), "Initial balance does not match, is %s, expected %f", a.InitialBalance, tt.initialBalance) assert.False(t, a.External, "Account is marked external") assert.Equal(t, tt.onBudget, a.OnBudget, "On Budget is wrong") - assert.Equal(t, tt.hidden, a.Hidden, "Hidden is wrong") + assert.Equal(t, tt.archived, a.Archived, "Archived is wrong") assert.Equal(t, tt.note, a.Note, "Note differs. Should be '%s', but is '%s'", tt.note, a.Note) if tt.initialBalance != 0 { @@ -236,13 +236,13 @@ func testMatchRules(t *testing.T, matchRules []models.MatchRule, accounts []mode // testCategories tests all the categories for correct import. func testCategories(t *testing.T, categories []models.Category) { - // 3 categories, 1 (Rainy Day Funds) only has hidden envelopes + // 3 categories, 1 (Rainy Day Funds) only has archived envelopes assert.Len(t, categories, 3, "Number of categories is wrong") tests := []struct { - name string - note string - hidden bool + name string + note string + archived bool }{ {"Savings Goals", "Money I'm saving for big expenses", false}, {"Everyday Expenses", "", false}, @@ -255,6 +255,10 @@ func testCategories(t *testing.T, categories []models.Category) { if !assert.NotEqual(t, -1, idx, "No category with expected name") { return } + + assert.Equal(t, tt.archived, categories[idx].Archived) + assert.Equal(t, tt.note, categories[idx].Note) + assert.Equal(t, tt.name, categories[idx].Name) }) } } @@ -267,7 +271,7 @@ func testEnvelopes(t *testing.T, categories []models.Category, envelopes []model name string category string note string - hidden bool + archived bool }{ {"Groceries", "Everyday Expenses", "", false}, {"Transport", "Everyday Expenses", "", false}, @@ -296,7 +300,7 @@ func testEnvelopes(t *testing.T, categories []models.Category, envelopes []model e := envelopes[idx] assert.Equal(t, tt.note, e.Note, "Note differs, is '%s', should be '%s'", e.Note, tt.note) - assert.Equal(t, tt.hidden, e.Hidden, "Hidden is wrong") + assert.Equal(t, tt.archived, e.Archived, "Archived is wrong") }) } } diff --git a/pkg/importer/parser/ynab4/types.go b/pkg/importer/parser/ynab4/types.go index 12a0d843..c12baa6d 100644 --- a/pkg/importer/parser/ynab4/types.go +++ b/pkg/importer/parser/ynab4/types.go @@ -40,7 +40,7 @@ type Account struct { EntityID string `json:"entityId"` Note string `json:"note"` OnBudget bool `json:"onBudget"` - Hidden bool `json:"hidden"` + Archived bool `json:"hidden"` Name string `json:"accountName"` } diff --git a/pkg/models/account.go b/pkg/models/account.go index c2d6c7c3..aa316db1 100644 --- a/pkg/models/account.go +++ b/pkg/models/account.go @@ -20,7 +20,7 @@ type AccountCreate struct { 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"` // 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 - Hidden bool `json:"hidden" example:"true" default:"false"` // Is the account archived? + 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 } @@ -28,21 +28,13 @@ type AccountCreate struct { type Account struct { DefaultModel AccountCreate - Budget Budget `json:"-"` - Archived bool `json:"archived" example:"true" default:"false" gorm:"-"` // Is the account archived? + Budget Budget `json:"-"` } func (Account) Self() string { return "Account" } -func (a *Account) AfterFind(_ *gorm.DB) (err error) { - // Set the Archived field to the value of Hidden - a.Archived = a.Hidden - - return nil -} - // BeforeUpdate verifies the state of the account before // committing an update to the database. func (a Account) BeforeUpdate(tx *gorm.DB) (err error) { diff --git a/pkg/models/category.go b/pkg/models/category.go index f976951b..dd08b999 100644 --- a/pkg/models/category.go +++ b/pkg/models/category.go @@ -11,15 +11,14 @@ import ( type Category struct { DefaultModel CategoryCreate - Budget Budget `json:"-"` - Archived bool `json:"archived" example:"true" default:"false" gorm:"-"` // Is the Category archived? + Budget Budget `json:"-"` } type CategoryCreate struct { Name string `json:"name" gorm:"uniqueIndex:category_budget_name" example:"Saving" default:""` // Name of the category BudgetID uuid.UUID `json:"budgetId" gorm:"uniqueIndex:category_budget_name" 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 - Hidden bool `json:"hidden" example:"true" default:"false"` // Is the category hidden? + Archived bool `json:"archived" example:"true" default:"false"` // Is the category archived? } func (c Category) Self() string { @@ -33,16 +32,9 @@ func (c *Category) BeforeSave(_ *gorm.DB) error { return nil } -func (c *Category) AfterFind(_ *gorm.DB) (err error) { - // Set the Archived field to the value of Hidden - c.Archived = c.Hidden - - return nil -} - // BeforeUpdate archives all envelopes when the category is archived. func (c *Category) BeforeUpdate(tx *gorm.DB) (err error) { - if tx.Statement.Changed("Hidden") && !c.Hidden { + if tx.Statement.Changed("Archived") && !c.Archived { var envelopes []Envelope err = tx.Where(&Envelope{EnvelopeCreate: EnvelopeCreate{ CategoryID: c.ID, @@ -53,7 +45,7 @@ func (c *Category) BeforeUpdate(tx *gorm.DB) (err error) { } for _, e := range envelopes { - e.Hidden = true + e.Archived = true err = tx.Model(&e).Updates(&e).Error if err != nil { return diff --git a/pkg/models/category_test.go b/pkg/models/category_test.go index 046a13e3..85b0b176 100644 --- a/pkg/models/category_test.go +++ b/pkg/models/category_test.go @@ -28,18 +28,18 @@ func (suite *TestSuiteStandard) TestCategoryArchiveArchivesEnvelopes() { envelope := suite.createTestEnvelope(models.EnvelopeCreate{ CategoryID: category.ID, - Hidden: false, + Archived: false, }) - assert.False(suite.T(), envelope.Hidden, "Envelope archived on creation, it should not be") + assert.False(suite.T(), envelope.Archived, "Envelope archived on creation, it should not be") // Archive the category - err := suite.db.Model(&category).Select("Hidden").Updates(models.Category{CategoryCreate: models.CategoryCreate{Hidden: true}}).Error + err := suite.db.Model(&category).Select("Archived").Updates(models.Category{CategoryCreate: models.CategoryCreate{Archived: true}}).Error assert.Nil(suite.T(), err) // Verify that the envelope is archived err = suite.db.First(&envelope, envelope.ID).Error assert.Nil(suite.T(), err) - assert.True(suite.T(), envelope.Hidden, "Envelope was not archived together with category") + assert.True(suite.T(), envelope.Archived, "Envelope was not archived together with category") } func (suite *TestSuiteStandard) TestCategoryArchiveNoEnvelopes() { @@ -55,18 +55,18 @@ func (suite *TestSuiteStandard) TestCategoryArchiveNoEnvelopes() { envelope := suite.createTestEnvelope(models.EnvelopeCreate{ CategoryID: category2.ID, - Hidden: false, + Archived: false, }) - assert.False(suite.T(), envelope.Hidden, "Envelope archived on creation, it should not be") + assert.False(suite.T(), envelope.Archived, "Envelope archived on creation, it should not be") // Archive the empty category - err := suite.db.Model(&category).Select("Hidden").Updates(models.Category{CategoryCreate: models.CategoryCreate{Hidden: true}}).Error + err := suite.db.Model(&category).Select("Archived").Updates(models.Category{CategoryCreate: models.CategoryCreate{Archived: true}}).Error assert.Nil(suite.T(), err) // Verify that the envelope is not archived err = suite.db.First(&envelope, envelope.ID).Error assert.Nil(suite.T(), err) - assert.False(suite.T(), envelope.Hidden, "Envelope was archived together with category") + assert.False(suite.T(), envelope.Archived, "Envelope was archived together with category") } func (suite *TestSuiteStandard) TestCategorySetEnvelopes() { diff --git a/pkg/models/database.go b/pkg/models/database.go index ec07c20e..439c9c63 100644 --- a/pkg/models/database.go +++ b/pkg/models/database.go @@ -11,6 +11,33 @@ import ( // Migrate migrates all models to the schema defined in the code. func Migrate(db *gorm.DB) (err error) { + // https://github.com/envelope-zero/backend/issues/871 + // Remove with 5.0.0 + if db.Migrator().HasColumn(&Account{}, "Hidden") { + err = db.Migrator().RenameColumn(&Account{}, "Hidden", "Archived") + if err != nil { + return fmt.Errorf("error when renaming Hidden -> Archived for Account: %w", err) + } + } + + // https://github.com/envelope-zero/backend/issues/871 + // Remove with 5.0.0 + if db.Migrator().HasColumn(&Category{}, "Hidden") { + err = db.Migrator().RenameColumn(&Category{}, "Hidden", "Archived") + if err != nil { + return fmt.Errorf("error when renaming Hidden -> Archived for Category: %w", err) + } + } + + // https://github.com/envelope-zero/backend/issues/871 + // Remove with 5.0.0 + if db.Migrator().HasColumn(&Envelope{}, "Hidden") { + err = db.Migrator().RenameColumn(&Envelope{}, "Hidden", "Archived") + if err != nil { + return fmt.Errorf("error when renaming Hidden -> Archived for Envelope: %w", err) + } + } + err = db.AutoMigrate(Budget{}, Account{}, Category{}, Envelope{}, Transaction{}, MonthConfig{}, MatchRule{}, Goal{}) if err != nil { return fmt.Errorf("error during DB migration: %w", err) diff --git a/pkg/models/envelope.go b/pkg/models/envelope.go index 980a6b79..cbef07e9 100644 --- a/pkg/models/envelope.go +++ b/pkg/models/envelope.go @@ -16,14 +16,13 @@ type Envelope struct { DefaultModel EnvelopeCreate Category Category `json:"-"` - Archived bool `json:"archived" example:"true" default:"false" gorm:"-"` // Is the Envelope archived? } type EnvelopeCreate struct { Name string `json:"name" gorm:"uniqueIndex:envelope_category_name" example:"Groceries" default:""` // Name of the envelope CategoryID uuid.UUID `json:"categoryId" gorm:"uniqueIndex:envelope_category_name" 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 - Hidden bool `json:"hidden" example:"true" default:"false"` // Is the envelope hidden? + Archived bool `json:"archived" example:"true" default:"false"` // Is the envelope archived? } func (e Envelope) Self() string { @@ -37,27 +36,20 @@ func (e *Envelope) BeforeSave(_ *gorm.DB) error { return nil } -func (e *Envelope) AfterFind(_ *gorm.DB) (err error) { - // Set the Archived field to the value of Hidden - e.Archived = e.Hidden - - return nil -} - // BeforeUpdate verifies the state of the envelope before // committing an update to the database. func (e *Envelope) BeforeUpdate(tx *gorm.DB) (err error) { // If the archival state is updated from archived to unarchived and the category is // archived, unarchive the category, too. - if tx.Statement.Changed("Hidden") && e.Hidden { + if tx.Statement.Changed("Archived") && e.Archived { var category Category err = tx.First(&category, e.CategoryID).Error if err != nil { return } - if category.Hidden { - tx.Model(&category).Updates(map[string]any{"hidden": false}) + if category.Archived { + tx.Model(&category).Updates(map[string]any{"archived": false}) } } diff --git a/pkg/models/envelope_test.go b/pkg/models/envelope_test.go index 8137ea03..5d0fe1dc 100644 --- a/pkg/models/envelope_test.go +++ b/pkg/models/envelope_test.go @@ -229,22 +229,22 @@ func (suite *TestSuiteStandard) TestEnvelopeUnarchiveUnarchivesCategory() { budget := suite.createTestBudget(models.BudgetCreate{}) category := suite.createTestCategory(models.CategoryCreate{ BudgetID: budget.ID, - Hidden: true, + Archived: true, }) envelope := suite.createTestEnvelope(models.EnvelopeCreate{ CategoryID: category.ID, Name: "TestEnvelopeUnarchiveUnarchivesCategory", - Hidden: true, + Archived: true, }) // Unarchive the envelope - data := models.Envelope{EnvelopeCreate: models.EnvelopeCreate{Hidden: false}} - suite.db.Model(&envelope).Select("hidden").Updates(data) + data := models.Envelope{EnvelopeCreate: models.EnvelopeCreate{Archived: false}} + suite.db.Model(&envelope).Select("Archived").Updates(data) // Reload the category suite.db.First(&category, category.ID) - assert.False(suite.T(), category.Hidden, "Category should be unarchived when child envelope is unarchived") + assert.False(suite.T(), category.Archived, "Category should be unarchived when child envelope is unarchived") } func (suite *TestSuiteStandard) TestEnvelopeSelf() { From 8a8e31c97c6665bcde193780b7dfd4c03eb7dcc0 Mon Sep 17 00:00:00 2001 From: Morre Date: Sun, 31 Dec 2023 17:48:24 +0100 Subject: [PATCH 09/15] test: update api version in parsing tests --- pkg/httputil/query_test.go | 2 +- pkg/models/envelope.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/httputil/query_test.go b/pkg/httputil/query_test.go index 721ce79a..da47c168 100644 --- a/pkg/httputil/query_test.go +++ b/pkg/httputil/query_test.go @@ -15,7 +15,7 @@ import ( ) func TestGetURLFields(t *testing.T) { - url, _ := url.Parse("http://example.com/api/v1/accounts?budget=87645467-ad8a-4e16-ae7f-9d879b45f569&onBudget=false&name=") + url, _ := url.Parse("http://example.com/api/v3/accounts?budget=87645467-ad8a-4e16-ae7f-9d879b45f569&onBudget=false&name=") queryFields, setFields := httputil.GetURLFields(url, controllers.AccountQueryFilter{}) diff --git a/pkg/models/envelope.go b/pkg/models/envelope.go index cbef07e9..8722ef5a 100644 --- a/pkg/models/envelope.go +++ b/pkg/models/envelope.go @@ -241,7 +241,7 @@ func (e Envelope) Balance(db *gorm.DB, month types.Month) (decimal.Decimal, erro } type EnvelopeMonthLinks struct { - Allocation string `json:"allocation" example:"https://example.com/api/v1/allocations/772d6956-ecba-485b-8a27-46a506c5a2a3"` // The allocations for this envelope for this month + Allocation string `json:"allocation" example:"https://example.com/api/v3/allocations/772d6956-ecba-485b-8a27-46a506c5a2a3"` // The allocations for this envelope for this month } // EnvelopeMonth contains data about an Envelope for a specific month. From d8b2fc8241483dbb5d6f04cd0c5263fde60703fe Mon Sep 17 00:00:00 2001 From: Morre Date: Mon, 1 Jan 2024 18:34:35 +0100 Subject: [PATCH 10/15] fixup! docs: add upgrading notes --- docs/upgrading.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/upgrading.md b/docs/upgrading.md index 63ff6920..a32bb7c7 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -6,7 +6,7 @@ If upgrades between versions require manual actions, those are described here. # to v4.0.0 -1. Upgrade to v3.22.1 before upgrading to v4.0.0 +1. Upgrade to v3.22.2 before upgrading to v4.0.0 2. Upgrade to v4.0.0 For breaking changes, see the release notes From 87989ab69bb58f390f19696268f284e46ea02811 Mon Sep 17 00:00:00 2001 From: Morre Date: Mon, 1 Jan 2024 21:10:17 +0100 Subject: [PATCH 11/15] fixup! chore!: remove overspend handling --- internal/types/month.go | 24 +++- pkg/controllers/budget_v3.go | 5 - pkg/controllers/import_v3_test.go | 50 ++++++++ pkg/importer/creator.go | 2 +- pkg/importer/parser/ynab4/parse.go | 150 ++++++++++++++++++---- pkg/importer/parser/ynab4/parse_test.go | 2 +- pkg/importer/parser/ynab4/types.go | 3 +- pkg/importer/types.go | 12 +- pkg/models/database.go | 38 +++--- pkg/models/database_test.go | 73 +++++++++++ testdata/migrations/overspend-handling.db | Bin 0 -> 167936 bytes 11 files changed, 300 insertions(+), 59 deletions(-) create mode 100644 testdata/migrations/overspend-handling.db diff --git a/internal/types/month.go b/internal/types/month.go index e0d7e29d..ac5ac840 100644 --- a/internal/types/month.go +++ b/internal/types/month.go @@ -5,6 +5,8 @@ import ( "database/sql" "database/sql/driver" "fmt" + "regexp" + "strings" "time" ) @@ -31,13 +33,29 @@ func (m Month) MarshalJSON() ([]byte, error) { // The month is expected to be a string in a format accepted by ParseDate. // From the parsed string, everything is then ignored except the year and month func (m *Month) UnmarshalJSON(data []byte) error { - var date time.Time - err := date.UnmarshalJSON(data) + value := strings.Trim(string(data), `"`) // get rid of " + if value == "" || value == "null" { + return nil + } + + // This allows to parse strings in the "2006-01-02" format + match, err := regexp.MatchString("^[0-9]{4}-[0-9]{2}-[0-9]{2}$", string(value)) + if err != nil { + return err + } + + // This is the default pattern + pattern := "2006-01-02T15:04:05Z07:00" + if match { + pattern = "2006-01-02" + } + + t, err := time.Parse(pattern, string(value)) if err != nil { return err } - month := NewMonth(date.Year(), date.Month()) + month := NewMonth(t.Year(), t.Month()) *m = month return nil } diff --git a/pkg/controllers/budget_v3.go b/pkg/controllers/budget_v3.go index cb83c5e8..28eedbcc 100644 --- a/pkg/controllers/budget_v3.go +++ b/pkg/controllers/budget_v3.go @@ -91,11 +91,6 @@ type BudgetResponseV3 struct { Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred } -type BudgetMonthResponseV3 struct { - Data *models.BudgetMonth `json:"data"` // Data for the budget's month - Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred -} - // RegisterBudgetRoutesV3 registers the routes for Budgets with // the RouterGroup that is passed. func (co Controller) RegisterBudgetRoutesV3(r *gin.RouterGroup) { diff --git a/pkg/controllers/import_v3_test.go b/pkg/controllers/import_v3_test.go index c7663d48..5605bb4f 100644 --- a/pkg/controllers/import_v3_test.go +++ b/pkg/controllers/import_v3_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/envelope-zero/backend/v4/internal/types" @@ -55,6 +56,55 @@ func (suite *TestSuiteStandard) TestImportV3Success() { } } +// 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 := suite.loadTestFile("importer/Budget.yfull") + recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v3/import/ynab4?budgetName=Test Budget", body, headers) + assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) + + var budget controllers.BudgetResponseV3 + suite.decodeResponse(&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(suite.controller, t, http.MethodGet, strings.Replace(budget.Data.Links.Month, "YYYY-MM", tt.month.String(), 1), "") + assertHTTPStatus(t, &recorder, http.StatusOK) + var month controllers.MonthResponseV3 + suite.decodeResponse(&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) + }) + } +} + // TestImportYnab4V3Fails tests failing imports for the YNAB 4 budget import endpoint. func (suite *TestSuiteStandard) TestImportYnab4V3Fails() { tests := []struct { diff --git a/pkg/importer/creator.go b/pkg/importer/creator.go index 937bc627..7f0dc6f4 100644 --- a/pkg/importer/creator.go +++ b/pkg/importer/creator.go @@ -156,7 +156,7 @@ func Create(db *gorm.DB, resources ParsedResources) (models.Budget, error) { // Add the balance // We need to subtract the overspent amount, since the balance is negative the overspent amount, we add it - monthConfig.Allocation.Add(balance) + monthConfig.Allocation = monthConfig.Allocation.Add(balance) err = tx.Save(&monthConfig).Error if err != nil { tx.Rollback() diff --git a/pkg/importer/parser/ynab4/parse.go b/pkg/importer/parser/ynab4/parse.go index 8b89180a..3c21870c 100644 --- a/pkg/importer/parser/ynab4/parse.go +++ b/pkg/importer/parser/ynab4/parse.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "regexp" + "sort" "strings" "time" @@ -65,6 +66,8 @@ func Parse(f io.Reader) (importer.ParsedResources, error) { return importer.ParsedResources{}, fmt.Errorf("error parsing budget allocations: %w", err) } + generateOverspendFixes(&resources) + // Fix duplicate account names fixDuplicateAccountNames(&resources) @@ -467,51 +470,49 @@ func parseTransactions(resources *importer.ParsedResources, transactions []Trans } func parseMonthlyBudgets(resources *importer.ParsedResources, monthlyBudgets []MonthlyBudget, envelopeIDNames IDToEnvelopes) error { - for _, monthBudget := range monthlyBudgets { - month, err := types.ParseDateToMonth(monthBudget.Month) - if err != nil { - return fmt.Errorf("could not parse date, the Budget.yfull file seems to be corrupt: %w", err) + slices.SortFunc(monthlyBudgets, func(a, b MonthlyBudget) int { + if a.Month.Before(b.Month) { + return -1 + } + + if b.Month.Before(a.Month) { + return 1 } + return 0 + }) + + for _, monthBudget := range monthlyBudgets { for _, subCategoryBudget := range monthBudget.MonthlySubCategoryBudgets { // If the budget allocation is deleted, we don't need to do anything. // This is the case when a category that has budgeted amounts gets deleted. // - // We also don't need to do anything when nothing is budgeted and the overspend handling - // is the default - if subCategoryBudget.Deleted || (subCategoryBudget.Budgeted.IsZero() && (subCategoryBudget.OverspendingHandling == "AffectsBuffer" || subCategoryBudget.OverspendingHandling == "")) { + // Category/PreYNABDebt: All occurrences of PreYNABDebt configurations that I could find are set for + // months before there is any budget data. + // Configuration for months before any data exists is not needed and therefore skipped + // + // If you find a budget where it is actually needed, please let me know! + if subCategoryBudget.Deleted || strings.HasPrefix(subCategoryBudget.CategoryID, "Category/PreYNABDebt") { continue } monthConfig := importer.MonthConfig{ Model: models.MonthConfig{ - Month: month, + Month: monthBudget.Month, + MonthConfigCreate: models.MonthConfigCreate{ + Allocation: subCategoryBudget.Budgeted, + }, }, Category: envelopeIDNames[subCategoryBudget.CategoryID].Category, Envelope: envelopeIDNames[subCategoryBudget.CategoryID].Envelope, } - // If something is budgeted, set the amount - if !subCategoryBudget.Budgeted.IsZero() { - monthConfig.Model.Allocation = subCategoryBudget.Budgeted - } - - // If the overspendHandling is confined, work with it - if subCategoryBudget.OverspendingHandling == "Confined" { - // All occurrences of PreYNABDebt configurations that I could find are set for - // months before there is any budget data. - // Configuration for months before any data exists is not needed and therefore skipped - // - // If you find a budget where it is actually needed, please let me know! - if strings.HasPrefix(subCategoryBudget.CategoryID, "Category/PreYNABDebt") { - continue + // If the overspendHandling is configured, work with it + if subCategoryBudget.OverspendingHandling != "" { + monthConfig.OverspendMode = importer.AffectAvailable + if subCategoryBudget.OverspendingHandling == "Confined" { + monthConfig.OverspendMode = importer.AffectEnvelope } - - resources.OverspendFixes = append(resources.OverspendFixes, importer.OverspendFix{ - Category: envelopeIDNames[subCategoryBudget.CategoryID].Category, - Envelope: envelopeIDNames[subCategoryBudget.CategoryID].Envelope, - Month: month, - }) } resources.MonthConfigs = append(resources.MonthConfigs, monthConfig) @@ -543,3 +544,96 @@ func fixDuplicateAccountNames(r *importer.ParsedResources) { } } } + +// generateOverspendFixes translates the overspend handling behaviour of YNAB 4 into +// the overspend handling of EZ. In YNAB 4, when the overspendHandling is set to "Confined", +// it affects all months until it is explicitly set back to "AffectsBuffer". +// +// Envelope Zero does not support overspend handling, so we generate an OverspendFix for every +// month that is affected by "Confined" overspend handling in YNAB4. +// +// The OverspendFixes then will be used by the creator to correctly update allocations to envelopes +// to preserve the correct budgeted values +func generateOverspendFixes(resources *importer.ParsedResources) { + // sorter is a map of category names to a map of envelope names to the month configs + sorter := make(map[string]map[string][]importer.MonthConfig, 0) + + // Sort by envelope + for _, monthConfig := range resources.MonthConfigs { + _, ok := sorter[monthConfig.Category] + if !ok { + sorter[monthConfig.Category] = make(map[string][]importer.MonthConfig, 0) + } + + _, ok = sorter[monthConfig.Category][monthConfig.Envelope] + if !ok { + sorter[monthConfig.Category][monthConfig.Envelope] = make([]importer.MonthConfig, 0) + } + + sorter[monthConfig.Category][monthConfig.Envelope] = append(sorter[monthConfig.Category][monthConfig.Envelope], monthConfig) + } + + // Fix handling for all envelopes + for _, envelopes := range sorter { + for _, monthConfigs := range envelopes { + // Sort by time so that earlier months are first + sort.Slice(monthConfigs, func(i, j int) bool { + return monthConfigs[i].Model.Month.Before(monthConfigs[j].Model.Month) + }) + + for i, monthConfig := range monthConfigs { + // If we are switching back to "Available for budget", we don't need to do anything + // anymore and can go to the next month config + if monthConfig.OverspendMode == importer.AffectAvailable { + continue + } + + // Append an overspend fix for this month + resources.OverspendFixes = append(resources.OverspendFixes, importer.OverspendFix{ + Category: monthConfig.Category, + Envelope: monthConfig.Envelope, + Month: monthConfig.Model.Month, + }) + + // Start with the next month since we already appended + // an overspend fix for the current one + checkMonth := monthConfig.Model.Month.AddDate(0, 1) + + // If the checkMonth is the last month for which we have a month config, + // we then add overspend fixes for all month up until the date at which + // the import happens. + // + // This is done so that the budget values are exactly the same up until + // the month where the users is importing to Envelope Zero. + // + // This enables users to compare the values and verify they are the same. + if i+1 == len(monthConfigs) { + for ok := true; ok; ok = !checkMonth.AfterTime(time.Now()) { + resources.OverspendFixes = append(resources.OverspendFixes, importer.OverspendFix{ + Category: monthConfig.Category, + Envelope: monthConfig.Envelope, + Month: checkMonth, + }) + + checkMonth = checkMonth.AddDate(0, 1) + } + continue + } + + // Set all months up to the next one with a configuration to "AFFECT_ENVELOPE" + // We have not arrived at the last month config in the list, so we add overspend + // fixes for the current month and all months that are between the current month + // and the next month for which we have a month config + for ok := !checkMonth.Equal(monthConfigs[i+1].Model.Month); ok; ok = !checkMonth.Equal(monthConfigs[i+1].Model.Month) { + resources.OverspendFixes = append(resources.OverspendFixes, importer.OverspendFix{ + Category: monthConfig.Category, + Envelope: monthConfig.Envelope, + Month: checkMonth, + }) + + checkMonth = checkMonth.AddDate(0, 1) + } + } + } + } +} diff --git a/pkg/importer/parser/ynab4/parse_test.go b/pkg/importer/parser/ynab4/parse_test.go index 702dc831..6ba1f3dc 100644 --- a/pkg/importer/parser/ynab4/parse_test.go +++ b/pkg/importer/parser/ynab4/parse_test.go @@ -64,7 +64,7 @@ func TestParseFail(t *testing.T) { {"CorruptNonParseableHidden", "hidden category could not be parsed"}, {"EmptyFile", "not a valid YNAB4 Budget.yfull file"}, {"CorruptNonParseableTransactionDate", "error parsing transactions: could not parse date"}, - {"CorruptMonthlyBudget", "error parsing budget allocations: could not parse date"}, + {"CorruptMonthlyBudget", "parsing time \"2022-12-01-12\" as \"2006-01-02T15:04:05Z07:00\""}, {"CorruptNoMatchingTransfer", "could not find corresponding transaction"}, {"CorruptMissingTargetTransaction", "could not find corresponding transaction for sub-transaction transfer"}, } diff --git a/pkg/importer/parser/ynab4/types.go b/pkg/importer/parser/ynab4/types.go index c12baa6d..bcdcf85b 100644 --- a/pkg/importer/parser/ynab4/types.go +++ b/pkg/importer/parser/ynab4/types.go @@ -1,6 +1,7 @@ package ynab4 import ( + "github.com/envelope-zero/backend/v4/internal/types" "github.com/shopspring/decimal" "golang.org/x/text/language" ) @@ -102,7 +103,7 @@ type MonthlySubCategoryBudget struct { } type MonthlyBudget struct { - Month string `json:"month"` + Month types.Month `json:"month"` MonthlySubCategoryBudgets []MonthlySubCategoryBudget `json:"monthlySubCategoryBudgets"` } diff --git a/pkg/importer/types.go b/pkg/importer/types.go index e08db4db..1d635b62 100644 --- a/pkg/importer/types.go +++ b/pkg/importer/types.go @@ -50,10 +50,16 @@ type MatchRule struct { Account string } +const ( + AffectAvailable string = "AFFECT_AVAILABLE" + AffectEnvelope string = "AFFECT_ENVELOPE" +) + type MonthConfig struct { - Model models.MonthConfig - Category string // There is a category here since an envelope with the same name can exist for multiple categories - Envelope string + Model models.MonthConfig + Category string // There is a category here since an envelope with the same name can exist for multiple categories + Envelope string + OverspendMode string // The OverspendMode used by YNAB4 } type Transaction struct { diff --git a/pkg/models/database.go b/pkg/models/database.go index 439c9c63..a2e0f561 100644 --- a/pkg/models/database.go +++ b/pkg/models/database.go @@ -13,7 +13,7 @@ import ( func Migrate(db *gorm.DB) (err error) { // https://github.com/envelope-zero/backend/issues/871 // Remove with 5.0.0 - if db.Migrator().HasColumn(&Account{}, "Hidden") { + if db.Migrator().HasColumn(&Account{}, "hidden") { err = db.Migrator().RenameColumn(&Account{}, "Hidden", "Archived") if err != nil { return fmt.Errorf("error when renaming Hidden -> Archived for Account: %w", err) @@ -22,7 +22,7 @@ func Migrate(db *gorm.DB) (err error) { // https://github.com/envelope-zero/backend/issues/871 // Remove with 5.0.0 - if db.Migrator().HasColumn(&Category{}, "Hidden") { + if db.Migrator().HasColumn(&Category{}, "hidden") { err = db.Migrator().RenameColumn(&Category{}, "Hidden", "Archived") if err != nil { return fmt.Errorf("error when renaming Hidden -> Archived for Category: %w", err) @@ -31,7 +31,7 @@ func Migrate(db *gorm.DB) (err error) { // https://github.com/envelope-zero/backend/issues/871 // Remove with 5.0.0 - if db.Migrator().HasColumn(&Envelope{}, "Hidden") { + if db.Migrator().HasColumn(&Envelope{}, "hidden") { err = db.Migrator().RenameColumn(&Envelope{}, "Hidden", "Archived") if err != nil { return fmt.Errorf("error when renaming Hidden -> Archived for Envelope: %w", err) @@ -43,9 +43,22 @@ func Migrate(db *gorm.DB) (err error) { return fmt.Errorf("error during DB migration: %w", err) } + // https://github.com/envelope-zero/backend/issues/440 + // Remove with 5.0.0 + // + // This migration has to be executed before the overspend handling migration + // so that the allocation values are correct when updated by the overspend + // handling migration + if db.Migrator().HasTable("allocations") { + err = migrateAllocationToMonthConfig(db) + if err != nil { + return fmt.Errorf("error during migrateAllocationToMonthConfig: %w", err) + } + } + // https://github.com/envelope-zero/backend/issues/856 // Remove with 5.0.0 - if db.Migrator().HasColumn(&MonthConfig{}, "OverspendMode") { + if db.Migrator().HasColumn(&MonthConfig{}, "overspend_mode") { err = migrateOverspendHandling(db) if err != nil { return fmt.Errorf("error during overspend handling migration: %w", err) @@ -54,22 +67,13 @@ func Migrate(db *gorm.DB) (err error) { // https://github.com/envelope-zero/backend/issues/359 // Remove with 5.0.0 - if db.Migrator().HasColumn(&Transaction{}, "Reconciled") { + if db.Migrator().HasColumn(&Transaction{}, "reconciled") { err = db.Migrator().DropColumn(&Transaction{}, "Reconciled") if err != nil { return fmt.Errorf("error when dropping reconciled column for transactions: %w", err) } } - // https://github.com/envelope-zero/backend/issues/440 - // Remove with 5.0.0 - if db.Migrator().HasTable("allocations") { - err = migrateAllocationToMonthConfig(db) - if err != nil { - return fmt.Errorf("error during migrateAllocationToMonthConfig: %w", err) - } - } - return nil } @@ -127,7 +131,7 @@ func migrateOverspendHandling(db *gorm.DB) (err error) { } var overspends []overspend - err = db.Raw("select envelope_id, month, overspend_mode from month_configs WHERE overspend_mode != ''").Scan(&overspends).Error + err = db.Raw("select envelope_id, month, overspend_mode from month_configs WHERE overspend_mode = 'AFFECT_ENVELOPE'").Scan(&overspends).Error if err != nil { return err } @@ -173,7 +177,7 @@ func migrateOverspendHandling(db *gorm.DB) (err error) { // Add the balance // We need to subtract the overspent amount, since the balance is negative the overspent amount, we add it - monthConfig.Allocation.Add(balance) + monthConfig.Allocation = monthConfig.Allocation.Add(balance) err = tx.Save(&monthConfig).Error if err != nil { tx.Rollback() @@ -182,5 +186,5 @@ func migrateOverspendHandling(db *gorm.DB) (err error) { } tx.Commit() - return db.Migrator().DropColumn(&MonthConfig{}, "OverspendMode") + return db.Migrator().DropColumn(&MonthConfig{}, "overspend_mode") } diff --git a/pkg/models/database_test.go b/pkg/models/database_test.go index 179e5e9c..3859a869 100644 --- a/pkg/models/database_test.go +++ b/pkg/models/database_test.go @@ -1,9 +1,16 @@ package models_test import ( + "fmt" + "os" + "path/filepath" + "testing" + "github.com/envelope-zero/backend/v4/internal/types" + "github.com/envelope-zero/backend/v4/pkg/database" "github.com/envelope-zero/backend/v4/pkg/models" "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" ) func (suite *TestSuiteStandard) TestMigrate() { @@ -50,3 +57,69 @@ func (suite *TestSuiteStandard) TestMigrateAllocation() { suite.Assert().Nil(err) suite.Assert().Equal(0, count) } + +func TestOverspendMigration(t *testing.T) { + // Copy test database to a temporary file + dir := t.TempDir() + dbFile := filepath.Join(dir, "overspend-handling.db") + + input, err := os.ReadFile("../../testdata/migrations/overspend-handling.db") + if err != nil { + t.Error("Could not read overspend handling test database") + } + err = os.WriteFile(dbFile, input, 0o644) + if err != nil { + t.Error("Could not create temporary copy for database") + } + + // Connect to the database + db, err := database.Connect(fmt.Sprintf("%s?_pragma=foreign_keys(1)", dbFile)) + if err != nil { + t.Errorf("Database connection failed with: %#v", err) + } + + err = models.Migrate(db) + if err != nil { + t.Errorf("Database migration failed with: %s", err) + } + + // The envelope are hard-coded here because the test database file does not change + tests := []struct { + envelopeID string + month string + allocation int + }{ + {"c9b0fce7-d51b-4641-9b43-666fe295cb30", "2022-11-01 00:00:00+00:00", -10}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2022-12-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-01-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-02-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-03-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-04-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-05-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-06-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-07-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-08-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-09-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-10-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-11-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2023-12-01 00:00:00+00:00", -5}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2024-01-01 00:00:00+00:00", -5}, + {"d9a80290-cc75-4a00-ad1d-de9d0c40f814", "2022-11-01 00:00:00+00:00", -120}, + {"d9a80290-cc75-4a00-ad1d-de9d0c40f814", "2022-12-01 00:00:00+00:00", -120}, + {"3c0de838-ef14-4b2f-83e6-079ffa321a32", "2024-02-01 00:00:00+00:00", -5}, + {"d9a80290-cc75-4a00-ad1d-de9d0c40f814", "2023-01-01 00:00:00+00:00", -120}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%s - %s", tt.envelopeID, tt.month), func(t *testing.T) { + // Get the number of records matching the month config. This must always be 1 + var count int + db.Raw("SELECT count(*) FROM month_configs WHERE envelope_id = ? AND month = ? AND allocation = ?", tt.envelopeID, tt.month, tt.allocation).Scan(&count) + assert.Equal(t, 1, count) + }) + } + + if db.Migrator().HasColumn(&models.MonthConfig{}, "overspend_mode") { + t.Error("column overspend_mode has not been deleted") + } +} diff --git a/testdata/migrations/overspend-handling.db b/testdata/migrations/overspend-handling.db new file mode 100644 index 0000000000000000000000000000000000000000..4d8c6620528ae6483c689d0fe56225414a9ff606 GIT binary patch literal 167936 zcmeIb4U8m5b|%)<|9{mrGn`?QoF%7b$2_vhNp3_&WM(93iW&}^%keCS+!^++*3h~; zG9r>wVs}@oyN2XwE$M1VNhkXxobLxX=pfrV2zSO|jn$rEofkUD3m+D;WXKB}0&Zo< zI%s9sD_bWR0`AU--Lw5g)^As5Rb}_guDE+q+ncP(7nS+qdoNx@ym&9-)~7#JYnWF&!zw8j4fRGwk>nM z^6lk+x>8^M(WO6K{3}cUXtBKTx8|Q)NansY_rjE%o1FZkQe*NTO#I2P$KA*M=KQ(2 z)t4@nnlIFLwRvA|Z`YM<)atvNJN4bh?M<`$uGy~dnI7Tb;j1^-ue`Nh-MDe>(;Ms6 zYd2n5|B>oeufV`Bx2kX4=qDd+J-wAZd+YL6=jod@y|s3aJv6s^=~C%KuOXA|{x|lX zeQ>t@^0%(M{HgWBkF7qvg~wDI=KaRyEwyiC!{|-f*s5xLYt(kk<*hq=_WR&nZMMzP zyDc8GA79>*JM}xec+@NFSFgQ(h%zx{}Z{7s`=I5%{*FV?dXYKOUH*VZ| z>*kefH{PmlrSEL^Ri=yXR`u04ZmwVZ8N)ws*4t{(aYP%`#H0s&E550``z4_cvotay``ednjF)LTo?{7BtXT2Fe&MGxVfUBp|9<+gxcf<44_Mw- zGx~L9h{$*AwpdHak~{is)3D0hzHv~$v#)@^QdV912-+MpYP+q5b(GpK?)H=RjjHde z+P1;7ckk?&{hGSG)w{XbdQ(2F?`3@j_m4ikL&>dD8;*W9buCTJzOh#8>TBy)uUDTwf?4(Ai`D+0^fYsAEjm5B zdi}{^cIA+yX#c;?>{sSiU%XIip6gncF6Vt_rF-wxQJd1G^_6cKlrCk!n*8L-+-i(> zej}0_LNutn{jWUS?%R}_ z>1gj7Ezhp5Ul^uey?4>>*OhmdFjTo%YChW4tlne$RHb)w`KWI7h<(3kNx4TF(5}0S zbE^T~yOzDTUF>e#@@2Z!`{`1ya^?Ld=cR4k>NB4AF3-=cUc6ZP@KUB(z4!FKEgaUZ z9^t#GTs;;ah-&qizOVjdjV;Wse)8flrRsYN?R{U|ots;I;qg-QbXTo9okyRFbZ*Tb zRjCfCuXIbc9m1eG&B9pWeJyor7s}MAqf#~9`RQD)I_3Z7$V-o^(+B6K=T;woy!10a z*&1TE#wi%}W#g5DnZv5ox&7T#q<)ihM5!gntu<(>}{XCEURb$95{ba^j2si{B0uBL(z=;uPo-fUBy#D%xDL<63K+%BsUaSJ;ah>>{=oz4rEgwnF#KdtY2cy-JmDOyJLb90Cpjhk!%CA>a^j2si{B0uBL( zfJ49`;1KveL*S{YjfEoahSvQ5I}?@fe4ka&vFi|U2si{B0uBL(fJ49`;1F;KI0PI5 z4grTiAq1wTCKmc~^PjAId+B!Fd=aye4+@85=nx@ z_-XTYsME|gEHACbVs(!_~WB#2`c zG}Cfc<+W~=O$gOhQhSNk!s9XLo(z@rLR401KMZ0&9#Un>M+>-1zp`7~k=xZL3}Gopg>@PTUkQ~OZ3J&_FJ~1Ex)oI> zh|)+2kA*SXDr2z+Kv2<$gpm=_m>8`gCu~Vj!bdmOF8x*cZhhbGL~^x`Y;J=Z*Y^rl z7Yb?!q{TxTC#g!)I8JzCBFZ$O6l#}B=}TgKBM^GUHRDtSQD8zP3;-g{&84im=eyPA zgfYz|6j)FnQCgaKGFFLa5??clrx+RLSC_<`jZ)S;oQDn(%+SVD-DAaU-80z!_PYB8(frEU#P8l#4&SE#hMnj3l| zi6f6elBozn5umQQ0)>ef*;sq@TX*&h_LSLw#}un&0*fRxm0U-XC~F%NKLO%gK|v*h zyb^A0G=k;~+G8S-BB&t=8U$$4JZWw%WVL*}TT2a_8^pqR#@b#kLg}H&Q!hwK06<9? zrbF5%3H_03+oP}V>}}WY7HpX+hJJ)3Nk(IG@6NmKMT;49VW zUt*%D6p^NAVKi#0`2j&j9A-5}FAC!udmOgfi(@FQQaojhMp6dp5JB=F8mX#X`Ww3l z8azgWM4!P0k3v7T+#87Kj``(4|ue(uJV zm#h4_LinMZvh|hP8m%Z~1S2IW2}gU0gaU{RprTidO_1^+RRNCzU+@$LF|qn{Z)zq} znrF)!3-jk*!T=Lif=feUkEsAQPV2~%J|`Bw7?Ses@G}>_2-d|P(KPz#x9r|Ex_b4t zQSTHSdU<{p!pqtrzF9~wqIz5JgF&K6K+qHWfe``Puu3q56fsu@gHfPE{WS1ts8IJv z3O|gwh{Kfoj46z5?ma)9X%THc3m7UB3(c|+uK^IkQ%quzls?pAA!Vk)Fk^pTOmz$A)%6`=oP3?m%Fi3nk5U`Qja0!H!A z7%HX=Ofj=#TLW$43BC8wRHj1bt^m7Lt3uM(=yM47A~Zpdr$%9b7f>|A1pSbV(c*_; z$Hu5omwpxd5xrjD1t!<3O78F9wN>7^TivUxf<32CpQAQv-1jK)Xjnb0QVFFxfcAvw zGE)Kjk)T~M^qT?`fG9GI#^^jzh$s=sMB+BIFT&VSCQPJBb8|97#SV{Jb}4|eC9yJI z8bm+^LtrmSlgJYk^BovyLI7&s-~m=E7&ScVqu-M6qCFiHG98$rU>akDuc`L&49Eqh z0<#*XXu@L=Xz3?025v?%bg;@uj*$h6nW3@bvETecDXSp{4h!=SS-C423lVXOQLezi zAxdqSKIU*3YWPM6k&%9oUqje>I?8nP=*O`cG&313`33{>MVAmtOhBbCh#zv1Br@S( zkVqzA_!tHUKA{8xLMMcI4l0a!U%+_GFkAn3PTBSUhnK%FvHYtQzVbsW-z;BP{zBzT z%fDNGZRIPKrOM~a|D^omm7lGAW98h+Un~FV%G>2%TK<WH5O4@M1RMem0f&G? zz#-rea0oaA@)3A?YNNyxtV9q$@M61C#zU+CVy(@tFTg$R!%c%Z{lm5((U{K#5%U6# zsV6s>lVR3r;N9^#)?su^i9BZu20mOO6jOD$O;SwSZH~2!QV(k#Si&-?!h+ygTM&-A zB*hHLQ&9{DQOW{OU|#HLZs1If7*_g#b_c@U8}_fv+|= zlf63N6m9{Ab&?P~GRZuwWeSv*aP0!R&2o{i&kY@ zu*jm%vMpF-xnwDOb%9lfMO(0l<9@*wEaLv2Z}B+ZgFKf#v4GQc))p+{UYxN7i#Xt> zZNVbmrYT#nhy!NQ#{U=bV`TIHrEsFc%P&;+DqmlEe`U3Nu=HOq|NPSbv2tzsN0$Hb z^1rV9rz_uH`R`VKs{GCJmzS$3>^=?whk!%CA>a^j2si{B0uF(Hs}Q&{{o+Qc72i<3 zmK`ip^{O}O)i-VILy=X>hyUGawo&RV0v5A#pz(9bkcaoV@BgGW*T;!;I@VAHOGt< zo4-1J1-#u?=+#`N+5^uhqS~{qA09u1XXb@zx=}hB^it%d)86zsw0i`76e&HG3Hunq z5R(L!VWDGIio7D5|5yG7^8YLUs`BTRe_r`k<&P_WSo!_R?^V8D`JKwwDqpSqM&;Kl zU#@(y^7ku$xAM!Czm3A~;}CENI0PI54grUNL%<>65O4@M1RMem0f)f-A}~EQQF_>3 z&e_XZdwIxS&e+RodpTt<6?<8+m$JPq+sl%@EZWP0z0BLooW0E2%Z$BD+sl-_OxpZ^ zd;Z^4`Cm>{KELwc;6Lu;5O4@M1RMem0f&G?z#-rea0oaA90EU32sB@sTCm%VJhk!0 ze8{y3eUW(B4Gbw599w1x9T-nxmlElRq0f!V&34E3Na3g*BKqjvFqLI7oX- zgr|0@2iaBt*&ZZG?d__0AG<{yn1klWC$k!SVxv4CU|TXlwDf%J5Q6<->>d{!I}#+q zU@Ii-`;cPKue==zuo09PxzSILzK&z`?rM3ry544>$l`jlTFRJwcB8xyV=oVE;(~+y z2)35O)sVmZ28s_35hDn@SS(xcy$wcWebSHR_~cXo9lpzZqqROM$U zDqpf${y$Jk?keLDa0oaA90Cpjhk!%CA>a^j2si{B0uF(X7y>q}zla~EmH%JB-I3-0 z7jZeX=Kr6WsQg0Zzps4eBL<6O$06Vla0oaA90Cpjhk!%CA>a^j2si{B0v~Av!s(~5 zuR-e&gW^FCQ_oL-Vxx4TNQ21;8+Vv++xh<=X;2+=4grUNL%<>65O4@M1RMem z0f&G?z#-rea0nDe;AsB;()|B7QTfr8udF;#ZZ7}t%dafmS^QrXe`w+F;I{iX1RMem z0f&G?z#-re_(4OU`RG!Xq4_w{NW)MYMtJHaMn*U>KE=shI5FGvH4`{XEQ(?ro}8OT zDp)WwL$gQ!j8w8wtM3*G_H0%cJK3Z2Nuoj?Na>}D2@hx2<1~9Fa0VF*{D>HwM-~lB z()Ghphi(fTBHyN8mHXA3W^Y?6vx9Bh3$gg`Uy$UxdB4Zr2WQ487XEqs;*_uj{(ns8`>v zU#`}asj7V=cdC1Gf4jbaP&EzpT=T+gR-H8*?>7pu_Skd=o@FfxqQqF%5+ZPRA&y4$B#rIC^I@3y z5{C_vWC%EZG_sXPUHT{Y>&oocOd(BZKGNDJz_y`O$8i{i%!@dO2{IJ>1ke-*D{>Xb ziu*YCg61=atoMvNjNHN??9yMW-#IY1>)RUZ`E^|ky^F2%e-G*ZJPtVFkg9J&93mMDhV*})cu~kr zNO=_Tc!+437`@9vkN%pG+l|}R?jagQ65O4@M1RMem0U(g&tru{cF4=-be3*;2U=dH^f-P9Y ztv7EA7ICu8Wd#ekuV!t*B3`5!Td;^vXWAAl;$O+~|BHA#vi$!dJ_noszf}Im*#E!$ zXDd%to~is+a^j2si{B0uBL(fJ49`;1F;KI0QZd2v8jM zUOG-h(NcEg{R!fH7PD6!C;Da~JG%ZDF){Oe`qD<}n2{=T(exS;?T;M~GV4!Y*eD$< zPGctf$+4m*rZWtV5z{c0$>#)-29sji+gLd9`hVqj+WG&Le_r`k<&P_WSo!_R?^V8z z+wS8Ka0oaA90Cpjhk!%CA>a^j2si{B0uBL(z()!Jo7cZ&FN^lFU@!CbGG{Nd_A+BH z)AllDFOySK6ALq_L#gs@Ta%|M-(GxX{+H)onfZh1AD;YH>C+#n>V3a3^TFB0GxliB zmr7SR@R^^Vf2mncSr~CmB9BO6kQ+kIrImJ@RScu5xr7nBndfZ zCP8)@MaCx)KE#47K|GoX5+q0jmAQFCF`>bzyrIK%)QEb%7!b?igTh`2z~F;-62{0N zr1tnz5<$a|cb6tKjA>{y2s18@;F=&0Qket^W~r}Zirl>j|6>W!LBf0zNR~<^4ANgk zptWY9i5Lxx7KDRHgAX2^Z{zxUPu)>Ap|OfRsza!I8VWB7De^&)Kqiq4OK|e(FkHu| zJME)qHKJqy*O?o}XrrqK^&KQD%7fd_S3g^8+}8W@J-J(@Vkcbu~oyIA9_c`G@7kgamw~De9-^%SI2%=0V9q`a0nO;a-$-)Cwt{ zo(u!(NukqOm0b zXZf~8#4#V4%Xx?noQdZL18rdHTSb7?IE6TXxIIIWSYDEOZ3xa#>L+0YyKP8JV)nt}Ok06J zkr|f-+m|K+mYyMhQX~pVvLr#XxS(~pHeG7t3-J-UJ|JrnaR!^22v5gR45bN(7su$%mEtL5G?FqvwF>3A=Ce)=VpeTGuFyI( zMkB^ggbuY7nuJKtj%0|;|A4X-8M9n*PK}HS4{q==n8z;?{dcHuh{<`}a}`MZ%MZHi`R{ z!o9mwZ7En=voW7F3GPSGdycM0rOe}u6HjoBEd{9Jg6brKV1_h_V08X-+)r=Wn!*0I z@}RIMS}_$w%nLLIk{qqrjvh~aBpL>5Bts-61H4$Fsvyo5q=+F z_?Sjss?kyF)L>_h{Gq*wvDwJT7SK-z_88g^Vt@8QVPCxe#4vMkY)(>{0KNqK03?a; zW4y=-(<(}$i18?dA&)R*1jyJ|B|?{yhJXZSA3Xy)K^@$?Tk69VI&J$;jZ^O_rZ?Dr zMf$LZm@Os3ht&_#C`d8Fk}`jsA~5(Jxn;*8dP}DVYveyDT#L+$3hq0Tae4%MMIt8c z4lALKREkCt3nIfXK*NRM1!6LS*{4u;%!{CycGH0vyGZz{#EkkcE!z11)8*GDZ1(@k zx$>Wuf4ux!a^j2si{B0uBL(fJ49`;1F;K zI0S%z&EU2>A7Xz)=J_nb9&aYb5<@D9r+v75sh|I z0iOZWa5*p@q~eq65O4@M1RMem0f&G?z#-re za0q-45U@e}r|spGy;SUF#a_zxvTQHMkN+=&@42P*iOQ{&zgPa(KYLU3-<})*0X6;J0M1Dh}!7)-m4N`t&=rJS`A*C%rTzLKoYRDuC zMx~k@rh{2}G{jvONxf-4Gu>r2;C^b?^$|#pMfo5|Jc(s(4@Za^AIH(*RKH;vkYHy- zRsg7d^n7NYE|Q_rd}gY{Eb7L5Bt3W`M{EH{8U|tl($EVcWbGOy5Sx&n#1j)SGSvPs z9n9t=4=fhb*~u=mse!DK9fbf2sDUpsPysK}F(WGcz{nvDj0iR=uMW)i(es%ts0O7D zvq)@14lxp0B8`M;j#dMm4Uo>@hd6vP)ig508W^&hN2)65O4@M1RMemf$uW}Y>q+^uUr=YU&JGp z#sA9!-l#18Ul#BLW%2(-yfCfv{|b0OviScZ-iOxw|MJ92edY4XKV112l`m98Kkul(Ua{{I728}~4WfJ49`;1F;KI0PI54grUNL%<>65O4@M z1iq^X*gXM8pZ`~`{67;bb6=XMELVPe#lN!jj~2@de{24^g=Fqa zb1zKExyi{tDm5nm!Ni|T?BFLS{mi|5PvX}3)#zi z1vhuOd_UVl-3%vpZzs%zxd-m4ltgPUAFf#SY2dI`ZJ75Pm$wv-^J}5DRn_>`sO^}` zTX**CcZ+5J-99|}%C>mSZd~4yJM}xe_|YrtSFgQ(h%zx{}Z{7s`=I5%{ z*FV?dXYKOUH*VZ|>*kefH{PmlrSBY)ZKwWQ)mPuRxqj`FH`>4J+|O{S-dulm{pR|O ztLwL_UBnI`M+@3oaCUa}+NIJ&Ccc9o->x;xro7XrXa7F*GTH~@xrvGC|MBXRrRK%1 z&NcSs?txT;O4on;p`%*YPwrFImeBi&1A2$|-&lR}$;NbMU3^*j3t_$y`ArH`P^L8G>twdfpbzqs2^+Bd4c zt7_W@&)&VWWAcKWX6sG)w7!@16_D@B+O}<2o9TXir&ZI-TeY3N`hH{cHVy=A zS7N*d_e){3g~6Z#_mEjTq`s~D9pxQt8iyWy7|X#I9Ht&^Cv6cFdQ%@>9pMbVE~m)- zy|W|o!54MzAANdFwHDbb5C6 z`jf-#${|V7{(qf8AJ};DLaBMKYgxLS_Zf)py;DbRN|)AGzGYCllmTn&m-J7^++>H6QJ2R`0QWs?xi;d{no3#J*p&q}(G7XxH7v zxzzygUCZ9vE_SzV`7+(={d6f;x$=IK^U}6%^%>85m*?kJFJ3Htcq!AY-g|oA77puH zkMP}8t{#gIM74TM-&cRK#ujE*KY4MOQuV!s_P#If&dsg9@OY_tx~o>5&ZAF7I=AMJ zs#J&6SGpzJ4q;H8W?`)GzLq+*3uS85QK_2l{B$l?o$`Nk4S6AbE}U(Uiz7z zYz?tn;}negvhm8n%wbjP-2QGVQol($qEz+Wt~zwbKp5Sd{av};vs_!X-CCn2w>J~H zZI2%948{g-Zw?Od1})%;#zGy8x{Mwk$oHyr*dDg-ca7mEW@cBfK0ZuuyYHZ#Z?iu( z1sytHYL>e?+kRr761MM5AJy2SWm~$_|w)EQKmlq#f_?h`XoB!sQ~}T)**|^-sO==6aF667WLi zk?82|AkDLn&cFWpNTz5oYBvXp12S9m6SEcOvGvFpws2m_NVdB46SEcMv9&smt#CA2 zIN$O<(c*b*Jv@%Bz7vV<1MD+s{CvQl58~(SLDn7miP3_34IV|>JbP{&TXf{MCN%8O zPs~b{lskLqIG&4TXf_uG&JbY2iPhSL)Rw&LY~k5TCKWy=F}J)&qssRA)kyXmb*_; z^K4}dRyebLq%rN%Ps~=X2v^3j#YWqB-l3nEt(;)c=GpQ%w&>`7ns?|YWsAgl#&mfM zTTFx_h1jK^m@SdR)|sU-3eU!H)h3^qsoX)$;yA4O_RGra4_SwPVzzPzH4Ed|BBR?O zPEN}mJruNA=%aH7HS=TG^2O->uuDHNTDgOoxp8a-qgBiA&`-=(?x1FN99wL(dGI^* z6SI{&sF@kZR^R4(c`EOB=qF|?*TzqeVT+EjT3!0d*cvvdnHtAdFnaVrJMqM&!WGa^0gPN^Q} z5&6!K3&(s!4i--w^AR~%Jbt`K&K4yh@5HgqsM$i&Xo0$ zV?H8hdbxVcN90TeA3o+Ia;9zPj`@fj?L2#&N95a!hmQG(94^jek0|Je$ZzbYkMoH9 z#(wIUkI1P=<(QAiY3wV`TXdL{4L0Jmw>E8vDXAACc48 z=a2b_oW_p)e{{FM{cJF%?E7y-gI|Oq`!ouM^mVI@*_HKVt!T7k}7E+xT4#xa;?vD>xUt4=(cJ;;8 zVX;`P*U;=g7k}bobE}6_qIP6M-QL{4vu*lQqWbP$I4a{lYM(~5B;7|Fh|g@k`PiJz zL%R2NChxw2eP16xEc-sngVun({aSs$)>3$;@?t)qY_;U z&W}nG>SKPB_Wk3D=H#PutLsmdnoo9{S1T(FxhZMw?L&gdvc=l>Kb9kke)>^i^iv1L zv9mh2`qWdU4<2oaqrd1OZXO*FNq<2rl>1K!tG{dZ5BAKiMv9y6#+mj9B=)z4cC+kn ze}_fWUl>BQ{34+%eq?s_^{0lll0!9ywE8Rg7))>$z4#Lk54N7Z)UQ79`|kcACKR49 zHSQjJC~N;N53!qVSirdd)S0=}zsQ8b6Xz@a+!LqfTqa`MSoNEv_N`vhVPD97@21N{ zJd*QyIMlxrvyX1g=8BEXdCW=18ys)t6gk>DkMqus$Om84xqtNO9ZDyC;ZT(xd?w<4 zWp4F*oluyq|5t7f?EhE!{mSoEzFzsA%GWAit^7vi*DGJHe6jNPD}T50%ay-f`5TpA zto(fC!^%%rexh=xvR8Shl2&BpM=PJjW8B9f;1F;KI0PI54grUNL%<>65O4@M1RMhY zmLOpF+`D8iAGeo__ENQ%3-65O4@M1bzS!Xg)SGzk!{*<}YppOhP#8PSd)!3AL-H8&0$(IX z1RS|yao>(Ly?vhq3w%0a&yYU)>vyYfO1o3-K@f1NWz0MlhQ{MeGEXQiJfS5A7upb` znh#I6n0s`CN6P0>$} zREx258wr~fEbItm<7@c@xsX93_cT!=9yB9ztbCv1OUztyd+hr$F+pAX)ZjU>o`eG z3OXDWQ6^7#s{{A}eLl`s7OHgi`chk!%CA>a^j2si{B z0uBL(fJ49`;1F;KoB)BRr#4DO+(k3CU=i2Nv@KY~l`>@u7IAJ&J~g$mP{hm7n*V>R zFaG}ofViJH1RMem0f&G?z#-rea0oaA90Cpjhk!%CA@Gqvz-9u>*vquNOxeri)YQa6 z*>ZR4Bf+9$!Xe-ga0oaA90Cpjhk!%CA>a^j2si{B0uF)i5(0Mp-_8HOOW?Z4Is_a7 z4grUNL%<>65O4@M1RMem0f&G?;3I;7oBw}A1mW0l2si{B0uBL(fJ49`;1F;KI0PI5 z4grV2cM$mKb8a0oaA90Cpjhk!%CA>a^j2si{B0uF(X2m&_$|I|b}QJGw+ zmKT?Pck#Ctes2D+&wX+Bw`cx%rZjzKYHczsy@mq!^=WRNs%%`pK5?PEF~3lmm~WP2 z!Gl;Q!b?Mm0~3OndxE7np@GFX2qUB%2Q=iLfI!2M2U>K=*LDx??8{wc5*8>L5Z{Yc zpm5ki;(L-a9(WsL7DMK~VM!-T|< z@QETUN+byqSNi7zBQzht!aZk!L*6 zF8$3r$#zXuU*4}2gm6g%%2mQl6yhk6NU$g*IGchiV3H6`LK6lg z_GJu-$OKT(Fs6y$+*`?N`(n4Y!ss}nk@iB$aD0kLAy0yHK_KQNWN{p_WXK66l!biM z@hLreyS8sZst0@Z{YLd_eP>VZ-YryRMnm63NlKOR6O{%eiv1*tIb;w>RF~?6;%p5Z zXQF&c{fGo6mL^UlcF~M+mPa!!XH{P7R@sD5T_v@bXq?=FLq@nKLxnRxP+6t@Fo^vj zC&p09M+>-1zp`7~k=r<9#B4Xre!+@zpXxB6GK4&hu!?M$Kx;GjMwk*hA}Yb3AuP?# zZ%G5;E1^=Ojo{7g<*cGXx1vSP5gDS%gpY2lUHYr?-THp5VIb4)sVar43k5X<(&8b- z(J3lT<2d1oi73;AQm9=jr7wx`jers0nsJKrUjh>RV^CyZ$(p}>Or zuos$mGFFLa5+5hs;3-Ci`PC&cXQNcPM}JGcW9oMrg=(ld-j2hEq>?0|nkFKO1Zk;IY5a7;}q z0vwQoy5=5_0~0Z_vG(Y%pQ=1FsFA*(9ozp zz5YH7?ylTkD^zK2AW$7cq-h$#zA_?XBf=P}97T{f358G55gHLw3^4?Uz?qJ9z=DW| zJi7Pd>?u2Zc(xgB%+F^dq)2EL>X3UWI%vq2;%G9(AzPkCg5o$UbgDySOZ}1E3+d7a zYW>#j+Cde6vf2O=RkX1B9bl2x_i<}i0wGo3-mV?g>bup%G~P32w<_`1E+=<2%I(R! zI2Eh5Tm9UPD=%01bA|9jH)ZRKL$F}9qL2}cl%yma?IjWlATof8UNJU7%7at|Ji@_j zJcU6_tp41an#q*r+49E1{JEDfz=W0H(va9=Du9jCI`X8?iG?qQq=#XLgsS0&{6?637c7$lkm1U<1IWM|GXm0$=dVy+AZ zqd?sz2~PhEuzh50YhbCp;;CJmfaV=N5U9dL~%qJ zjzr@)r7fjtVn=gf{z+pvXiki>5PkG3O4T$@wZlLKU#-eyiUj+A07bbip-?>~Y1}q< zt;V!}v3<8%-!r>LKexDetdz z9K(qSVP{}SBdr341M|WdDy9rfF|%V^18w37z4y>mrb6eg0J~MILekghb8s49geK_m z)F=$_0*YpspdXSkTKq8V*ccV+(yz*c+ppJmfyuS1lKcC2ZIyTKR`=?vV9)8(=V%0# z@;ypC8V;ybLa7d*Jt4ZxRKR{DXjcsVroaRsiVUMMI!_cLO5nUciQCY=2xCVa-Y3$e zxjC7kVuwd9yA(j#aF(9&(jWpV7y{!IyCm`i#e4?_nh=1RH+X>63Puf&`slahyJ$}b zg-i#gD450=;cKdWJOgrpslcp;DVp$D1X}t@jDed`3>~a8l4E4SVrFQpc$}UiuI55BG5hI0PI54grUNL%<>65cqyU;Dcu#&Srow-uvm!N()cm>?JsB zI<|`uJj5akmNV@6)bIfMmwWarRKI#{Cv(et)f;sjbPhK_$Dx2p zAEtSG^#hFRyY7O_t+2gaZCHQ4R1J6)4yujYvQd4{?8AeQJ4+<{l4{i|E`JNpVh$Nc>j zI6G>)(lXFtuF%W_ekr)0WZ=X39AVA?#|(T9@G?*WuPmnOI9%Pjq3yBqX{>381LiR$ zN#L}M19&!?wR733SZ~dUXv|uI2&c|#Ot84Y%mZ^P10ROZvD%EMfjlFRfI1vNihlY6pEmVEbs)T-k#yR*@MrNi@?lSxtLu zVH}WfyZFVi#015K90e9jJdK~EL2BU7&Yv>FEgFvUw)N4m?uX`mtVKTst%U0}Qpzsb z5QEymsV-71bp}j_EQRwcFc1!0Vj)!5$6}ws(tvdwDJ&2*H&163?JW)k0jGe8c`OXk zLYZV9)~f|-E8)%$v{jFMt)*Bp9wpKq{Y|+KZ(1?$M@+R!LO-A}!IF-T^)2|94FtNdEzgYV8#L9=|%S*qu_{{t-&%HA92h%@1 z`7PXZpW`F&!P&(#)|3BI>FNeP^YimBHOpAti8z$ZBNFRTJi+RC;xpj|Fey6VI#yBs z>NwgQ8D$6#)8QiWJvQi7)>q}e_39sN*WZJ?HQ6`PdaJ9t{@!j8b5X<{R>UcYb*+#G zSc*$wVr|F=Jn{&@@HBc91Yp2kM7HTKceu`!8*B!YUw z>4gvucrq0LRv{Kl17ldAgOG(19^mxe#zI>xWk_YBB^HvvYB7VQU988!biqxY_%W7y zv4A5G*O3_X=6T(1Oz6nvv%_@Mh#Dckq z@INdf>mXq^G=d?HMPOMtf+JOH78-Qzfzg6+5NYtiqw{TCU+<|q%3@hk#UA1ipzdiX zyd4`0*&xWGi#=~IVA1&bfIlC^&qc8f9O}~ZWupgW z^Ppr=aH%H04cs@*pP6mpJlVD_7UzTroic$~Ct&Jb57@F=uFZC159qceFCNcY9ai*=npU8~Mg6&HaVT1Jq#$5>G0!kpa(_#oQKJSjULWn5bef=c8joe3FNV!Zei(b2?@nD)~H<`;@}H zyHjl`SX+aMrfm`&?gp3|_;ggtFeyQpgFqaWhpwFq1U5zx%n&Ic4sxWe>8H1B&0v39 zDPn4hU|+=fq2h$33aX|9YJ+0zB5lH%hshfznV6=cJ+!bVN5d;H1&z=l!+;ueqnQ4N znCT;^gb6JKg3YL++{dsHE91&n7y~AVD3pj=3aQqJGrYGk*@t~+3<+B<5T60CVTtg2 zgy}?S6y5|xYM|mwpht}0RJ5bzjMF37D>&w)H9Vn?6e5J;7(sO*VviBG;u9c-NVycj zX^D-0fMUj)CozGPQW>TsjqUt@ZstcOR(`%*UiwcKSLgrg?04`__i+d~1RMemffFL| z!Nt?;3dbLkopPoaq|1Ln1o~eP5pHA6cm30 zei8qZSZC8avii1NHiK^@AnhQ_c6={_E_Z707mH;tqBZGONlvsZX0}a=Pi3nDA;Mh-7#4bwnLHSf$MnNW{Y%J|-gk59N z3IlrvkB#pm-ZK@*Q$xZ8hJy^z$B3xQS%QO%$f&G=!}NR^okd=VbP_WV}**ERr+Byl;8TSK9J;wkHoAXe?e}y*l*$%#Xt1M-h>yLdD>sg_m(i0CSA- zEhK1jIQRGZ@{6KTKM&M>*M%xI{$oLWR^BCgb zk%|$*VTeVC5JN!~*hQi=L^e@?F?9m33X)^)y|;44CTzY`YE3gU^JPMf$rn`Lx*%~u zMh0SSV>s|byZnU|!6EU`HZNktEb|aK6T}gx?)ycMFQVW@R-H8z3?>A*0xCjosw7wu zKya}KEyVgEoVWt>#9;$+=_{nTAifp}G!YQNm^+KHMv|eD$m9w_EJYfmkzv^*mY5kK z`W#ULK@`H1fMwx(cgu$+LmBeO&Wlkn@?h0$$AMHs_(x(7&RjvLNRZ^0@AJZ{VBcit zs59{}9oXY<(3x0hp`yqHvPfcG@d8lp^Q|s!QQe=am=AzgjLYzq|C$mR?)@^5SC)KQsSl^Pipj>fGAw-<VT!jvxI6e41w-n@7Kd7@L>!1y7^iB2yod$`s(`Cw>H;rd}jSq zZ@jr)aG?%)TUf*&99lQeK00rc_eL;9M}=o$$*w~`FGNvGOCf-=}L=! zVzz=jwpPcn)rnRcBI2+^KQUWYGX~~I&9e`WW2=?GN{lQ4x>e5flR?ZZpdG?`kY;|;Jh;9WP`pMYJhEO>|JTs0h z7LP`&Lq9QFxoDjp$JXd66m}13Pq7BrDmawrw4%dlu+1~4#@Kj18mtcaWK1zT3eFK> zWeiqGzaJ^WF8#!8<%)1+99ttJNn?s-_gouaP=r7$Xa3ndTOP+29ojKZ@qT(!w3W-513vp5bbHY!dfrmRChF7gg;WVy7UvHl{=`J8^_k@c@nmYxL@7M9n{Q@V~dS84}OP! zVzhE?{LDDE$mpKO@6b=oR<4bo9>W$LW3{^Uld&~yP%|}-tQZwVzzS8njFWL zKYG@K^p;!$QQS9`%T{R&TkuzpG!R|-09wV_%IT=>{C{EbzdAhs|D&aU54YUMA>a^j z2si{B0uBL(fJ49`@KHdZdG^wnk#ro69^iNB10(6;3;(&3$B&O=Yvg9Q*ch@yKQUW* z^NuqY$M}}U_~|?36El@F>u8>>j=>7~A0sD2UHSl91=sP0IG)d57{^vLdX6SK^pmkQ z%=P@lIJWp`g%=(AiP_52th0}gW6K}y#1S3(iP_54t&fdi3tLx?)U7W4q-^E8p3jeC WYmC)GyhOfRqF9b8+;BOr=l>rNFdcOO literal 0 HcmV?d00001 From 9c7634b1495768f0b6f4bbcb230ec6a91ab724b7 Mon Sep 17 00:00:00 2001 From: Morre Date: Mon, 1 Jan 2024 21:19:27 +0100 Subject: [PATCH 12/15] feat!: use rootless OCI image With this change, the image is built rootless. BREAKING CHANGE: Container is now running rootless. You need to update the ownership of your database file to uid and gid 65532. For details, see the updating instructions. --- .goreleaser.yaml | 30 +++++++++++++++++------------- README.md | 3 +++ docs/upgrading.md | 15 +++++++++++++-- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 222365b3..95f1d4f3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -32,16 +32,20 @@ changelog: regexp: "^.*chore(deps):+.*$" order: 3 -dockers: - - dockerfile: Dockerfile.goreleaser - image_templates: - - "ghcr.io/envelope-zero/backend:{{ .Tag }}" - - "ghcr.io/envelope-zero/backend:v{{ .Major }}" - - "ghcr.io/envelope-zero/backend:v{{ .Major }}.{{ .Minor }}" - - "ghcr.io/envelope-zero/backend:latest" - - build_flag_templates: - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" +kos: + - repository: ghcr.io/envelope-zero/backend + tags: + - "{{ .Tag }}" # v{{ .Major}}.{{ .Minor }}.{{ .Patch }} + - "v{{ .Major }}" + - "v{{ .Major }}.{{ .Minor }}" + - latest + bare: true + preserve_import_paths: false + platforms: + - linux/amd64 + - linux/arm64 + labels: + org.opencontainers.image.created: "{{.Date}}" + org.opencontainers.image.revision: "{{.FullCommit}}" + org.opencontainers.image.title: "{{.ProjectName}}" + org.opencontainers.image.version: "{{.Version}}" diff --git a/README.md b/README.md index a59a66da..dc5f42cd 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ persistence: enabled: true mountPath: /data +podSecurityContext: + fsGroup: 65532 + affinity: podAffinity: requiredDuringSchedulingIgnoredDuringExecution: diff --git a/docs/upgrading.md b/docs/upgrading.md index a32bb7c7..a53a3463 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -6,10 +6,21 @@ If upgrades between versions require manual actions, those are described here. # to v4.0.0 -1. Upgrade to v3.22.2 before upgrading to v4.0.0 +For breaking changes to functionality, see the release notes. Upgrade as follows: + +## Using the binary directly + +1. Upgrade to v3.22.2 before upgrading to v4.0.0. 2. Upgrade to v4.0.0 -For breaking changes, see the release notes +## Using the OCI image + +With the upgrade to v4.0.0, the image will now run rootless. + +1. Upgrade to v3.22.2 before upgrading to v4.0.0. +2. Turn off your backend instance. This depends on how you have deployed the backend. On Kubernetes, scale the Deployment to 0, with docker(-compose), delete the container. +3. Update the ownership of the database file. Enter the directory where it is stored and run `chown 65532:65532 gorm.db` to update the permissions to the user the image is now using. +4. Upgrade to v4.0.0 # to v3.0.0 From 4781ffc5d228e2934429826f0acbc36ca8fed23a Mon Sep 17 00:00:00 2001 From: Morre Date: Mon, 1 Jan 2024 21:41:40 +0100 Subject: [PATCH 13/15] chore: remove outdated comment --- pkg/controllers/goals_v3.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/controllers/goals_v3.go b/pkg/controllers/goals_v3.go index 6cd31c89..59c22dcf 100644 --- a/pkg/controllers/goals_v3.go +++ b/pkg/controllers/goals_v3.go @@ -19,8 +19,6 @@ func (co Controller) RegisterGoalRoutesV3(r *gin.RouterGroup) { } { r.OPTIONS("/:id", co.OptionsGoalDetailV3) - - // FIMXE: These three r.GET("/:id", co.GetGoalV3) r.PATCH("/:id", co.UpdateGoalV3) r.DELETE("/:id", co.DeleteGoalV3) From cc4b6362bd3b250c44c9c2288f601412ae0bf5e0 Mon Sep 17 00:00:00 2001 From: Morre Date: Mon, 1 Jan 2024 21:59:56 +0100 Subject: [PATCH 14/15] fixup! chore!: remove API v1 and v2 --- pkg/router/router_test.go | 203 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 pkg/router/router_test.go diff --git a/pkg/router/router_test.go b/pkg/router/router_test.go new file mode 100644 index 00000000..68455cba --- /dev/null +++ b/pkg/router/router_test.go @@ -0,0 +1,203 @@ +package router_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "reflect" + "testing" + + "github.com/envelope-zero/backend/v4/pkg/controllers" + "github.com/envelope-zero/backend/v4/pkg/database" + "github.com/envelope-zero/backend/v4/pkg/router" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// decodeResponse decodes an HTTP response into a target struct. +func decodeResponse(t *testing.T, r *httptest.ResponseRecorder, target interface{}) { + err := json.NewDecoder(r.Body).Decode(target) + if err != nil { + assert.FailNow(t, "Parsing error", "Unable to parse response from server %q into %v, '%v', Request ID: %s", r.Body, reflect.TypeOf(target), err, r.Result().Header.Get("x-request-id")) + } +} + +func TestGinMode(t *testing.T) { + os.Setenv("GIN_MODE", "debug") + url, _ := url.Parse("http://example.com") + + r, teardown, err := router.Config(url) + defer teardown() + + assert.Nil(t, err, "Error on router initialization") + + db, err := database.Connect(":memory:?_pragma=foreign_keys(1)") + assert.Nil(t, err, "Error on database connection") + + router.AttachRoutes(controllers.Controller{DB: db}, r.Group("/")) + + assert.Nil(t, err, "%T: %v", err, err) + assert.True(t, gin.IsDebugging()) + + os.Unsetenv("GIN_MODE") +} + +func TestPprofOff(t *testing.T) { + os.Setenv("ENABLE_PPROF", "false") + url, _ := url.Parse("http://example.com") + + r, teardown, err := router.Config(url) + defer teardown() + + assert.Nil(t, err, "Error on router initialization") + + db, err := database.Connect(":memory:?_pragma=foreign_keys(1)") + assert.Nil(t, err, "Error on database connection") + + router.AttachRoutes(controllers.Controller{DB: db}, r.Group("/")) + + for _, r := range r.Routes() { + assert.NotContains(t, r.Path, "pprof", "pprof routes are registered erroneously! Route: %s", r) + } + + os.Unsetenv("ENABLE_PPROF") +} + +// TestCorsSetting checks that setting of CORS works. +// It does not check the actual headers as this is already done in testing of the module. +func TestCorsSetting(t *testing.T) { + os.Setenv("CORS_ALLOW_ORIGINS", "http://localhost:3000 https://example.com") + url, _ := url.Parse("http://example.com") + + _, teardown, err := router.Config(url) + defer teardown() + + assert.Nil(t, err) + os.Unsetenv("CORS_ALLOW_ORIGINS") +} + +func TestGetRoot(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + + r.GET("/", func(ctx *gin.Context) { + router.GetRoot(c) + }) + + // Test contexts cannot be injected any middleware, therefore + // this only tests the path, not the host + l := router.RootResponse{ + Links: router.RootLinks{ + Docs: "/docs/index.html", + Healthz: "/healthz", + Version: "/version", + Metrics: "/metrics", + V3: "/v3", + }, + } + + var lr router.RootResponse + + c.Request, _ = http.NewRequest(http.MethodGet, "https://example.com/", nil) + r.ServeHTTP(w, c.Request) + + decodeResponse(t, w, &lr) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, l, lr) +} + +func TestGetV3(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + + r.GET("/v3", func(ctx *gin.Context) { + router.GetV3(c) + }) + + // Test contexts cannot be injected any middleware, therefore + // this only tests the path, not the host + l := router.V3Response{ + Links: router.V3Links{ + Accounts: "/v3/accounts", + Budgets: "/v3/budgets", + Categories: "/v3/categories", + Envelopes: "/v3/envelopes", + Goals: "/v3/goals", + Import: "/v3/import", + MatchRules: "/v3/match-rules", + Months: "/v3/months", + Transactions: "/v3/transactions", + }, + } + + var lr router.V3Response + + c.Request, _ = http.NewRequest(http.MethodGet, "http://example.com/v3", nil) + r.ServeHTTP(w, c.Request) + + decodeResponse(t, w, &lr) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, l, lr) +} + +func TestGetVersion(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + + r.GET("/version", func(ctx *gin.Context) { + router.GetVersion(c) + }) + + l := router.VersionResponse{ + Data: router.VersionObject{ + Version: "0.0.0", + }, + } + + var lr router.VersionResponse + + c.Request, _ = http.NewRequest(http.MethodGet, "https://example.com/version", nil) + r.ServeHTTP(w, c.Request) + + decodeResponse(t, w, &lr) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, l, lr) +} + +func TestOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + path string + f func(*gin.Context) + expected string + }{ + {"/", router.OptionsRoot, "OPTIONS, GET"}, + {"/version", router.OptionsVersion, "OPTIONS, GET"}, + {"/v3", router.OptionsV3, "OPTIONS, GET, DELETE"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + + r.OPTIONS(tt.path, func(ctx *gin.Context) { + tt.f(c) + }) + + url := fmt.Sprintf("http://example.com%s", tt.path) + c.Request, _ = http.NewRequest(http.MethodOptions, url, nil) + r.ServeHTTP(w, c.Request) + + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, tt.expected, w.Header().Get("allow")) + }) + } +} From 5a34df7f04686e8d17b6021b12af2eb8c8739b36 Mon Sep 17 00:00:00 2001 From: Morre Date: Mon, 1 Jan 2024 22:02:07 +0100 Subject: [PATCH 15/15] fixup! chore!: remove overspend handling --- pkg/importer/parser/ynab4/parse.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/pkg/importer/parser/ynab4/parse.go b/pkg/importer/parser/ynab4/parse.go index 3c21870c..48d29b0d 100644 --- a/pkg/importer/parser/ynab4/parse.go +++ b/pkg/importer/parser/ynab4/parse.go @@ -61,11 +61,7 @@ func Parse(f io.Reader) (importer.ParsedResources, error) { return importer.ParsedResources{}, fmt.Errorf("error parsing transactions: %w", err) } - err = parseMonthlyBudgets(&resources, budget.MonthlyBudgets, envelopeIDNames) - if err != nil { - return importer.ParsedResources{}, fmt.Errorf("error parsing budget allocations: %w", err) - } - + parseMonthlyBudgets(&resources, budget.MonthlyBudgets, envelopeIDNames) generateOverspendFixes(&resources) // Fix duplicate account names @@ -469,7 +465,7 @@ func parseTransactions(resources *importer.ParsedResources, transactions []Trans return nil } -func parseMonthlyBudgets(resources *importer.ParsedResources, monthlyBudgets []MonthlyBudget, envelopeIDNames IDToEnvelopes) error { +func parseMonthlyBudgets(resources *importer.ParsedResources, monthlyBudgets []MonthlyBudget, envelopeIDNames IDToEnvelopes) { slices.SortFunc(monthlyBudgets, func(a, b MonthlyBudget) int { if a.Month.Before(b.Month) { return -1 @@ -478,7 +474,6 @@ func parseMonthlyBudgets(resources *importer.ParsedResources, monthlyBudgets []M if b.Month.Before(a.Month) { return 1 } - return 0 }) @@ -518,8 +513,6 @@ func parseMonthlyBudgets(resources *importer.ParsedResources, monthlyBudgets []M resources.MonthConfigs = append(resources.MonthConfigs, monthConfig) } } - - return nil } // fixDuplicateAccountNames detects if an account name is the same for an internal and