Updated transaction routers, removed receipts router

This commit is contained in:
2026-05-09 12:04:20 +03:00
parent 2dc8ff01b7
commit a57f918d23
22 changed files with 1376 additions and 752 deletions
+11 -11
View File
@@ -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": [
+11 -11
View File
@@ -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": [
+11 -11
View File
@@ -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
+10 -8
View File
@@ -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 {
+44
View File
@@ -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,
)
}
}
+153
View File
@@ -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
}
+2 -1
View File
@@ -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
}
+62
View File
@@ -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"})
}
}
+5 -14
View File
@@ -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"})
}
}
-215
View File
@@ -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
}
-217
View File
@@ -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)
+73 -22
View File
@@ -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"})
}
}
@@ -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)
}
+6 -5
View File
@@ -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"})
}
}
+9 -6
View File
@@ -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)
+121
View File
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -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 {
@@ -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
@@ -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
}
@@ -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
}