Added transaction feature, fixed some mistakes

This commit is contained in:
2026-04-11 11:12:54 +03:00
parent 6872563c62
commit 545b05d5a0
37 changed files with 2509 additions and 115 deletions
+13 -3
View File
@@ -5,7 +5,9 @@ import (
"FamilyHub/src/domain"
"database/sql"
"errors"
"log"
"net/http"
"runtime/debug"
"strconv"
"github.com/gin-gonic/gin"
@@ -23,7 +25,7 @@ func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) {
families := r.Group("/families")
{
families.POST("", router.Create)
families.GET("/:id", router.GetByID)
families.GET("/:id", router.Read)
families.PATCH("/:id", router.Update)
families.DELETE("/:id", router.Delete)
}
@@ -58,7 +60,7 @@ func (router *FamiliesRouter) Create(c *gin.Context) {
c.JSON(http.StatusCreated, resp.ModelToResponse(family))
}
// GetByID GoDoc
// Read GoDoc
// @Summary Получить семью по ID
// @Description Возвращает семью по ее внутреннему ID
// @Tags Families
@@ -70,7 +72,7 @@ func (router *FamiliesRouter) Create(c *gin.Context) {
// @Failure 404 {object} map[string]string "family not found"
// @Failure 500 {object} map[string]string "internal server error"
// @Router /families/{id} [get]
func (router *FamiliesRouter) GetByID(c *gin.Context) {
func (router *FamiliesRouter) Read(c *gin.Context) {
var resp domain.FamilyResponse
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
@@ -164,6 +166,14 @@ func handleFamilyError(c *gin.Context, err error) {
case errors.Is(err, sql.ErrNoRows):
c.JSON(http.StatusNotFound, gin.H{"error": "family not found"})
default:
log.Printf(
"family request failed: method=%s path=%s route=%s error=%v\n%s",
c.Request.Method,
c.Request.URL.Path,
c.FullPath(),
err,
debug.Stack(),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
}
+15 -4
View File
@@ -19,6 +19,14 @@ import (
"github.com/stretchr/testify/require"
)
func int64Ptr(v int64) *int64 {
return &v
}
func stringPtr(v string) *string {
return &v
}
type familyServiceMock struct {
createFn func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error)
getByIDFn func(ctx context.Context, id int64) (*domain.Family, error)
@@ -68,8 +76,8 @@ func sampleFamily() *domain.Family {
ID: 7,
Name: "Belan",
OwnerID: 10,
TelegramChatID: 12345,
TelegramChatName: "Family Chat",
TelegramChatID: int64Ptr(12345),
TelegramChatName: stringPtr("Family Chat"),
CreatedAt: time.Date(2026, time.January, 21, 10, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, time.January, 21, 11, 0, 0, 0, time.UTC),
}
@@ -106,9 +114,11 @@ func TestFamiliesRouter_Create(t *testing.T) {
expected := sampleFamily()
r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
assert.Equal(t, "Belan", req.Name)
assert.Nil(t, req.TelegramChatID)
assert.Nil(t, req.TelegramChatName)
return expected, nil
}})
req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10,"telegram_chat_id":12345,"telegram_chat_name":"Family Chat"}`))
req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
@@ -233,7 +243,8 @@ func TestFamiliesRouter_Update(t *testing.T) {
assert.Equal(t, int64(7), id)
require.NotNil(t, req.Name)
assert.Equal(t, updatedName, *req.Name)
assert.Equal(t, "Updated", req.TelegramChatName)
require.NotNil(t, req.TelegramChatName)
assert.Equal(t, "Updated", *req.TelegramChatName)
return expected, nil
}})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/families/7", bytes.NewBufferString(`{"name":"`+updatedName+`","telegram_chat_name":"Updated"}`))
+19 -5
View File
@@ -13,7 +13,7 @@ import (
)
type receiptService interface {
GetReceipt(ctx context.Context, date string, number string) (*domain.Receipt, error)
GetReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error)
}
type ReceiptRouter struct {
@@ -30,6 +30,17 @@ func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) {
}
}
// 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 {
@@ -46,7 +57,9 @@ func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
receipt, err := router.service.GetReceipt(ctx, isoDate, req.Number)
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())
@@ -54,9 +67,10 @@ func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
}
resp := domain.AddReceiptResponse{
ID: 1,
Number: receipt.ReceiptNumber,
Date: receipt.IssuedAt,
ID: int32(receipt.ID),
Number: receipt.ReceiptNumber,
Date: receipt.IssuedAt,
TransactionID: receipt.TransactionID,
}
context_.JSON(http.StatusOK, resp)
}
+11 -11
View File
@@ -18,12 +18,12 @@ import (
)
type receiptServiceMock struct {
getReceiptFn func(ctx context.Context, date string, number string) (*domain.Receipt, error)
getReceiptFn func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error)
}
func (m *receiptServiceMock) GetReceipt(ctx context.Context, date string, number string) (*domain.Receipt, error) {
func (m *receiptServiceMock) GetReceipt(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
if m.getReceiptFn != nil {
return m.getReceiptFn(ctx, date, number)
return m.getReceiptFn(ctx, req)
}
return nil, errors.New("mock is not configured")
}
@@ -60,9 +60,9 @@ func TestReceiptRouter_AddReceipt(t *testing.T) {
{
name: "bad request on service error",
body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`,
mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, date string, number string) (*domain.Receipt, error) {
assert.Equal(t, expectedDate, date)
assert.Equal(t, validNumber, number)
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,
@@ -71,10 +71,10 @@ func TestReceiptRouter_AddReceipt(t *testing.T) {
{
name: "ok",
body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`,
mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, date string, number string) (*domain.Receipt, error) {
assert.Equal(t, expectedDate, date)
assert.Equal(t, validNumber, number)
return &domain.Receipt{ReceiptNumber: validNumber, IssuedAt: now}, nil
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,
@@ -108,7 +108,7 @@ func TestReceiptRouter_AddReceipt(t *testing.T) {
}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, int32(1), resp.ID)
assert.Equal(t, int32(7), resp.ID)
assert.Equal(t, validNumber, resp.Number)
assert.Equal(t, now, resp.Date)
}
+266
View File
@@ -0,0 +1,266 @@
package routers
import (
"FamilyHub/src/api/dto"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type TransactionsRouter struct {
service services.TransactionService
}
func NewTransactionsRouter(s services.TransactionService) *TransactionsRouter {
return &TransactionsRouter{service: s}
}
func (router *TransactionsRouter) RegisterRoutes(r *gin.RouterGroup) {
transactions := r.Group("/transactions")
{
transactions.POST("", router.Create)
transactions.GET("", router.List)
transactions.GET("/:id", router.Read)
transactions.PATCH("/:id", router.Update)
transactions.DELETE("/:id", router.Delete)
}
}
// Create GoDoc
// @Summary Создать транзакцию
// @Description Создает новую транзакцию и при необходимости привязывает к ней чек
// @Tags Transactions
// @Accept json
// @Produce json
// @Param transaction body dto.CreateTransactionRequest true "Transaction payload"
// @Success 201 {object} dto.TransactionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions [post]
func (router *TransactionsRouter) Create(c *gin.Context) {
var req dto.CreateTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
dateTime, err := time.Parse(time.RFC3339, req.DateTime)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "datetime must be RFC3339"})
return
}
transaction, err := router.service.Create(c.Request.Context(), domain.CreateTransactionRequest{
FamilyID: req.FamilyID,
Description: req.Description,
Type: req.Type,
DateTime: dateTime,
Category: req.Category,
Amount: req.Amount,
CreatedBy: req.CreatedBy,
ReceiptID: req.ReceiptID,
})
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction))
}
// List GoDoc
// @Summary Получить список транзакций
// @Description Возвращает список транзакций с фильтрами и пагинацией
// @Tags Transactions
// @Accept json
// @Produce json
// @Param family_id query int false "Family ID"
// @Param created_by query int false "User ID"
// @Param type query string false "Transaction type"
// @Param category query string false "Category"
// @Param date_from query string false "RFC3339 start datetime"
// @Param date_to query string false "RFC3339 end datetime"
// @Param limit query int false "Limit, default 50"
// @Param offset query int false "Offset"
// @Success 200 {object} dto.TransactionListResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions [get]
func (router *TransactionsRouter) List(c *gin.Context) {
var query dto.ListTransactionsQuery
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
filter, err := transactionQueryToFilter(query)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
transactions, err := router.service.List(c.Request.Context(), filter)
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusOK, dto.TransactionsToListResponse(transactions))
}
// Read GoDoc
// @Summary Получить транзакцию по ID
// @Description Возвращает транзакцию по ее внутреннему ID
// @Tags Transactions
// @Accept json
// @Produce json
// @Param id path int true "Transaction ID"
// @Success 200 {object} dto.TransactionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions/{id} [get]
func (router *TransactionsRouter) Read(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"})
return
}
transaction, err := router.service.GetByID(c.Request.Context(), id)
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusOK, dto.TransactionToResponse(transaction))
}
// Update GoDoc
// @Summary Обновить транзакцию
// @Description Частично обновляет данные транзакции и связь с чеком
// @Tags Transactions
// @Accept json
// @Produce json
// @Param id path int true "Transaction ID"
// @Param transaction body dto.UpdateTransactionRequest true "Transaction patch payload"
// @Success 200 {object} dto.TransactionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions/{id} [patch]
func (router *TransactionsRouter) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"})
return
}
var req dto.UpdateTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
var dateTime *time.Time
if req.DateTime != nil {
parsed, err := time.Parse(time.RFC3339, *req.DateTime)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "datetime must be RFC3339"})
return
}
dateTime = &parsed
}
transaction, err := router.service.Update(c.Request.Context(), id, domain.UpdateTransactionRequest{
Description: req.Description,
Type: req.Type,
DateTime: dateTime,
Category: req.Category,
Amount: req.Amount,
ReceiptID: req.ReceiptID,
DetachReceipt: req.DetachReceipt,
})
if err != nil {
handleTransactionError(c, err)
return
}
c.JSON(http.StatusOK, dto.TransactionToResponse(transaction))
}
// Delete GoDoc
// @Summary Удалить транзакцию
// @Description Удаляет транзакцию по ID
// @Tags Transactions
// @Accept json
// @Produce json
// @Param id path int true "Transaction ID"
// @Success 204 {string} string "no content"
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /transactions/{id} [delete]
func (router *TransactionsRouter) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "invalid id"})
return
}
if err := router.service.Delete(c.Request.Context(), id); err != nil {
handleTransactionError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func handleTransactionError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrTransactionNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrTransactionPatch),
errors.Is(err, services.ErrReceiptLinkConflict),
errors.Is(err, services.ErrInvalidTransaction):
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
default:
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
}
func transactionQueryToFilter(query dto.ListTransactionsQuery) (domain.TransactionListFilter, error) {
filter := domain.TransactionListFilter{
FamilyID: query.FamilyID,
CreatedBy: query.CreatedBy,
Type: query.Type,
Category: query.Category,
Limit: query.Limit,
Offset: query.Offset,
}
if query.DateFrom != nil {
parsed, err := time.Parse(time.RFC3339, *query.DateFrom)
if err != nil {
return domain.TransactionListFilter{}, errors.New("date_from must be RFC3339")
}
filter.DateFrom = &parsed
}
if query.DateTo != nil {
parsed, err := time.Parse(time.RFC3339, *query.DateTo)
if err != nil {
return domain.TransactionListFilter{}, errors.New("date_to must be RFC3339")
}
filter.DateTo = &parsed
}
return filter, nil
}
+7 -7
View File
@@ -21,15 +21,15 @@ func NewUsersRouter(s services.UserService) *UsersRouter {
func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) {
users := r.Group("/users")
{
users.POST("", router.CreateUser)
users.GET("/:id", router.GetByID)
users.GET("/by-telegram/:telegramId", router.GetByTelegramID)
users.POST("", router.Create)
users.GET("/:id", router.Read)
users.PATCH("/:id", router.Update)
users.DELETE("/:id", router.Delete)
users.GET("/by-telegram/:telegramId", router.GetByTelegramID)
}
}
// CreateUser GoDoc
// Create GoDoc
// @Summary Создать пользователя
// @Tags Users
// @Accept json
@@ -39,7 +39,7 @@ func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) {
// @Failure 400 {object} domain.UserErrorResponse
// @Failure 500 {object} domain.UserErrorResponse
// @Router /users [post]
func (router *UsersRouter) CreateUser(c *gin.Context) {
func (router *UsersRouter) Create(c *gin.Context) {
var req domain.CreateUserRequest
var resp domain.UserResponse
@@ -57,7 +57,7 @@ func (router *UsersRouter) CreateUser(c *gin.Context) {
c.JSON(http.StatusCreated, resp.ModelToResponse(user))
}
// GetByID GoDoc
// Read GoDoc
// @Summary Получить пользователя по ID
// @Description Возвращает пользователя по его внутреннему ID
// @Tags Users
@@ -69,7 +69,7 @@ func (router *UsersRouter) CreateUser(c *gin.Context) {
// @Failure 404 {object} domain.UserErrorResponse "user not found"
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
// @Router /users/{id} [get]
func (router *UsersRouter) GetByID(c *gin.Context) {
func (router *UsersRouter) Read(c *gin.Context) {
var resp domain.UserResponse
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {