Added analytics by transactions
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user