From 545b05d5a01c424c43bdaab9bdbdceaeef4f5150 Mon Sep 17 00:00:00 2001 From: AlexBelyan Date: Sat, 11 Apr 2026 11:12:54 +0300 Subject: [PATCH] Added transaction feature, fixed some mistakes --- .gitignore | 3 +- backend/migrations/000001_create_users.up.sql | 2 +- .../migrations/000002_create_families.up.sql | 2 +- .../000004_create_family_threads.down.sql | 2 - .../000004_create_family_threads.up.sql | 19 - ...tp.down.sql => 000004_create_otp.down.sql} | 0 ...te_otp.up.sql => 000004_create_otp.up.sql} | 0 .../000005_create_receipts.down.sql | 3 +- .../migrations/000005_create_receipts.up.sql | 24 +- backend/src/api/docs/docs.go | 505 ++++++++++++++++++ backend/src/api/docs/swagger.json | 505 ++++++++++++++++++ backend/src/api/docs/swagger.yaml | 337 ++++++++++++ backend/src/api/dto/transactions.go | 79 +++ backend/src/api/routers/families.go | 16 +- backend/src/api/routers/families_test.go | 19 +- backend/src/api/routers/receipts.go | 24 +- backend/src/api/routers/receipts_test.go | 22 +- backend/src/api/routers/transactions.go | 266 +++++++++ backend/src/api/routers/users.go | 14 +- backend/src/api/server.go | 15 +- backend/src/api/services/families.go | 2 +- backend/src/api/services/transactions.go | 177 ++++++ backend/src/bot/handlers/create_family.go | 5 +- backend/src/domain/auth.go | 3 +- backend/src/domain/families.go | 40 +- backend/src/domain/receipt.go | 19 +- backend/src/domain/transaction.go | 48 ++ .../receiptService/receipt_service.go | 99 +++- backend/src/repositories/families.go | 1 - backend/src/repositories/receipts.go | 15 +- backend/src/repositories/transactions.go | 279 ++++++++++ docs/finance_module_specification.md | 6 +- frontend/src/App.vue | 28 +- frontend/src/api/families.ts | 19 + frontend/src/components/Header.vue | 9 +- frontend/src/vite-env.d.ts | 9 + frontend/vite.config.ts | 8 + 37 files changed, 2509 insertions(+), 115 deletions(-) delete mode 100644 backend/migrations/000004_create_family_threads.down.sql delete mode 100644 backend/migrations/000004_create_family_threads.up.sql rename backend/migrations/{000007_create_otp.down.sql => 000004_create_otp.down.sql} (100%) rename backend/migrations/{000007_create_otp.up.sql => 000004_create_otp.up.sql} (100%) create mode 100644 backend/src/api/dto/transactions.go create mode 100644 backend/src/api/routers/transactions.go create mode 100644 backend/src/api/services/transactions.go create mode 100644 backend/src/domain/transaction.go create mode 100644 backend/src/repositories/transactions.go create mode 100644 frontend/src/api/families.ts create mode 100644 frontend/src/vite-env.d.ts diff --git a/.gitignore b/.gitignore index b9e09f7..f4f75a7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ secret_key.json data archive volumes -*.dtmp \ No newline at end of file +*.dtmp +*.gocache \ No newline at end of file diff --git a/backend/migrations/000001_create_users.up.sql b/backend/migrations/000001_create_users.up.sql index 9c4e28d..32638d6 100644 --- a/backend/migrations/000001_create_users.up.sql +++ b/backend/migrations/000001_create_users.up.sql @@ -1,7 +1,7 @@ CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, - telegram_id BIGINT UNIQUE NOT NULL, + telegram_id BIGINT UNIQUE, username TEXT, first_name TEXT NOT NULL, last_name TEXT, diff --git a/backend/migrations/000002_create_families.up.sql b/backend/migrations/000002_create_families.up.sql index ec260d4..9d7b640 100644 --- a/backend/migrations/000002_create_families.up.sql +++ b/backend/migrations/000002_create_families.up.sql @@ -3,7 +3,7 @@ CREATE TABLE families id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, owner_id BIGINT NOT NULL REFERENCES users (id), - telegram_chat_id BIGINT NOT NULL, + telegram_chat_id BIGINT, telegram_chat_name TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() diff --git a/backend/migrations/000004_create_family_threads.down.sql b/backend/migrations/000004_create_family_threads.down.sql deleted file mode 100644 index 32fdf42..0000000 --- a/backend/migrations/000004_create_family_threads.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS threads; -DROP TYPE IF EXISTS thread_type; \ No newline at end of file diff --git a/backend/migrations/000004_create_family_threads.up.sql b/backend/migrations/000004_create_family_threads.up.sql deleted file mode 100644 index 309481e..0000000 --- a/backend/migrations/000004_create_family_threads.up.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE family_threads -( - id BIGSERIAL PRIMARY KEY, - family_id BIGINT NOT NULL REFERENCES families (id) ON DELETE CASCADE, - type TEXT NOT NULL, - title TEXT NOT NULL, - telegram_topic_id BIGINT NOT NULL, - is_system BOOLEAN NOT NULL DEFAULT FALSE, - created_by BIGINT NOT NULL REFERENCES users (id), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - - UNIQUE (family_id, telegram_topic_id) -); - -CREATE UNIQUE INDEX idx_unique_system_threads ON family_threads (family_id, type) WHERE is_system = TRUE; - -CREATE INDEX idx_threads_family_id ON family_threads (family_id); - -CREATE INDEX idx_threads_family_type ON family_threads (family_id, type); diff --git a/backend/migrations/000007_create_otp.down.sql b/backend/migrations/000004_create_otp.down.sql similarity index 100% rename from backend/migrations/000007_create_otp.down.sql rename to backend/migrations/000004_create_otp.down.sql diff --git a/backend/migrations/000007_create_otp.up.sql b/backend/migrations/000004_create_otp.up.sql similarity index 100% rename from backend/migrations/000007_create_otp.up.sql rename to backend/migrations/000004_create_otp.up.sql diff --git a/backend/migrations/000005_create_receipts.down.sql b/backend/migrations/000005_create_receipts.down.sql index 55f6cbd..b1dad7c 100644 --- a/backend/migrations/000005_create_receipts.down.sql +++ b/backend/migrations/000005_create_receipts.down.sql @@ -1 +1,2 @@ -DROP TABLE receipts; +DROP TABLE IF EXISTS receipts; +DROP TABLE IF EXISTS transactions; diff --git a/backend/migrations/000005_create_receipts.up.sql b/backend/migrations/000005_create_receipts.up.sql index acabcf2..ecca608 100644 --- a/backend/migrations/000005_create_receipts.up.sql +++ b/backend/migrations/000005_create_receipts.up.sql @@ -1,6 +1,25 @@ +CREATE TABLE transactions +( + id BIGSERIAL PRIMARY KEY, + family_id BIGINT NOT NULL REFERENCES families (id) ON DELETE CASCADE, + description TEXT, + type TEXT NOT NULL, + datetime TIMESTAMP NOT NULL, + category TEXT NOT NULL, + amount NUMERIC(14, 2) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE RESTRICT +); + +CREATE INDEX idx_transactions_family_id ON transactions (family_id); +CREATE INDEX idx_transactions_datetime ON transactions (datetime); +CREATE INDEX idx_transactions_created_by ON transactions (created_by); +CREATE INDEX idx_transactions_family_datetime ON transactions (family_id, datetime DESC); + CREATE TABLE receipts ( id BIGSERIAL PRIMARY KEY, + transaction_id BIGINT UNIQUE REFERENCES transactions (id) ON DELETE SET NULL, receipt_number TEXT NOT NULL UNIQUE, ui TEXT NOT NULL, status INTEGER NOT NULL, @@ -29,7 +48,8 @@ CREATE TABLE receipts skno_number TEXT, unp TEXT, success TEXT, - created_at TIMESTAMP DEFAULT NOW() + created_at TIMESTAMP NOT NULL DEFAULT NOW() ); -CREATE INDEX idx_receipts_issued_at ON receipts(issued_at); \ No newline at end of file +CREATE INDEX idx_receipts_issued_at ON receipts (issued_at); +CREATE INDEX idx_receipts_transaction_id ON receipts (transaction_id); diff --git a/backend/src/api/docs/docs.go b/backend/src/api/docs/docs.go index 108a342..3ee49d4 100644 --- a/backend/src/api/docs/docs.go +++ b/backend/src/api/docs/docs.go @@ -249,6 +249,342 @@ const docTemplate = `{ } } }, + "/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" + } + } + } + } + }, + "/transactions": { + "get": { + "description": "Возвращает список транзакций с фильтрами и пагинацией", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Получить список транзакций", + "parameters": [ + { + "type": "integer", + "description": "Family ID", + "name": "family_id", + "in": "query" + }, + { + "type": "integer", + "description": "User ID", + "name": "created_by", + "in": "query" + }, + { + "type": "string", + "description": "Transaction type", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Category", + "name": "category", + "in": "query" + }, + { + "type": "string", + "description": "RFC3339 start datetime", + "name": "date_from", + "in": "query" + }, + { + "type": "string", + "description": "RFC3339 end datetime", + "name": "date_to", + "in": "query" + }, + { + "type": "integer", + "description": "Limit, default 50", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.TransactionListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + }, + "post": { + "description": "Создает новую транзакцию и при необходимости привязывает к ней чек", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Создать транзакцию", + "parameters": [ + { + "description": "Transaction payload", + "name": "transaction", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CreateTransactionRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/dto.TransactionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, + "/transactions/{id}": { + "get": { + "description": "Возвращает транзакцию по ее внутреннему ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Получить транзакцию по ID", + "parameters": [ + { + "type": "integer", + "description": "Transaction ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.TransactionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Удаляет транзакцию по ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Удалить транзакцию", + "parameters": [ + { + "type": "integer", + "description": "Transaction ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + }, + "patch": { + "description": "Частично обновляет данные транзакции и связь с чеком", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Обновить транзакцию", + "parameters": [ + { + "type": "integer", + "description": "Transaction ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Transaction patch payload", + "name": "transaction", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.UpdateTransactionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.TransactionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/users": { "post": { "consumes": [ @@ -501,6 +837,55 @@ 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": { @@ -574,6 +959,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "telegram_chat_id": { + "type": "integer" + }, "telegram_chat_name": { "type": "string" } @@ -632,6 +1020,123 @@ const docTemplate = `{ "type": "string" } } + }, + "dto.CreateTransactionRequest": { + "type": "object", + "required": [ + "amount", + "category", + "created_by", + "datetime", + "family_id", + "type" + ], + "properties": { + "amount": { + "type": "number" + }, + "category": { + "type": "string" + }, + "created_by": { + "type": "integer" + }, + "datetime": { + "type": "string" + }, + "description": { + "type": "string" + }, + "family_id": { + "type": "integer" + }, + "receipt_id": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "dto.ErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "dto.TransactionListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TransactionResponse" + } + } + } + }, + "dto.TransactionResponse": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "category": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "integer" + }, + "datetime": { + "type": "string" + }, + "description": { + "type": "string" + }, + "family_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "receipt_id": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "dto.UpdateTransactionRequest": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "category": { + "type": "string" + }, + "datetime": { + "type": "string" + }, + "description": { + "type": "string" + }, + "detach_receipt": { + "type": "boolean" + }, + "receipt_id": { + "type": "integer" + }, + "type": { + "type": "string" + } + } } } }` diff --git a/backend/src/api/docs/swagger.json b/backend/src/api/docs/swagger.json index 4681e34..dcd4d65 100644 --- a/backend/src/api/docs/swagger.json +++ b/backend/src/api/docs/swagger.json @@ -238,6 +238,342 @@ } } }, + "/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" + } + } + } + } + }, + "/transactions": { + "get": { + "description": "Возвращает список транзакций с фильтрами и пагинацией", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Получить список транзакций", + "parameters": [ + { + "type": "integer", + "description": "Family ID", + "name": "family_id", + "in": "query" + }, + { + "type": "integer", + "description": "User ID", + "name": "created_by", + "in": "query" + }, + { + "type": "string", + "description": "Transaction type", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Category", + "name": "category", + "in": "query" + }, + { + "type": "string", + "description": "RFC3339 start datetime", + "name": "date_from", + "in": "query" + }, + { + "type": "string", + "description": "RFC3339 end datetime", + "name": "date_to", + "in": "query" + }, + { + "type": "integer", + "description": "Limit, default 50", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.TransactionListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + }, + "post": { + "description": "Создает новую транзакцию и при необходимости привязывает к ней чек", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Создать транзакцию", + "parameters": [ + { + "description": "Transaction payload", + "name": "transaction", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CreateTransactionRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/dto.TransactionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, + "/transactions/{id}": { + "get": { + "description": "Возвращает транзакцию по ее внутреннему ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Получить транзакцию по ID", + "parameters": [ + { + "type": "integer", + "description": "Transaction ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.TransactionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Удаляет транзакцию по ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Удалить транзакцию", + "parameters": [ + { + "type": "integer", + "description": "Transaction ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + }, + "patch": { + "description": "Частично обновляет данные транзакции и связь с чеком", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Обновить транзакцию", + "parameters": [ + { + "type": "integer", + "description": "Transaction ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Transaction patch payload", + "name": "transaction", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.UpdateTransactionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.TransactionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/users": { "post": { "consumes": [ @@ -490,6 +826,55 @@ } }, "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": { @@ -563,6 +948,9 @@ "name": { "type": "string" }, + "telegram_chat_id": { + "type": "integer" + }, "telegram_chat_name": { "type": "string" } @@ -621,6 +1009,123 @@ "type": "string" } } + }, + "dto.CreateTransactionRequest": { + "type": "object", + "required": [ + "amount", + "category", + "created_by", + "datetime", + "family_id", + "type" + ], + "properties": { + "amount": { + "type": "number" + }, + "category": { + "type": "string" + }, + "created_by": { + "type": "integer" + }, + "datetime": { + "type": "string" + }, + "description": { + "type": "string" + }, + "family_id": { + "type": "integer" + }, + "receipt_id": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "dto.ErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "dto.TransactionListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TransactionResponse" + } + } + } + }, + "dto.TransactionResponse": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "category": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "integer" + }, + "datetime": { + "type": "string" + }, + "description": { + "type": "string" + }, + "family_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "receipt_id": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "dto.UpdateTransactionRequest": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "category": { + "type": "string" + }, + "datetime": { + "type": "string" + }, + "description": { + "type": "string" + }, + "detach_receipt": { + "type": "boolean" + }, + "receipt_id": { + "type": "integer" + }, + "type": { + "type": "string" + } + } } } } \ No newline at end of file diff --git a/backend/src/api/docs/swagger.yaml b/backend/src/api/docs/swagger.yaml index e254aeb..ad9c251 100644 --- a/backend/src/api/docs/swagger.yaml +++ b/backend/src/api/docs/swagger.yaml @@ -1,4 +1,37 @@ 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: @@ -47,6 +80,8 @@ definitions: properties: name: type: string + telegram_chat_id: + type: integer telegram_chat_name: type: string type: object @@ -85,6 +120,84 @@ definitions: username: type: string type: object + dto.CreateTransactionRequest: + properties: + amount: + type: number + category: + type: string + created_by: + type: integer + datetime: + type: string + description: + type: string + family_id: + type: integer + receipt_id: + type: integer + type: + type: string + required: + - amount + - category + - created_by + - datetime + - family_id + - type + type: object + dto.ErrorResponse: + properties: + message: + type: string + type: object + dto.TransactionListResponse: + properties: + items: + items: + $ref: '#/definitions/dto.TransactionResponse' + type: array + type: object + dto.TransactionResponse: + properties: + amount: + type: number + category: + type: string + created_at: + type: string + created_by: + type: integer + datetime: + type: string + description: + type: string + family_id: + type: integer + id: + type: integer + receipt_id: + type: integer + type: + type: string + type: object + dto.UpdateTransactionRequest: + properties: + amount: + type: number + category: + type: string + datetime: + type: string + description: + type: string + detach_receipt: + type: boolean + receipt_id: + type: integer + type: + type: string + type: object info: contact: {} paths: @@ -243,6 +356,230 @@ paths: summary: Обновить семью tags: - Families + /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 + /transactions: + get: + consumes: + - application/json + description: Возвращает список транзакций с фильтрами и пагинацией + parameters: + - description: Family ID + in: query + name: family_id + type: integer + - description: User ID + in: query + name: created_by + type: integer + - description: Transaction type + in: query + name: type + type: string + - description: Category + in: query + name: category + type: string + - description: RFC3339 start datetime + in: query + name: date_from + type: string + - description: RFC3339 end datetime + in: query + name: date_to + type: string + - description: Limit, default 50 + in: query + name: limit + type: integer + - description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.TransactionListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/dto.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: Получить список транзакций + tags: + - Transactions + post: + consumes: + - application/json + description: Создает новую транзакцию и при необходимости привязывает к ней + чек + parameters: + - description: Transaction payload + in: body + name: transaction + required: true + schema: + $ref: '#/definitions/dto.CreateTransactionRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/dto.TransactionResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/dto.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/dto.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: Создать транзакцию + tags: + - Transactions + /transactions/{id}: + delete: + consumes: + - application/json + description: Удаляет транзакцию по ID + parameters: + - description: Transaction ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: no content + schema: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/dto.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/dto.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: Удалить транзакцию + tags: + - Transactions + get: + consumes: + - application/json + description: Возвращает транзакцию по ее внутреннему ID + parameters: + - description: Transaction ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.TransactionResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/dto.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/dto.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: Получить транзакцию по ID + tags: + - Transactions + patch: + consumes: + - application/json + description: Частично обновляет данные транзакции и связь с чеком + parameters: + - description: Transaction ID + in: path + name: id + required: true + type: integer + - description: Transaction patch payload + in: body + name: transaction + required: true + schema: + $ref: '#/definitions/dto.UpdateTransactionRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.TransactionResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/dto.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/dto.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: Обновить транзакцию + tags: + - Transactions /users: post: consumes: diff --git a/backend/src/api/dto/transactions.go b/backend/src/api/dto/transactions.go new file mode 100644 index 0000000..9a78bcd --- /dev/null +++ b/backend/src/api/dto/transactions.go @@ -0,0 +1,79 @@ +package dto + +import ( + "FamilyHub/src/domain" + "time" +) + +type CreateTransactionRequest struct { + FamilyID int64 `json:"family_id" binding:"required"` + Description *string `json:"description"` + Type string `json:"type" binding:"required"` + DateTime string `json:"datetime" binding:"required"` + Category string `json:"category" binding:"required"` + Amount float64 `json:"amount" binding:"required"` + CreatedBy int64 `json:"created_by" binding:"required"` + ReceiptID *int64 `json:"receipt_id"` +} + +type UpdateTransactionRequest struct { + Description *string `json:"description"` + Type *string `json:"type"` + DateTime *string `json:"datetime"` + Category *string `json:"category"` + Amount *float64 `json:"amount"` + ReceiptID *int64 `json:"receipt_id"` + DetachReceipt bool `json:"detach_receipt"` +} + +type ListTransactionsQuery struct { + FamilyID *int64 `form:"family_id"` + CreatedBy *int64 `form:"created_by"` + Type *string `form:"type"` + Category *string `form:"category"` + DateFrom *string `form:"date_from"` + DateTo *string `form:"date_to"` + Limit int `form:"limit"` + Offset int `form:"offset"` +} + +type TransactionResponse struct { + ID int64 `json:"id"` + FamilyID int64 `json:"family_id"` + Description *string `json:"description"` + Type string `json:"type"` + DateTime string `json:"datetime"` + Category string `json:"category"` + Amount float64 `json:"amount"` + CreatedAt string `json:"created_at"` + CreatedBy int64 `json:"created_by"` + ReceiptID *int64 `json:"receipt_id"` +} + +type TransactionListResponse struct { + Items []TransactionResponse `json:"items"` +} + +func TransactionToResponse(transaction *domain.Transaction) TransactionResponse { + return TransactionResponse{ + ID: transaction.ID, + FamilyID: transaction.FamilyID, + Description: transaction.Description, + Type: transaction.Type, + DateTime: transaction.DateTime.Format(time.RFC3339), + Category: transaction.Category, + Amount: transaction.Amount, + CreatedAt: transaction.CreatedAt.Format(time.RFC3339), + CreatedBy: transaction.CreatedBy, + ReceiptID: transaction.ReceiptID, + } +} + +func TransactionsToListResponse(transactions []*domain.Transaction) TransactionListResponse { + items := make([]TransactionResponse, 0, len(transactions)) + for _, transaction := range transactions { + items = append(items, TransactionToResponse(transaction)) + } + + return TransactionListResponse{Items: items} +} diff --git a/backend/src/api/routers/families.go b/backend/src/api/routers/families.go index 65a677b..24802c1 100644 --- a/backend/src/api/routers/families.go +++ b/backend/src/api/routers/families.go @@ -5,7 +5,9 @@ import ( "FamilyHub/src/domain" "database/sql" "errors" + "log" "net/http" + "runtime/debug" "strconv" "github.com/gin-gonic/gin" @@ -23,7 +25,7 @@ func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) { families := r.Group("/families") { families.POST("", router.Create) - families.GET("/:id", router.GetByID) + families.GET("/:id", router.Read) families.PATCH("/:id", router.Update) families.DELETE("/:id", router.Delete) } @@ -58,7 +60,7 @@ func (router *FamiliesRouter) Create(c *gin.Context) { c.JSON(http.StatusCreated, resp.ModelToResponse(family)) } -// GetByID GoDoc +// Read GoDoc // @Summary Получить семью по ID // @Description Возвращает семью по ее внутреннему ID // @Tags Families @@ -70,7 +72,7 @@ func (router *FamiliesRouter) Create(c *gin.Context) { // @Failure 404 {object} map[string]string "family not found" // @Failure 500 {object} map[string]string "internal server error" // @Router /families/{id} [get] -func (router *FamiliesRouter) GetByID(c *gin.Context) { +func (router *FamiliesRouter) Read(c *gin.Context) { var resp domain.FamilyResponse id, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -164,6 +166,14 @@ func handleFamilyError(c *gin.Context, err error) { case errors.Is(err, sql.ErrNoRows): c.JSON(http.StatusNotFound, gin.H{"error": "family not found"}) default: + log.Printf( + "family request failed: method=%s path=%s route=%s error=%v\n%s", + c.Request.Method, + c.Request.URL.Path, + c.FullPath(), + err, + debug.Stack(), + ) 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 03d10a4..095ea7b 100644 --- a/backend/src/api/routers/families_test.go +++ b/backend/src/api/routers/families_test.go @@ -19,6 +19,14 @@ import ( "github.com/stretchr/testify/require" ) +func int64Ptr(v int64) *int64 { + return &v +} + +func stringPtr(v string) *string { + return &v +} + type familyServiceMock struct { createFn func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) getByIDFn func(ctx context.Context, id int64) (*domain.Family, error) @@ -68,8 +76,8 @@ func sampleFamily() *domain.Family { ID: 7, Name: "Belan", OwnerID: 10, - TelegramChatID: 12345, - TelegramChatName: "Family Chat", + TelegramChatID: int64Ptr(12345), + TelegramChatName: stringPtr("Family Chat"), CreatedAt: time.Date(2026, time.January, 21, 10, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, time.January, 21, 11, 0, 0, 0, time.UTC), } @@ -106,9 +114,11 @@ func TestFamiliesRouter_Create(t *testing.T) { expected := sampleFamily() r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) { assert.Equal(t, "Belan", req.Name) + assert.Nil(t, req.TelegramChatID) + assert.Nil(t, req.TelegramChatName) return expected, nil }}) - req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10,"telegram_chat_id":12345,"telegram_chat_name":"Family Chat"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -233,7 +243,8 @@ func TestFamiliesRouter_Update(t *testing.T) { assert.Equal(t, int64(7), id) require.NotNil(t, req.Name) assert.Equal(t, updatedName, *req.Name) - assert.Equal(t, "Updated", req.TelegramChatName) + require.NotNil(t, req.TelegramChatName) + assert.Equal(t, "Updated", *req.TelegramChatName) return expected, nil }}) req := httptest.NewRequest(http.MethodPatch, "/api/v1/families/7", bytes.NewBufferString(`{"name":"`+updatedName+`","telegram_chat_name":"Updated"}`)) diff --git a/backend/src/api/routers/receipts.go b/backend/src/api/routers/receipts.go index 616eef0..c157207 100644 --- a/backend/src/api/routers/receipts.go +++ b/backend/src/api/routers/receipts.go @@ -13,7 +13,7 @@ import ( ) type receiptService interface { - GetReceipt(ctx context.Context, date string, number string) (*domain.Receipt, error) + GetReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) } type ReceiptRouter struct { @@ -30,6 +30,17 @@ func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) { } } +// AddReceipt GoDoc +// @Summary Загрузить чек +// @Description Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию +// @Tags Receipts +// @Accept json +// @Produce json +// @Param receipt body domain.AddReceiptRequest true "Receipt payload" +// @Success 200 {object} domain.AddReceiptResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /receipts [post] func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) { var req domain.AddReceiptRequest if err := context_.ShouldBindJSON(&req); err != nil { @@ -46,7 +57,9 @@ func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - receipt, err := router.service.GetReceipt(ctx, isoDate, req.Number) + req.Date = isoDate + + receipt, err := router.service.GetReceipt(ctx, req) if err != nil { context_.JSON(400, gin.H{"error": err.Error()}) log.Printf("API error, %s", err.Error()) @@ -54,9 +67,10 @@ func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) { } resp := domain.AddReceiptResponse{ - ID: 1, - Number: receipt.ReceiptNumber, - Date: receipt.IssuedAt, + ID: int32(receipt.ID), + Number: receipt.ReceiptNumber, + Date: receipt.IssuedAt, + TransactionID: receipt.TransactionID, } context_.JSON(http.StatusOK, resp) } diff --git a/backend/src/api/routers/receipts_test.go b/backend/src/api/routers/receipts_test.go index 64d857d..0244a24 100644 --- a/backend/src/api/routers/receipts_test.go +++ b/backend/src/api/routers/receipts_test.go @@ -18,12 +18,12 @@ import ( ) type receiptServiceMock struct { - getReceiptFn func(ctx context.Context, date string, number string) (*domain.Receipt, error) + getReceiptFn func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) } -func (m *receiptServiceMock) GetReceipt(ctx context.Context, date string, number string) (*domain.Receipt, error) { +func (m *receiptServiceMock) GetReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { if m.getReceiptFn != nil { - return m.getReceiptFn(ctx, date, number) + return m.getReceiptFn(ctx, req) } return nil, errors.New("mock is not configured") } @@ -60,9 +60,9 @@ func TestReceiptRouter_AddReceipt(t *testing.T) { { name: "bad request on service error", body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`, - mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, date string, number string) (*domain.Receipt, error) { - assert.Equal(t, expectedDate, date) - assert.Equal(t, validNumber, number) + mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + assert.Equal(t, expectedDate, req.Date) + assert.Equal(t, validNumber, req.Number) return nil, errors.New("receipt not found") }}, expectedStatus: http.StatusBadRequest, @@ -71,10 +71,10 @@ func TestReceiptRouter_AddReceipt(t *testing.T) { { name: "ok", body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`, - mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, date string, number string) (*domain.Receipt, error) { - assert.Equal(t, expectedDate, date) - assert.Equal(t, validNumber, number) - return &domain.Receipt{ReceiptNumber: validNumber, IssuedAt: now}, nil + mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + assert.Equal(t, expectedDate, req.Date) + assert.Equal(t, validNumber, req.Number) + return &domain.Receipt{ID: 7, ReceiptNumber: validNumber, IssuedAt: now}, nil }}, expectedStatus: http.StatusOK, expectedContains: validNumber, @@ -108,7 +108,7 @@ func TestReceiptRouter_AddReceipt(t *testing.T) { } err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err) - assert.Equal(t, int32(1), resp.ID) + assert.Equal(t, int32(7), resp.ID) assert.Equal(t, validNumber, resp.Number) assert.Equal(t, now, resp.Date) } diff --git a/backend/src/api/routers/transactions.go b/backend/src/api/routers/transactions.go new file mode 100644 index 0000000..25bb0e0 --- /dev/null +++ b/backend/src/api/routers/transactions.go @@ -0,0 +1,266 @@ +package routers + +import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/api/services" + "FamilyHub/src/domain" + "errors" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type TransactionsRouter struct { + service services.TransactionService +} + +func NewTransactionsRouter(s services.TransactionService) *TransactionsRouter { + return &TransactionsRouter{service: s} +} + +func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) { + transactions := r.Group("/transactions") + { + transactions.POST("", router.Create) + transactions.GET("", router.List) + transactions.GET("/:id", router.Read) + transactions.PATCH("/:id", router.Update) + transactions.DELETE("/:id", router.Delete) + } +} + +// Create GoDoc +// @Summary Создать транзакцию +// @Description Создает новую транзакцию и при необходимости привязывает к ней чек +// @Tags Transactions +// @Accept json +// @Produce json +// @Param transaction body dto.CreateTransactionRequest true "Transaction payload" +// @Success 201 {object} dto.TransactionResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /transactions [post] +func (router *TransactionsRouter) Create(c *gin.Context) { + var req dto.CreateTransactionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + return + } + + dateTime, err := time.Parse(time.RFC3339, req.DateTime) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "datetime must be RFC3339"}) + return + } + + transaction, err := router.service.Create(c.Request.Context(), domain.CreateTransactionRequest{ + FamilyID: req.FamilyID, + Description: req.Description, + Type: req.Type, + DateTime: dateTime, + Category: req.Category, + Amount: req.Amount, + CreatedBy: req.CreatedBy, + ReceiptID: req.ReceiptID, + }) + if err != nil { + handleTransactionError(c, err) + return + } + + c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction)) +} + +// List GoDoc +// @Summary Получить список транзакций +// @Description Возвращает список транзакций с фильтрами и пагинацией +// @Tags Transactions +// @Accept json +// @Produce json +// @Param family_id query int false "Family ID" +// @Param created_by query int false "User ID" +// @Param type query string false "Transaction type" +// @Param category query string false "Category" +// @Param date_from query string false "RFC3339 start datetime" +// @Param date_to query string false "RFC3339 end datetime" +// @Param limit query int false "Limit, default 50" +// @Param offset query int false "Offset" +// @Success 200 {object} dto.TransactionListResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /transactions [get] +func (router *TransactionsRouter) List(c *gin.Context) { + var query dto.ListTransactionsQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + return + } + + filter, err := transactionQueryToFilter(query) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + return + } + + transactions, err := router.service.List(c.Request.Context(), filter) + if err != nil { + handleTransactionError(c, err) + return + } + + c.JSON(http.StatusOK, dto.TransactionsToListResponse(transactions)) +} + +// Read GoDoc +// @Summary Получить транзакцию по ID +// @Description Возвращает транзакцию по ее внутреннему ID +// @Tags Transactions +// @Accept json +// @Produce json +// @Param id path int true "Transaction ID" +// @Success 200 {object} dto.TransactionResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /transactions/{id} [get] +func (router *TransactionsRouter) Read(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"}) + return + } + + transaction, err := router.service.GetByID(c.Request.Context(), id) + if err != nil { + handleTransactionError(c, err) + return + } + + c.JSON(http.StatusOK, dto.TransactionToResponse(transaction)) +} + +// Update GoDoc +// @Summary Обновить транзакцию +// @Description Частично обновляет данные транзакции и связь с чеком +// @Tags Transactions +// @Accept json +// @Produce json +// @Param id path int true "Transaction ID" +// @Param transaction body dto.UpdateTransactionRequest true "Transaction patch payload" +// @Success 200 {object} dto.TransactionResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /transactions/{id} [patch] +func (router *TransactionsRouter) Update(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"}) + return + } + + var req dto.UpdateTransactionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + return + } + + var dateTime *time.Time + if req.DateTime != nil { + parsed, err := time.Parse(time.RFC3339, *req.DateTime) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "datetime must be RFC3339"}) + return + } + dateTime = &parsed + } + + transaction, err := router.service.Update(c.Request.Context(), id, domain.UpdateTransactionRequest{ + Description: req.Description, + Type: req.Type, + DateTime: dateTime, + Category: req.Category, + Amount: req.Amount, + ReceiptID: req.ReceiptID, + DetachReceipt: req.DetachReceipt, + }) + if err != nil { + handleTransactionError(c, err) + return + } + + c.JSON(http.StatusOK, dto.TransactionToResponse(transaction)) +} + +// Delete GoDoc +// @Summary Удалить транзакцию +// @Description Удаляет транзакцию по ID +// @Tags Transactions +// @Accept json +// @Produce json +// @Param id path int true "Transaction ID" +// @Success 204 {string} string "no content" +// @Failure 400 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /transactions/{id} [delete] +func (router *TransactionsRouter) Delete(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"}) + return + } + + if err := router.service.Delete(c.Request.Context(), id); err != nil { + handleTransactionError(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +func handleTransactionError(c *gin.Context, err error) { + switch { + case errors.Is(err, services.ErrTransactionNotFound): + c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) + case errors.Is(err, services.ErrTransactionPatch), + errors.Is(err, services.ErrReceiptLinkConflict), + errors.Is(err, services.ErrInvalidTransaction): + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + case errors.Is(err, services.ErrReceiptNotFound): + c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) + default: + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) + } +} + +func transactionQueryToFilter(query dto.ListTransactionsQuery) (domain.TransactionListFilter, error) { + filter := domain.TransactionListFilter{ + FamilyID: query.FamilyID, + CreatedBy: query.CreatedBy, + Type: query.Type, + Category: query.Category, + Limit: query.Limit, + Offset: query.Offset, + } + + if query.DateFrom != nil { + parsed, err := time.Parse(time.RFC3339, *query.DateFrom) + if err != nil { + return domain.TransactionListFilter{}, errors.New("date_from must be RFC3339") + } + filter.DateFrom = &parsed + } + if query.DateTo != nil { + parsed, err := time.Parse(time.RFC3339, *query.DateTo) + if err != nil { + return domain.TransactionListFilter{}, errors.New("date_to must be RFC3339") + } + filter.DateTo = &parsed + } + + return filter, nil +} diff --git a/backend/src/api/routers/users.go b/backend/src/api/routers/users.go index 7752108..b3abfad 100644 --- a/backend/src/api/routers/users.go +++ b/backend/src/api/routers/users.go @@ -21,15 +21,15 @@ func NewUsersRouter(s services.UserService) *UsersRouter { func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) { users := r.Group("/users") { - users.POST("", router.CreateUser) - users.GET("/:id", router.GetByID) - users.GET("/by-telegram/:telegramId", router.GetByTelegramID) + users.POST("", router.Create) + users.GET("/:id", router.Read) users.PATCH("/:id", router.Update) users.DELETE("/:id", router.Delete) + users.GET("/by-telegram/:telegramId", router.GetByTelegramID) } } -// CreateUser GoDoc +// Create GoDoc // @Summary Создать пользователя // @Tags Users // @Accept json @@ -39,7 +39,7 @@ func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) { // @Failure 400 {object} domain.UserErrorResponse // @Failure 500 {object} domain.UserErrorResponse // @Router /users [post] -func (router *UsersRouter) CreateUser(c *gin.Context) { +func (router *UsersRouter) Create(c *gin.Context) { var req domain.CreateUserRequest var resp domain.UserResponse @@ -57,7 +57,7 @@ func (router *UsersRouter) CreateUser(c *gin.Context) { c.JSON(http.StatusCreated, resp.ModelToResponse(user)) } -// GetByID GoDoc +// Read GoDoc // @Summary Получить пользователя по ID // @Description Возвращает пользователя по его внутреннему ID // @Tags Users @@ -69,7 +69,7 @@ func (router *UsersRouter) CreateUser(c *gin.Context) { // @Failure 404 {object} domain.UserErrorResponse "user not found" // @Failure 500 {object} domain.UserErrorResponse "internal server error" // @Router /users/{id} [get] -func (router *UsersRouter) GetByID(c *gin.Context) { +func (router *UsersRouter) Read(c *gin.Context) { var resp domain.UserResponse id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { diff --git a/backend/src/api/server.go b/backend/src/api/server.go index b8be76c..88ae7e2 100644 --- a/backend/src/api/server.go +++ b/backend/src/api/server.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "net/http/httptest" + "os" "strings" "github.com/gin-gonic/gin" @@ -36,8 +37,10 @@ func NewServer(cfg config.Config) *Server { log.Fatal(err) } - gin.SetMode(gin.ReleaseMode) - router := gin.Default() + //gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Logger()) + router.Use(gin.RecoveryWithWriter(os.Stderr)) if cfg.OpenAPIEnabled { openAPIEndpoint := cfg.OpenAPIEndpoint if openAPIEndpoint == "" { @@ -84,11 +87,17 @@ func NewServer(cfg config.Config) *Server { apiV1 := router.Group("/api/v1") + transactionRepo := repositories.NewTransactionsSQLRepository(dbConn) + receiptRepo := repositories.NewReceiptsSQLRepository(dbConn) - receiptService_ := receiptService.NewReceiptService(receiptRepo) + receiptService_ := receiptService.NewReceiptService(receiptRepo, transactionRepo) receiptRouter := routers.NewReceiptRouter(receiptService_) receiptRouter.RegisterRoutes(apiV1) + transactionService := services.NewTransactionService(transactionRepo) + transactionRouter := routers.NewTransactionsRouter(transactionService) + transactionRouter.RegisterRoutes(apiV1) + usersRepo := repositories.NewUsersSQLRepository(dbConn) usersService := services.NewUserService(usersRepo) usersRouter := routers.NewUsersRouter(usersService) diff --git a/backend/src/api/services/families.go b/backend/src/api/services/families.go index 0689b50..5b28537 100644 --- a/backend/src/api/services/families.go +++ b/backend/src/api/services/families.go @@ -61,7 +61,7 @@ func (s *familyService) Update(ctx context.Context, id int64, req domain.UpdateF ID: id, Name: *req.Name, OwnerID: existing.OwnerID, - TelegramChatID: existing.TelegramChatID, + TelegramChatID: req.TelegramChatID, TelegramChatName: req.TelegramChatName, }); err != nil { return nil, err diff --git a/backend/src/api/services/transactions.go b/backend/src/api/services/transactions.go new file mode 100644 index 0000000..9fe5057 --- /dev/null +++ b/backend/src/api/services/transactions.go @@ -0,0 +1,177 @@ +package services + +import ( + "FamilyHub/src/domain" + "FamilyHub/src/repositories" + "context" + "database/sql" + "errors" + "strings" +) + +type TransactionService interface { + Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) + GetByID(ctx context.Context, id int64) (*domain.Transaction, error) + List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) + Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) + Delete(ctx context.Context, id int64) error +} + +type transactionService struct { + repo repositories.TransactionRepository +} + +func NewTransactionService(repo repositories.TransactionRepository) TransactionService { + return &transactionService{repo: repo} +} + +var ( + ErrTransactionNotFound = errors.New("transaction not found") + ErrTransactionPatch = errors.New("empty update payload") + ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together") + ErrInvalidTransaction = errors.New("type and category are required") + ErrReceiptNotFound = errors.New("receipt not found") +) + +func (s *transactionService) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) { + if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Category) == "" { + return nil, ErrInvalidTransaction + } + + transaction := &domain.Transaction{ + FamilyID: req.FamilyID, + Description: req.Description, + Type: req.Type, + DateTime: req.DateTime, + Category: req.Category, + Amount: req.Amount, + CreatedBy: req.CreatedBy, + ReceiptID: req.ReceiptID, + } + + if err := s.repo.Create(ctx, transaction); err != nil { + if errors.Is(err, repositories.ErrReceiptNotFound) { + return nil, ErrReceiptNotFound + } + return nil, err + } + + return transaction, nil +} + +func (s *transactionService) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) { + transaction, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if transaction == nil { + return nil, ErrTransactionNotFound + } + + return transaction, nil +} + +func (s *transactionService) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) { + if filter.Limit <= 0 { + filter.Limit = 50 + } + if filter.Limit > 200 { + filter.Limit = 200 + } + if filter.Offset < 0 { + filter.Offset = 0 + } + + return s.repo.List(ctx, filter) +} + +func (s *transactionService) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) { + if req.ReceiptID != nil && req.DetachReceipt { + return nil, ErrReceiptLinkConflict + } + + existing, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if existing == nil { + return nil, ErrTransactionNotFound + } + + if req.Description == nil && + req.Type == nil && + req.DateTime == nil && + req.Category == nil && + req.Amount == nil && + req.ReceiptID == nil && + !req.DetachReceipt { + return nil, ErrTransactionPatch + } + + updated := &domain.Transaction{ + ID: existing.ID, + FamilyID: existing.FamilyID, + Description: existing.Description, + Type: existing.Type, + DateTime: existing.DateTime, + Category: existing.Category, + Amount: existing.Amount, + CreatedAt: existing.CreatedAt, + CreatedBy: existing.CreatedBy, + ReceiptID: existing.ReceiptID, + } + + if req.Description != nil { + updated.Description = req.Description + } + if req.Type != nil { + updated.Type = *req.Type + } + if req.DateTime != nil { + updated.DateTime = *req.DateTime + } + if req.Category != nil { + updated.Category = *req.Category + } + if req.Amount != nil { + updated.Amount = *req.Amount + } + + syncReceipt := false + if req.DetachReceipt { + updated.ReceiptID = nil + syncReceipt = true + } + if req.ReceiptID != nil { + updated.ReceiptID = req.ReceiptID + syncReceipt = true + } + + if strings.TrimSpace(updated.Type) == "" || strings.TrimSpace(updated.Category) == "" { + return nil, ErrInvalidTransaction + } + + if err := s.repo.Update(ctx, updated, syncReceipt); err != nil { + if errors.Is(err, repositories.ErrReceiptNotFound) { + return nil, ErrReceiptNotFound + } + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrTransactionNotFound + } + return nil, err + } + + return s.repo.GetByID(ctx, id) +} + +func (s *transactionService) Delete(ctx context.Context, id int64) error { + transaction, err := s.repo.GetByID(ctx, id) + if err != nil { + return err + } + if transaction == nil { + return ErrTransactionNotFound + } + + return s.repo.Delete(ctx, id) +} diff --git a/backend/src/bot/handlers/create_family.go b/backend/src/bot/handlers/create_family.go index 51697df..fb84442 100644 --- a/backend/src/bot/handlers/create_family.go +++ b/backend/src/bot/handlers/create_family.go @@ -76,12 +76,13 @@ func (h *Handler) handleCreateFamilyName(msg *tgbotapi.Message) { if strings.TrimSpace(chatName) == "" { chatName = familyName } + chatID := msg.Chat.ID err = h.receiptApi.CreateFamily(ctx, domain.CreateFamilyRequest{ Name: familyName, OwnerID: user.ID, - TelegramChatID: msg.Chat.ID, - TelegramChatName: chatName, + TelegramChatID: &chatID, + TelegramChatName: &chatName, }) if err != nil { log.Printf("failed to create family in api: %v", err) diff --git a/backend/src/domain/auth.go b/backend/src/domain/auth.go index c8ab7f8..db39bc3 100644 --- a/backend/src/domain/auth.go +++ b/backend/src/domain/auth.go @@ -3,5 +3,6 @@ package domain type AuthRequest struct { TelegramId *string `json:"telegram_id"` OTP *int64 `json:"otp"` - InitData *string `json:"init_data"` + + InitData *string `json:"init_data"` } diff --git a/backend/src/domain/families.go b/backend/src/domain/families.go index 82e3a2a..a65ab86 100644 --- a/backend/src/domain/families.go +++ b/backend/src/domain/families.go @@ -6,8 +6,8 @@ type Family struct { ID int64 Name string OwnerID int64 - TelegramChatID int64 - TelegramChatName string + TelegramChatID *int64 + TelegramChatName *string CreatedAt time.Time UpdatedAt time.Time } @@ -29,37 +29,27 @@ type FamilyMember struct { JoinedAt time.Time } -type FamilyThread struct { - ID int64 - FamilyID int64 - Type string - Title string - TelegramTopicID int64 - IsSystem bool - CreatedBy int64 - CreatedAt time.Time -} - type CreateFamilyRequest struct { - Name string `json:"name"` - OwnerID int64 `json:"owner_id"` - TelegramChatID int64 `json:"telegram_chat_id"` - TelegramChatName string `json:"telegram_chat_name"` + Name string `json:"name"` + OwnerID int64 `json:"owner_id"` + TelegramChatID *int64 `json:"telegram_chat_id"` + TelegramChatName *string `json:"telegram_chat_name"` } type UpdateFamilyRequest struct { Name *string `json:"name"` - TelegramChatName string `json:"telegram_chat_name"` + TelegramChatID *int64 `json:"telegram_chat_id"` + TelegramChatName *string `json:"telegram_chat_name"` } type FamilyResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - OwnerID int64 `json:"owner_id"` - TelegramChatID int64 `json:"telegram_chat_id"` - TelegramChatName string `json:"telegram_chat_name"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + OwnerID int64 `json:"owner_id"` + TelegramChatID *int64 `json:"telegram_chat_id"` + TelegramChatName *string `json:"telegram_chat_name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } func (response *FamilyResponse) ModelToResponse(f *Family) FamilyResponse { diff --git a/backend/src/domain/receipt.go b/backend/src/domain/receipt.go index 7739038..e344a17 100644 --- a/backend/src/domain/receipt.go +++ b/backend/src/domain/receipt.go @@ -3,6 +3,8 @@ package domain import "time" type Position struct { + ID int64 `json:"id"` + ReceiptID int64 `json:"receipt_id"` SectionNumber string `json:"section_number"` GTINCode string `json:"gtin_code"` Tag string `json:"tag"` @@ -25,6 +27,7 @@ type Position struct { type Receipt struct { ID int `json:"id"` + TransactionID *int64 `json:"transaction_id"` Status int `json:"STATUS"` AnotherAmount float64 `json:"another_amount"` CashAmount float64 `json:"cash_amount"` @@ -64,12 +67,18 @@ type Receipt struct { } type AddReceiptRequest struct { - Number string `json:"number" binding:"required,min=24,max=24"` - Date string `json:"date" binding:"required"` + Number string `json:"number" binding:"required,min=24,max=24"` + Date string `json:"date" binding:"required"` + FamilyID *int64 `json:"family_id"` + CreatedBy *int64 `json:"created_by"` + Type *string `json:"type"` + Category *string `json:"category"` + Description *string `json:"description"` } type AddReceiptResponse struct { - ID int32 `json:"id"` - Number string `json:"number"` - Date time.Time `json:"date"` + ID int32 `json:"id"` + Number string `json:"number"` + Date time.Time `json:"date"` + TransactionID *int64 `json:"transaction_id,omitempty"` } diff --git a/backend/src/domain/transaction.go b/backend/src/domain/transaction.go new file mode 100644 index 0000000..a1ef51c --- /dev/null +++ b/backend/src/domain/transaction.go @@ -0,0 +1,48 @@ +package domain + +import "time" + +type Transaction struct { + ID int64 + FamilyID int64 + Description *string + Type string + DateTime time.Time + Category string + Amount float64 + CreatedAt time.Time + CreatedBy int64 + ReceiptID *int64 +} + +type CreateTransactionRequest struct { + FamilyID int64 + Description *string + Type string + DateTime time.Time + Category string + Amount float64 + CreatedBy int64 + ReceiptID *int64 +} + +type UpdateTransactionRequest struct { + Description *string + Type *string + DateTime *time.Time + Category *string + Amount *float64 + ReceiptID *int64 + DetachReceipt bool +} + +type TransactionListFilter struct { + FamilyID *int64 + CreatedBy *int64 + Type *string + Category *string + DateFrom *time.Time + DateTo *time.Time + Limit int + Offset int +} diff --git a/backend/src/integrations/receiptService/receipt_service.go b/backend/src/integrations/receiptService/receipt_service.go index ec26f47..cbd9849 100644 --- a/backend/src/integrations/receiptService/receipt_service.go +++ b/backend/src/integrations/receiptService/receipt_service.go @@ -13,15 +13,20 @@ import ( "log" "mime/multipart" "net/http" + "strings" "time" ) type ReceiptService struct { - client *http.Client - repo repositories.ReceiptsRepository + client *http.Client + repo repositories.ReceiptsRepository + transactionRepo repositories.TransactionRepository } -func NewReceiptService(repo repositories.ReceiptsRepository) *ReceiptService { +func NewReceiptService( + repo repositories.ReceiptsRepository, + transactionRepo repositories.TransactionRepository, +) *ReceiptService { return &ReceiptService{ client: &http.Client{ Timeout: 60 * time.Second, @@ -31,20 +36,20 @@ func NewReceiptService(repo repositories.ReceiptsRepository) *ReceiptService { }, }, }, - repo: repo, + repo: repo, + transactionRepo: transactionRepo, } } func (s *ReceiptService) GetReceipt( ctx context.Context, - date string, - number string, + req domain.AddReceiptRequest, ) (*domain.Receipt, error) { url := "https://ch.info-center.by/ajax/check1.php" var receipt domain.Receipt - body, contentType := buildMultipartBody(date, number) - req, err := http.NewRequestWithContext( + body, contentType := buildMultipartBody(req.Date, req.Number) + httpReq, err := http.NewRequestWithContext( ctx, http.MethodPost, url, @@ -55,9 +60,9 @@ func (s *ReceiptService) GetReceipt( return nil, err } - req.Header.Set("Content-Type", contentType) + httpReq.Header.Set("Content-Type", contentType) - resp, err := s.client.Do(req) + resp, err := s.client.Do(httpReq) if err != nil { log.Println(err.Error()) return nil, err @@ -119,9 +124,23 @@ func (s *ReceiptService) GetReceipt( p.Surcharge, _ = utils.ParseFloat(p.SurchargeRaw) } - if _, err := s.repo.Create(ctx, &receipt); err != nil { + receiptID, err := s.repo.Create(ctx, &receipt) + if err != nil { return nil, err } + receipt.ID = int(receiptID) + + if s.shouldCreateTransaction(req) { + transaction, err := s.createTransactionForReceipt(ctx, &receipt, req, receiptID) + if err != nil { + if rollbackErr := s.repo.Delete(ctx, receiptID); rollbackErr != nil { + log.Printf("failed to rollback receipt %d after transaction error: %v", receiptID, rollbackErr) + } + return nil, err + } + receipt.TransactionID = &transaction.ID + } + return &receipt, nil } @@ -150,3 +169,61 @@ func parsePositions(raw string) ([]domain.Position, error) { return positions, nil } + +func (s *ReceiptService) shouldCreateTransaction(req domain.AddReceiptRequest) bool { + return s.transactionRepo != nil && req.FamilyID != nil && req.CreatedBy != nil +} + +func (s *ReceiptService) createTransactionForReceipt( + ctx context.Context, + receipt *domain.Receipt, + req domain.AddReceiptRequest, + receiptID int64, +) (*domain.Transaction, error) { + transactionType := "expense" + if req.Type != nil && strings.TrimSpace(*req.Type) != "" { + transactionType = strings.TrimSpace(*req.Type) + } + + category := "receipt" + if req.Category != nil && strings.TrimSpace(*req.Category) != "" { + category = strings.TrimSpace(*req.Category) + } + + description := buildReceiptTransactionDescription(receipt, req.Description) + + transaction := &domain.Transaction{ + FamilyID: *req.FamilyID, + Description: description, + Type: transactionType, + DateTime: receipt.IssuedAt, + Category: category, + Amount: receipt.TotalAmount, + CreatedBy: *req.CreatedBy, + ReceiptID: &receiptID, + } + + if err := s.transactionRepo.Create(ctx, transaction); err != nil { + return nil, err + } + + return transaction, nil +} + +func buildReceiptTransactionDescription(receipt *domain.Receipt, explicit *string) *string { + if explicit != nil && strings.TrimSpace(*explicit) != "" { + value := strings.TrimSpace(*explicit) + return &value + } + + if name := strings.TrimSpace(receipt.NameSPD); name != "" { + return &name + } + + if number := strings.TrimSpace(receipt.ReceiptNumber); number != "" { + value := fmt.Sprintf("Receipt %s", number) + return &value + } + + return nil +} diff --git a/backend/src/repositories/families.go b/backend/src/repositories/families.go index 8187751..a4a1de4 100644 --- a/backend/src/repositories/families.go +++ b/backend/src/repositories/families.go @@ -93,7 +93,6 @@ func (r *FamilySQLRepository) Update(ctx context.Context, family *domain.Family) family.Name, family.TelegramChatID, family.TelegramChatName, - family.UpdatedAt, family.ID, ).Scan(&family.UpdatedAt) } diff --git a/backend/src/repositories/receipts.go b/backend/src/repositories/receipts.go index e399858..34f1094 100644 --- a/backend/src/repositories/receipts.go +++ b/backend/src/repositories/receipts.go @@ -36,7 +36,7 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece } res, err := tx.ExecContext(ctx, ` INSERT INTO receipts ( - receipt_number, ui, status, issued_at, + transaction_id, receipt_number, ui, status, issued_at, total_amount, payment_amount, cash_amount, another_amount, clearing_amount, margin, currency, payment_type, @@ -46,8 +46,9 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece kod_soato, oblast_soato, rayon_soato, selsovet_soato, doc_num, skno_number, unp, success - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, + receipt.TransactionID, receipt.ReceiptNumber, receipt.UI, receipt.Status, @@ -144,6 +145,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain. err := r.db.QueryRowContext(ctx, ` SELECT id, + transaction_id, receipt_number, ui, status, issued_at, total_amount, payment_amount, cash_amount, another_amount, clearing_amount, margin, @@ -158,6 +160,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain. WHERE id = ? `, id).Scan( &receipt.ID, + &receipt.TransactionID, &receipt.ReceiptNumber, &receipt.UI, &receipt.Status, @@ -205,6 +208,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain. rows, err := r.db.QueryContext(ctx, ` SELECT + id, receipt_id, section_number, gtin_code, product_name, product_count, amount, discount, surcharge, @@ -219,6 +223,8 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain. for rows.Next() { var p domain.Position if err := rows.Scan( + &p.ID, + &p.ReceiptID, &p.SectionNumber, &p.GTINCode, &p.ProductName, @@ -241,7 +247,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain. func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Receipt, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, receipt_number, issued_at, total_amount, currency + SELECT id, transaction_id, receipt_number, issued_at, total_amount, currency FROM receipts ORDER BY issued_at DESC LIMIT ? OFFSET ? @@ -257,6 +263,7 @@ func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) ( var rct domain.Receipt if err := rows.Scan( &rct.ID, + &rct.TransactionID, &rct.ReceiptNumber, &rct.IssuedAt, &rct.TotalAmount, @@ -280,11 +287,13 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece _, err = tx.ExecContext(ctx, ` UPDATE receipts SET + transaction_id = ?, issued_at = ?, total_amount = ?, currency = ? WHERE id = ? `, + receipt.TransactionID, receipt.IssuedAt, receipt.TotalAmount, receipt.Currency, diff --git a/backend/src/repositories/transactions.go b/backend/src/repositories/transactions.go new file mode 100644 index 0000000..a518745 --- /dev/null +++ b/backend/src/repositories/transactions.go @@ -0,0 +1,279 @@ +package repositories + +import ( + "FamilyHub/src/domain" + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +var ErrReceiptNotFound = errors.New("receipt not found") + +type TransactionRepository interface { + Create(ctx context.Context, transaction *domain.Transaction) error + GetByID(ctx context.Context, id int64) (*domain.Transaction, error) + List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) + Update(ctx context.Context, transaction *domain.Transaction, syncReceipt bool) error + Delete(ctx context.Context, id int64) error +} + +type TransactionsSQLRepository struct { + db *sql.DB +} + +func NewTransactionsSQLRepository(db *sql.DB) *TransactionsSQLRepository { + return &TransactionsSQLRepository{db: db} +} + +func (r *TransactionsSQLRepository) Create(ctx context.Context, transaction *domain.Transaction) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + query := ` + INSERT INTO transactions + (family_id, description, type, datetime, category, amount, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, created_at + ` + + if err := tx.QueryRowContext( + ctx, + query, + transaction.FamilyID, + transaction.Description, + transaction.Type, + transaction.DateTime, + transaction.Category, + transaction.Amount, + transaction.CreatedBy, + ).Scan(&transaction.ID, &transaction.CreatedAt); err != nil { + return err + } + + if err := r.rebindReceipt(ctx, tx, transaction.ID, transaction.ReceiptID); err != nil { + return err + } + + return tx.Commit() +} + +func (r *TransactionsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) { + query := ` + SELECT + t.id, + t.family_id, + t.description, + t.type, + t.datetime, + t.category, + t.amount, + t.created_at, + t.created_by, + r.id + FROM transactions t + LEFT JOIN receipts r ON r.transaction_id = t.id + WHERE t.id = $1 + ` + + var transaction domain.Transaction + err := r.db.QueryRowContext(ctx, query, id).Scan( + &transaction.ID, + &transaction.FamilyID, + &transaction.Description, + &transaction.Type, + &transaction.DateTime, + &transaction.Category, + &transaction.Amount, + &transaction.CreatedAt, + &transaction.CreatedBy, + &transaction.ReceiptID, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return &transaction, nil +} + +func (r *TransactionsSQLRepository) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) { + var ( + whereClauses []string + args []any + ) + + baseQuery := ` + SELECT + t.id, + t.family_id, + t.description, + t.type, + t.datetime, + t.category, + t.amount, + t.created_at, + t.created_by, + r.id + FROM transactions t + LEFT JOIN receipts r ON r.transaction_id = t.id + ` + + appendFilter := func(condition string, value any) { + args = append(args, value) + whereClauses = append(whereClauses, fmt.Sprintf(condition, len(args))) + } + + if filter.FamilyID != nil { + appendFilter("t.family_id = $%d", *filter.FamilyID) + } + if filter.CreatedBy != nil { + appendFilter("t.created_by = $%d", *filter.CreatedBy) + } + if filter.Type != nil { + appendFilter("t.type = $%d", *filter.Type) + } + if filter.Category != nil { + appendFilter("t.category = $%d", *filter.Category) + } + if filter.DateFrom != nil { + appendFilter("t.datetime >= $%d", *filter.DateFrom) + } + if filter.DateTo != nil { + appendFilter("t.datetime <= $%d", *filter.DateTo) + } + + var queryBuilder strings.Builder + queryBuilder.WriteString(baseQuery) + if len(whereClauses) > 0 { + queryBuilder.WriteString(" WHERE ") + queryBuilder.WriteString(strings.Join(whereClauses, " AND ")) + } + + args = append(args, filter.Limit, filter.Offset) + queryBuilder.WriteString(fmt.Sprintf(" ORDER BY t.datetime DESC LIMIT $%d OFFSET $%d", len(args)-1, len(args))) + + rows, err := r.db.QueryContext(ctx, queryBuilder.String(), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var transactions []*domain.Transaction + for rows.Next() { + var transaction domain.Transaction + if err := rows.Scan( + &transaction.ID, + &transaction.FamilyID, + &transaction.Description, + &transaction.Type, + &transaction.DateTime, + &transaction.Category, + &transaction.Amount, + &transaction.CreatedAt, + &transaction.CreatedBy, + &transaction.ReceiptID, + ); err != nil { + return nil, err + } + transactions = append(transactions, &transaction) + } + + return transactions, rows.Err() +} + +func (r *TransactionsSQLRepository) Update(ctx context.Context, transaction *domain.Transaction, syncReceipt bool) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + query := ` + UPDATE transactions SET + description = $1, + type = $2, + datetime = $3, + category = $4, + amount = $5 + WHERE id = $6 + ` + + result, err := tx.ExecContext( + ctx, + query, + transaction.Description, + transaction.Type, + transaction.DateTime, + transaction.Category, + transaction.Amount, + transaction.ID, + ) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return sql.ErrNoRows + } + + if syncReceipt { + if err := r.rebindReceipt(ctx, tx, transaction.ID, transaction.ReceiptID); err != nil { + return err + } + } + + return tx.Commit() +} + +func (r *TransactionsSQLRepository) Delete(ctx context.Context, id int64) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM transactions WHERE id = $1`, id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +func (r *TransactionsSQLRepository) rebindReceipt(ctx context.Context, tx *sql.Tx, transactionID int64, receiptID *int64) error { + if _, err := tx.ExecContext(ctx, `UPDATE receipts SET transaction_id = NULL WHERE transaction_id = $1`, transactionID); err != nil { + return err + } + + if receiptID == nil { + return nil + } + + result, err := tx.ExecContext(ctx, `UPDATE receipts SET transaction_id = $1 WHERE id = $2`, transactionID, *receiptID) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return ErrReceiptNotFound + } + + return nil +} diff --git a/docs/finance_module_specification.md b/docs/finance_module_specification.md index 3027752..6ffc309 100644 --- a/docs/finance_module_specification.md +++ b/docs/finance_module_specification.md @@ -282,9 +282,9 @@ GET /positions --- ## 8. Архитектурные решения - -- Position — основная сущность финансов -- Receipt — агрегат для чеков +- Transaction — основная сущность финансов +- Receipt — дополняет транзакцию +- Position - позиции из чека - Категории определяют тип операции - Поддержка multi-tenant через family_id diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 152c4fd..cabf6b2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@