diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index dab91c7..a8ff07e 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -11,6 +11,7 @@ type CreateExpenseRequest struct { TransactionDate string `json:"transaction_date" validate:"required"` CodeNumber string `json:"code_number" validate:"required"` OutletID string `json:"outlet_id" validate:"required"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"` Description *string `json:"description,omitempty"` Tax float64 `json:"tax"` Total float64 `json:"total" validate:"required"` @@ -29,6 +30,7 @@ type UpdateExpenseRequest struct { TransactionDate *string `json:"transaction_date,omitempty"` CodeNumber *string `json:"code_number,omitempty"` OutletID *string `json:"outlet_id,omitempty"` + Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"` Description *string `json:"description,omitempty"` Tax *float64 `json:"tax,omitempty"` Total *float64 `json:"total,omitempty"` @@ -50,6 +52,7 @@ type ExpenseResponse struct { Receiver string `json:"receiver"` TransactionDate time.Time `json:"transaction_date"` CodeNumber string `json:"code_number"` + Status string `json:"status"` Description *string `json:"description"` Tax float64 `json:"tax"` Total float64 `json:"total"` @@ -76,6 +79,7 @@ type ListExpenseRequest struct { Limit int `json:"limit" validate:"min=1,max=100"` Search string `json:"search,omitempty"` OutletID string `json:"outlet_id,omitempty"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"` StartDate string `json:"start_date,omitempty"` EndDate string `json:"end_date,omitempty"` } diff --git a/internal/entities/expense.go b/internal/entities/expense.go index a9ba53e..93d9789 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -15,6 +15,7 @@ type Expense struct { 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"` + Status string `gorm:"not null;size:20;default:'draft'" json:"status"` 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"` diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go index 1b3fc8e..c09cc79 100644 --- a/internal/handler/expense_handler.go +++ b/internal/handler/expense_handler.go @@ -164,6 +164,10 @@ func (h *ExpenseHandler) ListExpenses(c *gin.Context) { req.Search = search } + if status := c.Query("status"); status != "" { + req.Status = status + } + // Prioritize outlet_id from context (e.g. outlet-scoped user), // fall back to query param if context has no outlet. if contextInfo.OutletID != uuid.Nil { diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index 3bb111b..34015d4 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -17,6 +17,7 @@ func ExpenseEntityToModel(entity *entities.Expense) *models.Expense { Receiver: entity.Receiver, TransactionDate: entity.TransactionDate, CodeNumber: entity.CodeNumber, + Status: entity.Status, Description: entity.Description, Tax: entity.Tax, Total: entity.Total, @@ -38,6 +39,7 @@ func ExpenseModelToEntity(model *models.Expense) *entities.Expense { Receiver: model.Receiver, TransactionDate: model.TransactionDate, CodeNumber: model.CodeNumber, + Status: model.Status, Description: model.Description, Tax: model.Tax, Total: model.Total, @@ -59,6 +61,7 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse { Receiver: entity.Receiver, TransactionDate: entity.TransactionDate, CodeNumber: entity.CodeNumber, + Status: entity.Status, Description: entity.Description, Tax: entity.Tax, Total: entity.Total, diff --git a/internal/models/expense.go b/internal/models/expense.go index 8ee1011..52a34e5 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -13,6 +13,7 @@ type Expense struct { Receiver string `json:"receiver"` TransactionDate time.Time `json:"transaction_date"` CodeNumber string `json:"code_number"` + Status string `json:"status"` Description *string `json:"description"` Tax float64 `json:"tax"` Total float64 `json:"total"` @@ -39,6 +40,7 @@ type ExpenseResponse struct { Receiver string `json:"receiver"` TransactionDate time.Time `json:"transaction_date"` CodeNumber string `json:"code_number"` + Status string `json:"status"` Description *string `json:"description"` Tax float64 `json:"tax"` Total float64 `json:"total"` @@ -65,6 +67,7 @@ type CreateExpenseRequest struct { TransactionDate string `json:"transaction_date"` CodeNumber string `json:"code_number"` OutletID string `json:"outlet_id"` + Status *string `json:"status,omitempty"` Description *string `json:"description"` Tax float64 `json:"tax"` Total float64 `json:"total"` @@ -83,6 +86,7 @@ type UpdateExpenseRequest struct { TransactionDate *string `json:"transaction_date,omitempty"` CodeNumber *string `json:"code_number,omitempty"` OutletID *string `json:"outlet_id,omitempty"` + Status *string `json:"status,omitempty"` Description *string `json:"description,omitempty"` Tax *float64 `json:"tax,omitempty"` Total *float64 `json:"total,omitempty"` @@ -102,6 +106,7 @@ type ListExpenseRequest struct { Limit int `json:"limit"` Search string `json:"search,omitempty"` OutletID string `json:"outlet_id,omitempty"` + Status string `json:"status,omitempty"` StartDate string `json:"start_date,omitempty"` EndDate string `json:"end_date,omitempty"` } diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 29eaf55..4e0557b 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" @@ -41,12 +42,18 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID return nil, fmt.Errorf("invalid transaction_date format, expected YYYY-MM-DD: %w", err) } + status := string(constants.ExpenseStatusDraft) + if req.Status != nil { + status = *req.Status + } + expenseEntity := &entities.Expense{ OrganizationID: organizationID, OutletID: outletID, Receiver: req.Receiver, TransactionDate: transactionDate, CodeNumber: req.CodeNumber, + Status: status, Description: req.Description, Tax: req.Tax, Total: req.Total, @@ -104,6 +111,9 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati if req.CodeNumber != nil { expenseEntity.CodeNumber = *req.CodeNumber } + if req.Status != nil { + expenseEntity.Status = *req.Status + } if req.OutletID != nil { outletID, err := uuid.Parse(*req.OutletID) if err != nil { diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 09d8631..821b060 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -512,6 +512,7 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id"). Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) if outletID != nil { @@ -535,6 +536,7 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context Joins("JOIN expenses e ON ei.expense_id = e.id"). Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) if outletID != nil { diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index a66a3df..335d0eb 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -84,6 +84,10 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU if outletID, ok := value.(uuid.UUID); ok { query = query.Where("outlet_id = ?", outletID) } + case "status": + if status, ok := value.(string); ok && status != "" { + query = query.Where("status = ?", status) + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) diff --git a/internal/service/expense_service.go b/internal/service/expense_service.go index 24af26c..bb8a417 100644 --- a/internal/service/expense_service.go +++ b/internal/service/expense_service.go @@ -87,6 +87,9 @@ func (s *ExpenseServiceImpl) ListExpenses(ctx context.Context, apctx *appcontext if modelReq.Search != "" { filters["search"] = modelReq.Search } + if modelReq.Status != "" { + filters["status"] = modelReq.Status + } if modelReq.OutletID != "" { outletID, err := uuid.Parse(modelReq.OutletID) if err == nil { diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index d86714b..5b3c47c 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -16,6 +16,7 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre TransactionDate: req.TransactionDate, CodeNumber: req.CodeNumber, OutletID: req.OutletID, + Status: req.Status, Description: req.Description, Tax: req.Tax, Total: req.Total, @@ -38,6 +39,7 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd TransactionDate: req.TransactionDate, CodeNumber: req.CodeNumber, OutletID: req.OutletID, + Status: req.Status, Description: req.Description, Tax: req.Tax, Total: req.Total, @@ -70,6 +72,7 @@ func ListExpenseRequestToModel(req *contract.ListExpenseRequest) *models.ListExp Limit: req.Limit, Search: req.Search, OutletID: req.OutletID, + Status: req.Status, StartDate: req.StartDate, EndDate: req.EndDate, } @@ -92,6 +95,7 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E Receiver: expense.Receiver, TransactionDate: expense.TransactionDate, CodeNumber: expense.CodeNumber, + Status: expense.Status, Description: expense.Description, Tax: expense.Tax, Total: expense.Total, diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go index 7101f45..c9306eb 100644 --- a/internal/validator/expense_validator.go +++ b/internal/validator/expense_validator.go @@ -48,6 +48,10 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create return errors.New("outlet_id must be a valid UUID"), constants.MalformedFieldErrorCode } + if req.Status != nil && !constants.IsValidExpenseStatus(constants.ExpenseStatus(*req.Status)) { + return errors.New("status must be one of: draft, sent, approved, cancel"), constants.MalformedFieldErrorCode + } + if req.Total <= 0 { return errors.New("total must be greater than 0"), constants.MalformedFieldErrorCode } @@ -91,6 +95,10 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return errors.New("code_number cannot be empty"), constants.MalformedFieldErrorCode } + if req.Status != nil && !constants.IsValidExpenseStatus(constants.ExpenseStatus(*req.Status)) { + return errors.New("status must be one of: draft, sent, approved, cancel"), constants.MalformedFieldErrorCode + } + if req.OutletID != nil { if strings.TrimSpace(*req.OutletID) == "" { return errors.New("outlet_id cannot be empty"), constants.MalformedFieldErrorCode @@ -143,5 +151,9 @@ func (v *ExpenseValidatorImpl) ValidateListExpenseRequest(req *contract.ListExpe return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode } + if req.Status != "" && !constants.IsValidExpenseStatus(constants.ExpenseStatus(req.Status)) { + return errors.New("status must be one of: draft, sent, approved, cancel"), constants.MalformedFieldErrorCode + } + return nil, "" } diff --git a/migrations/000073_add_status_to_expenses.down.sql b/migrations/000073_add_status_to_expenses.down.sql new file mode 100644 index 0000000..3a51baa --- /dev/null +++ b/migrations/000073_add_status_to_expenses.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_expenses_status; +ALTER TABLE expenses DROP CONSTRAINT IF EXISTS expenses_status_check; +ALTER TABLE expenses DROP COLUMN IF EXISTS status; diff --git a/migrations/000073_add_status_to_expenses.up.sql b/migrations/000073_add_status_to_expenses.up.sql new file mode 100644 index 0000000..5bffd13 --- /dev/null +++ b/migrations/000073_add_status_to_expenses.up.sql @@ -0,0 +1,10 @@ +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'draft'; + +UPDATE expenses +SET status = 'approved' +WHERE status = 'draft'; + +ALTER TABLE expenses DROP CONSTRAINT IF EXISTS expenses_status_check; +ALTER TABLE expenses ADD CONSTRAINT expenses_status_check CHECK (status IN ('draft', 'sent', 'approved', 'cancel')); + +CREATE INDEX IF NOT EXISTS idx_expenses_status ON expenses(status);