diff --git a/backend/src/api/docs/docs.go b/backend/src/api/docs/docs.go index ca0b642..667287a 100644 --- a/backend/src/api/docs/docs.go +++ b/backend/src/api/docs/docs.go @@ -15,7 +15,7 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/activities": { + "/api/v1/activities": { "get": { "description": "Возвращает список действий пользователей с пагинацией", "consumes": [ @@ -76,7 +76,7 @@ const docTemplate = `{ } } }, - "/families": { + "/api/v1/families": { "post": { "description": "Создает новую семью", "consumes": [ @@ -128,7 +128,7 @@ const docTemplate = `{ } } }, - "/families/{id}": { + "/api/v1/families/{id}": { "get": { "description": "Возвращает семью по ее внутреннему ID", "consumes": [ @@ -310,7 +310,7 @@ const docTemplate = `{ } } }, - "/receipts": { + "/api/v1/receipts": { "post": { "description": "Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию", "consumes": [ @@ -356,7 +356,7 @@ const docTemplate = `{ } } }, - "/receipts/photo": { + "/api/v1/receipts/photo": { "post": { "description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию", "consumes": [ @@ -430,7 +430,7 @@ const docTemplate = `{ } } }, - "/transactions": { + "/api/v1/transactions": { "get": { "description": "Возвращает список транзакций с фильтрами и пагинацией", "consumes": [ @@ -565,7 +565,7 @@ const docTemplate = `{ } } }, - "/transactions/analytics": { + "/api/v1/transactions/analytics": { "get": { "description": "Возвращает расходы, доходы и total за период. При фильтре по type второй тип возвращается как 0.", "consumes": [ @@ -628,7 +628,7 @@ const docTemplate = `{ } } }, - "/transactions/{id}": { + "/api/v1/transactions/{id}": { "get": { "description": "Возвращает транзакцию по ее внутреннему ID", "consumes": [ @@ -783,7 +783,7 @@ const docTemplate = `{ } } }, - "/users": { + "/api/v1/users": { "post": { "consumes": [ "application/json" @@ -828,7 +828,7 @@ const docTemplate = `{ } } }, - "/users/by-telegram/{telegramId}": { + "/api/v1/users/by-telegram/{telegramId}": { "get": { "description": "Возвращает пользователя по его Telegram ID", "consumes": [ @@ -878,7 +878,7 @@ const docTemplate = `{ } } }, - "/users/{id}": { + "/api/v1/users/{id}": { "get": { "description": "Возвращает пользователя по его внутреннему ID", "consumes": [ diff --git a/backend/src/api/docs/swagger.json b/backend/src/api/docs/swagger.json index 97ebc73..b9046b3 100644 --- a/backend/src/api/docs/swagger.json +++ b/backend/src/api/docs/swagger.json @@ -4,7 +4,7 @@ "contact": {} }, "paths": { - "/activities": { + "/api/v1/activities": { "get": { "description": "Возвращает список действий пользователей с пагинацией", "consumes": [ @@ -65,7 +65,7 @@ } } }, - "/families": { + "/api/v1/families": { "post": { "description": "Создает новую семью", "consumes": [ @@ -117,7 +117,7 @@ } } }, - "/families/{id}": { + "/api/v1/families/{id}": { "get": { "description": "Возвращает семью по ее внутреннему ID", "consumes": [ @@ -299,7 +299,7 @@ } } }, - "/receipts": { + "/api/v1/receipts": { "post": { "description": "Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию", "consumes": [ @@ -345,7 +345,7 @@ } } }, - "/receipts/photo": { + "/api/v1/receipts/photo": { "post": { "description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию", "consumes": [ @@ -419,7 +419,7 @@ } } }, - "/transactions": { + "/api/v1/transactions": { "get": { "description": "Возвращает список транзакций с фильтрами и пагинацией", "consumes": [ @@ -554,7 +554,7 @@ } } }, - "/transactions/analytics": { + "/api/v1/transactions/analytics": { "get": { "description": "Возвращает расходы, доходы и total за период. При фильтре по type второй тип возвращается как 0.", "consumes": [ @@ -617,7 +617,7 @@ } } }, - "/transactions/{id}": { + "/api/v1/transactions/{id}": { "get": { "description": "Возвращает транзакцию по ее внутреннему ID", "consumes": [ @@ -772,7 +772,7 @@ } } }, - "/users": { + "/api/v1/users": { "post": { "consumes": [ "application/json" @@ -817,7 +817,7 @@ } } }, - "/users/by-telegram/{telegramId}": { + "/api/v1/users/by-telegram/{telegramId}": { "get": { "description": "Возвращает пользователя по его Telegram ID", "consumes": [ @@ -867,7 +867,7 @@ } } }, - "/users/{id}": { + "/api/v1/users/{id}": { "get": { "description": "Возвращает пользователя по его внутреннему ID", "consumes": [ diff --git a/backend/src/api/docs/swagger.yaml b/backend/src/api/docs/swagger.yaml index d83e14c..8803d5d 100644 --- a/backend/src/api/docs/swagger.yaml +++ b/backend/src/api/docs/swagger.yaml @@ -240,7 +240,7 @@ definitions: info: contact: {} paths: - /activities: + /api/v1/activities: get: consumes: - application/json @@ -280,7 +280,7 @@ paths: summary: Получить активность пользователей tags: - Activities - /families: + /api/v1/families: post: consumes: - application/json @@ -314,7 +314,7 @@ paths: summary: Создать семью tags: - Families - /families/{id}: + /api/v1/families/{id}: delete: consumes: - application/json @@ -435,7 +435,7 @@ paths: summary: Обновить семью tags: - Families - /receipts: + /api/v1/receipts: post: consumes: - application/json @@ -466,7 +466,7 @@ paths: summary: Загрузить чек tags: - Receipts - /receipts/photo: + /api/v1/receipts/photo: post: consumes: - multipart/form-data @@ -516,7 +516,7 @@ paths: summary: Загрузить чек по фото tags: - Receipts - /transactions: + /api/v1/transactions: get: consumes: - application/json @@ -606,7 +606,7 @@ paths: summary: Создать транзакцию tags: - Transactions - /transactions/{id}: + /api/v1/transactions/{id}: delete: consumes: - application/json @@ -709,7 +709,7 @@ paths: summary: Обновить транзакцию tags: - Transactions - /transactions/analytics: + /api/v1/transactions/analytics: get: consumes: - application/json @@ -752,7 +752,7 @@ paths: summary: Получить аналитику по транзакциям tags: - Transactions - /users: + /api/v1/users: post: consumes: - application/json @@ -781,7 +781,7 @@ paths: summary: Создать пользователя tags: - Users - /users/{id}: + /api/v1/users/{id}: delete: consumes: - application/json @@ -884,7 +884,7 @@ paths: summary: Обновить пользователя tags: - Users - /users/by-telegram/{telegramId}: + /api/v1/users/by-telegram/{telegramId}: get: consumes: - application/json diff --git a/backend/src/api/dto/transactions.go b/backend/src/api/dto/transactions.go index 559fa80..63931a2 100644 --- a/backend/src/api/dto/transactions.go +++ b/backend/src/api/dto/transactions.go @@ -6,14 +6,16 @@ import ( ) 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"` + 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"` + CreatedBy *int64 `json:"created_by"` + ReceiptID *int64 `json:"receipt_id"` + ReceiptNumber *string `json:"receipt_number"` + ReceiptDate *string `json:"receipt_date"` } type UpdateTransactionRequest struct { diff --git a/backend/src/api/request_logging.go b/backend/src/api/request_logging.go new file mode 100644 index 0000000..40c483a --- /dev/null +++ b/backend/src/api/request_logging.go @@ -0,0 +1,44 @@ +package api + +import ( + "log" + "time" + + "github.com/gin-gonic/gin" +) + +func requestLoggingMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + startedAt := time.Now() + log.Printf( + "request started: method=%s path=%s query=%s client_ip=%s", + c.Request.Method, + c.Request.URL.Path, + c.Request.URL.RawQuery, + c.ClientIP(), + ) + + c.Next() + + finishedAt := time.Since(startedAt) + if len(c.Errors) > 0 { + log.Printf( + "request finished with errors: method=%s path=%s status=%d latency=%s errors=%s", + c.Request.Method, + c.Request.URL.Path, + c.Writer.Status(), + finishedAt, + c.Errors.String(), + ) + return + } + + log.Printf( + "request finished: method=%s path=%s status=%d latency=%s", + c.Request.Method, + c.Request.URL.Path, + c.Writer.Status(), + finishedAt, + ) + } +} diff --git a/backend/src/api/requests/transactions.go b/backend/src/api/requests/transactions.go new file mode 100644 index 0000000..796533c --- /dev/null +++ b/backend/src/api/requests/transactions.go @@ -0,0 +1,153 @@ +package requests + +import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/api/services" + "FamilyHub/src/domain" + "FamilyHub/src/utils" + "errors" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +func BuildCreateTransactionInput(req dto.CreateTransactionRequest) (services.CreateTransactionInput, error) { + if req.ReceiptNumber != nil || req.ReceiptDate != nil { + receiptReq, err := BuildReceiptTransactionRequest(req) + if err != nil { + return services.CreateTransactionInput{}, err + } + return services.CreateTransactionInput{Receipt: &receiptReq}, nil + } + + manualReq, err := BuildManualTransactionRequest(req) + if err != nil { + return services.CreateTransactionInput{}, err + } + + return services.CreateTransactionInput{Manual: &manualReq}, nil +} + +func BuildPhotoCreateTransactionInput(c *gin.Context, image []byte) (services.CreateTransactionInput, error) { + familyID, err := parseOptionalInt64Form(c, "family_id") + if err != nil { + return services.CreateTransactionInput{}, err + } + createdBy, err := parseOptionalInt64Form(c, "created_by") + if err != nil { + return services.CreateTransactionInput{}, err + } + + return services.CreateTransactionInput{ + Photo: &services.CreateTransactionPhotoInput{ + Image: image, + FamilyID: familyID, + CreatedBy: createdBy, + Type: parseOptionalStringForm(c, "type"), + Category: parseOptionalStringForm(c, "category"), + Description: parseOptionalStringForm(c, "description"), + }, + }, nil +} + +func BuildManualTransactionRequest(req dto.CreateTransactionRequest) (domain.CreateTransactionRequest, error) { + if req.FamilyID == nil || req.CreatedBy == nil { + return domain.CreateTransactionRequest{}, errors.New("family_id and created_by are required") + } + if req.Type == nil || strings.TrimSpace(*req.Type) == "" { + return domain.CreateTransactionRequest{}, errors.New("type is required") + } + if req.Category == nil || strings.TrimSpace(*req.Category) == "" { + return domain.CreateTransactionRequest{}, errors.New("category is required") + } + if req.Amount == nil { + return domain.CreateTransactionRequest{}, errors.New("amount is required") + } + if req.DateTime == nil || strings.TrimSpace(*req.DateTime) == "" { + return domain.CreateTransactionRequest{}, errors.New("datetime is required") + } + + dateTime, err := time.Parse(time.RFC3339, *req.DateTime) + if err != nil { + return domain.CreateTransactionRequest{}, errors.New("datetime must be RFC3339") + } + + return domain.CreateTransactionRequest{ + FamilyID: *req.FamilyID, + Description: req.Description, + Type: strings.TrimSpace(*req.Type), + DateTime: dateTime, + Category: strings.TrimSpace(*req.Category), + Amount: *req.Amount, + CreatedBy: *req.CreatedBy, + ReceiptID: req.ReceiptID, + }, nil +} + +func BuildReceiptTransactionRequest(req dto.CreateTransactionRequest) (domain.AddReceiptRequest, error) { + if req.ReceiptNumber == nil || strings.TrimSpace(*req.ReceiptNumber) == "" { + return domain.AddReceiptRequest{}, errors.New("receipt_number is required") + } + if req.ReceiptDate == nil || strings.TrimSpace(*req.ReceiptDate) == "" { + return domain.AddReceiptRequest{}, errors.New("receipt_date is required") + } + if req.FamilyID == nil || req.CreatedBy == nil { + return domain.AddReceiptRequest{}, errors.New("family_id and created_by are required") + } + if req.Amount != nil || req.DateTime != nil || req.ReceiptID != nil { + return domain.AddReceiptRequest{}, errors.New("manual transaction fields cannot be combined with receipt input") + } + + isoDate, err := utils.NormalizeDateToISO(strings.TrimSpace(*req.ReceiptDate)) + if err != nil { + return domain.AddReceiptRequest{}, errors.New("invalid receipt_date format") + } + + return domain.AddReceiptRequest{ + Number: strings.TrimSpace(*req.ReceiptNumber), + Date: isoDate, + FamilyID: req.FamilyID, + CreatedBy: req.CreatedBy, + Type: trimOptionalString(req.Type), + Category: trimOptionalString(req.Category), + Description: trimOptionalString(req.Description), + }, nil +} + +func trimOptionalString(value *string) *string { + if value == nil { + return nil + } + + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + + return &trimmed +} + +func parseOptionalInt64Form(c *gin.Context, key string) (*int64, error) { + value := strings.TrimSpace(c.PostForm(key)) + if value == "" { + return nil, nil + } + + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, errors.New(key + " must be int64") + } + + return &parsed, nil +} + +func parseOptionalStringForm(c *gin.Context, key string) *string { + value := strings.TrimSpace(c.PostForm(key)) + if value == "" { + return nil + } + + return &value +} diff --git a/backend/src/api/routers/activities.go b/backend/src/api/routers/activities.go index 240241b..efca235 100644 --- a/backend/src/api/routers/activities.go +++ b/backend/src/api/routers/activities.go @@ -37,7 +37,7 @@ func (router *ActivitiesRouter) RegisterRoutes(r *gin.RouterGroup) { // @Success 200 {object} dto.ActivityListResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 500 {object} dto.ErrorResponse -// @Router /activities [get] +// @Router /api/v1/activities [get] func (router *ActivitiesRouter) List(c *gin.Context) { var query dto.ActivityListQuery if err := c.ShouldBindQuery(&query); err != nil { @@ -52,6 +52,7 @@ func (router *ActivitiesRouter) List(c *gin.Context) { Offset: query.Offset, }) if err != nil { + logInternalError(c, "activity request", err) c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) return } diff --git a/backend/src/api/routers/errors.go b/backend/src/api/routers/errors.go new file mode 100644 index 0000000..3624be1 --- /dev/null +++ b/backend/src/api/routers/errors.go @@ -0,0 +1,62 @@ +package routers + +import ( + "FamilyHub/src/api/dto" + receiptServiceIntegration "FamilyHub/src/integrations/receiptProvider" + "errors" + "log" + "net/http" + "runtime/debug" + + "github.com/gin-gonic/gin" +) + +func logError(c *gin.Context, scope string, err error) { + log.Printf( + "%s failed: method=%s path=%s route=%s error=%v", + scope, + c.Request.Method, + c.Request.URL.Path, + c.FullPath(), + err, + ) +} + +func logInternalError(c *gin.Context, scope string, err error) { + log.Printf( + "%s failed: method=%s path=%s route=%s error=%v\n%s", + scope, + c.Request.Method, + c.Request.URL.Path, + c.FullPath(), + err, + debug.Stack(), + ) +} + +func handleReceiptError(c *gin.Context, err error) { + var externalErr *receiptServiceIntegration.ExternalServiceError + + switch { + case errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound): + c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) + case errors.As(err, &externalErr): + log.Printf( + "receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q", + c.Request.Method, + c.Request.URL.Path, + externalErr.StatusCode, + externalErr.Body, + ) + logError(c, "receipt external service", err) + switch externalErr.StatusCode { + case http.StatusForbidden, http.StatusTooManyRequests: + c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service temporarily unavailable"}) + default: + c.JSON(http.StatusBadGateway, dto.ErrorResponse{Message: "receipt service error"}) + } + default: + logInternalError(c, "receipt request", err) + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) + } +} diff --git a/backend/src/api/routers/families.go b/backend/src/api/routers/families.go index 24802c1..db878e4 100644 --- a/backend/src/api/routers/families.go +++ b/backend/src/api/routers/families.go @@ -5,9 +5,7 @@ import ( "FamilyHub/src/domain" "database/sql" "errors" - "log" "net/http" - "runtime/debug" "strconv" "github.com/gin-gonic/gin" @@ -41,7 +39,7 @@ func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) { // @Success 201 {object} domain.FamilyResponse // @Failure 400 {object} map[string]string "invalid body" // @Failure 500 {object} map[string]string "internal server error" -// @Router /families [post] +// @Router /api/v1/families [post] func (router *FamiliesRouter) Create(c *gin.Context) { var req domain.CreateFamilyRequest var resp domain.FamilyResponse @@ -71,7 +69,7 @@ func (router *FamiliesRouter) Create(c *gin.Context) { // @Failure 400 {object} map[string]string "invalid id" // @Failure 404 {object} map[string]string "family not found" // @Failure 500 {object} map[string]string "internal server error" -// @Router /families/{id} [get] +// @Router /api/v1/families/{id} [get] func (router *FamiliesRouter) Read(c *gin.Context) { var resp domain.FamilyResponse @@ -103,7 +101,7 @@ func (router *FamiliesRouter) Read(c *gin.Context) { // @Failure 400 {object} map[string]string "name is required" // @Failure 404 {object} map[string]string "family not found" // @Failure 500 {object} map[string]string "internal server error" -// @Router /families/{id} [patch] +// @Router /api/v1/families/{id} [patch] func (router *FamiliesRouter) Update(c *gin.Context) { var resp domain.FamilyResponse @@ -143,7 +141,7 @@ func (router *FamiliesRouter) Update(c *gin.Context) { // @Failure 400 {object} map[string]string "invalid id" // @Failure 404 {object} map[string]string "family not found" // @Failure 500 {object} map[string]string "internal server error" -// @Router /families/{id} [delete] +// @Router /api/v1/families/{id} [delete] func (router *FamiliesRouter) Delete(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -166,14 +164,7 @@ 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(), - ) + logInternalError(c, "family request", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) } } diff --git a/backend/src/api/routers/receipts.go b/backend/src/api/routers/receipts.go deleted file mode 100644 index 830d0cc..0000000 --- a/backend/src/api/routers/receipts.go +++ /dev/null @@ -1,215 +0,0 @@ -package routers - -import ( - "FamilyHub/src/api/dto" - "FamilyHub/src/domain" - "FamilyHub/src/integrations/ocr" - "FamilyHub/src/utils" - "context" - "errors" - "io" - "log" - "net/http" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" -) - -type receiptService interface { - GetReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) -} - -type ReceiptRouter struct { - service receiptService - ocr ocr.OCR -} - -func NewReceiptRouter(s receiptService, ocrSvc ocr.OCR) *ReceiptRouter { - return &ReceiptRouter{service: s, ocr: ocrSvc} -} -func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) { - receipts := r.Group("/receipts") - { - receipts.POST("", router.AddReceipt) - receipts.POST("/photo", router.AddReceiptByPhoto) - } -} - -// 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 { - log.Println("bind error:", err) - context_.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) - return - } - isoDate, err := utils.NormalizeDateToISO(req.Date) - if err != nil { - context_.JSON(400, gin.H{"error": "invalid date format"}) - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - 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()) - return - } - - resp := domain.AddReceiptResponse{ - ID: int32(receipt.ID), - Number: receipt.ReceiptNumber, - Date: receipt.IssuedAt, - TransactionID: receipt.TransactionID, - } - context_.JSON(http.StatusOK, resp) -} - -// AddReceiptByPhoto GoDoc -// @Summary Загрузить чек по фото -// @Description Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию -// @Tags Receipts -// @Accept multipart/form-data -// @Produce json -// @Param photo formData file true "Receipt photo" -// @Param family_id formData int false "Family ID for auto-created transaction" -// @Param created_by formData int false "User ID for auto-created transaction" -// @Param type formData string false "Transaction type, default expense" -// @Param category formData string false "Transaction category, default receipt" -// @Param description formData string false "Transaction description override" -// @Success 200 {object} domain.AddReceiptResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 500 {object} dto.ErrorResponse -// @Router /receipts/photo [post] -func (router *ReceiptRouter) AddReceiptByPhoto(c *gin.Context) { - if router.ocr == nil { - c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "ocr is not configured"}) - return - } - - fileHeader, err := c.FormFile("photo") - if err != nil { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"}) - return - } - - file, err := fileHeader.Open() - if err != nil { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "failed to open uploaded photo"}) - return - } - defer file.Close() - - imageBytes, err := io.ReadAll(file) - if err != nil { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "failed to read uploaded photo"}) - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - text, err := router.ocr.Recognize(ctx, imageBytes) - if err != nil { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "ocr failed"}) - return - } - if strings.TrimSpace(text) == "" { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "text not found"}) - return - } - - receiptMeta := utils.ExtractReceiptMeta(text) - if receiptMeta.ReceiptID == "" { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "receipt number not found"}) - return - } - if receiptMeta.Date == "" { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "receipt date not found"}) - return - } - - req, err := buildPhotoReceiptRequest(c, receiptMeta) - if err != nil { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) - return - } - - receipt, err := router.service.GetReceipt(ctx, req) - if err != nil { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) - log.Printf("photo receipt API error, %s", err.Error()) - return - } - - c.JSON(http.StatusOK, domain.AddReceiptResponse{ - ID: int32(receipt.ID), - Number: receipt.ReceiptNumber, - Date: receipt.IssuedAt, - TransactionID: receipt.TransactionID, - }) -} - -func buildPhotoReceiptRequest(c *gin.Context, meta utils.ReceiptMeta) (domain.AddReceiptRequest, error) { - req := domain.AddReceiptRequest{ - Number: meta.ReceiptID, - Date: meta.Date, - } - - familyID, err := parseOptionalInt64Form(c, "family_id") - if err != nil { - return domain.AddReceiptRequest{}, err - } - createdBy, err := parseOptionalInt64Form(c, "created_by") - if err != nil { - return domain.AddReceiptRequest{}, err - } - - req.FamilyID = familyID - req.CreatedBy = createdBy - req.Type = parseOptionalStringForm(c, "type") - req.Category = parseOptionalStringForm(c, "category") - req.Description = parseOptionalStringForm(c, "description") - - return req, nil -} - -func parseOptionalInt64Form(c *gin.Context, key string) (*int64, error) { - value := strings.TrimSpace(c.PostForm(key)) - if value == "" { - return nil, nil - } - - parsed, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return nil, errors.New(key + " must be int64") - } - - return &parsed, nil -} - -func parseOptionalStringForm(c *gin.Context, key string) *string { - value := strings.TrimSpace(c.PostForm(key)) - if value == "" { - return nil - } - - return &value -} diff --git a/backend/src/api/routers/receipts_test.go b/backend/src/api/routers/receipts_test.go deleted file mode 100644 index 612bad0..0000000 --- a/backend/src/api/routers/receipts_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package routers - -import ( - "FamilyHub/src/domain" - "FamilyHub/src/integrations/ocr" - "bytes" - "context" - "encoding/json" - "errors" - "mime/multipart" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type receiptServiceMock struct { - getReceiptFn func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) -} - -func (m *receiptServiceMock) GetReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { - if m.getReceiptFn != nil { - return m.getReceiptFn(ctx, req) - } - return nil, errors.New("mock is not configured") -} - -type ocrMock struct { - recognizeFn func(ctx context.Context, image []byte) (string, error) -} - -func (m *ocrMock) Recognize(ctx context.Context, image []byte) (string, error) { - if m.recognizeFn != nil { - return m.recognizeFn(ctx, image) - } - return "", errors.New("mock is not configured") -} - -func (m *ocrMock) Close() error { - return nil -} - -func TestReceiptRouter_AddReceipt(t *testing.T) { - gin.SetMode(gin.TestMode) - - validNumber := strings.Repeat("1", 24) - validDate := "21.01.2026" - expectedDate := "2026-01-21" - now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC) - - tests := []struct { - name string - body string - mock *receiptServiceMock - expectedStatus int - expectedContains string - }{ - { - name: "bad request on invalid body", - body: `{"date":"21.01.2026"}`, - mock: &receiptServiceMock{}, - expectedStatus: http.StatusBadRequest, - expectedContains: "Number", - }, - { - name: "bad request on invalid date format", - body: `{"number":"` + validNumber + `","date":"2026-01-21"}`, - mock: &receiptServiceMock{}, - expectedStatus: http.StatusBadRequest, - expectedContains: "invalid date format", - }, - { - name: "bad request on service error", - body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`, - 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, - expectedContains: "receipt not found", - }, - { - name: "ok", - body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`, - 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, - }, - } - - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - r := gin.New() - apiV1 := r.Group("/api/v1") - router := NewReceiptRouter(tc.mock, nil) - router.RegisterRoutes(apiV1) - - req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts", bytes.NewBufferString(tc.body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - require.Equal(t, tc.expectedStatus, w.Code) - assert.Contains(t, w.Body.String(), tc.expectedContains) - - if tc.expectedStatus == http.StatusOK { - var resp struct { - ID int32 `json:"id"` - Number string `json:"number"` - Date time.Time `json:"date"` - } - err := json.Unmarshal(w.Body.Bytes(), &resp) - require.NoError(t, err) - assert.Equal(t, int32(7), resp.ID) - assert.Equal(t, validNumber, resp.Number) - assert.Equal(t, now, resp.Date) - } - }) - } -} - -func TestReceiptRouter_AddReceiptByPhoto(t *testing.T) { - gin.SetMode(gin.TestMode) - - validNumber := strings.Repeat("1", 24) - now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC) - - newRequest := func(t *testing.T, extraFields map[string]string) (*http.Request, string) { - t.Helper() - - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - part, err := writer.CreateFormFile("photo", "receipt.jpg") - require.NoError(t, err) - _, err = part.Write([]byte("fake-image")) - require.NoError(t, err) - - for key, value := range extraFields { - require.NoError(t, writer.WriteField(key, value)) - } - - require.NoError(t, writer.Close()) - - req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts/photo", body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - return req, writer.FormDataContentType() - } - - t.Run("bad request when photo missing", func(t *testing.T) { - r := gin.New() - apiV1 := r.Group("/api/v1") - router := NewReceiptRouter(&receiptServiceMock{}, &ocrMock{}) - router.RegisterRoutes(apiV1) - - req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts/photo", bytes.NewBufferString("")) - req.Header.Set("Content-Type", "multipart/form-data") - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "photo is required") - }) - - t.Run("ok", func(t *testing.T) { - r := gin.New() - apiV1 := r.Group("/api/v1") - - ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) { - assert.Equal(t, []byte("fake-image"), image) - return "21.01.2026 " + validNumber, nil - }} - service := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { - assert.Equal(t, validNumber, req.Number) - assert.Equal(t, "21.01.2026", req.Date) - require.NotNil(t, req.FamilyID) - require.NotNil(t, req.CreatedBy) - assert.Equal(t, int64(1), *req.FamilyID) - assert.Equal(t, int64(2), *req.CreatedBy) - require.NotNil(t, req.Category) - assert.Equal(t, "groceries", *req.Category) - return &domain.Receipt{ID: 9, ReceiptNumber: validNumber, IssuedAt: now}, nil - }} - - router := NewReceiptRouter(service, ocrSvc) - router.RegisterRoutes(apiV1) - - req, _ := newRequest(t, map[string]string{ - "family_id": "1", - "created_by": "2", - "category": "groceries", - }) - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Body.String(), validNumber) - }) -} - -var _ ocr.OCR = (*ocrMock)(nil) diff --git a/backend/src/api/routers/transactions.go b/backend/src/api/routers/transactions.go index c77aed0..4bf7763 100644 --- a/backend/src/api/routers/transactions.go +++ b/backend/src/api/routers/transactions.go @@ -2,22 +2,29 @@ package routers import ( "FamilyHub/src/api/dto" + "FamilyHub/src/api/requests" "FamilyHub/src/api/services" "FamilyHub/src/domain" "errors" + "io" "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" ) type TransactionsRouter struct { - service services.TransactionService + service services.TransactionService + creationService services.TransactionCreationService } -func NewTransactionsRouter(s services.TransactionService) *TransactionsRouter { - return &TransactionsRouter{service: s} +func NewTransactionsRouter( + s services.TransactionService, + creationService services.TransactionCreationService, +) *TransactionsRouter { + return &TransactionsRouter{service: s, creationService: creationService} } func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) { @@ -37,36 +44,70 @@ func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) { // @Description Создает новую транзакцию и при необходимости привязывает к ней чек // @Tags Transactions // @Accept json +// @Accept multipart/form-data // @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] +// @Router /api/v1/transactions [post] func (router *TransactionsRouter) Create(c *gin.Context) { + if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") { + router.createFromMultipart(c) + return + } + 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) + input, err := requests.BuildCreateTransactionInput(req) if err != nil { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "datetime must be RFC3339"}) + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) 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, - }) + transaction, err := router.creationService.Create(c.Request.Context(), input) + if err != nil { + handleTransactionError(c, err) + return + } + + c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction)) +} + +func (router *TransactionsRouter) createFromMultipart(c *gin.Context) { + fileHeader, err := c.FormFile("photo") + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"}) + return + } + + file, err := fileHeader.Open() + if err != nil { + logInternalError(c, "transaction upload", err) + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) + return + } + defer file.Close() + + imageBytes, err := io.ReadAll(file) + if err != nil { + logInternalError(c, "transaction upload", err) + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) + return + } + + input, err := requests.BuildPhotoCreateTransactionInput(c, imageBytes) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + return + } + + transaction, err := router.creationService.Create(c.Request.Context(), input) if err != nil { handleTransactionError(c, err) return @@ -92,7 +133,7 @@ func (router *TransactionsRouter) Create(c *gin.Context) { // @Success 200 {object} dto.TransactionListResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 500 {object} dto.ErrorResponse -// @Router /transactions [get] +// @Router /api/v1/transactions [get] func (router *TransactionsRouter) List(c *gin.Context) { var query dto.ListTransactionsQuery if err := c.ShouldBindQuery(&query); err != nil { @@ -128,7 +169,7 @@ func (router *TransactionsRouter) List(c *gin.Context) { // @Success 200 {object} dto.TransactionAnalyticsResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 500 {object} dto.ErrorResponse -// @Router /transactions/analytics [get] +// @Router /api/v1/transactions/analytics [get] func (router *TransactionsRouter) Analytics(c *gin.Context) { var query dto.TransactionAnalyticsQuery if err := c.ShouldBindQuery(&query); err != nil { @@ -173,7 +214,7 @@ func (router *TransactionsRouter) Analytics(c *gin.Context) { // @Failure 400 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 500 {object} dto.ErrorResponse -// @Router /transactions/{id} [get] +// @Router /api/v1/transactions/{id} [get] func (router *TransactionsRouter) Read(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -202,7 +243,7 @@ func (router *TransactionsRouter) Read(c *gin.Context) { // @Failure 400 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 500 {object} dto.ErrorResponse -// @Router /transactions/{id} [patch] +// @Router /api/v1/transactions/{id} [patch] func (router *TransactionsRouter) Update(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -254,7 +295,7 @@ func (router *TransactionsRouter) Update(c *gin.Context) { // @Failure 400 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 500 {object} dto.ErrorResponse -// @Router /transactions/{id} [delete] +// @Router /api/v1/transactions/{id} [delete] func (router *TransactionsRouter) Delete(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -277,11 +318,21 @@ func handleTransactionError(c *gin.Context, err error) { case errors.Is(err, services.ErrTransactionPatch), errors.Is(err, services.ErrReceiptLinkConflict), errors.Is(err, services.ErrInvalidTransaction), - errors.Is(err, services.ErrInvalidAnalytics): + errors.Is(err, services.ErrInvalidAnalytics), + errors.Is(err, services.ErrInvalidTransactionCreateInput), + errors.Is(err, services.ErrReceiptTransactionActorsMissing), + errors.Is(err, services.ErrOCRTextNotFound), + errors.Is(err, services.ErrReceiptNumberNotFound), + errors.Is(err, services.ErrReceiptDateNotFound): c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + case errors.Is(err, services.ErrReceiptServiceNotConfigured), + errors.Is(err, services.ErrOCRNotConfigured), + errors.Is(err, services.ErrReceiptTransactionNotCreated): + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: err.Error()}) case errors.Is(err, services.ErrReceiptNotFound): c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) default: + logInternalError(c, "transaction request", err) c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) } } diff --git a/backend/src/api/routers/transactions_test.go b/backend/src/api/routers/transactions_test.go new file mode 100644 index 0000000..4870a6d --- /dev/null +++ b/backend/src/api/routers/transactions_test.go @@ -0,0 +1,317 @@ +package routers + +import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/api/requests" + "FamilyHub/src/api/services" + "FamilyHub/src/domain" + "bytes" + "context" + "errors" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type transactionServiceMock struct { + createFn func(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) + getByIDFn func(ctx context.Context, id int64) (*domain.Transaction, error) +} + +type receiptServiceMock struct { + getReceiptFn func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) +} + +func (m *receiptServiceMock) AddReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + if m.getReceiptFn != nil { + return m.getReceiptFn(ctx, req) + } + return nil, errors.New("mock is not configured") +} + +type ocrMock struct { + recognizeFn func(ctx context.Context, image []byte) (string, error) +} + +func (m *ocrMock) Recognize(ctx context.Context, image []byte) (string, error) { + if m.recognizeFn != nil { + return m.recognizeFn(ctx, image) + } + return "", errors.New("mock is not configured") +} + +func (m *ocrMock) Close() error { + return nil +} + +func (m *transactionServiceMock) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) { + if m.createFn != nil { + return m.createFn(ctx, req) + } + return nil, errors.New("mock is not configured") +} + +func (m *transactionServiceMock) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) { + if m.getByIDFn != nil { + return m.getByIDFn(ctx, id) + } + return nil, errors.New("mock is not configured") +} + +func (m *transactionServiceMock) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) { + return nil, errors.New("not implemented") +} + +func (m *transactionServiceMock) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) { + return domain.TransactionAnalytics{}, errors.New("not implemented") +} + +func (m *transactionServiceMock) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) { + return nil, errors.New("not implemented") +} + +func (m *transactionServiceMock) Delete(ctx context.Context, id int64) error { + return errors.New("not implemented") +} + +func TestTransactionsRouter_Create(t *testing.T) { + gin.SetMode(gin.TestMode) + + now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC) + validNumber := strings.Repeat("1", 24) + + newMultipartRequest := func(t *testing.T, fields map[string]string) *http.Request { + t.Helper() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("photo", "receipt.jpg") + require.NoError(t, err) + _, err = part.Write([]byte("fake-image")) + require.NoError(t, err) + + for key, value := range fields { + require.NoError(t, writer.WriteField(key, value)) + } + require.NoError(t, writer.Close()) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req + } + + t.Run("creates manual transaction from json", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + + service := &transactionServiceMock{createFn: func(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) { + assert.Equal(t, int64(1), req.FamilyID) + assert.Equal(t, int64(2), req.CreatedBy) + assert.Equal(t, "expense", req.Type) + assert.Equal(t, "groceries", req.Category) + assert.Equal(t, 150.5, req.Amount) + assert.Equal(t, now, req.DateTime) + return &domain.Transaction{ + ID: 11, + FamilyID: req.FamilyID, + Type: req.Type, + DateTime: req.DateTime, + Category: req.Category, + Amount: req.Amount, + CreatedBy: req.CreatedBy, + CreatedAt: now, + }, nil + }} + + creationService := services.NewTransactionCreationService(service, nil, nil) + router := NewTransactionsRouter(service, creationService) + router.RegisterRoutes(apiV1) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{ + "family_id":1, + "created_by":2, + "type":"expense", + "category":"groceries", + "amount":150.5, + "datetime":"2026-01-21T10:11:12Z" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), `"id":11`) + }) + + t.Run("creates transaction from receipt number and date", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + + receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + assert.Equal(t, validNumber, req.Number) + assert.Equal(t, "2026-01-21", req.Date) + require.NotNil(t, req.FamilyID) + require.NotNil(t, req.CreatedBy) + assert.Equal(t, int64(1), *req.FamilyID) + assert.Equal(t, int64(2), *req.CreatedBy) + return &domain.Receipt{ID: 7, ReceiptNumber: validNumber, IssuedAt: now, TransactionID: ptrInt64(21)}, nil + }} + service := &transactionServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) { + assert.Equal(t, int64(21), id) + return &domain.Transaction{ + ID: 21, + FamilyID: 1, + Type: "expense", + DateTime: now, + Category: "receipt", + Amount: 99.9, + CreatedBy: 2, + CreatedAt: now, + ReceiptID: ptrInt64(7), + }, nil + }} + + creationService := services.NewTransactionCreationService(service, receiptSvc, nil) + router := NewTransactionsRouter(service, creationService) + router.RegisterRoutes(apiV1) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{ + "family_id":1, + "created_by":2, + "receipt_number":"`+validNumber+`", + "receipt_date":"21.01.2026" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), `"id":21`) + }) + + t.Run("creates transaction from photo upload", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + + ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) { + assert.Equal(t, []byte("fake-image"), image) + return "21.01.2026 " + validNumber, nil + }} + receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + assert.Equal(t, validNumber, req.Number) + assert.Equal(t, "21.01.2026", req.Date) + require.NotNil(t, req.FamilyID) + require.NotNil(t, req.CreatedBy) + assert.Equal(t, int64(1), *req.FamilyID) + assert.Equal(t, int64(2), *req.CreatedBy) + return &domain.Receipt{ID: 8, ReceiptNumber: validNumber, IssuedAt: now, TransactionID: ptrInt64(22)}, nil + }} + service := &transactionServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) { + assert.Equal(t, int64(22), id) + return &domain.Transaction{ + ID: 22, + FamilyID: 1, + Type: "expense", + DateTime: now, + Category: "receipt", + Amount: 123.4, + CreatedBy: 2, + CreatedAt: now, + ReceiptID: ptrInt64(8), + }, nil + }} + + creationService := services.NewTransactionCreationService(service, receiptSvc, ocrSvc) + router := NewTransactionsRouter(service, creationService) + router.RegisterRoutes(apiV1) + + req := newMultipartRequest(t, map[string]string{ + "family_id": "1", + "created_by": "2", + }) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), `"id":22`) + }) + + t.Run("rejects mixed manual and receipt payload", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + + creationService := services.NewTransactionCreationService(&transactionServiceMock{}, &receiptServiceMock{}, nil) + router := NewTransactionsRouter(&transactionServiceMock{}, creationService) + router.RegisterRoutes(apiV1) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{ + "family_id":1, + "created_by":2, + "receipt_number":"`+validNumber+`", + "receipt_date":"21.01.2026", + "amount":10 + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "manual transaction fields cannot be combined with receipt input") + }) + + t.Run("returns validation error when photo flow misses family data", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + + ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) { + return "21.01.2026 " + validNumber, nil + }} + creationService := services.NewTransactionCreationService(&transactionServiceMock{}, &receiptServiceMock{}, ocrSvc) + router := NewTransactionsRouter(&transactionServiceMock{}, creationService) + router.RegisterRoutes(apiV1) + + req := newMultipartRequest(t, nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "family_id and created_by are required for receipt transaction") + }) +} + +func ptrInt64(v int64) *int64 { + return &v +} + +func TestBuildManualTransactionRequest(t *testing.T) { + dateTime := "2026-01-21T10:11:12Z" + typeValue := "expense" + category := "groceries" + amount := 12.5 + familyID := int64(1) + createdBy := int64(2) + + req, err := requests.BuildManualTransactionRequest(dto.CreateTransactionRequest{ + FamilyID: &familyID, + CreatedBy: &createdBy, + Type: &typeValue, + Category: &category, + Amount: &amount, + DateTime: &dateTime, + }) + require.NoError(t, err) + assert.Equal(t, familyID, req.FamilyID) +} diff --git a/backend/src/api/routers/users.go b/backend/src/api/routers/users.go index b3abfad..a210f20 100644 --- a/backend/src/api/routers/users.go +++ b/backend/src/api/routers/users.go @@ -38,7 +38,7 @@ func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) { // @Success 201 {object} domain.UserResponse // @Failure 400 {object} domain.UserErrorResponse // @Failure 500 {object} domain.UserErrorResponse -// @Router /users [post] +// @Router /api/v1/users [post] func (router *UsersRouter) Create(c *gin.Context) { var req domain.CreateUserRequest var resp domain.UserResponse @@ -68,7 +68,7 @@ func (router *UsersRouter) Create(c *gin.Context) { // @Failure 400 {object} domain.UserErrorResponse "invalid id" // @Failure 404 {object} domain.UserErrorResponse "user not found" // @Failure 500 {object} domain.UserErrorResponse "internal server error" -// @Router /users/{id} [get] +// @Router /api/v1/users/{id} [get] func (router *UsersRouter) Read(c *gin.Context) { var resp domain.UserResponse id, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -97,7 +97,7 @@ func (router *UsersRouter) Read(c *gin.Context) { // @Failure 400 {object} domain.UserErrorResponse "invalid telegram id" // @Failure 404 {object} domain.UserErrorResponse "user not found" // @Failure 500 {object} domain.UserErrorResponse "internal server error" -// @Router /users/by-telegram/{telegramId} [get] +// @Router /api/v1/users/by-telegram/{telegramId} [get] func (router *UsersRouter) GetByTelegramID(c *gin.Context) { var resp domain.UserResponse telegramID, err := strconv.ParseInt(c.Param("telegramId"), 10, 64) @@ -127,7 +127,7 @@ func (router *UsersRouter) GetByTelegramID(c *gin.Context) { // @Failure 400 {object} domain.UserErrorResponse "invalid id or invalid body" // @Failure 404 {object} domain.UserErrorResponse "user not found" // @Failure 500 {object} domain.UserErrorResponse "internal server error" -// @Router /users/{id} [patch] +// @Router /api/v1/users/{id} [patch] func (router *UsersRouter) Update(c *gin.Context) { var resp domain.UserResponse id, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -162,7 +162,7 @@ func (router *UsersRouter) Update(c *gin.Context) { // @Failure 400 {object} domain.UserErrorResponse "invalid id" // @Failure 404 {object} domain.UserErrorResponse "user not found" // @Failure 500 {object} domain.UserErrorResponse "internal server error" -// @Router /users/{id} [delete] +// @Router /api/v1/users/{id} [delete] func (router *UsersRouter) Delete(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -187,6 +187,7 @@ func handleError(c *gin.Context, err error) { case errors.Is(err, services.ErrTelegramIDMissing): c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()}) default: + logInternalError(c, "user request", err) c.JSON(http.StatusInternalServerError, domain.UserErrorResponse{Error: "internal server error"}) } } diff --git a/backend/src/api/server.go b/backend/src/api/server.go index 5ff22c9..bf07bb1 100644 --- a/backend/src/api/server.go +++ b/backend/src/api/server.go @@ -7,7 +7,7 @@ import ( "FamilyHub/src/config" "FamilyHub/src/database" "FamilyHub/src/integrations/ocr" - "FamilyHub/src/integrations/receiptService" + "FamilyHub/src/integrations/receiptProvider" "FamilyHub/src/repositories" "context" "log" @@ -39,9 +39,12 @@ func NewServer(cfg config.Config) *Server { log.Fatal(err) } - //gin.SetMode(gin.ReleaseMode) + if !cfg.DebugMode { + gin.SetMode(gin.ReleaseMode) + } router := gin.New() router.Use(gin.Logger()) + //router.Use(requestLoggingMiddleware()) router.Use(gin.RecoveryWithWriter(os.Stderr)) if cfg.OpenAPIEnabled { openAPIEndpoint := cfg.OpenAPIEndpoint @@ -98,12 +101,12 @@ func NewServer(cfg config.Config) *Server { } receiptRepo := repositories.NewReceiptsSQLRepository(dbConn) - receiptService_ := receiptService.NewReceiptService(receiptRepo, transactionRepo) - receiptRouter := routers.NewReceiptRouter(receiptService_, ocrSvc) - receiptRouter.RegisterRoutes(apiV1) + receiptProvider_ := receiptProvider.NewReceiptProvider() + receiptService := services.NewReceiptService(receiptProvider_, receiptRepo, transactionRepo) transactionService := services.NewTransactionService(transactionRepo, activityRepo) - transactionRouter := routers.NewTransactionsRouter(transactionService) + transactionCreationService := services.NewTransactionCreationService(transactionService, receiptService, ocrSvc) + transactionRouter := routers.NewTransactionsRouter(transactionService, transactionCreationService) transactionRouter.RegisterRoutes(apiV1) activityService := services.NewActivityService(activityRepo) diff --git a/backend/src/api/services/receipts.go b/backend/src/api/services/receipts.go new file mode 100644 index 0000000..462763a --- /dev/null +++ b/backend/src/api/services/receipts.go @@ -0,0 +1,121 @@ +package services + +import ( + "FamilyHub/src/domain" + "FamilyHub/src/integrations/receiptProvider" + "FamilyHub/src/repositories" + "context" + "fmt" + "strings" +) + +type ReceiptService interface { + AddReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) +} + +type receiptService struct { + provider receiptProvider.ReceiptProvider + repo repositories.ReceiptsRepository + transactionRepo repositories.TransactionRepository +} + +func NewReceiptService( + provider receiptProvider.ReceiptProvider, + repo repositories.ReceiptsRepository, + transactionRepo repositories.TransactionRepository, +) ReceiptService { + return &receiptService{ + provider: provider, + repo: repo, + transactionRepo: transactionRepo, + } +} + +func (s *receiptService) AddReceipt( + ctx context.Context, + req domain.AddReceiptRequest, +) (*domain.Receipt, error) { + receipt, err := s.provider.GetReceipt(ctx, req.Date, req.Number) + if err != nil { + return nil, err + } + + receiptID, err := s.repo.Create(ctx, receipt) + if err != nil { + return nil, err + } + receipt.ID = int(receiptID) + + if !s.shouldCreateTransaction(req) { + return receipt, nil + } + + transaction, err := s.createTransactionForReceipt(ctx, receipt, req, receiptID) + if err != nil { + if rollbackErr := s.repo.Delete(ctx, receiptID); rollbackErr != nil { + return nil, fmt.Errorf("create receipt transaction: %w (rollback receipt %d: %v)", err, receiptID, rollbackErr) + } + return nil, err + } + + receipt.TransactionID = &transaction.ID + return receipt, 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/api/services/transaction_creation.go b/backend/src/api/services/transaction_creation.go new file mode 100644 index 0000000..d7369f8 --- /dev/null +++ b/backend/src/api/services/transaction_creation.go @@ -0,0 +1,147 @@ +package services + +import ( + "FamilyHub/src/domain" + "FamilyHub/src/integrations/ocr" + "FamilyHub/src/utils" + "context" + "errors" + "strings" +) + +type TransactionCreationService interface { + Create(ctx context.Context, req CreateTransactionInput) (*domain.Transaction, error) +} + +type CreateTransactionInput struct { + Manual *domain.CreateTransactionRequest + Receipt *domain.AddReceiptRequest + Photo *CreateTransactionPhotoInput +} + +type CreateTransactionPhotoInput struct { + Image []byte + FamilyID *int64 + CreatedBy *int64 + Type *string + Category *string + Description *string +} + +type transactionCreationService struct { + transactionService TransactionService + receiptService ReceiptService + ocr ocr.OCR +} + +var ( + ErrInvalidTransactionCreateInput = errors.New("exactly one transaction creation mode is required") + ErrReceiptServiceNotConfigured = errors.New("receipt service is not configured") + ErrOCRNotConfigured = errors.New("ocr is not configured") + ErrReceiptTransactionNotCreated = errors.New("transaction was not created for receipt") + ErrReceiptTransactionActorsMissing = errors.New("family_id and created_by are required for receipt transaction") + ErrOCRTextNotFound = errors.New("text not found") + ErrReceiptNumberNotFound = errors.New("receipt number not found") + ErrReceiptDateNotFound = errors.New("receipt date not found") +) + +func NewTransactionCreationService( + transactionService TransactionService, + receiptService ReceiptService, + ocrSvc ocr.OCR, +) TransactionCreationService { + return &transactionCreationService{ + transactionService: transactionService, + receiptService: receiptService, + ocr: ocrSvc, + } +} + +func (s *transactionCreationService) Create(ctx context.Context, req CreateTransactionInput) (*domain.Transaction, error) { + modeCount := 0 + if req.Manual != nil { + modeCount++ + } + if req.Receipt != nil { + modeCount++ + } + if req.Photo != nil { + modeCount++ + } + if modeCount != 1 { + return nil, ErrInvalidTransactionCreateInput + } + + switch { + case req.Manual != nil: + return s.transactionService.Create(ctx, *req.Manual) + case req.Receipt != nil: + return s.createFromReceipt(ctx, *req.Receipt) + default: + return s.createFromPhoto(ctx, *req.Photo) + } +} + +func (s *transactionCreationService) createFromReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Transaction, error) { + if s.receiptService == nil { + return nil, ErrReceiptServiceNotConfigured + } + + receipt, err := s.receiptService.AddReceipt(ctx, req) + if err != nil { + return nil, err + } + + return s.transactionFromReceipt(ctx, receipt) +} + +func (s *transactionCreationService) createFromPhoto(ctx context.Context, req CreateTransactionPhotoInput) (*domain.Transaction, error) { + if s.ocr == nil { + return nil, ErrOCRNotConfigured + } + if s.receiptService == nil { + return nil, ErrReceiptServiceNotConfigured + } + if req.FamilyID == nil || req.CreatedBy == nil { + return nil, ErrReceiptTransactionActorsMissing + } + + text, err := s.ocr.Recognize(ctx, req.Image) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + return nil, ErrOCRTextNotFound + } + + receiptMeta := utils.ExtractReceiptMeta(text) + if receiptMeta.ReceiptID == "" { + return nil, ErrReceiptNumberNotFound + } + if receiptMeta.Date == "" { + return nil, ErrReceiptDateNotFound + } + + receipt, err := s.receiptService.AddReceipt(ctx, domain.AddReceiptRequest{ + Number: receiptMeta.ReceiptID, + Date: receiptMeta.Date, + FamilyID: req.FamilyID, + CreatedBy: req.CreatedBy, + Type: req.Type, + Category: req.Category, + Description: req.Description, + }) + if err != nil { + return nil, err + } + + return s.transactionFromReceipt(ctx, receipt) +} + +func (s *transactionCreationService) transactionFromReceipt(ctx context.Context, receipt *domain.Receipt) (*domain.Transaction, error) { + if receipt.TransactionID == nil { + return nil, ErrReceiptTransactionNotCreated + } + + return s.transactionService.GetByID(ctx, *receipt.TransactionID) +} diff --git a/backend/src/api/services/transaction_creation_test.go b/backend/src/api/services/transaction_creation_test.go new file mode 100644 index 0000000..1cfdaf9 --- /dev/null +++ b/backend/src/api/services/transaction_creation_test.go @@ -0,0 +1,161 @@ +package services + +import ( + "FamilyHub/src/domain" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type transactionServiceCreateMock struct { + createFn func(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) + getByIDFn func(ctx context.Context, id int64) (*domain.Transaction, error) +} + +func (m *transactionServiceCreateMock) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) { + if m.createFn != nil { + return m.createFn(ctx, req) + } + return nil, errors.New("mock is not configured") +} + +func (m *transactionServiceCreateMock) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) { + if m.getByIDFn != nil { + return m.getByIDFn(ctx, id) + } + return nil, errors.New("mock is not configured") +} + +func (m *transactionServiceCreateMock) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) { + return nil, errors.New("not implemented") +} + +func (m *transactionServiceCreateMock) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) { + return domain.TransactionAnalytics{}, errors.New("not implemented") +} + +func (m *transactionServiceCreateMock) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) { + return nil, errors.New("not implemented") +} + +func (m *transactionServiceCreateMock) Delete(ctx context.Context, id int64) error { + return errors.New("not implemented") +} + +type receiptServiceCreateMock struct { + addReceiptFn func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) +} + +func (m *receiptServiceCreateMock) AddReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + if m.addReceiptFn != nil { + return m.addReceiptFn(ctx, req) + } + return nil, errors.New("mock is not configured") +} + +type ocrCreateMock struct { + recognizeFn func(ctx context.Context, image []byte) (string, error) +} + +func (m *ocrCreateMock) Recognize(ctx context.Context, image []byte) (string, error) { + if m.recognizeFn != nil { + return m.recognizeFn(ctx, image) + } + return "", errors.New("mock is not configured") +} + +func (m *ocrCreateMock) Close() error { + return nil +} + +func TestTransactionCreationService_Create_Manual(t *testing.T) { + now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC) + svc := NewTransactionCreationService( + &transactionServiceCreateMock{createFn: func(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) { + assert.Equal(t, int64(1), req.FamilyID) + return &domain.Transaction{ID: 1, FamilyID: req.FamilyID, DateTime: now}, nil + }}, + nil, + nil, + ) + + transaction, err := svc.Create(context.Background(), CreateTransactionInput{ + Manual: &domain.CreateTransactionRequest{FamilyID: 1}, + }) + require.NoError(t, err) + assert.Equal(t, int64(1), transaction.ID) +} + +func TestTransactionCreationService_Create_Receipt(t *testing.T) { + now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC) + svc := NewTransactionCreationService( + &transactionServiceCreateMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) { + assert.Equal(t, int64(15), id) + return &domain.Transaction{ID: id, DateTime: now}, nil + }}, + &receiptServiceCreateMock{addReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + assert.Equal(t, "123", req.Number) + return &domain.Receipt{TransactionID: ptrInt64Service(15)}, nil + }}, + nil, + ) + + transaction, err := svc.Create(context.Background(), CreateTransactionInput{ + Receipt: &domain.AddReceiptRequest{Number: "123"}, + }) + require.NoError(t, err) + assert.Equal(t, int64(15), transaction.ID) +} + +func TestTransactionCreationService_Create_Photo(t *testing.T) { + now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC) + svc := NewTransactionCreationService( + &transactionServiceCreateMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) { + assert.Equal(t, int64(17), id) + return &domain.Transaction{ID: id, DateTime: now}, nil + }}, + &receiptServiceCreateMock{addReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + assert.Equal(t, strings.Repeat("1", 24), req.Number) + assert.Equal(t, "21.01.2026", req.Date) + return &domain.Receipt{TransactionID: ptrInt64Service(17)}, nil + }}, + &ocrCreateMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) { + assert.Equal(t, []byte("image"), image) + return "21.01.2026 " + strings.Repeat("1", 24), nil + }}, + ) + + familyID := int64(1) + createdBy := int64(2) + transaction, err := svc.Create(context.Background(), CreateTransactionInput{ + Photo: &CreateTransactionPhotoInput{ + Image: []byte("image"), + FamilyID: &familyID, + CreatedBy: &createdBy, + }, + }) + require.NoError(t, err) + assert.Equal(t, int64(17), transaction.ID) +} + +func TestTransactionCreationService_Create_PhotoRequiresActors(t *testing.T) { + svc := NewTransactionCreationService( + &transactionServiceCreateMock{}, + &receiptServiceCreateMock{}, + &ocrCreateMock{}, + ) + + _, err := svc.Create(context.Background(), CreateTransactionInput{ + Photo: &CreateTransactionPhotoInput{Image: []byte("image")}, + }) + require.ErrorIs(t, err, ErrReceiptTransactionActorsMissing) +} + +func ptrInt64Service(v int64) *int64 { + return &v +} diff --git a/backend/src/integrations/familyHub/apiClient.go b/backend/src/integrations/familyHub/apiClient.go index efe3554..71a250e 100644 --- a/backend/src/integrations/familyHub/apiClient.go +++ b/backend/src/integrations/familyHub/apiClient.go @@ -25,7 +25,28 @@ func NewApiClient(config config.Config) (*HTTPClient, error) { } func (c *HTTPClient) SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error { - body, err := json.Marshal(payload) + requestBody := map[string]any{ + "receipt_number": payload.Number, + "receipt_date": payload.Date, + } + + if payload.FamilyID != nil { + requestBody["family_id"] = *payload.FamilyID + } + if payload.CreatedBy != nil { + requestBody["created_by"] = *payload.CreatedBy + } + if payload.Type != nil { + requestBody["type"] = *payload.Type + } + if payload.Category != nil { + requestBody["category"] = *payload.Category + } + if payload.Description != nil { + requestBody["description"] = *payload.Description + } + + body, err := json.Marshal(requestBody) if err != nil { return err } @@ -33,7 +54,7 @@ func (c *HTTPClient) SendReceipt(ctx context.Context, payload domain.AddReceiptR req, err := http.NewRequestWithContext( ctx, http.MethodPost, - c.config.APIHost+c.config.APIPort+"/receipts", + c.config.APIHost+c.config.APIPort+"/api/v1/transactions", bytes.NewReader(body), ) if err != nil { diff --git a/backend/src/integrations/familyHub/apiClient_test.go b/backend/src/integrations/familyHub/apiClient_test.go index 349b75d..875fae8 100644 --- a/backend/src/integrations/familyHub/apiClient_test.go +++ b/backend/src/integrations/familyHub/apiClient_test.go @@ -21,6 +21,39 @@ func testConfig(baseURL string) config.Config { } } +func TestHTTPClient_SendReceipt_UsesTransactionsEndpoint(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/transactions" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode body: %v", err) + } + + if payload["receipt_number"] != "123" || payload["receipt_date"] != "21.01.2026" { + t.Fatalf("unexpected payload: %+v", payload) + } + + w.WriteHeader(http.StatusCreated) + })) + defer ts.Close() + + client, err := NewApiClient(testConfig(ts.URL)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + err = client.SendReceipt(context.Background(), domain.AddReceiptRequest{ + Number: "123", + Date: "21.01.2026", + }) + if err != nil { + t.Fatalf("SendReceipt returned error: %v", err) + } +} + func TestHTTPClient_EnsureUser_AlreadyExists(t *testing.T) { var postCalls int32 diff --git a/backend/src/integrations/receiptProvider/receipt_provider.go b/backend/src/integrations/receiptProvider/receipt_provider.go new file mode 100644 index 0000000..61215b9 --- /dev/null +++ b/backend/src/integrations/receiptProvider/receipt_provider.go @@ -0,0 +1,177 @@ +package receiptProvider + +import ( + "FamilyHub/src/domain" + "FamilyHub/src/utils" + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "strings" + "time" +) + +type ReceiptProvider interface { + GetReceipt(ctx context.Context, date, number string) (*domain.Receipt, error) +} + +var ( + ErrReceiptNotFound = errors.New("receipt not found") + ErrExternalService = errors.New("external receipt service failure") +) + +type ExternalServiceError struct { + StatusCode int + Body string +} + +func (e *ExternalServiceError) Error() string { + return fmt.Sprintf("%s: status %d", ErrExternalService, e.StatusCode) +} + +func (e *ExternalServiceError) Unwrap() error { + return ErrExternalService +} + +type receiptProvider struct { + client *http.Client +} + +func NewReceiptProvider() *receiptProvider { + return &receiptProvider{ + client: &http.Client{ + Timeout: 60 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + }, + } +} + +func (s *receiptProvider) GetReceipt( + ctx context.Context, + date, number string, +) (*domain.Receipt, error) { + url := "https://ch.info-center.by/ajax/check1.php" + var receipt domain.Receipt + + body, contentType := buildMultipartBody(date, number) + httpReq, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + url, + body, + ) + if err != nil { + log.Println(err.Error()) + return nil, err + } + + httpReq.Header.Set("Content-Type", contentType) + + resp, err := s.client.Do(httpReq) + if err != nil { + log.Println(err.Error()) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if readErr != nil { + log.Printf("failed to read external service error body: %v", readErr) + } + bodyText := strings.TrimSpace(string(responseBody)) + log.Printf("external service returned %d body=%q", resp.StatusCode, bodyText) + return nil, &ExternalServiceError{ + StatusCode: resp.StatusCode, + Body: bodyText, + } + } + + var raw struct { + Message map[string]interface{} `json:"message"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + log.Printf("external service returned %s\n", err.Error()) + return nil, err + } + + bytes_, _ := json.Marshal(raw.Message) + + if err := json.Unmarshal(bytes_, &receipt); err != nil { + return nil, err + } + + if receipt.IssuedAtRaw == "" { + return nil, ErrReceiptNotFound + } + + positions, err := parsePositions(receipt.PositionsRaw) + if err != nil { + log.Printf("failed to parse positions: %s", err.Error()) + return nil, err + } + receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw) + if err != nil { + log.Printf("failed to parse issued at: %s", err.Error()) + return nil, err + } + + receipt.Positions = positions + + for i := range receipt.Positions { + p := &receipt.Positions[i] + + p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw) + if err != nil { + log.Printf("failed to parse product count: %s", err.Error()) + return nil, err + } + + p.Amount, err = utils.ParseFloat(p.AmountRaw) + if err != nil { + log.Printf("failed to parse amount: %s", err.Error()) + return nil, err + } + + p.Discount, _ = utils.ParseFloat(p.DiscountRaw) + p.Surcharge, _ = utils.ParseFloat(p.SurchargeRaw) + } + + return &receipt, nil +} + +func buildMultipartBody(date, number string) (*bytes.Buffer, string) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + _ = writer.WriteField("orig_date", date) + _ = writer.WriteField("orig_ui", number) + + _ = writer.Close() + + return body, writer.FormDataContentType() +} + +func parsePositions(raw string) ([]domain.Position, error) { + var positions []domain.Position + + if raw == "" { + return positions, nil + } + + if err := json.Unmarshal([]byte(raw), &positions); err != nil { + return nil, err + } + + return positions, nil +} diff --git a/backend/src/integrations/receiptService/receipt_service.go b/backend/src/integrations/receiptService/receipt_service.go deleted file mode 100644 index cbd9849..0000000 --- a/backend/src/integrations/receiptService/receipt_service.go +++ /dev/null @@ -1,229 +0,0 @@ -package receiptService - -import ( - "FamilyHub/src/domain" - "FamilyHub/src/repositories" - "FamilyHub/src/utils" - "bytes" - "context" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "log" - "mime/multipart" - "net/http" - "strings" - "time" -) - -type ReceiptService struct { - client *http.Client - repo repositories.ReceiptsRepository - transactionRepo repositories.TransactionRepository -} - -func NewReceiptService( - repo repositories.ReceiptsRepository, - transactionRepo repositories.TransactionRepository, -) *ReceiptService { - return &ReceiptService{ - client: &http.Client{ - Timeout: 60 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, - }, - repo: repo, - transactionRepo: transactionRepo, - } -} - -func (s *ReceiptService) GetReceipt( - ctx context.Context, - req domain.AddReceiptRequest, -) (*domain.Receipt, error) { - url := "https://ch.info-center.by/ajax/check1.php" - var receipt domain.Receipt - - body, contentType := buildMultipartBody(req.Date, req.Number) - httpReq, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - url, - body, - ) - if err != nil { - log.Println(err.Error()) - return nil, err - } - - httpReq.Header.Set("Content-Type", contentType) - - resp, err := s.client.Do(httpReq) - if err != nil { - log.Println(err.Error()) - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - log.Printf("external service returned %d\n", resp.StatusCode) - return nil, fmt.Errorf("external service returned %d", resp.StatusCode) - } - - var raw struct { - Message map[string]interface{} `json:"message"` - } - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - log.Printf("external service returned %s\n", err.Error()) - return nil, err - } - - bytes_, _ := json.Marshal(raw.Message) - - if err := json.Unmarshal(bytes_, &receipt); err != nil { - return nil, err - } - - if receipt.IssuedAtRaw == "" { - return nil, errors.New("receipt not found") - } - - positions, err := parsePositions(receipt.PositionsRaw) - if err != nil { - log.Printf("failed to parse positions: %s", err.Error()) - return nil, err - } - receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw) - if err != nil { - log.Printf("failed to parse issued at: %s", err.Error()) - return nil, err - } - - receipt.Positions = positions - - for i := range receipt.Positions { - p := &receipt.Positions[i] - - p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw) - if err != nil { - log.Printf("failed to parse product count: %s", err.Error()) - return nil, err - } - - p.Amount, err = utils.ParseFloat(p.AmountRaw) - if err != nil { - log.Printf("failed to parse amount: %s", err.Error()) - return nil, err - } - - p.Discount, _ = utils.ParseFloat(p.DiscountRaw) - p.Surcharge, _ = utils.ParseFloat(p.SurchargeRaw) - } - - 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 -} - -func buildMultipartBody(date, number string) (*bytes.Buffer, string) { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - _ = writer.WriteField("orig_date", date) - _ = writer.WriteField("orig_ui", number) - - _ = writer.Close() - - return body, writer.FormDataContentType() -} - -func parsePositions(raw string) ([]domain.Position, error) { - var positions []domain.Position - - if raw == "" { - return positions, nil - } - - if err := json.Unmarshal([]byte(raw), &positions); err != nil { - return nil, err - } - - 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 -}