diff --git a/backend/src/api/docs/docs.go b/backend/src/api/docs/docs.go index 667287a..408ba05 100644 --- a/backend/src/api/docs/docs.go +++ b/backend/src/api/docs/docs.go @@ -110,19 +110,13 @@ const docTemplate = `{ "400": { "description": "invalid body", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -160,28 +154,19 @@ const docTemplate = `{ "400": { "description": "invalid id", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "family not found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -217,28 +202,19 @@ const docTemplate = `{ "400": { "description": "invalid id", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "family not found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -283,146 +259,17 @@ const docTemplate = `{ "400": { "description": "name is required", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "family not found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/receipts": { - "post": { - "description": "Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Receipts" - ], - "summary": "Загрузить чек", - "parameters": [ - { - "description": "Receipt payload", - "name": "receipt", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.AddReceiptRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.AddReceiptResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/dto.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/dto.ErrorResponse" - } - } - } - } - }, - "/api/v1/receipts/photo": { - "post": { - "description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Receipts" - ], - "summary": "Загрузить чек по фото", - "parameters": [ - { - "type": "file", - "description": "Receipt photo", - "name": "photo", - "in": "formData", - "required": true - }, - { - "type": "integer", - "description": "Family ID for auto-created transaction", - "name": "family_id", - "in": "formData" - }, - { - "type": "integer", - "description": "User ID for auto-created transaction", - "name": "created_by", - "in": "formData" - }, - { - "type": "string", - "description": "Transaction type, default expense", - "name": "type", - "in": "formData" - }, - { - "type": "string", - "description": "Transaction category, default receipt", - "name": "category", - "in": "formData" - }, - { - "type": "string", - "description": "Transaction description override", - "name": "description", - "in": "formData" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.AddReceiptResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/dto.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", "schema": { "$ref": "#/definitions/dto.ErrorResponse" } @@ -515,9 +362,10 @@ const docTemplate = `{ } }, "post": { - "description": "Создает новую транзакцию и при необходимости привязывает к ней чек", + "description": "Создает транзакцию одним из трех способов.\n1. application/json: ручная транзакция с полями family_id, created_by, type, category, amount, datetime.\n2. application/json: транзакция по чеку с полями family_id, created_by, receipt_number, receipt_date.\n3. multipart/form-data: транзакция по фото чека с полями photo, family_id, created_by и опциональными type, category, description.\nВ одном JSON-запросе нельзя смешивать ручные поля транзакции с полями receipt_number и receipt_date.", "consumes": [ - "application/json" + "application/json", + "multipart/form-data" ], "produces": [ "application/json" @@ -528,10 +376,9 @@ const docTemplate = `{ "summary": "Создать транзакцию", "parameters": [ { - "description": "Transaction payload", + "description": "JSON payload for manual or receipt-based transaction creation", "name": "transaction", "in": "body", - "required": true, "schema": { "$ref": "#/definitions/dto.CreateTransactionRequest" } @@ -816,13 +663,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -860,19 +707,19 @@ const docTemplate = `{ "400": { "description": "invalid telegram id", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "user not found", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -910,19 +757,19 @@ const docTemplate = `{ "400": { "description": "invalid id", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "user not found", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -958,19 +805,19 @@ const docTemplate = `{ "400": { "description": "invalid id", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "user not found", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -1015,19 +862,19 @@ const docTemplate = `{ "400": { "description": "invalid id or invalid body", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "user not found", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -1035,55 +882,6 @@ const docTemplate = `{ } }, "definitions": { - "domain.AddReceiptRequest": { - "type": "object", - "required": [ - "date", - "number" - ], - "properties": { - "category": { - "type": "string" - }, - "created_by": { - "type": "integer" - }, - "date": { - "type": "string" - }, - "description": { - "type": "string" - }, - "family_id": { - "type": "integer" - }, - "number": { - "type": "string", - "maxLength": 24, - "minLength": 24 - }, - "type": { - "type": "string" - } - } - }, - "domain.AddReceiptResponse": { - "type": "object", - "properties": { - "date": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "number": { - "type": "string" - }, - "transaction_id": { - "type": "integer" - } - } - }, "domain.CreateFamilyRequest": { "type": "object", "properties": { @@ -1182,14 +980,6 @@ const docTemplate = `{ } } }, - "domain.UserErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - }, "domain.UserResponse": { "type": "object", "properties": { @@ -1267,14 +1057,6 @@ const docTemplate = `{ }, "dto.CreateTransactionRequest": { "type": "object", - "required": [ - "amount", - "category", - "created_by", - "datetime", - "family_id", - "type" - ], "properties": { "amount": { "type": "number" @@ -1294,9 +1076,15 @@ const docTemplate = `{ "family_id": { "type": "integer" }, + "receipt_date": { + "type": "string" + }, "receipt_id": { "type": "integer" }, + "receipt_number": { + "type": "string" + }, "type": { "type": "string" } diff --git a/backend/src/api/docs/swagger.json b/backend/src/api/docs/swagger.json index b9046b3..e6d727d 100644 --- a/backend/src/api/docs/swagger.json +++ b/backend/src/api/docs/swagger.json @@ -99,19 +99,13 @@ "400": { "description": "invalid body", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -149,28 +143,19 @@ "400": { "description": "invalid id", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "family not found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -206,28 +191,19 @@ "400": { "description": "invalid id", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "family not found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -272,146 +248,17 @@ "400": { "description": "name is required", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "family not found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/receipts": { - "post": { - "description": "Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Receipts" - ], - "summary": "Загрузить чек", - "parameters": [ - { - "description": "Receipt payload", - "name": "receipt", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domain.AddReceiptRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.AddReceiptResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/dto.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/dto.ErrorResponse" - } - } - } - } - }, - "/api/v1/receipts/photo": { - "post": { - "description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Receipts" - ], - "summary": "Загрузить чек по фото", - "parameters": [ - { - "type": "file", - "description": "Receipt photo", - "name": "photo", - "in": "formData", - "required": true - }, - { - "type": "integer", - "description": "Family ID for auto-created transaction", - "name": "family_id", - "in": "formData" - }, - { - "type": "integer", - "description": "User ID for auto-created transaction", - "name": "created_by", - "in": "formData" - }, - { - "type": "string", - "description": "Transaction type, default expense", - "name": "type", - "in": "formData" - }, - { - "type": "string", - "description": "Transaction category, default receipt", - "name": "category", - "in": "formData" - }, - { - "type": "string", - "description": "Transaction description override", - "name": "description", - "in": "formData" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.AddReceiptResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/dto.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", "schema": { "$ref": "#/definitions/dto.ErrorResponse" } @@ -504,9 +351,10 @@ } }, "post": { - "description": "Создает новую транзакцию и при необходимости привязывает к ней чек", + "description": "Создает транзакцию одним из трех способов.\n1. application/json: ручная транзакция с полями family_id, created_by, type, category, amount, datetime.\n2. application/json: транзакция по чеку с полями family_id, created_by, receipt_number, receipt_date.\n3. multipart/form-data: транзакция по фото чека с полями photo, family_id, created_by и опциональными type, category, description.\nВ одном JSON-запросе нельзя смешивать ручные поля транзакции с полями receipt_number и receipt_date.", "consumes": [ - "application/json" + "application/json", + "multipart/form-data" ], "produces": [ "application/json" @@ -517,10 +365,9 @@ "summary": "Создать транзакцию", "parameters": [ { - "description": "Transaction payload", + "description": "JSON payload for manual or receipt-based transaction creation", "name": "transaction", "in": "body", - "required": true, "schema": { "$ref": "#/definitions/dto.CreateTransactionRequest" } @@ -805,13 +652,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -849,19 +696,19 @@ "400": { "description": "invalid telegram id", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "user not found", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -899,19 +746,19 @@ "400": { "description": "invalid id", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "user not found", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -947,19 +794,19 @@ "400": { "description": "invalid id", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "user not found", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -1004,19 +851,19 @@ "400": { "description": "invalid id or invalid body", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "404": { "description": "user not found", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "$ref": "#/definitions/domain.UserErrorResponse" + "$ref": "#/definitions/dto.ErrorResponse" } } } @@ -1024,55 +871,6 @@ } }, "definitions": { - "domain.AddReceiptRequest": { - "type": "object", - "required": [ - "date", - "number" - ], - "properties": { - "category": { - "type": "string" - }, - "created_by": { - "type": "integer" - }, - "date": { - "type": "string" - }, - "description": { - "type": "string" - }, - "family_id": { - "type": "integer" - }, - "number": { - "type": "string", - "maxLength": 24, - "minLength": 24 - }, - "type": { - "type": "string" - } - } - }, - "domain.AddReceiptResponse": { - "type": "object", - "properties": { - "date": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "number": { - "type": "string" - }, - "transaction_id": { - "type": "integer" - } - } - }, "domain.CreateFamilyRequest": { "type": "object", "properties": { @@ -1171,14 +969,6 @@ } } }, - "domain.UserErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - }, "domain.UserResponse": { "type": "object", "properties": { @@ -1256,14 +1046,6 @@ }, "dto.CreateTransactionRequest": { "type": "object", - "required": [ - "amount", - "category", - "created_by", - "datetime", - "family_id", - "type" - ], "properties": { "amount": { "type": "number" @@ -1283,9 +1065,15 @@ "family_id": { "type": "integer" }, + "receipt_date": { + "type": "string" + }, "receipt_id": { "type": "integer" }, + "receipt_number": { + "type": "string" + }, "type": { "type": "string" } diff --git a/backend/src/api/docs/swagger.yaml b/backend/src/api/docs/swagger.yaml index 8803d5d..2852259 100644 --- a/backend/src/api/docs/swagger.yaml +++ b/backend/src/api/docs/swagger.yaml @@ -1,37 +1,4 @@ definitions: - domain.AddReceiptRequest: - properties: - category: - type: string - created_by: - type: integer - date: - type: string - description: - type: string - family_id: - type: integer - number: - maxLength: 24 - minLength: 24 - type: string - type: - type: string - required: - - date - - number - type: object - domain.AddReceiptResponse: - properties: - date: - type: string - id: - type: integer - number: - type: string - transaction_id: - type: integer - type: object domain.CreateFamilyRequest: properties: name: @@ -96,11 +63,6 @@ definitions: username: type: string type: object - domain.UserErrorResponse: - properties: - error: - type: string - type: object domain.UserResponse: properties: created_at: @@ -164,17 +126,14 @@ definitions: type: string family_id: type: integer + receipt_date: + type: string receipt_id: type: integer + receipt_number: + type: string type: type: string - required: - - amount - - category - - created_by - - datetime - - family_id - - type type: object dto.ErrorResponse: properties: @@ -302,15 +261,11 @@ paths: "400": description: invalid body schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' "500": description: internal server error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' summary: Создать семью tags: - Families @@ -335,21 +290,15 @@ paths: "400": description: invalid id schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' "404": description: family not found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' "500": description: internal server error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' summary: Удалить семью tags: - Families @@ -373,21 +322,15 @@ paths: "400": description: invalid id schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' "404": description: family not found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' "500": description: internal server error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' summary: Получить семью по ID tags: - Families @@ -417,105 +360,18 @@ paths: "400": description: name is required schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' "404": description: family not found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' "500": description: internal server error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/dto.ErrorResponse' summary: Обновить семью tags: - Families - /api/v1/receipts: - post: - consumes: - - application/json - description: Загружает чек из внешнего сервиса и опционально автоматически создает - связанную транзакцию - parameters: - - description: Receipt payload - in: body - name: receipt - required: true - schema: - $ref: '#/definitions/domain.AddReceiptRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.AddReceiptResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/dto.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/dto.ErrorResponse' - summary: Загрузить чек - tags: - - Receipts - /api/v1/receipts/photo: - post: - consumes: - - multipart/form-data - description: Принимает фото, распознает текст через Google OCR и создает чек - с позициями; опционально создает связанную транзакцию - parameters: - - description: Receipt photo - in: formData - name: photo - required: true - type: file - - description: Family ID for auto-created transaction - in: formData - name: family_id - type: integer - - description: User ID for auto-created transaction - in: formData - name: created_by - type: integer - - description: Transaction type, default expense - in: formData - name: type - type: string - - description: Transaction category, default receipt - in: formData - name: category - type: string - - description: Transaction description override - in: formData - name: description - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.AddReceiptResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/dto.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/dto.ErrorResponse' - summary: Загрузить чек по фото - tags: - - Receipts /api/v1/transactions: get: consumes: @@ -575,13 +431,17 @@ paths: post: consumes: - application/json - description: Создает новую транзакцию и при необходимости привязывает к ней - чек + - multipart/form-data + description: |- + Создает транзакцию одним из трех способов. + 1. application/json: ручная транзакция с полями family_id, created_by, type, category, amount, datetime. + 2. application/json: транзакция по чеку с полями family_id, created_by, receipt_number, receipt_date. + 3. multipart/form-data: транзакция по фото чека с полями photo, family_id, created_by и опциональными type, category, description. + В одном JSON-запросе нельзя смешивать ручные поля транзакции с полями receipt_number и receipt_date. parameters: - - description: Transaction payload + - description: JSON payload for manual or receipt-based transaction creation in: body name: transaction - required: true schema: $ref: '#/definitions/dto.CreateTransactionRequest' produces: @@ -773,11 +633,11 @@ paths: "400": description: Bad Request schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' summary: Создать пользователя tags: - Users @@ -802,15 +662,15 @@ paths: "400": description: invalid id schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' "404": description: user not found schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' "500": description: internal server error schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' summary: Удалить пользователя tags: - Users @@ -834,15 +694,15 @@ paths: "400": description: invalid id schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' "404": description: user not found schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' "500": description: internal server error schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' summary: Получить пользователя по ID tags: - Users @@ -872,15 +732,15 @@ paths: "400": description: invalid id or invalid body schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' "404": description: user not found schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' "500": description: internal server error schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' summary: Обновить пользователя tags: - Users @@ -905,15 +765,15 @@ paths: "400": description: invalid telegram id schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' "404": description: user not found schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' "500": description: internal server error schema: - $ref: '#/definitions/domain.UserErrorResponse' + $ref: '#/definitions/dto.ErrorResponse' summary: Получить пользователя по Telegram ID tags: - Users diff --git a/backend/src/api/requests/activities.go b/backend/src/api/requests/activities.go new file mode 100644 index 0000000..e6d8eef --- /dev/null +++ b/backend/src/api/requests/activities.go @@ -0,0 +1,15 @@ +package requests + +import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/domain" +) + +func BuildActivityListFilter(query dto.ActivityListQuery) domain.ActivityLogListFilter { + return domain.ActivityLogListFilter{ + FamilyID: query.FamilyID, + UserID: query.UserID, + Limit: query.Limit, + Offset: query.Offset, + } +} diff --git a/backend/src/api/requests/common.go b/backend/src/api/requests/common.go new file mode 100644 index 0000000..341726e --- /dev/null +++ b/backend/src/api/requests/common.go @@ -0,0 +1,16 @@ +package requests + +import ( + "errors" + "strconv" + "strings" +) + +func ParseInt64(value string, invalidMessage string) (int64, error) { + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, errors.New(invalidMessage) + } + + return parsed, nil +} diff --git a/backend/src/api/requests/families.go b/backend/src/api/requests/families.go new file mode 100644 index 0000000..e48b537 --- /dev/null +++ b/backend/src/api/requests/families.go @@ -0,0 +1,16 @@ +package requests + +import ( + "FamilyHub/src/domain" + "errors" +) + +var ErrFamilyNameRequired = errors.New("name is required") + +func ValidateFamilyUpdate(req domain.UpdateFamilyRequest) error { + if req.Name == nil { + return ErrFamilyNameRequired + } + + return nil +} diff --git a/backend/src/api/requests/transactions.go b/backend/src/api/requests/transactions.go index 796533c..a874156 100644 --- a/backend/src/api/requests/transactions.go +++ b/backend/src/api/requests/transactions.go @@ -6,13 +6,19 @@ import ( "FamilyHub/src/domain" "FamilyHub/src/utils" "errors" - "strconv" "strings" "time" - - "github.com/gin-gonic/gin" ) +type PhotoCreateTransactionFields struct { + Image []byte + FamilyID *int64 + CreatedBy *int64 + Type *string + Category *string + Description *string +} + func BuildCreateTransactionInput(req dto.CreateTransactionRequest) (services.CreateTransactionInput, error) { if req.ReceiptNumber != nil || req.ReceiptDate != nil { receiptReq, err := BuildReceiptTransactionRequest(req) @@ -30,24 +36,15 @@ func BuildCreateTransactionInput(req dto.CreateTransactionRequest) (services.Cre return services.CreateTransactionInput{Manual: &manualReq}, nil } -func BuildPhotoCreateTransactionInput(c *gin.Context, image []byte) (services.CreateTransactionInput, error) { - familyID, err := parseOptionalInt64Form(c, "family_id") - if err != nil { - return services.CreateTransactionInput{}, err - } - createdBy, err := parseOptionalInt64Form(c, "created_by") - if err != nil { - return services.CreateTransactionInput{}, err - } - +func BuildPhotoCreateTransactionInput(fields PhotoCreateTransactionFields) (services.CreateTransactionInput, error) { return services.CreateTransactionInput{ Photo: &services.CreateTransactionPhotoInput{ - Image: image, - FamilyID: familyID, - CreatedBy: createdBy, - Type: parseOptionalStringForm(c, "type"), - Category: parseOptionalStringForm(c, "category"), - Description: parseOptionalStringForm(c, "description"), + Image: fields.Image, + FamilyID: fields.FamilyID, + CreatedBy: fields.CreatedBy, + Type: trimOptionalString(fields.Type), + Category: trimOptionalString(fields.Category), + Description: trimOptionalString(fields.Description), }, }, nil } @@ -128,26 +125,3 @@ func trimOptionalString(value *string) *string { return &trimmed } - -func parseOptionalInt64Form(c *gin.Context, key string) (*int64, error) { - value := strings.TrimSpace(c.PostForm(key)) - if value == "" { - return nil, nil - } - - parsed, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return nil, errors.New(key + " must be int64") - } - - return &parsed, nil -} - -func parseOptionalStringForm(c *gin.Context, key string) *string { - value := strings.TrimSpace(c.PostForm(key)) - if value == "" { - return nil - } - - return &value -} diff --git a/backend/src/api/routers/activities.go b/backend/src/api/routers/activities.go index efca235..4e43212 100644 --- a/backend/src/api/routers/activities.go +++ b/backend/src/api/routers/activities.go @@ -2,8 +2,8 @@ package routers import ( "FamilyHub/src/api/dto" + "FamilyHub/src/api/requests" "FamilyHub/src/api/services" - "FamilyHub/src/domain" "net/http" "github.com/gin-gonic/gin" @@ -45,15 +45,10 @@ func (router *ActivitiesRouter) List(c *gin.Context) { return } - activities, filter, err := router.service.List(c.Request.Context(), domain.ActivityLogListFilter{ - FamilyID: query.FamilyID, - UserID: query.UserID, - Limit: query.Limit, - Offset: query.Offset, - }) + filter := requests.BuildActivityListFilter(query) + activities, filter, err := router.service.List(c.Request.Context(), filter) if err != nil { - logInternalError(c, "activity request", err) - c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) + handleActivityError(c, err) return } diff --git a/backend/src/api/routers/errors.go b/backend/src/api/routers/errors.go index 3624be1..b29d43d 100644 --- a/backend/src/api/routers/errors.go +++ b/backend/src/api/routers/errors.go @@ -2,7 +2,10 @@ package routers import ( "FamilyHub/src/api/dto" + "FamilyHub/src/api/requests" + "FamilyHub/src/api/services" receiptServiceIntegration "FamilyHub/src/integrations/receiptProvider" + "database/sql" "errors" "log" "net/http" @@ -60,3 +63,36 @@ func handleReceiptError(c *gin.Context, err error) { c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) } } + +func handleActivityError(c *gin.Context, err error) { + logInternalError(c, "activity request", err) + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) +} + +func handleFamilyError(c *gin.Context, err error) { + switch { + case errors.Is(err, services.ErrFamilyNotFound): + c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) + case errors.Is(err, sql.ErrNoRows): + c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: "family not found"}) + case errors.Is(err, requests.ErrFamilyNameRequired): + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + default: + logInternalError(c, "family request", err) + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) + } +} + +func handleUserError(c *gin.Context, err error) { + switch { + case errors.Is(err, services.ErrUserNotFound): + c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) + case errors.Is(err, services.ErrInvalidPatch): + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + case errors.Is(err, services.ErrTelegramIDMissing): + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + default: + logInternalError(c, "user request", err) + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) + } +} diff --git a/backend/src/api/routers/families.go b/backend/src/api/routers/families.go index db878e4..fa8ad2b 100644 --- a/backend/src/api/routers/families.go +++ b/backend/src/api/routers/families.go @@ -1,12 +1,11 @@ package routers import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/api/requests" "FamilyHub/src/api/services" "FamilyHub/src/domain" - "database/sql" - "errors" "net/http" - "strconv" "github.com/gin-gonic/gin" ) @@ -37,15 +36,15 @@ func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) { // @Produce json // @Param family body domain.CreateFamilyRequest true "Family info" // @Success 201 {object} domain.FamilyResponse -// @Failure 400 {object} map[string]string "invalid body" -// @Failure 500 {object} map[string]string "internal server error" +// @Failure 400 {object} dto.ErrorResponse "invalid body" +// @Failure 500 {object} dto.ErrorResponse "internal server error" // @Router /api/v1/families [post] func (router *FamiliesRouter) Create(c *gin.Context) { var req domain.CreateFamilyRequest var resp domain.FamilyResponse if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } @@ -66,16 +65,16 @@ func (router *FamiliesRouter) Create(c *gin.Context) { // @Produce json // @Param id path int true "Family ID" // @Success 200 {object} domain.FamilyResponse -// @Failure 400 {object} map[string]string "invalid id" -// @Failure 404 {object} map[string]string "family not found" -// @Failure 500 {object} map[string]string "internal server error" +// @Failure 400 {object} dto.ErrorResponse "invalid id" +// @Failure 404 {object} dto.ErrorResponse "family not found" +// @Failure 500 {object} dto.ErrorResponse "internal server error" // @Router /api/v1/families/{id} [get] func (router *FamiliesRouter) Read(c *gin.Context) { var resp domain.FamilyResponse - id, err := strconv.ParseInt(c.Param("id"), 10, 64) + id, err := requests.ParseInt64(c.Param("id"), "invalid id") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } @@ -97,27 +96,27 @@ func (router *FamiliesRouter) Read(c *gin.Context) { // @Param id path int true "Family ID" // @Param family body domain.UpdateFamilyRequest true "Данные для обновления" // @Success 200 {object} domain.FamilyResponse -// @Failure 400 {object} map[string]string "invalid id or invalid body" -// @Failure 400 {object} map[string]string "name is required" -// @Failure 404 {object} map[string]string "family not found" -// @Failure 500 {object} map[string]string "internal server error" +// @Failure 400 {object} dto.ErrorResponse "invalid id or invalid body" +// @Failure 400 {object} dto.ErrorResponse "name is required" +// @Failure 404 {object} dto.ErrorResponse "family not found" +// @Failure 500 {object} dto.ErrorResponse "internal server error" // @Router /api/v1/families/{id} [patch] func (router *FamiliesRouter) Update(c *gin.Context) { var resp domain.FamilyResponse - id, err := strconv.ParseInt(c.Param("id"), 10, 64) + id, err := requests.ParseInt64(c.Param("id"), "invalid id") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } var req domain.UpdateFamilyRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } - if req.Name == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + if err := requests.ValidateFamilyUpdate(req); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } @@ -138,14 +137,14 @@ func (router *FamiliesRouter) Update(c *gin.Context) { // @Produce json // @Param id path int true "Family ID" // @Success 204 {string} string "no content" -// @Failure 400 {object} map[string]string "invalid id" -// @Failure 404 {object} map[string]string "family not found" -// @Failure 500 {object} map[string]string "internal server error" +// @Failure 400 {object} dto.ErrorResponse "invalid id" +// @Failure 404 {object} dto.ErrorResponse "family not found" +// @Failure 500 {object} dto.ErrorResponse "internal server error" // @Router /api/v1/families/{id} [delete] func (router *FamiliesRouter) Delete(c *gin.Context) { - id, err := strconv.ParseInt(c.Param("id"), 10, 64) + id, err := requests.ParseInt64(c.Param("id"), "invalid id") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } @@ -156,15 +155,3 @@ func (router *FamiliesRouter) Delete(c *gin.Context) { c.Status(http.StatusNoContent) } - -func handleFamilyError(c *gin.Context, err error) { - switch { - case errors.Is(err, services.ErrFamilyNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - case errors.Is(err, sql.ErrNoRows): - c.JSON(http.StatusNotFound, gin.H{"error": "family not found"}) - default: - logInternalError(c, "family request", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) - } -} diff --git a/backend/src/api/routers/families_test.go b/backend/src/api/routers/families_test.go index 095ea7b..e956546 100644 --- a/backend/src/api/routers/families_test.go +++ b/backend/src/api/routers/families_test.go @@ -93,7 +93,7 @@ func TestFamiliesRouter_Create(t *testing.T) { r.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "error") + assert.Contains(t, w.Body.String(), "message") }) t.Run("internal error", func(t *testing.T) { @@ -205,7 +205,7 @@ func TestFamiliesRouter_Update(t *testing.T) { r.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "error") + assert.Contains(t, w.Body.String(), "message") }) t.Run("bad request on missing name", func(t *testing.T) { diff --git a/backend/src/api/routers/transactions.go b/backend/src/api/routers/transactions.go index 4bf7763..9d3025e 100644 --- a/backend/src/api/routers/transactions.go +++ b/backend/src/api/routers/transactions.go @@ -41,12 +41,16 @@ func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) { // Create GoDoc // @Summary Создать транзакцию -// @Description Создает новую транзакцию и при необходимости привязывает к ней чек +// @Description Создает транзакцию одним из трех способов. +// @Description 1. application/json: ручная транзакция с полями family_id, created_by, type, category, amount, datetime. +// @Description 2. application/json: транзакция по чеку с полями family_id, created_by, receipt_number, receipt_date. +// @Description 3. multipart/form-data: транзакция по фото чека с полями photo, family_id, created_by и опциональными type, category, description. +// @Description В одном JSON-запросе нельзя смешивать ручные поля транзакции с полями receipt_number и receipt_date. // @Tags Transactions // @Accept json // @Accept multipart/form-data // @Produce json -// @Param transaction body dto.CreateTransactionRequest true "Transaction payload" +// @Param transaction body dto.CreateTransactionRequest false "JSON payload for manual or receipt-based transaction creation" // @Success 201 {object} dto.TransactionResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse @@ -101,7 +105,25 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) { return } - input, err := requests.BuildPhotoCreateTransactionInput(c, imageBytes) + familyID, err := parseOptionalInt64Form(c, "family_id") + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + return + } + createdBy, err := parseOptionalInt64Form(c, "created_by") + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + return + } + + input, err := requests.BuildPhotoCreateTransactionInput(requests.PhotoCreateTransactionFields{ + Image: imageBytes, + FamilyID: familyID, + CreatedBy: createdBy, + Type: parseOptionalStringForm(c, "type"), + Category: parseOptionalStringForm(c, "category"), + Description: parseOptionalStringForm(c, "description"), + }) if err != nil { c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return @@ -364,3 +386,26 @@ func transactionQueryToFilter(query dto.ListTransactionsQuery) (domain.Transacti return filter, nil } + +func parseOptionalInt64Form(c *gin.Context, key string) (*int64, error) { + value := strings.TrimSpace(c.PostForm(key)) + if value == "" { + return nil, nil + } + + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, errors.New(key + " must be int64") + } + + return &parsed, nil +} + +func parseOptionalStringForm(c *gin.Context, key string) *string { + value := strings.TrimSpace(c.PostForm(key)) + if value == "" { + return nil + } + + return &value +} diff --git a/backend/src/api/routers/users.go b/backend/src/api/routers/users.go index a210f20..780c4d0 100644 --- a/backend/src/api/routers/users.go +++ b/backend/src/api/routers/users.go @@ -1,11 +1,11 @@ package routers import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/api/requests" "FamilyHub/src/api/services" "FamilyHub/src/domain" - "errors" "net/http" - "strconv" "github.com/gin-gonic/gin" ) @@ -36,21 +36,21 @@ func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) { // @Produce json // @Param user body domain.CreateUserRequest true "User info" // @Success 201 {object} domain.UserResponse -// @Failure 400 {object} domain.UserErrorResponse -// @Failure 500 {object} domain.UserErrorResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse // @Router /api/v1/users [post] func (router *UsersRouter) Create(c *gin.Context) { var req domain.CreateUserRequest var resp domain.UserResponse if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } user, err := router.service.Create(c.Request.Context(), req) if err != nil { - handleError(c, err) + handleUserError(c, err) return } @@ -65,21 +65,21 @@ func (router *UsersRouter) Create(c *gin.Context) { // @Produce json // @Param id path int true "User ID" // @Success 200 {object} domain.UserResponse -// @Failure 400 {object} domain.UserErrorResponse "invalid id" -// @Failure 404 {object} domain.UserErrorResponse "user not found" -// @Failure 500 {object} domain.UserErrorResponse "internal server error" +// @Failure 400 {object} dto.ErrorResponse "invalid id" +// @Failure 404 {object} dto.ErrorResponse "user not found" +// @Failure 500 {object} dto.ErrorResponse "internal server error" // @Router /api/v1/users/{id} [get] func (router *UsersRouter) Read(c *gin.Context) { var resp domain.UserResponse - id, err := strconv.ParseInt(c.Param("id"), 10, 64) + id, err := requests.ParseInt64(c.Param("id"), "invalid id") if err != nil { - c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } user, err := router.service.GetByID(c.Request.Context(), id) if err != nil { - handleError(c, err) + handleUserError(c, err) return } @@ -94,21 +94,21 @@ func (router *UsersRouter) Read(c *gin.Context) { // @Produce json // @Param telegramId path int true "Telegram ID" // @Success 200 {object} domain.UserResponse -// @Failure 400 {object} domain.UserErrorResponse "invalid telegram id" -// @Failure 404 {object} domain.UserErrorResponse "user not found" -// @Failure 500 {object} domain.UserErrorResponse "internal server error" +// @Failure 400 {object} dto.ErrorResponse "invalid telegram id" +// @Failure 404 {object} dto.ErrorResponse "user not found" +// @Failure 500 {object} dto.ErrorResponse "internal server error" // @Router /api/v1/users/by-telegram/{telegramId} [get] func (router *UsersRouter) GetByTelegramID(c *gin.Context) { var resp domain.UserResponse - telegramID, err := strconv.ParseInt(c.Param("telegramId"), 10, 64) + telegramID, err := requests.ParseInt64(c.Param("telegramId"), "invalid telegram id") if err != nil { - c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid telegram id"}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } user, err := router.service.GetByTelegramID(c.Request.Context(), telegramID) if err != nil { - handleError(c, err) + handleUserError(c, err) return } @@ -124,27 +124,27 @@ func (router *UsersRouter) GetByTelegramID(c *gin.Context) { // @Param id path int true "User ID" // @Param user body domain.UpdateUserRequest true "Данные для обновления" // @Success 200 {object} domain.UserResponse -// @Failure 400 {object} domain.UserErrorResponse "invalid id or invalid body" -// @Failure 404 {object} domain.UserErrorResponse "user not found" -// @Failure 500 {object} domain.UserErrorResponse "internal server error" +// @Failure 400 {object} dto.ErrorResponse "invalid id or invalid body" +// @Failure 404 {object} dto.ErrorResponse "user not found" +// @Failure 500 {object} dto.ErrorResponse "internal server error" // @Router /api/v1/users/{id} [patch] func (router *UsersRouter) Update(c *gin.Context) { var resp domain.UserResponse - id, err := strconv.ParseInt(c.Param("id"), 10, 64) + id, err := requests.ParseInt64(c.Param("id"), "invalid id") if err != nil { - c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } var req domain.UpdateUserRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } user, err := router.service.Update(c.Request.Context(), id, req) if err != nil { - handleError(c, err) + handleUserError(c, err) return } @@ -159,35 +159,21 @@ func (router *UsersRouter) Update(c *gin.Context) { // @Produce json // @Param id path int true "User ID" // @Success 204 {string} string "no content" -// @Failure 400 {object} domain.UserErrorResponse "invalid id" -// @Failure 404 {object} domain.UserErrorResponse "user not found" -// @Failure 500 {object} domain.UserErrorResponse "internal server error" +// @Failure 400 {object} dto.ErrorResponse "invalid id" +// @Failure 404 {object} dto.ErrorResponse "user not found" +// @Failure 500 {object} dto.ErrorResponse "internal server error" // @Router /api/v1/users/{id} [delete] func (router *UsersRouter) Delete(c *gin.Context) { - id, err := strconv.ParseInt(c.Param("id"), 10, 64) + id, err := requests.ParseInt64(c.Param("id"), "invalid id") if err != nil { - c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } if err := router.service.Delete(c.Request.Context(), id); err != nil { - handleError(c, err) + handleUserError(c, err) return } c.Status(http.StatusNoContent) } - -func handleError(c *gin.Context, err error) { - switch { - case errors.Is(err, services.ErrUserNotFound): - c.JSON(http.StatusNotFound, domain.UserErrorResponse{Error: err.Error()}) - case errors.Is(err, services.ErrInvalidPatch): - c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()}) - case errors.Is(err, services.ErrTelegramIDMissing): - c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()}) - default: - logInternalError(c, "user request", err) - c.JSON(http.StatusInternalServerError, domain.UserErrorResponse{Error: "internal server error"}) - } -} diff --git a/backend/src/api/routers/users_test.go b/backend/src/api/routers/users_test.go index c09411a..f8bdb0b 100644 --- a/backend/src/api/routers/users_test.go +++ b/backend/src/api/routers/users_test.go @@ -99,7 +99,7 @@ func TestUsersRouter_CreateUser(t *testing.T) { r.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "error") + assert.Contains(t, w.Body.String(), "message") }) t.Run("bad request on domain validation error", func(t *testing.T) { @@ -227,7 +227,7 @@ func TestUsersRouter_Update(t *testing.T) { r.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "error") + assert.Contains(t, w.Body.String(), "message") }) t.Run("bad request on invalid patch", func(t *testing.T) { diff --git a/backend/src/domain/users.go b/backend/src/domain/users.go index 5062586..76f6c15 100644 --- a/backend/src/domain/users.go +++ b/backend/src/domain/users.go @@ -41,10 +41,6 @@ type UserResponse struct { UpdatedAt string `json:"updated_at"` } -type UserErrorResponse struct { - Error string `json:"error"` -} - func (response *UserResponse) ModelToResponse(u *UserModel) UserResponse { return UserResponse{ ID: u.ID, diff --git a/docs/finance_module_specification.md b/docs/finance_module_specification.md index 6ffc309..02fdc24 100644 --- a/docs/finance_module_specification.md +++ b/docs/finance_module_specification.md @@ -7,9 +7,10 @@ - расходов - категорий -Поддерживает два способа ввода расходов: +Поддерживает три способа ввода расходов: 1. Ручной ввод -2. Сканирование чека (QR-код) +2. Ввод номера и даты чека +3. Загрузка фото чека --- @@ -155,13 +156,27 @@ categories ( --- -### 4.3 Сканирование чека +### 4.3 Добавление расхода по номеру и дате чека #### Поток: -1. Пользователь отправляет QR-код +1. Пользователь вводит номер и дату чека 2. Backend получает данные чека через внешний сервис 3. Создаётся запись в `receipts` -4. Для каждой позиции создаётся запись в `positions` +4. Создаётся связанная транзакция в `transactions` +5. Для каждой позиции создаётся запись в `positions` + +--- + +### 4.4 Добавление расхода по фото чека + +#### Поток: +1. Пользователь загружает фото чека +2. Backend извлекает текст через OCR +3. Backend извлекает из текста номер и дату чека +4. Backend получает данные чека через внешний сервис +5. Создаётся запись в `receipts` +6. Создаётся связанная транзакция в `transactions` +7. Для каждой позиции создаётся запись в `positions` --- @@ -169,54 +184,92 @@ categories ( ### Ручной ввод -``` -User → API → positions +```text +User → API POST /transactions → transactions ``` ### Доход +```text +User → API POST /transactions → transactions ``` -User → API → positions + +### Чек по номеру и дате + +```text +User → API POST /transactions → receipt provider → receipts + positions + transactions +``` + +### Чек по фото + +```text +User → API POST /transactions multipart/form-data → OCR → receipt provider → receipts + positions + transactions ``` --- ## 6. API (черновик) -### Создание позиции +### Создание транзакции вручную -``` -POST /positions +```http +POST /api/v1/transactions +Content-Type: application/json ``` ```json { + "family_id": 1, + "created_by": 2, + "type": "expense", + "category": "groceries", "amount": 1000, - "category_id": 1, - "description": "Продукты" + "description": "Продукты", + "datetime": "2026-01-21T10:11:12Z" } ``` --- -### Сканирование чека +### Создание транзакции по номеру и дате чека -``` -POST /receipts/scan +```http +POST /api/v1/transactions +Content-Type: application/json ``` ```json { - "qr_data": "string" + "family_id": 1, + "created_by": 2, + "receipt_number": "0123456789ABCDEFGHIJKLMN", + "receipt_date": "21.01.2026" } ``` --- -### Получение позиций +### Создание транзакции по фото чека +```http +POST /api/v1/transactions +Content-Type: multipart/form-data ``` -GET /positions + +Поля формы: +- `photo` — файл изображения чека, обязательно +- `family_id` — ID семьи, обязательно +- `created_by` — ID пользователя, обязательно +- `type` — тип транзакции, опционально, по умолчанию `expense` +- `category` — категория, опционально, по умолчанию `receipt` +- `description` — описание транзакции, опционально + +--- + +### Получение транзакций + +```http +GET /api/v1/transactions ``` Фильтры: @@ -227,6 +280,21 @@ GET /positions --- +### Правила для `POST /api/v1/transactions` + +Используется ровно один сценарий создания: + +1. Ручная транзакция: + обязательны `family_id`, `created_by`, `type`, `category`, `amount`, `datetime` +2. Транзакция по чеку: + обязательны `family_id`, `created_by`, `receipt_number`, `receipt_date` +3. Транзакция по фото: + обязательны `photo`, `family_id`, `created_by` + +Нельзя смешивать ручные поля транзакции (`amount`, `datetime`, `receipt_id`) с полями чека (`receipt_number`, `receipt_date`) в одном JSON-запросе. + +--- + ## 7. Задачи для разработки ### Этап 1 — База @@ -242,10 +310,10 @@ GET /positions --- -### Этап 3 — Позиции +### Этап 3 — Транзакции -- [ ] Endpoint создания позиции -- [ ] Endpoint получения списка +- [x] Endpoint создания транзакции +- [x] Endpoint получения списка - [ ] Фильтрация --- @@ -259,9 +327,11 @@ GET /positions ### Этап 5 — Чеки -- [ ] Endpoint загрузки QR +- [x] Endpoint загрузки фото чека через `POST /transactions` - [ ] Интеграция с сервисом чеков -- [ ] Создание receipts +- [x] Создание receipts +- [x] Создание транзакции по номеру и дате чека +- [x] Создание транзакции по фото чека - [ ] Создание positions --- @@ -295,5 +365,5 @@ GET /positions - [ ] Нужна ли мультивалютность? - [ ] Можно ли редактировать чек? - [ ] Как обрабатывать ошибки OCR? +- [ ] Нужен ли отдельный endpoint для повторной привязки чека к существующей транзакции? - [ ] Нужны ли роли внутри семьи? -