diff --git a/internal/app/app.go b/internal/app/app.go index 3f57cd5..36742c7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -135,6 +135,8 @@ func (a *App) Initialize(cfg *config.Config) error { services.productOutletPriceService, validators.productOutletPriceValidator, selfOrderHandler, + services.expenseService, + validators.expenseValidator, ) return nil @@ -236,6 +238,7 @@ type repositories struct { notificationReceiverRepo *repository.NotificationReceiverRepositoryImpl notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl + expenseRepo *repository.ExpenseRepositoryImpl } func (a *App) initRepositories() *repositories { @@ -288,6 +291,7 @@ func (a *App) initRepositories() *repositories { notificationReceiverRepo: repository.NewNotificationReceiverRepository(a.db), notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db), productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db), + expenseRepo: repository.NewExpenseRepositoryImpl(a.db), } } @@ -333,6 +337,7 @@ type processors struct { userDeviceProcessor *processor.UserDeviceProcessorImpl notificationProcessor *processor.NotificationProcessorImpl productOutletPriceProcessor processor.ProductOutletPriceProcessor + expenseProcessor *processor.ExpenseProcessorImpl } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -383,6 +388,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo), notificationProcessor: buildNotificationProcessor(cfg, repos), productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo), + expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo), } } @@ -422,6 +428,7 @@ type services struct { userDeviceService service.UserDeviceService notificationService service.NotificationService productOutletPriceService service.ProductOutletPriceService + expenseService *service.ExpenseServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -499,6 +506,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con userDeviceService: userDeviceService, notificationService: notificationService, productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor), + expenseService: service.NewExpenseService(processors.expenseProcessor), } } @@ -541,6 +549,7 @@ type validators struct { userDeviceValidator *validator.UserDeviceValidatorImpl notificationValidator *validator.NotificationValidatorImpl productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl + expenseValidator *validator.ExpenseValidatorImpl } func (a *App) initValidators() *validators { @@ -571,6 +580,7 @@ func (a *App) initValidators() *validators { userDeviceValidator: validator.NewUserDeviceValidator(), notificationValidator: validator.NewNotificationValidator(), productOutletPriceValidator: validator.NewProductOutletPriceValidator(), + expenseValidator: validator.NewExpenseValidator(), } } diff --git a/internal/constants/error.go b/internal/constants/error.go index 4eb6366..8937a58 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -60,6 +60,7 @@ const ( NotificationServiceEntity = "notification_service" NotificationHandlerEntity = "notification_handler" ProductOutletPriceServiceEntity = "product_outlet_price_service" + ExpenseServiceEntity = "expense_service" ) var HttpErrorMap = map[string]int{ diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go new file mode 100644 index 0000000..ef8af97 --- /dev/null +++ b/internal/contract/expense_contract.go @@ -0,0 +1,61 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateExpenseRequest struct { + Receiver string `json:"receiver" validate:"required"` + TransactionDate string `json:"transaction_date" validate:"required"` + CodeNumber string `json:"code_number" validate:"required"` + ChartOfAccountID string `json:"chart_of_account_id" validate:"required"` + OutletID string `json:"outlet_id" validate:"required"` + Description *string `json:"description,omitempty"` + Tax float64 `json:"tax"` + Total float64 `json:"total" validate:"required"` +} + +type UpdateExpenseRequest struct { + Receiver *string `json:"receiver,omitempty"` + TransactionDate *string `json:"transaction_date,omitempty"` + CodeNumber *string `json:"code_number,omitempty"` + ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` + OutletID *string `json:"outlet_id,omitempty"` + Description *string `json:"description,omitempty"` + Tax *float64 `json:"tax,omitempty"` + Total *float64 `json:"total,omitempty"` + Reserved1 *string `json:"reserved1,omitempty"` +} + +type ExpenseResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + Receiver string `json:"receiver"` + TransactionDate time.Time `json:"transaction_date"` + CodeNumber string `json:"code_number"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name,omitempty"` + Description *string `json:"description"` + Tax float64 `json:"tax"` + Total float64 `json:"total"` + Reserved1 *string `json:"reserved1,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListExpenseRequest struct { + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search,omitempty"` +} + +type ListExpenseResponse struct { + Expenses []ExpenseResponse `json:"expenses"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} diff --git a/internal/entities/entities.go b/internal/entities/entities.go index dd2b914..c95a5c6 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -42,6 +42,7 @@ func GetAllEntities() []interface{} { &NotificationReceiver{}, &NotificationDelivery{}, &ProductOutletPrice{}, + &Expense{}, } } diff --git a/internal/entities/expense.go b/internal/entities/expense.go new file mode 100644 index 0000000..4b42c02 --- /dev/null +++ b/internal/entities/expense.go @@ -0,0 +1,40 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + + "gorm.io/gorm" +) + +type Expense struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` + OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"` + Receiver string `gorm:"not null;size:255" json:"receiver"` + TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date"` + CodeNumber string `gorm:"not null;size:50" json:"code_number"` + ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"` + Description *string `gorm:"type:text" json:"description"` + Tax float64 `gorm:"type:decimal(15,2);not null;default:0" json:"tax"` + Total float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total"` + Reserved1 *string `gorm:"type:text" json:"reserved1"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` + ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"` +} + +func (e *Expense) BeforeCreate(tx *gorm.DB) error { + if e.ID == uuid.Nil { + e.ID = uuid.New() + } + return nil +} + +func (Expense) TableName() string { + return "expenses" +} diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go new file mode 100644 index 0000000..c4980fd --- /dev/null +++ b/internal/handler/expense_handler.go @@ -0,0 +1,181 @@ +package handler + +import ( + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/util" + "strconv" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/validator" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type ExpenseHandler struct { + expenseService service.ExpenseService + expenseValidator validator.ExpenseValidator +} + +func NewExpenseHandler( + expenseService service.ExpenseService, + expenseValidator validator.ExpenseValidator, +) *ExpenseHandler { + return &ExpenseHandler{ + expenseService: expenseService, + expenseValidator: expenseValidator, + } +} + +func (h *ExpenseHandler) CreateExpense(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.CreateExpenseRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::CreateExpense -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::CreateExpense") + return + } + + validationError, validationErrorCode := h.expenseValidator.ValidateCreateExpenseRequest(&req) + if validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::CreateExpense") + return + } + + expenseResponse := h.expenseService.CreateExpense(ctx, contextInfo, &req) + if expenseResponse.HasErrors() { + errorResp := expenseResponse.GetErrors()[0] + logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::CreateExpense -> Failed to create expense from service") + } + + util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::CreateExpense") +} + +func (h *ExpenseHandler) UpdateExpense(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + expenseIDStr := c.Param("id") + expenseID, err := uuid.Parse(expenseIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::UpdateExpense -> Invalid expense ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense") + return + } + + var req contract.UpdateExpenseRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::UpdateExpense -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense") + return + } + + validationError, validationErrorCode := h.expenseValidator.ValidateUpdateExpenseRequest(&req) + if validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense") + return + } + + expenseResponse := h.expenseService.UpdateExpense(ctx, contextInfo, expenseID, &req) + if expenseResponse.HasErrors() { + errorResp := expenseResponse.GetErrors()[0] + logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::UpdateExpense -> Failed to update expense from service") + } + + util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::UpdateExpense") +} + +func (h *ExpenseHandler) DeleteExpense(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + expenseIDStr := c.Param("id") + expenseID, err := uuid.Parse(expenseIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::DeleteExpense -> Invalid expense ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::DeleteExpense") + return + } + + expenseResponse := h.expenseService.DeleteExpense(ctx, contextInfo, expenseID) + if expenseResponse.HasErrors() { + errorResp := expenseResponse.GetErrors()[0] + logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::DeleteExpense -> Failed to delete expense from service") + } + + util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::DeleteExpense") +} + +func (h *ExpenseHandler) GetExpense(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + expenseIDStr := c.Param("id") + expenseID, err := uuid.Parse(expenseIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpense -> Invalid expense ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpense") + return + } + + expenseResponse := h.expenseService.GetExpenseByID(ctx, contextInfo, expenseID) + if expenseResponse.HasErrors() { + errorResp := expenseResponse.GetErrors()[0] + logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpense -> Failed to get expense from service") + } + + util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpense") +} + +func (h *ExpenseHandler) ListExpenses(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + req := &contract.ListExpenseRequest{ + Page: 1, + Limit: 10, + } + + if pageStr := c.Query("page"); pageStr != "" { + if page, err := strconv.Atoi(pageStr); err == nil { + req.Page = page + } + } + + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil { + req.Limit = limit + } + } + + if search := c.Query("search"); search != "" { + req.Search = search + } + + validationError, validationErrorCode := h.expenseValidator.ValidateListExpenseRequest(req) + if validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::ListExpenses") + return + } + + expenseResponse := h.expenseService.ListExpenses(ctx, contextInfo, req) + if expenseResponse.HasErrors() { + errorResp := expenseResponse.GetErrors()[0] + logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::ListExpenses -> Failed to list expenses from service") + } + + util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::ListExpenses") +} diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go new file mode 100644 index 0000000..2ae595d --- /dev/null +++ b/internal/mappers/expense_mapper.go @@ -0,0 +1,90 @@ +package mappers + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +func ExpenseEntityToModel(entity *entities.Expense) *models.Expense { + if entity == nil { + return nil + } + + return &models.Expense{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + Receiver: entity.Receiver, + TransactionDate: entity.TransactionDate, + CodeNumber: entity.CodeNumber, + ChartOfAccountID: entity.ChartOfAccountID, + Description: entity.Description, + Tax: entity.Tax, + Total: entity.Total, + Reserved1: entity.Reserved1, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } +} + +func ExpenseModelToEntity(model *models.Expense) *entities.Expense { + if model == nil { + return nil + } + + return &entities.Expense{ + ID: model.ID, + OrganizationID: model.OrganizationID, + OutletID: model.OutletID, + Receiver: model.Receiver, + TransactionDate: model.TransactionDate, + CodeNumber: model.CodeNumber, + ChartOfAccountID: model.ChartOfAccountID, + Description: model.Description, + Tax: model.Tax, + Total: model.Total, + Reserved1: model.Reserved1, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + } +} + +func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse { + if entity == nil { + return nil + } + + resp := &models.ExpenseResponse{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + Receiver: entity.Receiver, + TransactionDate: entity.TransactionDate, + CodeNumber: entity.CodeNumber, + ChartOfAccountID: entity.ChartOfAccountID, + Description: entity.Description, + Tax: entity.Tax, + Total: entity.Total, + Reserved1: entity.Reserved1, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } + + if entity.ChartOfAccount != nil { + resp.ChartOfAccountName = entity.ChartOfAccount.Name + } + + return resp +} + +func ExpenseEntitiesToResponses(entities []*entities.Expense) []*models.ExpenseResponse { + if entities == nil { + return nil + } + + responses := make([]*models.ExpenseResponse, len(entities)) + for i, entity := range entities { + responses[i] = ExpenseEntityToResponse(entity) + } + return responses +} diff --git a/internal/models/expense.go b/internal/models/expense.go new file mode 100644 index 0000000..40801ac --- /dev/null +++ b/internal/models/expense.go @@ -0,0 +1,77 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Expense struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + Receiver string `json:"receiver"` + TransactionDate time.Time `json:"transaction_date"` + CodeNumber string `json:"code_number"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + Description *string `json:"description"` + Tax float64 `json:"tax"` + Total float64 `json:"total"` + Reserved1 *string `json:"reserved1"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ExpenseResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + Receiver string `json:"receiver"` + TransactionDate time.Time `json:"transaction_date"` + CodeNumber string `json:"code_number"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name,omitempty"` + Description *string `json:"description"` + Tax float64 `json:"tax"` + Total float64 `json:"total"` + Reserved1 *string `json:"reserved1"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateExpenseRequest struct { + Receiver string `json:"receiver"` + TransactionDate string `json:"transaction_date"` + CodeNumber string `json:"code_number"` + ChartOfAccountID string `json:"chart_of_account_id"` + OutletID string `json:"outlet_id"` + Description *string `json:"description"` + Tax float64 `json:"tax"` + Total float64 `json:"total"` +} + +type UpdateExpenseRequest struct { + Receiver *string `json:"receiver"` + TransactionDate *string `json:"transaction_date"` + CodeNumber *string `json:"code_number"` + ChartOfAccountID *string `json:"chart_of_account_id"` + OutletID *string `json:"outlet_id"` + Description *string `json:"description"` + Tax *float64 `json:"tax"` + Total *float64 `json:"total"` + Reserved1 *string `json:"reserved1"` +} + +type ListExpenseRequest struct { + Page int `json:"page"` + Limit int `json:"limit"` + Search string `json:"search,omitempty"` +} + +type ListExpenseResponse struct { + Expenses []*ExpenseResponse `json:"expenses"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go new file mode 100644 index 0000000..a223dda --- /dev/null +++ b/internal/processor/expense_processor.go @@ -0,0 +1,167 @@ +package processor + +import ( + "context" + "fmt" + "time" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +type ExpenseProcessor interface { + CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error) + UpdateExpense(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateExpenseRequest) (*models.ExpenseResponse, error) + DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error + GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error) + ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error) +} + +type ExpenseProcessorImpl struct { + expenseRepo ExpenseRepository +} + +func NewExpenseProcessorImpl(expenseRepo ExpenseRepository) *ExpenseProcessorImpl { + return &ExpenseProcessorImpl{ + expenseRepo: expenseRepo, + } +} + +func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error) { + chartOfAccountID, err := uuid.Parse(req.ChartOfAccountID) + if err != nil { + return nil, fmt.Errorf("invalid chart_of_account_id: %w", err) + } + + outletID, err := uuid.Parse(req.OutletID) + if err != nil { + return nil, fmt.Errorf("invalid outlet_id: %w", err) + } + + transactionDate, err := time.Parse("2006-01-02", req.TransactionDate) + if err != nil { + return nil, fmt.Errorf("invalid transaction_date format, expected YYYY-MM-DD: %w", err) + } + + expenseEntity := &entities.Expense{ + OrganizationID: organizationID, + OutletID: outletID, + Receiver: req.Receiver, + TransactionDate: transactionDate, + CodeNumber: req.CodeNumber, + ChartOfAccountID: chartOfAccountID, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, + } + + err = p.expenseRepo.Create(ctx, expenseEntity) + if err != nil { + return nil, fmt.Errorf("failed to create expense: %w", err) + } + + created, err := p.expenseRepo.GetByID(ctx, expenseEntity.ID) + if err != nil { + return mappers.ExpenseEntityToResponse(expenseEntity), nil + } + + return mappers.ExpenseEntityToResponse(created), nil +} + +func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateExpenseRequest) (*models.ExpenseResponse, error) { + expenseEntity, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("expense not found: %w", err) + } + + if req.Receiver != nil { + expenseEntity.Receiver = *req.Receiver + } + if req.TransactionDate != nil { + parsedDate, err := time.Parse("2006-01-02", *req.TransactionDate) + if err != nil { + return nil, fmt.Errorf("invalid transaction_date format, expected YYYY-MM-DD: %w", err) + } + expenseEntity.TransactionDate = parsedDate + } + if req.CodeNumber != nil { + expenseEntity.CodeNumber = *req.CodeNumber + } + if req.ChartOfAccountID != nil { + chartOfAccountID, err := uuid.Parse(*req.ChartOfAccountID) + if err != nil { + return nil, fmt.Errorf("invalid chart_of_account_id: %w", err) + } + expenseEntity.ChartOfAccountID = chartOfAccountID + } + if req.OutletID != nil { + outletID, err := uuid.Parse(*req.OutletID) + if err != nil { + return nil, fmt.Errorf("invalid outlet_id: %w", err) + } + expenseEntity.OutletID = outletID + } + if req.Description != nil { + expenseEntity.Description = req.Description + } + if req.Tax != nil { + expenseEntity.Tax = *req.Tax + } + if req.Total != nil { + expenseEntity.Total = *req.Total + } + if req.Reserved1 != nil { + expenseEntity.Reserved1 = req.Reserved1 + } + + err = p.expenseRepo.Update(ctx, expenseEntity) + if err != nil { + return nil, fmt.Errorf("failed to update expense: %w", err) + } + + updated, err := p.expenseRepo.GetByID(ctx, id) + if err != nil { + return mappers.ExpenseEntityToResponse(expenseEntity), nil + } + + return mappers.ExpenseEntityToResponse(updated), nil +} + +func (p *ExpenseProcessorImpl) DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error { + _, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return fmt.Errorf("expense not found: %w", err) + } + + err = p.expenseRepo.Delete(ctx, id) + if err != nil { + return fmt.Errorf("failed to delete expense: %w", err) + } + + return nil +} + +func (p *ExpenseProcessorImpl) GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error) { + expenseEntity, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("expense not found: %w", err) + } + + return mappers.ExpenseEntityToResponse(expenseEntity), nil +} + +func (p *ExpenseProcessorImpl) ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error) { + offset := (page - 1) * limit + expenseEntities, total, err := p.expenseRepo.List(ctx, organizationID, filters, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to list expenses: %w", err) + } + + expenseResponses := mappers.ExpenseEntitiesToResponses(expenseEntities) + totalPages := int((total + int64(limit) - 1) / int64(limit)) + + return expenseResponses, totalPages, nil +} diff --git a/internal/processor/expense_repository.go b/internal/processor/expense_repository.go new file mode 100644 index 0000000..46421e0 --- /dev/null +++ b/internal/processor/expense_repository.go @@ -0,0 +1,17 @@ +package processor + +import ( + "apskel-pos-be/internal/entities" + "context" + + "github.com/google/uuid" +) + +type ExpenseRepository interface { + Create(ctx context.Context, expense *entities.Expense) error + GetByID(ctx context.Context, id uuid.UUID) (*entities.Expense, error) + GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Expense, error) + Update(ctx context.Context, expense *entities.Expense) error + Delete(ctx context.Context, id uuid.UUID) error + List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.Expense, int64, error) +} diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go new file mode 100644 index 0000000..36148a3 --- /dev/null +++ b/internal/repository/expense_repository.go @@ -0,0 +1,83 @@ +package repository + +import ( + "context" + "strings" + + "github.com/google/uuid" + + "apskel-pos-be/internal/entities" + + "gorm.io/gorm" +) + +type ExpenseRepositoryImpl struct { + db *gorm.DB +} + +func NewExpenseRepositoryImpl(db *gorm.DB) *ExpenseRepositoryImpl { + return &ExpenseRepositoryImpl{ + db: db, + } +} + +func (r *ExpenseRepositoryImpl) Create(ctx context.Context, expense *entities.Expense) error { + return r.db.WithContext(ctx).Create(expense).Error +} + +func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Expense, error) { + var expense entities.Expense + err := r.db.WithContext(ctx).Preload("ChartOfAccount").First(&expense, "id = ?", id).Error + if err != nil { + return nil, err + } + return &expense, nil +} + +func (r *ExpenseRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Expense, error) { + var expense entities.Expense + err := r.db.WithContext(ctx).Preload("ChartOfAccount").Where("id = ? AND organization_id = ?", id, organizationID).First(&expense).Error + if err != nil { + return nil, err + } + return &expense, nil +} + +func (r *ExpenseRepositoryImpl) Update(ctx context.Context, expense *entities.Expense) error { + return r.db.WithContext(ctx).Save(expense).Error +} + +func (r *ExpenseRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.Expense{}, "id = ?", id).Error +} + +func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.Expense, int64, error) { + var expenses []*entities.Expense + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.Expense{}).Where("organization_id = ?", organizationID) + + for key, value := range filters { + switch key { + case "search": + if searchStr, ok := value.(string); ok && searchStr != "" { + searchPattern := "%" + strings.ToLower(searchStr) + "%" + query = query.Where("LOWER(receiver) LIKE ? OR LOWER(code_number) LIKE ? OR LOWER(description) LIKE ?", + searchPattern, searchPattern, searchPattern) + } + case "outlet_id": + if outletID, ok := value.(uuid.UUID); ok { + query = query.Where("outlet_id = ?", outletID) + } + default: + query = query.Where(key+" = ?", value) + } + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := query.Preload("ChartOfAccount").Order("created_at DESC").Limit(limit).Offset(offset).Find(&expenses).Error + return expenses, total, err +} diff --git a/internal/router/router.go b/internal/router/router.go index 96d29a0..4febd1b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -50,11 +50,12 @@ type Router struct { notificationHandler *handler.NotificationHandler selfOrderHandler *handler.SelfOrderHandler productOutletPriceHandler *handler.ProductOutletPriceHandler + expenseHandler *handler.ExpenseHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *Router { return &Router{ config: cfg, @@ -97,6 +98,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer notificationHandler: handler.NewNotificationHandler(notificationService, notificationValidator), selfOrderHandler: selfOrderHandler, productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator), + expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator), } } @@ -444,6 +446,16 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { accounts.GET("/:id/balance", r.accountHandler.GetAccountBalance) } + expenses := protected.Group("/expenses") + expenses.Use(r.authMiddleware.RequireAdminOrManager()) + { + expenses.POST("", r.expenseHandler.CreateExpense) + expenses.GET("", r.expenseHandler.ListExpenses) + expenses.GET("/:id", r.expenseHandler.GetExpense) + expenses.PUT("/:id", r.expenseHandler.UpdateExpense) + expenses.DELETE("/:id", r.expenseHandler.DeleteExpense) + } + orderIngredientTransactions := protected.Group("/order-ingredient-transactions") orderIngredientTransactions.Use(r.authMiddleware.RequireAdminOrManager()) { diff --git a/internal/service/expense_service.go b/internal/service/expense_service.go new file mode 100644 index 0000000..a7f13df --- /dev/null +++ b/internal/service/expense_service.go @@ -0,0 +1,107 @@ +package service + +import ( + "apskel-pos-be/internal/appcontext" + "context" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/transformer" + + "github.com/google/uuid" +) + +type ExpenseService interface { + CreateExpense(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateExpenseRequest) *contract.Response + UpdateExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateExpenseRequest) *contract.Response + DeleteExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + GetExpenseByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + ListExpenses(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListExpenseRequest) *contract.Response +} + +type ExpenseServiceImpl struct { + expenseProcessor processor.ExpenseProcessor +} + +func NewExpenseService(expenseProcessor processor.ExpenseProcessor) *ExpenseServiceImpl { + return &ExpenseServiceImpl{ + expenseProcessor: expenseProcessor, + } +} + +func (s *ExpenseServiceImpl) CreateExpense(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateExpenseRequest) *contract.Response { + modelReq := transformer.CreateExpenseRequestToModel(req) + + expenseResponse, err := s.expenseProcessor.CreateExpense(ctx, apctx.OrganizationID, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + contractResponse := transformer.ExpenseModelResponseToResponse(expenseResponse) + return contract.BuildSuccessResponse(contractResponse) +} + +func (s *ExpenseServiceImpl) UpdateExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateExpenseRequest) *contract.Response { + modelReq := transformer.UpdateExpenseRequestToModel(req) + + expenseResponse, err := s.expenseProcessor.UpdateExpense(ctx, id, apctx.OrganizationID, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + contractResponse := transformer.ExpenseModelResponseToResponse(expenseResponse) + return contract.BuildSuccessResponse(contractResponse) +} + +func (s *ExpenseServiceImpl) DeleteExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + err := s.expenseProcessor.DeleteExpense(ctx, id, apctx.OrganizationID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(map[string]interface{}{ + "message": "Expense deleted successfully", + }) +} + +func (s *ExpenseServiceImpl) GetExpenseByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + expenseResponse, err := s.expenseProcessor.GetExpenseByID(ctx, id, apctx.OrganizationID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + contractResponse := transformer.ExpenseModelResponseToResponse(expenseResponse) + return contract.BuildSuccessResponse(contractResponse) +} + +func (s *ExpenseServiceImpl) ListExpenses(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListExpenseRequest) *contract.Response { + modelReq := transformer.ListExpenseRequestToModel(req) + + filters := make(map[string]interface{}) + if modelReq.Search != "" { + filters["search"] = modelReq.Search + } + + expenses, totalPages, err := s.expenseProcessor.ListExpenses(ctx, apctx.OrganizationID, filters, modelReq.Page, modelReq.Limit) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + contractResponses := transformer.ExpenseModelResponsesToResponses(expenses) + + response := contract.ListExpenseResponse{ + Expenses: contractResponses, + TotalCount: len(contractResponses), + Page: modelReq.Page, + Limit: modelReq.Limit, + TotalPages: totalPages, + } + + return contract.BuildSuccessResponse(response) +} diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go new file mode 100644 index 0000000..4de0859 --- /dev/null +++ b/internal/transformer/expense_transformer.go @@ -0,0 +1,75 @@ +package transformer + +import ( + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" +) + +func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.CreateExpenseRequest { + return &models.CreateExpenseRequest{ + Receiver: req.Receiver, + TransactionDate: req.TransactionDate, + CodeNumber: req.CodeNumber, + ChartOfAccountID: req.ChartOfAccountID, + OutletID: req.OutletID, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, + } +} + +func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.UpdateExpenseRequest { + return &models.UpdateExpenseRequest{ + Receiver: req.Receiver, + TransactionDate: req.TransactionDate, + CodeNumber: req.CodeNumber, + ChartOfAccountID: req.ChartOfAccountID, + OutletID: req.OutletID, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, + Reserved1: req.Reserved1, + } +} + +func ListExpenseRequestToModel(req *contract.ListExpenseRequest) *models.ListExpenseRequest { + return &models.ListExpenseRequest{ + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + } +} + +func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.ExpenseResponse { + return &contract.ExpenseResponse{ + ID: expense.ID, + OrganizationID: expense.OrganizationID, + OutletID: expense.OutletID, + Receiver: expense.Receiver, + TransactionDate: expense.TransactionDate, + CodeNumber: expense.CodeNumber, + ChartOfAccountID: expense.ChartOfAccountID, + ChartOfAccountName: expense.ChartOfAccountName, + Description: expense.Description, + Tax: expense.Tax, + Total: expense.Total, + Reserved1: expense.Reserved1, + CreatedAt: expense.CreatedAt, + UpdatedAt: expense.UpdatedAt, + } +} + +func ExpenseModelResponsesToResponses(expenses []*models.ExpenseResponse) []contract.ExpenseResponse { + if expenses == nil { + return nil + } + + responses := make([]contract.ExpenseResponse, len(expenses)) + for i, expense := range expenses { + response := ExpenseModelResponseToResponse(expense) + if response != nil { + responses[i] = *response + } + } + return responses +} diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go new file mode 100644 index 0000000..5af4a35 --- /dev/null +++ b/internal/validator/expense_validator.go @@ -0,0 +1,125 @@ +package validator + +import ( + "errors" + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" +) + +type ExpenseValidator interface { + ValidateCreateExpenseRequest(req *contract.CreateExpenseRequest) (error, string) + ValidateUpdateExpenseRequest(req *contract.UpdateExpenseRequest) (error, string) + ValidateListExpenseRequest(req *contract.ListExpenseRequest) (error, string) +} + +type ExpenseValidatorImpl struct{} + +func NewExpenseValidator() *ExpenseValidatorImpl { + return &ExpenseValidatorImpl{} +} + +func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.CreateExpenseRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.Receiver) == "" { + return errors.New("receiver is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.TransactionDate) == "" { + return errors.New("transaction_date is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.CodeNumber) == "" { + return errors.New("code_number is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.ChartOfAccountID) == "" { + return errors.New("chart_of_account_id is required"), constants.MissingFieldErrorCode + } + + if _, err := uuid.Parse(req.ChartOfAccountID); err != nil { + return errors.New("chart_of_account_id must be a valid UUID"), constants.MalformedFieldErrorCode + } + + if strings.TrimSpace(req.OutletID) == "" { + return errors.New("outlet_id is required"), constants.MissingFieldErrorCode + } + + if _, err := uuid.Parse(req.OutletID); err != nil { + return errors.New("outlet_id must be a valid UUID"), constants.MalformedFieldErrorCode + } + + if req.Total <= 0 { + return errors.New("total must be greater than 0"), constants.MalformedFieldErrorCode + } + + if req.Tax < 0 { + return errors.New("tax cannot be negative"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.UpdateExpenseRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.Receiver != nil && strings.TrimSpace(*req.Receiver) == "" { + return errors.New("receiver cannot be empty"), constants.MalformedFieldErrorCode + } + + if req.CodeNumber != nil && strings.TrimSpace(*req.CodeNumber) == "" { + return errors.New("code_number cannot be empty"), constants.MalformedFieldErrorCode + } + + if req.ChartOfAccountID != nil { + if strings.TrimSpace(*req.ChartOfAccountID) == "" { + return errors.New("chart_of_account_id cannot be empty"), constants.MalformedFieldErrorCode + } + if _, err := uuid.Parse(*req.ChartOfAccountID); err != nil { + return errors.New("chart_of_account_id must be a valid UUID"), constants.MalformedFieldErrorCode + } + } + + if req.OutletID != nil { + if strings.TrimSpace(*req.OutletID) == "" { + return errors.New("outlet_id cannot be empty"), constants.MalformedFieldErrorCode + } + if _, err := uuid.Parse(*req.OutletID); err != nil { + return errors.New("outlet_id must be a valid UUID"), constants.MalformedFieldErrorCode + } + } + + if req.Total != nil && *req.Total <= 0 { + return errors.New("total must be greater than 0"), constants.MalformedFieldErrorCode + } + + if req.Tax != nil && *req.Tax < 0 { + return errors.New("tax cannot be negative"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *ExpenseValidatorImpl) ValidateListExpenseRequest(req *contract.ListExpenseRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.Page < 1 { + return errors.New("page must be at least 1"), constants.MalformedFieldErrorCode + } + + if req.Limit < 1 || req.Limit > 100 { + return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode + } + + return nil, "" +} diff --git a/migrations/000070_create_expenses_table.down.sql b/migrations/000070_create_expenses_table.down.sql new file mode 100644 index 0000000..2205240 --- /dev/null +++ b/migrations/000070_create_expenses_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS expenses; diff --git a/migrations/000070_create_expenses_table.up.sql b/migrations/000070_create_expenses_table.up.sql new file mode 100644 index 0000000..5652a99 --- /dev/null +++ b/migrations/000070_create_expenses_table.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE expenses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + outlet_id UUID NOT NULL REFERENCES outlets(id) ON DELETE CASCADE, + receiver VARCHAR(255) NOT NULL, + transaction_date DATE NOT NULL, + code_number VARCHAR(50) NOT NULL, + chart_of_account_id UUID NOT NULL REFERENCES chart_of_accounts(id) ON DELETE RESTRICT, + description TEXT, + tax DECIMAL(15,2) NOT NULL DEFAULT 0, + total DECIMAL(15,2) NOT NULL DEFAULT 0, + reserved1 TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_expenses_organization_id ON expenses(organization_id); +CREATE INDEX idx_expenses_outlet_id ON expenses(outlet_id); +CREATE INDEX idx_expenses_chart_of_account_id ON expenses(chart_of_account_id); +CREATE INDEX idx_expenses_transaction_date ON expenses(transaction_date); +CREATE INDEX idx_expenses_code_number ON expenses(code_number); +CREATE INDEX idx_expenses_created_at ON expenses(created_at);