Added analytics by transactions

This commit is contained in:
2026-04-11 11:37:48 +03:00
parent b66be96033
commit 8e074db55f
8 changed files with 346 additions and 1 deletions
+77
View File
@@ -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}": { "/transactions/{id}": {
"get": { "get": {
"description": "Возвращает транзакцию по ее внутреннему ID", "description": "Возвращает транзакцию по ее внутреннему ID",
@@ -1140,6 +1203,20 @@ const docTemplate = `{
} }
} }
}, },
"dto.TransactionAnalyticsResponse": {
"type": "object",
"properties": {
"expenses": {
"type": "number"
},
"incomes": {
"type": "number"
},
"total": {
"type": "number"
}
}
},
"dto.TransactionListResponse": { "dto.TransactionListResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
+77
View File
@@ -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}": { "/transactions/{id}": {
"get": { "get": {
"description": "Возвращает транзакцию по ее внутреннему ID", "description": "Возвращает транзакцию по ее внутреннему ID",
@@ -1129,6 +1192,20 @@
} }
} }
}, },
"dto.TransactionAnalyticsResponse": {
"type": "object",
"properties": {
"expenses": {
"type": "number"
},
"incomes": {
"type": "number"
},
"total": {
"type": "number"
}
}
},
"dto.TransactionListResponse": { "dto.TransactionListResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
+52
View File
@@ -151,6 +151,15 @@ definitions:
message: message:
type: string type: string
type: object type: object
dto.TransactionAnalyticsResponse:
properties:
expenses:
type: number
incomes:
type: number
total:
type: number
type: object
dto.TransactionListResponse: dto.TransactionListResponse:
properties: properties:
items: items:
@@ -630,6 +639,49 @@ paths:
summary: Обновить транзакцию summary: Обновить транзакцию
tags: tags:
- Transactions - 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: /users:
post: post:
consumes: consumes:
+21
View File
@@ -37,6 +37,13 @@ type ListTransactionsQuery struct {
Offset int `form:"offset"` 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 { type TransactionResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
FamilyID int64 `json:"family_id"` FamilyID int64 `json:"family_id"`
@@ -54,6 +61,12 @@ type TransactionListResponse struct {
Items []TransactionResponse `json:"items"` 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 { func TransactionToResponse(transaction *domain.Transaction) TransactionResponse {
return TransactionResponse{ return TransactionResponse{
ID: transaction.ID, ID: transaction.ID,
@@ -77,3 +90,11 @@ func TransactionsToListResponse(transactions []*domain.Transaction) TransactionL
return TransactionListResponse{Items: items} return TransactionListResponse{Items: items}
} }
func TransactionAnalyticsToResponse(analytics domain.TransactionAnalytics) TransactionAnalyticsResponse {
return TransactionAnalyticsResponse{
Expenses: analytics.Expenses,
Incomes: analytics.Incomes,
Total: analytics.Total,
}
}
+50 -1
View File
@@ -25,6 +25,7 @@ func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) {
{ {
transactions.POST("", router.Create) transactions.POST("", router.Create)
transactions.GET("", router.List) transactions.GET("", router.List)
transactions.GET("/analytics", router.Analytics)
transactions.GET("/:id", router.Read) transactions.GET("/:id", router.Read)
transactions.PATCH("/:id", router.Update) transactions.PATCH("/:id", router.Update)
transactions.DELETE("/:id", router.Delete) transactions.DELETE("/:id", router.Delete)
@@ -114,6 +115,53 @@ func (router *TransactionsRouter) List(c *gin.Context) {
c.JSON(http.StatusOK, dto.TransactionsToListResponse(transactions)) 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 // Read GoDoc
// @Summary Получить транзакцию по ID // @Summary Получить транзакцию по ID
// @Description Возвращает транзакцию по ее внутреннему ID // @Description Возвращает транзакцию по ее внутреннему ID
@@ -228,7 +276,8 @@ func handleTransactionError(c *gin.Context, err error) {
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrTransactionPatch), case errors.Is(err, services.ErrTransactionPatch),
errors.Is(err, services.ErrReceiptLinkConflict), 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()}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptNotFound): case errors.Is(err, services.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
+14
View File
@@ -13,6 +13,7 @@ type TransactionService interface {
Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error)
GetByID(ctx context.Context, id int64) (*domain.Transaction, error) GetByID(ctx context.Context, id int64) (*domain.Transaction, error)
List(ctx context.Context, filter domain.TransactionListFilter) ([]*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) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error)
Delete(ctx context.Context, id int64) 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") ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together")
ErrInvalidTransaction = errors.New("type and category are required") ErrInvalidTransaction = errors.New("type and category are required")
ErrReceiptNotFound = errors.New("receipt not found") 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) { 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) 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) { func (s *transactionService) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) {
if req.ReceiptID != nil && req.DetachReceipt { if req.ReceiptID != nil && req.DetachReceipt {
return nil, ErrReceiptLinkConflict return nil, ErrReceiptLinkConflict
+13
View File
@@ -46,3 +46,16 @@ type TransactionListFilter struct {
Limit int Limit int
Offset 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
}
+42
View File
@@ -15,6 +15,7 @@ type TransactionRepository interface {
Create(ctx context.Context, transaction *domain.Transaction) error Create(ctx context.Context, transaction *domain.Transaction) error
GetByID(ctx context.Context, id int64) (*domain.Transaction, error) GetByID(ctx context.Context, id int64) (*domain.Transaction, error)
List(ctx context.Context, filter domain.TransactionListFilter) ([]*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 Update(ctx context.Context, transaction *domain.Transaction, syncReceipt bool) error
Delete(ctx context.Context, id int64) 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() 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 { func (r *TransactionsSQLRepository) Update(ctx context.Context, transaction *domain.Transaction, syncReceipt bool) error {
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {