Updated transaction routers, removed receipts router
This commit is contained in:
@@ -15,7 +15,7 @@ const docTemplate = `{
|
|||||||
"host": "{{.Host}}",
|
"host": "{{.Host}}",
|
||||||
"basePath": "{{.BasePath}}",
|
"basePath": "{{.BasePath}}",
|
||||||
"paths": {
|
"paths": {
|
||||||
"/activities": {
|
"/api/v1/activities": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает список действий пользователей с пагинацией",
|
"description": "Возвращает список действий пользователей с пагинацией",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -76,7 +76,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/families": {
|
"/api/v1/families": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Создает новую семью",
|
"description": "Создает новую семью",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -128,7 +128,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/families/{id}": {
|
"/api/v1/families/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает семью по ее внутреннему ID",
|
"description": "Возвращает семью по ее внутреннему ID",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -310,7 +310,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/receipts": {
|
"/api/v1/receipts": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию",
|
"description": "Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -356,7 +356,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/receipts/photo": {
|
"/api/v1/receipts/photo": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию",
|
"description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -430,7 +430,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/transactions": {
|
"/api/v1/transactions": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает список транзакций с фильтрами и пагинацией",
|
"description": "Возвращает список транзакций с фильтрами и пагинацией",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -565,7 +565,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/transactions/analytics": {
|
"/api/v1/transactions/analytics": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает расходы, доходы и total за период. При фильтре по type второй тип возвращается как 0.",
|
"description": "Возвращает расходы, доходы и total за период. При фильтре по type второй тип возвращается как 0.",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -628,7 +628,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/transactions/{id}": {
|
"/api/v1/transactions/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает транзакцию по ее внутреннему ID",
|
"description": "Возвращает транзакцию по ее внутреннему ID",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -783,7 +783,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/users": {
|
"/api/v1/users": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -828,7 +828,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/users/by-telegram/{telegramId}": {
|
"/api/v1/users/by-telegram/{telegramId}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает пользователя по его Telegram ID",
|
"description": "Возвращает пользователя по его Telegram ID",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -878,7 +878,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/users/{id}": {
|
"/api/v1/users/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает пользователя по его внутреннему ID",
|
"description": "Возвращает пользователя по его внутреннему ID",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/activities": {
|
"/api/v1/activities": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает список действий пользователей с пагинацией",
|
"description": "Возвращает список действий пользователей с пагинацией",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/families": {
|
"/api/v1/families": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Создает новую семью",
|
"description": "Создает новую семью",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/families/{id}": {
|
"/api/v1/families/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает семью по ее внутреннему ID",
|
"description": "Возвращает семью по ее внутреннему ID",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/receipts": {
|
"/api/v1/receipts": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию",
|
"description": "Загружает чек из внешнего сервиса и опционально автоматически создает связанную транзакцию",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -345,7 +345,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/receipts/photo": {
|
"/api/v1/receipts/photo": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию",
|
"description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -419,7 +419,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/transactions": {
|
"/api/v1/transactions": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает список транзакций с фильтрами и пагинацией",
|
"description": "Возвращает список транзакций с фильтрами и пагинацией",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -554,7 +554,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/transactions/analytics": {
|
"/api/v1/transactions/analytics": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает расходы, доходы и total за период. При фильтре по type второй тип возвращается как 0.",
|
"description": "Возвращает расходы, доходы и total за период. При фильтре по type второй тип возвращается как 0.",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -617,7 +617,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/transactions/{id}": {
|
"/api/v1/transactions/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает транзакцию по ее внутреннему ID",
|
"description": "Возвращает транзакцию по ее внутреннему ID",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -772,7 +772,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/users": {
|
"/api/v1/users": {
|
||||||
"post": {
|
"post": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -817,7 +817,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/users/by-telegram/{telegramId}": {
|
"/api/v1/users/by-telegram/{telegramId}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает пользователя по его Telegram ID",
|
"description": "Возвращает пользователя по его Telegram ID",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
@@ -867,7 +867,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/users/{id}": {
|
"/api/v1/users/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Возвращает пользователя по его внутреннему ID",
|
"description": "Возвращает пользователя по его внутреннему ID",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ definitions:
|
|||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
paths:
|
paths:
|
||||||
/activities:
|
/api/v1/activities:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -280,7 +280,7 @@ paths:
|
|||||||
summary: Получить активность пользователей
|
summary: Получить активность пользователей
|
||||||
tags:
|
tags:
|
||||||
- Activities
|
- Activities
|
||||||
/families:
|
/api/v1/families:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -314,7 +314,7 @@ paths:
|
|||||||
summary: Создать семью
|
summary: Создать семью
|
||||||
tags:
|
tags:
|
||||||
- Families
|
- Families
|
||||||
/families/{id}:
|
/api/v1/families/{id}:
|
||||||
delete:
|
delete:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -435,7 +435,7 @@ paths:
|
|||||||
summary: Обновить семью
|
summary: Обновить семью
|
||||||
tags:
|
tags:
|
||||||
- Families
|
- Families
|
||||||
/receipts:
|
/api/v1/receipts:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -466,7 +466,7 @@ paths:
|
|||||||
summary: Загрузить чек
|
summary: Загрузить чек
|
||||||
tags:
|
tags:
|
||||||
- Receipts
|
- Receipts
|
||||||
/receipts/photo:
|
/api/v1/receipts/photo:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- multipart/form-data
|
- multipart/form-data
|
||||||
@@ -516,7 +516,7 @@ paths:
|
|||||||
summary: Загрузить чек по фото
|
summary: Загрузить чек по фото
|
||||||
tags:
|
tags:
|
||||||
- Receipts
|
- Receipts
|
||||||
/transactions:
|
/api/v1/transactions:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -606,7 +606,7 @@ paths:
|
|||||||
summary: Создать транзакцию
|
summary: Создать транзакцию
|
||||||
tags:
|
tags:
|
||||||
- Transactions
|
- Transactions
|
||||||
/transactions/{id}:
|
/api/v1/transactions/{id}:
|
||||||
delete:
|
delete:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -709,7 +709,7 @@ paths:
|
|||||||
summary: Обновить транзакцию
|
summary: Обновить транзакцию
|
||||||
tags:
|
tags:
|
||||||
- Transactions
|
- Transactions
|
||||||
/transactions/analytics:
|
/api/v1/transactions/analytics:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -752,7 +752,7 @@ paths:
|
|||||||
summary: Получить аналитику по транзакциям
|
summary: Получить аналитику по транзакциям
|
||||||
tags:
|
tags:
|
||||||
- Transactions
|
- Transactions
|
||||||
/users:
|
/api/v1/users:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -781,7 +781,7 @@ paths:
|
|||||||
summary: Создать пользователя
|
summary: Создать пользователя
|
||||||
tags:
|
tags:
|
||||||
- Users
|
- Users
|
||||||
/users/{id}:
|
/api/v1/users/{id}:
|
||||||
delete:
|
delete:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -884,7 +884,7 @@ paths:
|
|||||||
summary: Обновить пользователя
|
summary: Обновить пользователя
|
||||||
tags:
|
tags:
|
||||||
- Users
|
- Users
|
||||||
/users/by-telegram/{telegramId}:
|
/api/v1/users/by-telegram/{telegramId}:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CreateTransactionRequest struct {
|
type CreateTransactionRequest struct {
|
||||||
FamilyID int64 `json:"family_id" binding:"required"`
|
FamilyID *int64 `json:"family_id"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Type string `json:"type" binding:"required"`
|
Type *string `json:"type"`
|
||||||
DateTime string `json:"datetime" binding:"required"`
|
DateTime *string `json:"datetime"`
|
||||||
Category string `json:"category" binding:"required"`
|
Category *string `json:"category"`
|
||||||
Amount float64 `json:"amount" binding:"required"`
|
Amount *float64 `json:"amount"`
|
||||||
CreatedBy int64 `json:"created_by" binding:"required"`
|
CreatedBy *int64 `json:"created_by"`
|
||||||
ReceiptID *int64 `json:"receipt_id"`
|
ReceiptID *int64 `json:"receipt_id"`
|
||||||
|
ReceiptNumber *string `json:"receipt_number"`
|
||||||
|
ReceiptDate *string `json:"receipt_date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateTransactionRequest struct {
|
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
|
// @Success 200 {object} dto.ActivityListResponse
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
// @Failure 500 {object} dto.ErrorResponse
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
// @Router /activities [get]
|
// @Router /api/v1/activities [get]
|
||||||
func (router *ActivitiesRouter) List(c *gin.Context) {
|
func (router *ActivitiesRouter) List(c *gin.Context) {
|
||||||
var query dto.ActivityListQuery
|
var query dto.ActivityListQuery
|
||||||
if err := c.ShouldBindQuery(&query); err != nil {
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
@@ -52,6 +52,7 @@ func (router *ActivitiesRouter) List(c *gin.Context) {
|
|||||||
Offset: query.Offset,
|
Offset: query.Offset,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logInternalError(c, "activity request", err)
|
||||||
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
|
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
|
||||||
return
|
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"
|
"FamilyHub/src/domain"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime/debug"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -41,7 +39,7 @@ func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) {
|
|||||||
// @Success 201 {object} domain.FamilyResponse
|
// @Success 201 {object} domain.FamilyResponse
|
||||||
// @Failure 400 {object} map[string]string "invalid body"
|
// @Failure 400 {object} map[string]string "invalid body"
|
||||||
// @Failure 500 {object} map[string]string "internal server error"
|
// @Failure 500 {object} map[string]string "internal server error"
|
||||||
// @Router /families [post]
|
// @Router /api/v1/families [post]
|
||||||
func (router *FamiliesRouter) Create(c *gin.Context) {
|
func (router *FamiliesRouter) Create(c *gin.Context) {
|
||||||
var req domain.CreateFamilyRequest
|
var req domain.CreateFamilyRequest
|
||||||
var resp domain.FamilyResponse
|
var resp domain.FamilyResponse
|
||||||
@@ -71,7 +69,7 @@ func (router *FamiliesRouter) Create(c *gin.Context) {
|
|||||||
// @Failure 400 {object} map[string]string "invalid id"
|
// @Failure 400 {object} map[string]string "invalid id"
|
||||||
// @Failure 404 {object} map[string]string "family not found"
|
// @Failure 404 {object} map[string]string "family not found"
|
||||||
// @Failure 500 {object} map[string]string "internal server error"
|
// @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) {
|
func (router *FamiliesRouter) Read(c *gin.Context) {
|
||||||
var resp domain.FamilyResponse
|
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 400 {object} map[string]string "name is required"
|
||||||
// @Failure 404 {object} map[string]string "family not found"
|
// @Failure 404 {object} map[string]string "family not found"
|
||||||
// @Failure 500 {object} map[string]string "internal server error"
|
// @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) {
|
func (router *FamiliesRouter) Update(c *gin.Context) {
|
||||||
var resp domain.FamilyResponse
|
var resp domain.FamilyResponse
|
||||||
|
|
||||||
@@ -143,7 +141,7 @@ func (router *FamiliesRouter) Update(c *gin.Context) {
|
|||||||
// @Failure 400 {object} map[string]string "invalid id"
|
// @Failure 400 {object} map[string]string "invalid id"
|
||||||
// @Failure 404 {object} map[string]string "family not found"
|
// @Failure 404 {object} map[string]string "family not found"
|
||||||
// @Failure 500 {object} map[string]string "internal server error"
|
// @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) {
|
func (router *FamiliesRouter) Delete(c *gin.Context) {
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -166,14 +164,7 @@ func handleFamilyError(c *gin.Context, err error) {
|
|||||||
case errors.Is(err, sql.ErrNoRows):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "family not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "family not found"})
|
||||||
default:
|
default:
|
||||||
log.Printf(
|
logInternalError(c, "family request", err)
|
||||||
"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(),
|
|
||||||
)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
|
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 (
|
import (
|
||||||
"FamilyHub/src/api/dto"
|
"FamilyHub/src/api/dto"
|
||||||
|
"FamilyHub/src/api/requests"
|
||||||
"FamilyHub/src/api/services"
|
"FamilyHub/src/api/services"
|
||||||
"FamilyHub/src/domain"
|
"FamilyHub/src/domain"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -14,10 +17,14 @@ import (
|
|||||||
|
|
||||||
type TransactionsRouter struct {
|
type TransactionsRouter struct {
|
||||||
service services.TransactionService
|
service services.TransactionService
|
||||||
|
creationService services.TransactionCreationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTransactionsRouter(s services.TransactionService) *TransactionsRouter {
|
func NewTransactionsRouter(
|
||||||
return &TransactionsRouter{service: s}
|
s services.TransactionService,
|
||||||
|
creationService services.TransactionCreationService,
|
||||||
|
) *TransactionsRouter {
|
||||||
|
return &TransactionsRouter{service: s, creationService: creationService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) {
|
func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) {
|
||||||
@@ -37,36 +44,70 @@ func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) {
|
|||||||
// @Description Создает новую транзакцию и при необходимости привязывает к ней чек
|
// @Description Создает новую транзакцию и при необходимости привязывает к ней чек
|
||||||
// @Tags Transactions
|
// @Tags Transactions
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
// @Accept multipart/form-data
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param transaction body dto.CreateTransactionRequest true "Transaction payload"
|
// @Param transaction body dto.CreateTransactionRequest true "Transaction payload"
|
||||||
// @Success 201 {object} dto.TransactionResponse
|
// @Success 201 {object} dto.TransactionResponse
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
// @Failure 404 {object} dto.ErrorResponse
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
// @Failure 500 {object} dto.ErrorResponse
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
// @Router /transactions [post]
|
// @Router /api/v1/transactions [post]
|
||||||
func (router *TransactionsRouter) Create(c *gin.Context) {
|
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
|
var req dto.CreateTransactionRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dateTime, err := time.Parse(time.RFC3339, req.DateTime)
|
input, err := requests.BuildCreateTransactionInput(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "datetime must be RFC3339"})
|
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction, err := router.service.Create(c.Request.Context(), domain.CreateTransactionRequest{
|
transaction, err := router.creationService.Create(c.Request.Context(), input)
|
||||||
FamilyID: req.FamilyID,
|
if err != nil {
|
||||||
Description: req.Description,
|
handleTransactionError(c, err)
|
||||||
Type: req.Type,
|
return
|
||||||
DateTime: dateTime,
|
}
|
||||||
Category: req.Category,
|
|
||||||
Amount: req.Amount,
|
c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction))
|
||||||
CreatedBy: req.CreatedBy,
|
}
|
||||||
ReceiptID: req.ReceiptID,
|
|
||||||
})
|
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 {
|
if err != nil {
|
||||||
handleTransactionError(c, err)
|
handleTransactionError(c, err)
|
||||||
return
|
return
|
||||||
@@ -92,7 +133,7 @@ func (router *TransactionsRouter) Create(c *gin.Context) {
|
|||||||
// @Success 200 {object} dto.TransactionListResponse
|
// @Success 200 {object} dto.TransactionListResponse
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
// @Failure 500 {object} dto.ErrorResponse
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
// @Router /transactions [get]
|
// @Router /api/v1/transactions [get]
|
||||||
func (router *TransactionsRouter) List(c *gin.Context) {
|
func (router *TransactionsRouter) List(c *gin.Context) {
|
||||||
var query dto.ListTransactionsQuery
|
var query dto.ListTransactionsQuery
|
||||||
if err := c.ShouldBindQuery(&query); err != nil {
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
@@ -128,7 +169,7 @@ func (router *TransactionsRouter) List(c *gin.Context) {
|
|||||||
// @Success 200 {object} dto.TransactionAnalyticsResponse
|
// @Success 200 {object} dto.TransactionAnalyticsResponse
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
// @Failure 500 {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) {
|
func (router *TransactionsRouter) Analytics(c *gin.Context) {
|
||||||
var query dto.TransactionAnalyticsQuery
|
var query dto.TransactionAnalyticsQuery
|
||||||
if err := c.ShouldBindQuery(&query); err != nil {
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
@@ -173,7 +214,7 @@ func (router *TransactionsRouter) Analytics(c *gin.Context) {
|
|||||||
// @Failure 400 {object} dto.ErrorResponse
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
// @Failure 404 {object} dto.ErrorResponse
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
// @Failure 500 {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) {
|
func (router *TransactionsRouter) Read(c *gin.Context) {
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -202,7 +243,7 @@ func (router *TransactionsRouter) Read(c *gin.Context) {
|
|||||||
// @Failure 400 {object} dto.ErrorResponse
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
// @Failure 404 {object} dto.ErrorResponse
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
// @Failure 500 {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) {
|
func (router *TransactionsRouter) Update(c *gin.Context) {
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -254,7 +295,7 @@ func (router *TransactionsRouter) Update(c *gin.Context) {
|
|||||||
// @Failure 400 {object} dto.ErrorResponse
|
// @Failure 400 {object} dto.ErrorResponse
|
||||||
// @Failure 404 {object} dto.ErrorResponse
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
// @Failure 500 {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) {
|
func (router *TransactionsRouter) Delete(c *gin.Context) {
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -277,11 +318,21 @@ func handleTransactionError(c *gin.Context, err error) {
|
|||||||
case errors.Is(err, services.ErrTransactionPatch),
|
case errors.Is(err, services.ErrTransactionPatch),
|
||||||
errors.Is(err, services.ErrReceiptLinkConflict),
|
errors.Is(err, services.ErrReceiptLinkConflict),
|
||||||
errors.Is(err, services.ErrInvalidTransaction),
|
errors.Is(err, services.ErrInvalidTransaction),
|
||||||
errors.Is(err, services.ErrInvalidAnalytics):
|
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()})
|
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):
|
case errors.Is(err, services.ErrReceiptNotFound):
|
||||||
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
|
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
|
||||||
default:
|
default:
|
||||||
|
logInternalError(c, "transaction request", err)
|
||||||
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
|
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
|
// @Success 201 {object} domain.UserResponse
|
||||||
// @Failure 400 {object} domain.UserErrorResponse
|
// @Failure 400 {object} domain.UserErrorResponse
|
||||||
// @Failure 500 {object} domain.UserErrorResponse
|
// @Failure 500 {object} domain.UserErrorResponse
|
||||||
// @Router /users [post]
|
// @Router /api/v1/users [post]
|
||||||
func (router *UsersRouter) Create(c *gin.Context) {
|
func (router *UsersRouter) Create(c *gin.Context) {
|
||||||
var req domain.CreateUserRequest
|
var req domain.CreateUserRequest
|
||||||
var resp domain.UserResponse
|
var resp domain.UserResponse
|
||||||
@@ -68,7 +68,7 @@ func (router *UsersRouter) Create(c *gin.Context) {
|
|||||||
// @Failure 400 {object} domain.UserErrorResponse "invalid id"
|
// @Failure 400 {object} domain.UserErrorResponse "invalid id"
|
||||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
// @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) {
|
func (router *UsersRouter) Read(c *gin.Context) {
|
||||||
var resp domain.UserResponse
|
var resp domain.UserResponse
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
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 400 {object} domain.UserErrorResponse "invalid telegram id"
|
||||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
// @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) {
|
func (router *UsersRouter) GetByTelegramID(c *gin.Context) {
|
||||||
var resp domain.UserResponse
|
var resp domain.UserResponse
|
||||||
telegramID, err := strconv.ParseInt(c.Param("telegramId"), 10, 64)
|
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 400 {object} domain.UserErrorResponse "invalid id or invalid body"
|
||||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
// @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) {
|
func (router *UsersRouter) Update(c *gin.Context) {
|
||||||
var resp domain.UserResponse
|
var resp domain.UserResponse
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
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 400 {object} domain.UserErrorResponse "invalid id"
|
||||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
// @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) {
|
func (router *UsersRouter) Delete(c *gin.Context) {
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -187,6 +187,7 @@ func handleError(c *gin.Context, err error) {
|
|||||||
case errors.Is(err, services.ErrTelegramIDMissing):
|
case errors.Is(err, services.ErrTelegramIDMissing):
|
||||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
||||||
default:
|
default:
|
||||||
|
logInternalError(c, "user request", err)
|
||||||
c.JSON(http.StatusInternalServerError, domain.UserErrorResponse{Error: "internal server error"})
|
c.JSON(http.StatusInternalServerError, domain.UserErrorResponse{Error: "internal server error"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"FamilyHub/src/config"
|
"FamilyHub/src/config"
|
||||||
"FamilyHub/src/database"
|
"FamilyHub/src/database"
|
||||||
"FamilyHub/src/integrations/ocr"
|
"FamilyHub/src/integrations/ocr"
|
||||||
"FamilyHub/src/integrations/receiptService"
|
"FamilyHub/src/integrations/receiptProvider"
|
||||||
"FamilyHub/src/repositories"
|
"FamilyHub/src/repositories"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
@@ -39,9 +39,12 @@ func NewServer(cfg config.Config) *Server {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
//gin.SetMode(gin.ReleaseMode)
|
if !cfg.DebugMode {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(gin.Logger())
|
router.Use(gin.Logger())
|
||||||
|
//router.Use(requestLoggingMiddleware())
|
||||||
router.Use(gin.RecoveryWithWriter(os.Stderr))
|
router.Use(gin.RecoveryWithWriter(os.Stderr))
|
||||||
if cfg.OpenAPIEnabled {
|
if cfg.OpenAPIEnabled {
|
||||||
openAPIEndpoint := cfg.OpenAPIEndpoint
|
openAPIEndpoint := cfg.OpenAPIEndpoint
|
||||||
@@ -98,12 +101,12 @@ func NewServer(cfg config.Config) *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
receiptRepo := repositories.NewReceiptsSQLRepository(dbConn)
|
receiptRepo := repositories.NewReceiptsSQLRepository(dbConn)
|
||||||
receiptService_ := receiptService.NewReceiptService(receiptRepo, transactionRepo)
|
receiptProvider_ := receiptProvider.NewReceiptProvider()
|
||||||
receiptRouter := routers.NewReceiptRouter(receiptService_, ocrSvc)
|
receiptService := services.NewReceiptService(receiptProvider_, receiptRepo, transactionRepo)
|
||||||
receiptRouter.RegisterRoutes(apiV1)
|
|
||||||
|
|
||||||
transactionService := services.NewTransactionService(transactionRepo, activityRepo)
|
transactionService := services.NewTransactionService(transactionRepo, activityRepo)
|
||||||
transactionRouter := routers.NewTransactionsRouter(transactionService)
|
transactionCreationService := services.NewTransactionCreationService(transactionService, receiptService, ocrSvc)
|
||||||
|
transactionRouter := routers.NewTransactionsRouter(transactionService, transactionCreationService)
|
||||||
transactionRouter.RegisterRoutes(apiV1)
|
transactionRouter.RegisterRoutes(apiV1)
|
||||||
|
|
||||||
activityService := services.NewActivityService(activityRepo)
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -33,7 +54,7 @@ func (c *HTTPClient) SendReceipt(ctx context.Context, payload domain.AddReceiptR
|
|||||||
req, err := http.NewRequestWithContext(
|
req, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
c.config.APIHost+c.config.APIPort+"/receipts",
|
c.config.APIHost+c.config.APIPort+"/api/v1/transactions",
|
||||||
bytes.NewReader(body),
|
bytes.NewReader(body),
|
||||||
)
|
)
|
||||||
if err != nil {
|
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) {
|
func TestHTTPClient_EnsureUser_AlreadyExists(t *testing.T) {
|
||||||
var postCalls int32
|
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