From 8e074db55f75fb0eb25fbe4eb8fe25a3f52fe669 Mon Sep 17 00:00:00 2001 From: AlexBelyan Date: Sat, 11 Apr 2026 11:37:48 +0300 Subject: [PATCH] Added analytics by transactions --- backend/src/api/docs/docs.go | 77 ++++++++++++++++++++++++ backend/src/api/docs/swagger.json | 77 ++++++++++++++++++++++++ backend/src/api/docs/swagger.yaml | 52 ++++++++++++++++ backend/src/api/dto/transactions.go | 21 +++++++ backend/src/api/routers/transactions.go | 51 +++++++++++++++- backend/src/api/services/transactions.go | 14 +++++ backend/src/domain/transaction.go | 13 ++++ backend/src/repositories/transactions.go | 42 +++++++++++++ 8 files changed, 346 insertions(+), 1 deletion(-) diff --git a/backend/src/api/docs/docs.go b/backend/src/api/docs/docs.go index b716045..62365e4 100644 --- a/backend/src/api/docs/docs.go +++ b/backend/src/api/docs/docs.go @@ -504,6 +504,69 @@ const docTemplate = `{ } } }, + "/transactions/analytics": { + "get": { + "description": "Возвращает расходы, доходы и total за период. При фильтре по type второй тип возвращается как 0.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Получить аналитику по транзакциям", + "parameters": [ + { + "type": "integer", + "description": "Family ID", + "name": "family_id", + "in": "query" + }, + { + "type": "string", + "description": "Transaction type: income or expense", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "RFC3339 start datetime", + "name": "date_from", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "RFC3339 end datetime", + "name": "date_to", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.TransactionAnalyticsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/transactions/{id}": { "get": { "description": "Возвращает транзакцию по ее внутреннему ID", @@ -1140,6 +1203,20 @@ const docTemplate = `{ } } }, + "dto.TransactionAnalyticsResponse": { + "type": "object", + "properties": { + "expenses": { + "type": "number" + }, + "incomes": { + "type": "number" + }, + "total": { + "type": "number" + } + } + }, "dto.TransactionListResponse": { "type": "object", "properties": { diff --git a/backend/src/api/docs/swagger.json b/backend/src/api/docs/swagger.json index e87e17b..1853e2f 100644 --- a/backend/src/api/docs/swagger.json +++ b/backend/src/api/docs/swagger.json @@ -493,6 +493,69 @@ } } }, + "/transactions/analytics": { + "get": { + "description": "Возвращает расходы, доходы и total за период. При фильтре по type второй тип возвращается как 0.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Получить аналитику по транзакциям", + "parameters": [ + { + "type": "integer", + "description": "Family ID", + "name": "family_id", + "in": "query" + }, + { + "type": "string", + "description": "Transaction type: income or expense", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "RFC3339 start datetime", + "name": "date_from", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "RFC3339 end datetime", + "name": "date_to", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.TransactionAnalyticsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/transactions/{id}": { "get": { "description": "Возвращает транзакцию по ее внутреннему ID", @@ -1129,6 +1192,20 @@ } } }, + "dto.TransactionAnalyticsResponse": { + "type": "object", + "properties": { + "expenses": { + "type": "number" + }, + "incomes": { + "type": "number" + }, + "total": { + "type": "number" + } + } + }, "dto.TransactionListResponse": { "type": "object", "properties": { diff --git a/backend/src/api/docs/swagger.yaml b/backend/src/api/docs/swagger.yaml index 6896929..d787e13 100644 --- a/backend/src/api/docs/swagger.yaml +++ b/backend/src/api/docs/swagger.yaml @@ -151,6 +151,15 @@ definitions: message: type: string type: object + dto.TransactionAnalyticsResponse: + properties: + expenses: + type: number + incomes: + type: number + total: + type: number + type: object dto.TransactionListResponse: properties: items: @@ -630,6 +639,49 @@ paths: summary: Обновить транзакцию tags: - Transactions + /transactions/analytics: + get: + consumes: + - application/json + description: Возвращает расходы, доходы и total за период. При фильтре по type + второй тип возвращается как 0. + parameters: + - description: Family ID + in: query + name: family_id + type: integer + - description: 'Transaction type: income or expense' + in: query + name: type + type: string + - description: RFC3339 start datetime + in: query + name: date_from + required: true + type: string + - description: RFC3339 end datetime + in: query + name: date_to + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.TransactionAnalyticsResponse' + "400": + description: Bad Request + 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 index 9a78bcd..559fa80 100644 --- a/backend/src/api/dto/transactions.go +++ b/backend/src/api/dto/transactions.go @@ -37,6 +37,13 @@ type ListTransactionsQuery struct { Offset int `form:"offset"` } +type TransactionAnalyticsQuery struct { + FamilyID *int64 `form:"family_id"` + Type *string `form:"type"` + DateFrom string `form:"date_from" binding:"required"` + DateTo string `form:"date_to" binding:"required"` +} + type TransactionResponse struct { ID int64 `json:"id"` FamilyID int64 `json:"family_id"` @@ -54,6 +61,12 @@ type TransactionListResponse struct { Items []TransactionResponse `json:"items"` } +type TransactionAnalyticsResponse struct { + Expenses float64 `json:"expenses"` + Incomes float64 `json:"incomes"` + Total float64 `json:"total"` +} + func TransactionToResponse(transaction *domain.Transaction) TransactionResponse { return TransactionResponse{ ID: transaction.ID, @@ -77,3 +90,11 @@ func TransactionsToListResponse(transactions []*domain.Transaction) TransactionL return TransactionListResponse{Items: items} } + +func TransactionAnalyticsToResponse(analytics domain.TransactionAnalytics) TransactionAnalyticsResponse { + return TransactionAnalyticsResponse{ + Expenses: analytics.Expenses, + Incomes: analytics.Incomes, + Total: analytics.Total, + } +} diff --git a/backend/src/api/routers/transactions.go b/backend/src/api/routers/transactions.go index 25bb0e0..c77aed0 100644 --- a/backend/src/api/routers/transactions.go +++ b/backend/src/api/routers/transactions.go @@ -25,6 +25,7 @@ func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) { { transactions.POST("", router.Create) transactions.GET("", router.List) + transactions.GET("/analytics", router.Analytics) transactions.GET("/:id", router.Read) transactions.PATCH("/:id", router.Update) transactions.DELETE("/:id", router.Delete) @@ -114,6 +115,53 @@ func (router *TransactionsRouter) List(c *gin.Context) { c.JSON(http.StatusOK, dto.TransactionsToListResponse(transactions)) } +// Analytics GoDoc +// @Summary Получить аналитику по транзакциям +// @Description Возвращает расходы, доходы и total за период. При фильтре по type второй тип возвращается как 0. +// @Tags Transactions +// @Accept json +// @Produce json +// @Param family_id query int false "Family ID" +// @Param type query string false "Transaction type: income or expense" +// @Param date_from query string true "RFC3339 start datetime" +// @Param date_to query string true "RFC3339 end datetime" +// @Success 200 {object} dto.TransactionAnalyticsResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /transactions/analytics [get] +func (router *TransactionsRouter) Analytics(c *gin.Context) { + var query dto.TransactionAnalyticsQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + return + } + + dateFrom, err := time.Parse(time.RFC3339, query.DateFrom) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "date_from must be RFC3339"}) + return + } + + dateTo, err := time.Parse(time.RFC3339, query.DateTo) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "date_to must be RFC3339"}) + return + } + + analytics, err := router.service.Analytics(c.Request.Context(), domain.TransactionAnalyticsFilter{ + FamilyID: query.FamilyID, + Type: query.Type, + DateFrom: dateFrom, + DateTo: dateTo, + }) + if err != nil { + handleTransactionError(c, err) + return + } + + c.JSON(http.StatusOK, dto.TransactionAnalyticsToResponse(analytics)) +} + // Read GoDoc // @Summary Получить транзакцию по ID // @Description Возвращает транзакцию по ее внутреннему ID @@ -228,7 +276,8 @@ func handleTransactionError(c *gin.Context, err error) { 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): + errors.Is(err, services.ErrInvalidTransaction), + errors.Is(err, services.ErrInvalidAnalytics): c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) case errors.Is(err, services.ErrReceiptNotFound): c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) diff --git a/backend/src/api/services/transactions.go b/backend/src/api/services/transactions.go index 9fe5057..0cfea2a 100644 --- a/backend/src/api/services/transactions.go +++ b/backend/src/api/services/transactions.go @@ -13,6 +13,7 @@ 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) + Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) Delete(ctx context.Context, id int64) error } @@ -31,6 +32,7 @@ var ( 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") + ErrInvalidAnalytics = errors.New("type must be income or expense") ) func (s *transactionService) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) { @@ -85,6 +87,18 @@ func (s *transactionService) List(ctx context.Context, filter domain.Transaction return s.repo.List(ctx, filter) } +func (s *transactionService) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) { + if filter.Type != nil { + typeValue := strings.TrimSpace(*filter.Type) + if typeValue != "income" && typeValue != "expense" { + return domain.TransactionAnalytics{}, ErrInvalidAnalytics + } + filter.Type = &typeValue + } + + return s.repo.Analytics(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 diff --git a/backend/src/domain/transaction.go b/backend/src/domain/transaction.go index a1ef51c..d975f65 100644 --- a/backend/src/domain/transaction.go +++ b/backend/src/domain/transaction.go @@ -46,3 +46,16 @@ type TransactionListFilter struct { Limit int Offset int } + +type TransactionAnalyticsFilter struct { + FamilyID *int64 + Type *string + DateFrom time.Time + DateTo time.Time +} + +type TransactionAnalytics struct { + Expenses float64 + Incomes float64 + Total float64 +} diff --git a/backend/src/repositories/transactions.go b/backend/src/repositories/transactions.go index a518745..c248d04 100644 --- a/backend/src/repositories/transactions.go +++ b/backend/src/repositories/transactions.go @@ -15,6 +15,7 @@ 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) + Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) Update(ctx context.Context, transaction *domain.Transaction, syncReceipt bool) error Delete(ctx context.Context, id int64) error } @@ -188,6 +189,47 @@ func (r *TransactionsSQLRepository) List(ctx context.Context, filter domain.Tran return transactions, rows.Err() } +func (r *TransactionsSQLRepository) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) { + var ( + whereClauses []string + args []any + ) + + appendFilter := func(condition string, value any) { + args = append(args, value) + whereClauses = append(whereClauses, fmt.Sprintf(condition, len(args))) + } + + appendFilter("datetime >= $%d", filter.DateFrom) + appendFilter("datetime <= $%d", filter.DateTo) + + if filter.FamilyID != nil { + appendFilter("family_id = $%d", *filter.FamilyID) + } + if filter.Type != nil { + appendFilter("type = $%d", *filter.Type) + } + + query := ` + SELECT + COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) + FROM transactions + ` + + if len(whereClauses) > 0 { + query += " WHERE " + strings.Join(whereClauses, " AND ") + } + + var analytics domain.TransactionAnalytics + if err := r.db.QueryRowContext(ctx, query, args...).Scan(&analytics.Expenses, &analytics.Incomes); err != nil { + return domain.TransactionAnalytics{}, err + } + analytics.Total = analytics.Incomes - analytics.Expenses + + return analytics, nil +} + func (r *TransactionsSQLRepository) Update(ctx context.Context, transaction *domain.Transaction, syncReceipt bool) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil {