Updated transaction routers, removed receipts router
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,14 +6,16 @@ import (
|
||||
)
|
||||
|
||||
type CreateTransactionRequest struct {
|
||||
FamilyID int64 `json:"family_id" binding:"required"`
|
||||
FamilyID *int64 `json:"family_id"`
|
||||
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"`
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -2,11 +2,14 @@ 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"
|
||||
@@ -14,10 +17,14 @@ import (
|
||||
|
||||
type TransactionsRouter struct {
|
||||
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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user