From e386e05fbfb196815a773461334e3832b7990b50 Mon Sep 17 00:00:00 2001 From: Morre Date: Sun, 26 Nov 2023 18:11:09 +0100 Subject: [PATCH] feat: add /v3/budgets endpoints This adds support for Budgets to the v3 API --- api/docs.go | 418 +++++++++++++++- api/swagger.json | 418 +++++++++++++++- api/swagger.yaml | 292 ++++++++++- pkg/controllers/{budget.go => budget_v1.go} | 31 +- .../{budget_test.go => budget_v1_test.go} | 0 pkg/controllers/budget_v3.go | 464 ++++++++++++++++++ pkg/controllers/budget_v3_test.go | 343 +++++++++++++ pkg/controllers/import_v3.go | 8 +- pkg/controllers/import_v3_test.go | 6 +- pkg/controllers/match_rule_v3.go | 2 +- pkg/controllers/match_rule_v3_test.go | 8 +- pkg/controllers/month.go | 12 + pkg/controllers/test_options_test.go | 1 + pkg/controllers/transaction.go | 14 +- pkg/controllers/transaction_v1.go | 6 +- pkg/controllers/transaction_v2.go | 2 +- pkg/controllers/transaction_v3.go | 12 +- pkg/controllers/transaction_v3_test.go | 6 +- pkg/router/router.go | 3 + pkg/router/router_test.go | 1 + 20 files changed, 1984 insertions(+), 63 deletions(-) rename pkg/controllers/{budget.go => budget_v1.go} (97%) rename pkg/controllers/{budget_test.go => budget_v1_test.go} (100%) create mode 100644 pkg/controllers/budget_v3.go create mode 100644 pkg/controllers/budget_v3_test.go diff --git a/api/docs.go b/api/docs.go index a92ca0a0..d1df5234 100644 --- a/api/docs.go +++ b/api/docs.go @@ -3848,6 +3848,296 @@ const docTemplate = `{ } } }, + "/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" + } + ], + "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/import": { "get": { "description": "Returns general information about the v3 API", @@ -5044,6 +5334,23 @@ const docTemplate = `{ } } }, + "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": { @@ -5056,6 +5363,31 @@ const docTemplate = `{ } } }, + "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": { @@ -5089,7 +5421,7 @@ const docTemplate = `{ "description": "Data for the budget", "allOf": [ { - "$ref": "#/definitions/controllers.Budget" + "$ref": "#/definitions/controllers.BudgetV3" } ] }, @@ -5100,6 +5432,81 @@ const docTemplate = `{ } } }, + "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": { @@ -5810,7 +6217,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "List of created transactions", + "description": "List of created Transactions", "type": "array", "items": { "$ref": "#/definitions/controllers.TransactionResponseV3" @@ -5877,7 +6284,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "The transaction data, if creation was successful", + "description": "The Transaction data, if creation was successful", "allOf": [ { "$ref": "#/definitions/controllers.TransactionV3" @@ -6897,6 +7304,11 @@ const docTemplate = `{ "router.V3Links": { "type": "object", "properties": { + "budgets": { + "description": "URL of Budget collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/budgets" + }, "import": { "description": "URL of import list endpoint", "type": "string", diff --git a/api/swagger.json b/api/swagger.json index d2221792..408dacb0 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -3837,6 +3837,296 @@ } } }, + "/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" + } + ], + "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/import": { "get": { "description": "Returns general information about the v3 API", @@ -5033,6 +5323,23 @@ } } }, + "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": { @@ -5045,6 +5352,31 @@ } } }, + "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": { @@ -5078,7 +5410,7 @@ "description": "Data for the budget", "allOf": [ { - "$ref": "#/definitions/controllers.Budget" + "$ref": "#/definitions/controllers.BudgetV3" } ] }, @@ -5089,6 +5421,81 @@ } } }, + "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": { @@ -5799,7 +6206,7 @@ "type": "object", "properties": { "data": { - "description": "List of created transactions", + "description": "List of created Transactions", "type": "array", "items": { "$ref": "#/definitions/controllers.TransactionResponseV3" @@ -5866,7 +6273,7 @@ "type": "object", "properties": { "data": { - "description": "The transaction data, if creation was successful", + "description": "The Transaction data, if creation was successful", "allOf": [ { "$ref": "#/definitions/controllers.TransactionV3" @@ -6886,6 +7293,11 @@ "router.V3Links": { "type": "object", "properties": { + "budgets": { + "description": "URL of Budget collection endpoint", + "type": "string", + "example": "https://example.com/api/v3/budgets" + }, "import": { "description": "URL of import list endpoint", "type": "string", diff --git a/api/swagger.yaml b/api/swagger.yaml index 3af6b987..4eb418d9 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -249,6 +249,18 @@ definitions: description: Mode to allocate budget with example: ALLOCATE_LAST_MONTH_SPEND type: object + controllers.BudgetCreateResponseV3: + properties: + data: + description: List of created Budgets + items: + $ref: '#/definitions/controllers.BudgetResponseV3' + type: array + error: + description: The error, if any occurred + example: the specified resource ID is not a valid UUID + type: string + type: object controllers.BudgetListResponse: properties: data: @@ -257,6 +269,22 @@ definitions: $ref: '#/definitions/controllers.Budget' type: array type: object + controllers.BudgetListResponseV3: + properties: + data: + description: List of budgets + items: + $ref: '#/definitions/controllers.BudgetV3' + 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/controllers.Pagination' + description: Pagination information + type: object controllers.BudgetMonthResponse: properties: data: @@ -275,13 +303,72 @@ definitions: properties: data: allOf: - - $ref: '#/definitions/controllers.Budget' + - $ref: '#/definitions/controllers.BudgetV3' description: Data for the budget error: description: The error, if any occurred example: the specified resource ID is not a valid UUID type: string type: object + controllers.BudgetV3: + properties: + createdAt: + description: Time the resource was created + example: "2022-04-02T19:28:44.491514Z" + type: string + currency: + description: The currency for the budget + example: € + type: string + deletedAt: + description: Time the resource was marked as deleted + example: "2022-04-22T21:01:05.058161Z" + type: string + id: + description: UUID for the resource + example: 65392deb-5e92-4268-b114-297faad6cdce + type: string + links: + properties: + accounts: + description: Accounts for this budget + example: https://example.com/api/v3/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + categories: + description: Categories for this budget + example: https://example.com/api/v3/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + envelopes: + description: Envelopes for this budget + example: https://example.com/api/v3/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + month: + description: This uses 'YYYY-MM' for clients to replace with the actual + year and month. + example: https://example.com/api/v3/months?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf&month=YYYY-MM + type: string + self: + description: The budget itself + example: https://example.com/api/v3/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf + type: string + transactions: + description: Transactions for this budget + example: https://example.com/api/v3/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.Category: properties: budgetId: @@ -796,7 +883,7 @@ definitions: controllers.TransactionCreateResponseV3: properties: data: - description: List of created transactions + description: List of created Transactions items: $ref: '#/definitions/controllers.TransactionResponseV3' type: array @@ -841,7 +928,7 @@ definitions: data: allOf: - $ref: '#/definitions/controllers.TransactionV3' - description: The transaction data, if creation was successful + 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 @@ -1644,6 +1731,10 @@ definitions: type: object router.V3Links: properties: + budgets: + description: URL of Budget collection endpoint + example: https://example.com/api/v3/budgets + type: string import: description: URL of import list endpoint example: https://example.com/api/v3/import @@ -4296,6 +4387,201 @@ paths: summary: Allowed HTTP verbs tags: - v3 + /v3/budgets: + get: + description: Returns a list of budgets + parameters: + - description: Filter by name + in: query + name: name + type: string + - description: Filter by note + in: query + name: note + type: string + - description: Filter by currency + in: query + name: currency + type: string + - description: Search for this text in name and note + in: query + name: search + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.BudgetListResponseV3' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/controllers.BudgetListResponseV3' + summary: List budgets + tags: + - Budgets + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + responses: + "204": + description: No Content + summary: Allowed HTTP verbs + tags: + - Budgets + post: + consumes: + - application/json + description: Creates a new budget + parameters: + - description: Budget + in: body + name: budget + required: true + schema: + $ref: '#/definitions/models.BudgetCreate' + produces: + - application/json + 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' + summary: Create budget + tags: + - Budgets + /v3/budgets/{id}: + delete: + description: Deletes a budget + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Delete budget + tags: + - Budgets + get: + description: Returns a specific budget + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/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' + summary: Get budget + tags: + - Budgets + options: + description: Returns an empty response with the HTTP Header "allow" set to the + allowed HTTP verbs + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/httperrors.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/httperrors.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httperrors.HTTPError' + summary: Allowed HTTP verbs + tags: + - Budgets + patch: + consumes: + - application/json + description: Update an existing budget. Only values to be updated need to be + specified. + parameters: + - description: ID formatted as string + in: path + name: id + required: true + type: string + - description: Budget + in: body + name: budget + required: true + schema: + $ref: '#/definitions/models.BudgetCreate' + produces: + - application/json + 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' + summary: Update budget + tags: + - Budgets /v3/import: get: description: Returns general information about the v3 API diff --git a/pkg/controllers/budget.go b/pkg/controllers/budget_v1.go similarity index 97% rename from pkg/controllers/budget.go rename to pkg/controllers/budget_v1.go index 02bff815..512b815b 100644 --- a/pkg/controllers/budget.go +++ b/pkg/controllers/budget_v1.go @@ -14,6 +14,13 @@ import ( "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 @@ -74,34 +81,10 @@ type BudgetResponse struct { Data Budget `json:"data"` // Data for the budget } -type BudgetResponseV3 struct { - Data *Budget `json:"data"` // Data for the budget - Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred -} - type BudgetMonthResponse struct { Data models.BudgetMonth `json:"data"` // Data for the budget's month } -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 -} - -// 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 -} - // RegisterBudgetRoutes registers the routes for budgets with // the RouterGroup that is passed. func (co Controller) RegisterBudgetRoutes(r *gin.RouterGroup) { diff --git a/pkg/controllers/budget_test.go b/pkg/controllers/budget_v1_test.go similarity index 100% rename from pkg/controllers/budget_test.go rename to pkg/controllers/budget_v1_test.go diff --git a/pkg/controllers/budget_v3.go b/pkg/controllers/budget_v3.go new file mode 100644 index 00000000..004e6ab0 --- /dev/null +++ b/pkg/controllers/budget_v3.go @@ -0,0 +1,464 @@ +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" + "golang.org/x/exp/slices" +) + +type BudgetQueryFilterV3 struct { + Name string `form:"name" filterField:"false"` // By name + Note string `form:"note" filterField:"false"` // By note + Currency string `form:"currency"` // By currency + Search string `form:"search" filterField:"false"` // By string in name or note + Offset uint `form:"offset" filterField:"false"` // The offset of the first Transaction returned. Defaults to 0. + Limit int `form:"limit" filterField:"false"` // Maximum number of transactions to return. Defaults to -1 for all. +} + +// Budget is the API v3 representation of a Budget. +type BudgetV3 struct { + models.Budget + Links struct { + Self string `json:"self" example:"https://example.com/api/v3/budgets/550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // The budget itself + Accounts string `json:"accounts" example:"https://example.com/api/v3/accounts?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Accounts for this budget + Categories string `json:"categories" example:"https://example.com/api/v3/categories?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Categories for this budget + Envelopes string `json:"envelopes" example:"https://example.com/api/v3/envelopes?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Envelopes for this budget + Transactions string `json:"transactions" example:"https://example.com/api/v3/transactions?budget=550dc009-cea6-4c12-b2a5-03446eb7b7cf"` // Transactions for this budget + Month string `json:"month" example:"https://example.com/api/v3/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" gorm:"-"` +} + +// links sets all links for the Budget. +func (b *BudgetV3) links(c *gin.Context) { + url := c.GetString(string(database.ContextURL)) + + b.Links.Self = fmt.Sprintf("%s/v3/budgets/%s", url, b.ID) + b.Links.Accounts = fmt.Sprintf("%s/v3/accounts?budget=%s", url, b.ID) + b.Links.Categories = fmt.Sprintf("%s/v3/categories?budget=%s", url, b.ID) + b.Links.Envelopes = fmt.Sprintf("%s/v3/envelopes?budget=%s", url, b.ID) + b.Links.Transactions = fmt.Sprintf("%s/v3/transactions?budget=%s", url, b.ID) + b.Links.Month = fmt.Sprintf("%s/v3/months?budget=%s&month=YYYY-MM", url, b.ID) +} + +// getBudgetV3 returns a budget with all fields set. +func (co Controller) getBudgetV3(c *gin.Context, id uuid.UUID) (BudgetV3, httperrors.Error) { + m, err := getResourceByID[models.Budget](c, co, id) + if !err.Nil() { + return BudgetV3{}, err + } + + b := BudgetV3{ + Budget: m, + } + + b.links(c) + + return b, httperrors.Error{} +} + +type BudgetListResponseV3 struct { + Data []BudgetV3 `json:"data"` // List of budgets + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Pagination *Pagination `json:"pagination"` // Pagination information +} + +type BudgetCreateResponseV3 struct { + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred + Data []BudgetResponseV3 `json:"data"` // List of created Budgets +} + +type BudgetResponseV3 struct { + Data *BudgetV3 `json:"data"` // Data for the budget + Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred +} + +type 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) { + // Root group + { + r.OPTIONS("", co.OptionsBudgetListV3) + r.GET("", co.GetBudgetsV3) + r.POST("", co.CreateBudgetsV3) + } + + // Budget with ID + { + r.OPTIONS("/:id", co.OptionsBudgetDetailV3) + r.GET("/:id", co.GetBudgetV3) + r.PATCH("/:id", co.UpdateBudgetV3) + r.DELETE("/:id", co.DeleteBudgetV3) + } +} + +// OptionsBudgetListV3 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 /v3/budgets [options] +func (co Controller) OptionsBudgetListV3(c *gin.Context) { + httputil.OptionsGetPost(c) +} + +// OptionsBudgetDetailV3 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 /v3/budgets/{id} [options] +func (co Controller) OptionsBudgetDetailV3(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + _, err = getResourceByID[models.Budget](c, co, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + httputil.OptionsGetPatchDelete(c) +} + +// CreateBudgetV3 creates a new budget +// +// @Summary Create budget +// @Description Creates a new budget +// @Tags Budgets +// @Accept json +// @Produce json +// @Success 201 {object} BudgetCreateResponseV3 +// @Failure 400 {object} BudgetCreateResponseV3 +// @Failure 500 {object} BudgetCreateResponseV3 +// @Param budget body models.BudgetCreate true "Budget" +// @Router /v3/budgets [post] +func (co Controller) CreateBudgetsV3(c *gin.Context) { + var budgets []models.BudgetCreate + + // Bind data and return error if not possible + err := httputil.BindData(c, &budgets) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, BudgetCreateResponseV3{ + Error: &e, + }) + return + } + + // The final http status. Will be modified when errors occur + status := http.StatusCreated + r := BudgetCreateResponseV3{} + + for _, create := range budgets { + b := models.Budget{ + BudgetCreate: create, + } + + dbErr := co.DB.Create(&b).Error + if dbErr != nil { + err := httperrors.GenericDBError[models.Budget](b, c, dbErr) + s := err.Error() + c.JSON(err.Status, BudgetCreateResponseV3{ + Error: &s, + }) + return + } + + // Append the error + if !err.Nil() { + e := err.Error() + r.Data = append(r.Data, BudgetResponseV3{Error: &e}) + + // 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 + } + continue + } + + // Append the budget + bObject, err := co.getBudgetV3(c, b.ID) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, BudgetCreateResponseV3{ + Error: &e, + }) + return + } + r.Data = append(r.Data, BudgetResponseV3{Data: &bObject}) + } + + c.JSON(status, r) +} + +// GetBudgetsV3 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} BudgetListResponseV3 +// @Failure 500 {object} BudgetListResponseV3 +// @Router /v3/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" +func (co Controller) GetBudgetsV3(c *gin.Context) { + var filter BudgetQueryFilterV3 + + // Every parameter is bound into a string, so this will always succeed + _ = c.Bind(&filter) + + // Get the fields that we're filtering for + queryFields, setFields := httputil.GetURLFields(c.Request.URL, filter) + + var budgets []models.Budget + + // Always sort by name + q := co.DB. + Order("name ASC"). + Where(&models.Budget{ + BudgetCreate: models.BudgetCreate{ + Name: filter.Name, + Note: filter.Note, + Currency: filter.Currency, + }, + }, + queryFields...) + + q = stringFilters(co.DB, q, setFields, filter.Name, filter.Note, filter.Search) + + // Set the offset. Does not need checking since the default is 0 + q = q.Offset(int(filter.Offset)) + + // Default to all Budgets and set the limit + limit := -1 + if slices.Contains(setFields, "Limit") { + limit = filter.Limit + } + q = q.Limit(limit) + + err := query(c, q.Find(&budgets)) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetListResponseV3{ + Error: &s, + }) + return + } + + var count int64 + err = query(c, q.Limit(-1).Offset(-1).Count(&count)) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, BudgetListResponseV3{ + Error: &e, + }) + return + } + + budgetResources := make([]BudgetV3, 0) + for _, budget := range budgets { + r, err := co.getBudgetV3(c, budget.ID) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetListResponseV3{ + Error: &s, + }) + return + } + budgetResources = append(budgetResources, r) + } + + c.JSON(http.StatusOK, BudgetListResponseV3{ + Data: budgetResources, + Pagination: &Pagination{ + Count: len(budgetResources), + Total: count, + Offset: filter.Offset, + Limit: limit, + }, + }) +} + +// GetBudgetV3 returns data for a single budget +// +// @Summary Get budget +// @Description Returns a specific budget +// @Tags Budgets +// @Produce json +// @Success 200 {object} BudgetResponseV3 +// @Failure 400 {object} BudgetResponseV3 +// @Failure 404 {object} BudgetResponseV3 +// @Failure 500 {object} BudgetResponseV3 +// @Param id path string true "ID formatted as string" +// @Router /v3/budgets/{id} [get] +func (co Controller) GetBudgetV3(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponseV3{ + Error: &s, + }) + return + } + + m, err := getResourceByID[models.Budget](c, co, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponseV3{ + Error: &s, + }) + return + } + + r, err := co.getBudgetV3(c, m.ID) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponseV3{ + Error: &s, + }) + return + } + + c.JSON(http.StatusOK, BudgetResponseV3{Data: &r}) +} + +// UpdateBudgetV3 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} BudgetResponseV3 +// @Failure 400 {object} BudgetResponseV3 +// @Failure 404 {object} BudgetResponseV3 +// @Failure 500 {object} BudgetResponseV3 +// @Param id path string true "ID formatted as string" +// @Param budget body models.BudgetCreate true "Budget" +// @Router /v3/budgets/{id} [patch] +func (co Controller) UpdateBudgetV3(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponseV3{ + Error: &s, + }) + return + } + + budget, err := getResourceByID[models.Budget](c, co, id) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponseV3{ + Error: &s, + }) + return + } + + updateFields, err := httputil.GetBodyFields(c, models.BudgetCreate{}) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponseV3{ + Error: &s, + }) + return + } + + var data models.Budget + err = httputil.BindData(c, &data.BudgetCreate) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponseV3{ + Error: &s, + }) + return + } + + err = query(c, co.DB.Model(&budget).Select("", updateFields...).Updates(data)) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponseV3{ + Error: &s, + }) + return + } + + r, err := co.getBudgetV3(c, budget.ID) + if !err.Nil() { + s := err.Error() + c.JSON(err.Status, BudgetResponseV3{ + Error: &s, + }) + return + } + + c.JSON(http.StatusOK, BudgetResponseV3{Data: &r}) +} + +// DeleteBudgetV3 deletes a budget +// +// @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 /v3/budgets/{id} [delete] +func (co Controller) DeleteBudgetV3(c *gin.Context) { + id, err := httputil.UUIDFromString(c.Param("id")) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + budget, err := getResourceByID[models.Budget](c, co, id) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + err = query(c, co.DB.Delete(&budget)) + if !err.Nil() { + c.JSON(err.Status, httperrors.HTTPError{ + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusNoContent, nil) +} diff --git a/pkg/controllers/budget_v3_test.go b/pkg/controllers/budget_v3_test.go new file mode 100644 index 00000000..ce192192 --- /dev/null +++ b/pkg/controllers/budget_v3_test.go @@ -0,0 +1,343 @@ +package controllers_test + +import ( + "fmt" + "net/http" + "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/google/uuid" + "github.com/stretchr/testify/assert" +) + +func (suite *TestSuiteStandard) createTestBudgetV3(t *testing.T, c models.BudgetCreate, expectedStatus ...int) controllers.BudgetResponseV3 { + // Default to 201 Created as expected status + if len(expectedStatus) == 0 { + expectedStatus = append(expectedStatus, http.StatusCreated) + } + + body := []models.BudgetCreate{ + c, + } + + r := test.Request(suite.controller, t, http.MethodPost, "http://example.com/v3/budgets", body) + assertHTTPStatus(suite.T(), &r, expectedStatus...) + + var a controllers.BudgetCreateResponseV3 + suite.decodeResponse(&r, &a) + + if r.Code == http.StatusCreated { + return a.Data[0] + } + + return controllers.BudgetResponseV3{} +} + +// TestBudgetsV3DBClosed verifies that errors are processed correctly when +// the database is closed. +func (suite *TestSuiteStandard) TestBudgetsV3DBClosed() { + tests := []struct { + name string // Name of the test + test func(t *testing.T) // Code to run + }{ + { + "Creation fails", + func(t *testing.T) { + suite.createTestBudgetV3(t, models.BudgetCreate{}, http.StatusInternalServerError) + }, + }, + { + "GET fails", + func(t *testing.T) { + recorder := test.Request(suite.controller, t, http.MethodGet, "http://example.com/v3/budgets", "") + assertHTTPStatus(t, &recorder, http.StatusInternalServerError) + assert.Contains(t, test.DecodeError(t, recorder.Body.Bytes()), "there is a problem with the database connection") + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + suite.CloseDB() + + tt.test(t) + }) + } +} + +// TestBudgetV3Options verifies that OPTIONS requests are handled correctly. +func (suite *TestSuiteStandard) TestBudgetV3Options() { + tests := []struct { + name string + id string // path at the /v3/budgets endpoint to test + status int // Expected HTTP status code + }{ + {"No budget with this ID", uuid.New().String(), http.StatusNotFound}, + {"Not a valid UUID", "NotParseableAsUUID", http.StatusBadRequest}, + {"Budget exists", suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}).Data.ID.String(), http.StatusNoContent}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("%s/%s", "http://example.com/v3/budgets", tt.id) + r := test.Request(suite.controller, suite.T(), http.MethodOptions, path, "") + assertHTTPStatus(t, &r, tt.status) + }) + } +} + +// TestBudgetsV3GetSingle verifies that requests for the resource endpoints are +// handled correctly. +func (suite *TestSuiteStandard) TestBudgetsV3GetSingle() { + budget := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) + + tests := []struct { + name string + id string + status int + method string + }{ + {"GET Existing budget", budget.Data.ID.String(), http.StatusOK, http.MethodGet}, + {"GET ID nil", uuid.Nil.String(), http.StatusBadRequest, http.MethodGet}, + {"GET No budget with this ID", uuid.New().String(), http.StatusNotFound, http.MethodGet}, + {"GET Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodGet}, + {"GET Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodGet}, + {"GET Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodGet}, + {"PATCH Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodPatch}, + {"PATCH Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodPatch}, + {"PATCH Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodPatch}, + {"DELETE Invalid ID (negative number)", "-56", http.StatusBadRequest, http.MethodDelete}, + {"DELETE Invalid ID (positive number)", "23", http.StatusBadRequest, http.MethodDelete}, + {"DELETE Invalid ID (string)", "notaUUID", http.StatusBadRequest, http.MethodDelete}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(suite.controller, t, tt.method, fmt.Sprintf("http://example.com/v3/budgets/%s", tt.id), "") + + var budget controllers.BudgetResponseV3 + suite.decodeResponse(&r, &budget) + assertHTTPStatus(t, &r, tt.status) + }) + } +} + +func (suite *TestSuiteStandard) TestBudgetsV3GetFilter() { + _ = suite.createTestBudgetV3(suite.T(), models.BudgetCreate{ + Name: "Exact String Match", + Note: "This is a specific note", + Currency: "", + }) + + _ = suite.createTestBudgetV3(suite.T(), models.BudgetCreate{ + Name: "", + Note: "This is a specific note", + Currency: "$", + }) + + _ = suite.createTestBudgetV3(suite.T(), 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}, + {"Offset", "offset=1", 2}, + {"Limit", "limit=1", 1}, + } + + var re controllers.BudgetListResponseV3 + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(suite.controller, t, http.MethodGet, fmt.Sprintf("http://example.com/v3/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) TestBudgetsV3CreateFails() { + tests := []struct { + name string + body string + }{ + {"Broken Body", `{ "note": 2 }`}, + {"No body", ""}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + recorder := test.Request(suite.controller, t, http.MethodPost, "http://example.com/v3/budgets", tt.body) + assertHTTPStatus(t, &recorder, http.StatusBadRequest) + }) + } +} + +func (suite *TestSuiteStandard) TestBudgetsV3Update() { + budget := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{ + Name: "New Budget", + Note: "More tests something something", + }) + + 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.BudgetResponseV3 + suite.decodeResponse(&recorder, &updatedBudget) + + assert.Equal(suite.T(), "", updatedBudget.Data.Note) + assert.Equal(suite.T(), "Updated new budget", updatedBudget.Data.Name) +} + +func (suite *TestSuiteStandard) TestBudgetsV3UpdateFails() { + tests := []struct { + name string + id string + body string + status int // expected response status + }{ + {"Invalid type", "", `{"name": 2}`, http.StatusBadRequest}, + {"Non-existing budget", uuid.New().String(), `{"name": 2}`, http.StatusNotFound}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var recorder httptest.ResponseRecorder + + if tt.id == "" { + // Create test budget + budget := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{ + Name: "New Budget", + Note: "More tests something something", + }) + + tt.id = budget.Data.ID.String() + } + + // Update budget + recorder = test.Request(suite.controller, t, http.MethodPatch, fmt.Sprintf("http://example.com/v3/budgets/%s", tt.id), tt.body) + assertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestBudgetsV3Delete verifies all cases for budget deletions. +func (suite *TestSuiteStandard) TestBudgetsV3Delete() { + tests := []struct { + name string + id string + status int // expected response status + }{ + {"Success", "", http.StatusNoContent}, + {"Non-existing budget", uuid.New().String(), http.StatusNotFound}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + var recorder httptest.ResponseRecorder + + if tt.id == "" { + // Create test budget + recorder := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v3/budgets", `[{ "name": "New Budget", "note": "More tests something something" }]`) + assertHTTPStatus(suite.T(), &recorder, http.StatusCreated) + + var budget controllers.BudgetCreateResponseV3 + suite.decodeResponse(&recorder, &budget) + tt.id = budget.Data[0].Data.ID.String() + } + + // Update budget + recorder = test.Request(suite.controller, t, http.MethodDelete, fmt.Sprintf("http://example.com/v3/budgets/%s", tt.id), "") + assertHTTPStatus(t, &recorder, tt.status) + }) + } +} + +// TestBudgetsV3GetSorted verifies that budgets are sorted by name. +func (suite *TestSuiteStandard) TestBudgetsV3GetSorted() { + b1 := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{ + Name: "Alphabetically first", + }) + + b2 := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{ + Name: "Second in creation, third in list", + }) + + b3 := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{ + Name: "First is alphabetically second", + }) + + b4 := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{ + Name: "Zulu is the last one", + }) + + r := test.Request(suite.controller, suite.T(), http.MethodGet, "http://example.com/v3/budgets", "") + assertHTTPStatus(suite.T(), &r, http.StatusOK) + + var budgets controllers.BudgetListResponseV3 + suite.decodeResponse(&r, &budgets) + + if !assert.Len(suite.T(), budgets.Data, 4) { + assert.FailNow(suite.T(), "Budgets list has wrong length") + } + + assert.Equal(suite.T(), b1.Data.Name, budgets.Data[0].Name) + assert.Equal(suite.T(), b2.Data.Name, budgets.Data[2].Name) + assert.Equal(suite.T(), b3.Data.Name, budgets.Data[1].Name) + assert.Equal(suite.T(), b4.Data.Name, budgets.Data[3].Name) +} + +func (suite *TestSuiteStandard) TestBudgetsV3Pagination() { + for i := 0; i < 10; i++ { + suite.createTestBudgetV3(suite.T(), models.BudgetCreate{Name: fmt.Sprint(i)}) + } + + tests := []struct { + name string + offset uint + limit int + expectedCount int + expectedTotal int64 + }{ + {"All", 0, -1, 10, 10}, + {"First 5", 0, 5, 5, 10}, + {"Last 5", 5, -1, 5, 10}, + {"Offset 3", 3, -1, 7, 10}, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(suite.controller, suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v3/budgets?offset=%d&limit=%d", tt.offset, tt.limit), "") + assertHTTPStatus(suite.T(), &r, http.StatusOK) + + var budgets controllers.BudgetListResponseV3 + suite.decodeResponse(&r, &budgets) + + assert.Equal(suite.T(), tt.offset, budgets.Pagination.Offset) + assert.Equal(suite.T(), tt.limit, budgets.Pagination.Limit) + assert.Equal(suite.T(), tt.expectedCount, budgets.Pagination.Count) + assert.Equal(suite.T(), tt.expectedTotal, budgets.Pagination.Total) + }) + } +} diff --git a/pkg/controllers/import_v3.go b/pkg/controllers/import_v3.go index b90ce626..0567715f 100644 --- a/pkg/controllers/import_v3.go +++ b/pkg/controllers/import_v3.go @@ -280,8 +280,12 @@ func (co Controller) ImportYnab4V3(c *gin.Context) { return } - r, ok := co.getBudget(c, budget.ID) - if !ok { + r, e := co.getBudgetV3(c, budget.ID) + if !e.Nil() { + s := e.Error() + c.JSON(e.Status, BudgetResponseV3{ + Error: &s, + }) return } diff --git a/pkg/controllers/import_v3_test.go b/pkg/controllers/import_v3_test.go index 0a781ce7..60aa17b9 100644 --- a/pkg/controllers/import_v3_test.go +++ b/pkg/controllers/import_v3_test.go @@ -70,7 +70,7 @@ func (suite *TestSuiteStandard) TestImportYnab4V3Fails() { {"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"}) + _ = suite.createTestBudgetV3(suite.T(), 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() @@ -181,7 +181,7 @@ func (suite *TestSuiteStandard) TestImportYnabImportPreviewV3AvailableFrom() { func (suite *TestSuiteStandard) TestImportYnabImportPreviewV3FindAccounts() { // Create a budget and two existing accounts to use - budget := suite.createTestBudget(models.BudgetCreate{}) + budget := suite.createTestBudgetV3(suite.T(), 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 @@ -241,7 +241,7 @@ func (suite *TestSuiteStandard) TestImportYnabImportPreviewV3FindAccounts() { func (suite *TestSuiteStandard) TestImportYnabImportPreviewV3Match() { // Create a budget and two existing accounts to use - budget := suite.createTestBudget(models.BudgetCreate{}) + budget := suite.createTestBudgetV3(suite.T(), 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}) diff --git a/pkg/controllers/match_rule_v3.go b/pkg/controllers/match_rule_v3.go index a651ddf0..83745b90 100644 --- a/pkg/controllers/match_rule_v3.go +++ b/pkg/controllers/match_rule_v3.go @@ -245,7 +245,7 @@ func (co Controller) GetMatchRulesV3(c *gin.Context) { // Default to 50 Match Rules and set the limit limit := 50 if slices.Contains(setFields, "Limit") { - limit = int(filter.Limit) + limit = filter.Limit } q = q.Limit(limit) diff --git a/pkg/controllers/match_rule_v3_test.go b/pkg/controllers/match_rule_v3_test.go index 169a915c..557260e1 100644 --- a/pkg/controllers/match_rule_v3_test.go +++ b/pkg/controllers/match_rule_v3_test.go @@ -179,7 +179,7 @@ func (suite *TestSuiteStandard) TestMatchRulesV3DatabaseError() { // TestMatchRulesV3GetFilter verifies that filtering Match Rules works as expected. func (suite *TestSuiteStandard) TestMatchRulesV3GetFilter() { - b := suite.createTestBudget(models.BudgetCreate{}) + b := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) a1 := suite.createTestAccount(models.AccountCreate{BudgetID: b.Data.ID, Name: "TestMatchRulesV3GetFilter 1"}) a2 := suite.createTestAccount(models.AccountCreate{BudgetID: b.Data.ID, Name: "TestMatchRulesV3GetFilter 2"}) @@ -231,7 +231,7 @@ func (suite *TestSuiteStandard) TestMatchRulesV3GetFilter() { // TestMatchRulesV3GetFilterErrors verifies that filtering Match Rules returns errors as expected. func (suite *TestSuiteStandard) TestMatchRulesV3GetFilterErrors() { - b := suite.createTestBudget(models.BudgetCreate{}) + b := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) a1 := suite.createTestAccount(models.AccountCreate{BudgetID: b.Data.ID, Name: "TestMatchRulesV3GetFilter 1"}) a2 := suite.createTestAccount(models.AccountCreate{BudgetID: b.Data.ID, Name: "TestMatchRulesV3GetFilter 2"}) @@ -288,7 +288,7 @@ func (suite *TestSuiteStandard) TestMatchRulesV3CreateInvalidBody() { // TestMatchRulesV3Create verifies that transaction creation works. func (suite *TestSuiteStandard) TestMatchRulesV3Create() { - budget := suite.createTestBudget(models.BudgetCreate{}) + budget := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) internalAccount := suite.createTestAccount(models.AccountCreate{External: false, BudgetID: budget.Data.ID, Name: "TestMatchRulesV3Create Internal"}) tests := []struct { @@ -532,7 +532,7 @@ func (suite *TestSuiteStandard) TestMatchRulesV3Delete() { // TestMatchRulesV3GetSorted verifies that Match Rules are sorted as expected. func (suite *TestSuiteStandard) TestMatchRulesV3GetSorted() { - b := suite.createTestBudget(models.BudgetCreate{}) + b := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) a := suite.createTestAccount(models.AccountCreate{BudgetID: b.Data.ID, Name: "TestMatchRulesV3GetFilter 1"}) m1 := suite.createTestMatchRuleV3(suite.T(), models.MatchRuleCreate{ diff --git a/pkg/controllers/month.go b/pkg/controllers/month.go index 4821871f..bfad2804 100644 --- a/pkg/controllers/month.go +++ b/pkg/controllers/month.go @@ -12,6 +12,18 @@ import ( "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 } diff --git a/pkg/controllers/test_options_test.go b/pkg/controllers/test_options_test.go index fbad751c..ec4e80de 100644 --- a/pkg/controllers/test_options_test.go +++ b/pkg/controllers/test_options_test.go @@ -27,6 +27,7 @@ func (suite *TestSuiteStandard) TestOptionsHeaderResources() { {"http://example.com/v2/transactions", "OPTIONS, POST"}, {"http://example.com/v2/match-rules", "OPTIONS, GET, POST"}, {"http://example.com/v2/accounts", "OPTIONS, GET"}, + {"http://example.com/v3/budgets", "OPTIONS, GET, POST"}, {"http://example.com/v3/transactions", "OPTIONS, GET, POST"}, {"http://example.com/v3/match-rules", "OPTIONS, GET, POST"}, {"http://example.com/v3/import", "OPTIONS, GET"}, diff --git a/pkg/controllers/transaction.go b/pkg/controllers/transaction.go index a1c4c870..e264d831 100644 --- a/pkg/controllers/transaction.go +++ b/pkg/controllers/transaction.go @@ -13,28 +13,32 @@ import ( ) // createTransaction creates a single transaction after verifying it is a valid transaction. -func (co Controller) createTransaction(c *gin.Context, t models.Transaction) (models.Transaction, httperrors.Error) { +func (co Controller) createTransaction(c *gin.Context, create models.TransactionCreate) (models.Transaction, httperrors.Error) { + t := models.Transaction{ + TransactionCreate: create, + } + _, err := getResourceByID[models.Budget](c, co, t.BudgetID) if !err.Nil() { - return t, err + return models.Transaction{}, err } // Check the source account sourceAccount, err := getResourceByID[models.Account](c, co, t.SourceAccountID) if !err.Nil() { - return t, err + return models.Transaction{}, err } // Check the destination account destinationAccount, err := getResourceByID[models.Account](c, co, t.DestinationAccountID) if !err.Nil() { - return t, err + return models.Transaction{}, err } // Check the transaction err = co.checkTransaction(c, t, sourceAccount, destinationAccount) if !err.Nil() { - return t, err + return models.Transaction{}, err } dbErr := co.DB.Create(&t).Error diff --git a/pkg/controllers/transaction_v1.go b/pkg/controllers/transaction_v1.go index ed7541e3..34e3d7d5 100644 --- a/pkg/controllers/transaction_v1.go +++ b/pkg/controllers/transaction_v1.go @@ -152,11 +152,7 @@ func (co Controller) CreateTransaction(c *gin.Context) { return } - transaction := models.Transaction{ - TransactionCreate: transactionCreate, - } - - transaction, err := co.createTransaction(c, transaction) + transaction, err := co.createTransaction(c, transactionCreate) if !err.Nil() { c.JSON(err.Status, gin.H{"error": err.Error()}) return diff --git a/pkg/controllers/transaction_v2.go b/pkg/controllers/transaction_v2.go index 1dc3f9b2..bfbedded 100644 --- a/pkg/controllers/transaction_v2.go +++ b/pkg/controllers/transaction_v2.go @@ -92,7 +92,7 @@ func (co Controller) CreateTransactionsV2(c *gin.Context) { status := http.StatusCreated for _, t := range transactions { - t, err := co.createTransaction(c, t) + t, err := co.createTransaction(c, t.TransactionCreate) // Append the error or the successfully created transaction to the response list if !err.Nil() { diff --git a/pkg/controllers/transaction_v3.go b/pkg/controllers/transaction_v3.go index 0ba234c6..427747d8 100644 --- a/pkg/controllers/transaction_v3.go +++ b/pkg/controllers/transaction_v3.go @@ -24,12 +24,12 @@ type TransactionListResponseV3 struct { type TransactionCreateResponseV3 struct { Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred - Data []TransactionResponseV3 `json:"data"` // List of created transactions + Data []TransactionResponseV3 `json:"data"` // List of created Transactions } type TransactionResponseV3 struct { Error *string `json:"error" example:"the specified resource ID is not a valid UUID"` // The error, if any occurred for this transaction - Data *TransactionV3 `json:"data"` // The transaction data, if creation was successful + Data *TransactionV3 `json:"data"` // The Transaction data, if creation was successful } // TransactionV3 is the representation of a Transaction in API v3. @@ -295,7 +295,7 @@ func (co Controller) GetTransactionsV3(c *gin.Context) { // Default to 50 transactions and set the limit limit := 50 if slices.Contains(setFields, "Limit") { - limit = int(filter.Limit) + limit = filter.Limit } q = q.Limit(limit) @@ -357,7 +357,7 @@ func (co Controller) GetTransactionsV3(c *gin.Context) { // @Param transactions body []models.TransactionCreate true "Transactions" // @Router /v3/transactions [post] func (co Controller) CreateTransactionsV3(c *gin.Context) { - var transactions []models.Transaction + var transactions []models.TransactionCreate // Bind data and return error if not possible err := httputil.BindData(c, &transactions) @@ -373,8 +373,8 @@ func (co Controller) CreateTransactionsV3(c *gin.Context) { status := http.StatusCreated r := TransactionCreateResponseV3{} - for _, t := range transactions { - t, err := co.createTransaction(c, t) + for _, create := range transactions { + t, err := co.createTransaction(c, create) // Append the error if !err.Nil() { diff --git a/pkg/controllers/transaction_v3_test.go b/pkg/controllers/transaction_v3_test.go index 96166381..fc8c920a 100644 --- a/pkg/controllers/transaction_v3_test.go +++ b/pkg/controllers/transaction_v3_test.go @@ -150,7 +150,7 @@ func (suite *TestSuiteStandard) TestTransactionsV3Get() { // TestTransactionsV3GetFilter verifies that filtering transactions works as expected. func (suite *TestSuiteStandard) TestTransactionsV3GetFilter() { - b := suite.createTestBudget(models.BudgetCreate{}) + b := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) a1 := suite.createTestAccount(models.AccountCreate{BudgetID: b.Data.ID, Name: "TestTransactionsV3GetFilter 1"}) a2 := suite.createTestAccount(models.AccountCreate{BudgetID: b.Data.ID, Name: "TestTransactionsV3GetFilter 2"}) @@ -305,7 +305,7 @@ func (suite *TestSuiteStandard) TestTransactionsV3CreateInvalidBody() { // TestTransactionsV3Create verifies that transaction creation works. func (suite *TestSuiteStandard) TestTransactionsV3Create() { - budget := suite.createTestBudget(models.BudgetCreate{}) + budget := suite.createTestBudgetV3(suite.T(), models.BudgetCreate{}) internalAccount := suite.createTestAccount(models.AccountCreate{External: false, BudgetID: budget.Data.ID, Name: "TestTransactionsV3Create Internal"}) externalAccount := suite.createTestAccount(models.AccountCreate{External: true, BudgetID: budget.Data.ID, Name: "TestTransactionsV3Create External"}) @@ -533,7 +533,7 @@ func (suite *TestSuiteStandard) TestTransactionsV3Update() { transaction := suite.createTestTransactionV3(models.TransactionCreate{ Amount: decimal.NewFromFloat(23.14), Note: "Test note for transaction", - BudgetID: suite.createTestBudget(models.BudgetCreate{Name: "Testing budget for updating of outgoing transfer"}).Data.ID, + BudgetID: suite.createTestBudgetV3(suite.T(), 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, diff --git a/pkg/router/router.go b/pkg/router/router.go index 92ed3a33..986a2055 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -158,6 +158,7 @@ func AttachRoutes(co controllers.Controller, group *gin.RouterGroup) { v3.OPTIONS("", OptionsV3) } + co.RegisterBudgetRoutesV3(v3.Group("/budgets")) co.RegisterTransactionRoutesV3(v3.Group("/transactions")) co.RegisterMatchRuleRoutesV3(v3.Group("/match-rules")) co.RegisterImportRoutesV3(v3.Group("/import")) @@ -335,6 +336,7 @@ type V3Response struct { } type V3Links struct { + Budgets string `json:"budgets" example:"https://example.com/api/v3/budgets"` // URL of Budget collection endpoint Transactions string `json:"transactions" example:"https://example.com/api/v3/transactions"` // URL of Transaction collection endpoint MatchRules string `json:"matchRules" example:"https://example.com/api/v3/match-rules"` // URL of Match Rule collection endpoint Import string `json:"import" example:"https://example.com/api/v3/import"` // URL of import list endpoint @@ -350,6 +352,7 @@ type V3Links struct { func GetV3(c *gin.Context) { c.JSON(http.StatusOK, V3Response{ Links: V3Links{ + Budgets: c.GetString(string(database.ContextURL)) + "/v3/budgets", Transactions: c.GetString(string(database.ContextURL)) + "/v3/transactions", MatchRules: c.GetString(string(database.ContextURL)) + "/v3/match-rules", Import: c.GetString(string(database.ContextURL)) + "/v3/import", diff --git a/pkg/router/router_test.go b/pkg/router/router_test.go index 567f2e92..4b1ffd39 100644 --- a/pkg/router/router_test.go +++ b/pkg/router/router_test.go @@ -189,6 +189,7 @@ func TestGetV3(t *testing.T) { // this only tests the path, not the host l := router.V3Response{ Links: router.V3Links{ + Budgets: "/v3/budgets", Transactions: "/v3/transactions", MatchRules: "/v3/match-rules", Import: "/v3/import",