From a76ab69a845aff11a91bb6a34586204d20dc759a Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 11 May 2026 14:07:56 +0700 Subject: [PATCH] add api for update is paid to expense --- ...511070035_add_is_paid_to_expenses.down.sql | 1 + ...60511070035_add_is_paid_to_expenses.up.sql | 1 + internal/entities/expense.go | 1 + .../controllers/expense.controller.go | 21 ++++++++++++ internal/modules/expenses/dto/expense.dto.go | 2 ++ internal/modules/expenses/route.go | 1 + .../expenses/services/expense.service.go | 34 +++++++++++++++++++ 7 files changed, 61 insertions(+) create mode 100644 internal/database/migrations/20260511070035_add_is_paid_to_expenses.down.sql create mode 100644 internal/database/migrations/20260511070035_add_is_paid_to_expenses.up.sql diff --git a/internal/database/migrations/20260511070035_add_is_paid_to_expenses.down.sql b/internal/database/migrations/20260511070035_add_is_paid_to_expenses.down.sql new file mode 100644 index 00000000..9333efe3 --- /dev/null +++ b/internal/database/migrations/20260511070035_add_is_paid_to_expenses.down.sql @@ -0,0 +1 @@ +ALTER TABLE expenses DROP COLUMN is_paid; diff --git a/internal/database/migrations/20260511070035_add_is_paid_to_expenses.up.sql b/internal/database/migrations/20260511070035_add_is_paid_to_expenses.up.sql new file mode 100644 index 00000000..6a611039 --- /dev/null +++ b/internal/database/migrations/20260511070035_add_is_paid_to_expenses.up.sql @@ -0,0 +1 @@ +ALTER TABLE expenses ADD COLUMN is_paid BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 7bea3076..ec02e0c0 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -17,6 +17,7 @@ type Expense struct { RealizationDate time.Time `gorm:"type:date;column:realization_date"` TransactionDate time.Time `gorm:"type:date;not null"` Notes string `gorm:"type:text;column:notes"` + IsPaid bool `gorm:"column:is_paid;not null;default:false"` CreatedBy uint64 `gorm:""` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 44a97446..fddd4356 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -481,6 +481,27 @@ func (u *ExpenseController) CompleteExpense(c *fiber.Ctx) error { }) } +func (u *ExpenseController) Pay(c *fiber.Ctx) error { + expenseID := c.Params("id") + id, err := strconv.Atoi(expenseID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID") + } + + expense, err := u.ExpenseService.Pay(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Pay expense successfully", + Data: expense, + }) +} + func ensureExpenseBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error { requiredPerms := []string{} diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 58166a6e..762cad51 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -29,6 +29,7 @@ type ExpenseBaseDTO struct { RealizationDate *time.Time `json:"realization_date,omitempty"` TransactionDate time.Time `json:"transaction_date"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + IsPaid bool `json:"is_paid"` } type ExpenseListDTO struct { @@ -127,6 +128,7 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { RealizationDate: realizationDate, TransactionDate: e.TransactionDate, Location: location, + IsPaid: e.IsPaid, } } diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index f3815e7a..9cdf7808 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -36,6 +36,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense) + route.Patch("/:id/pay", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.Pay) route.Delete("/:id/documents/:documentId", m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument) route.Delete("/:id/realization-documents/:documentId", m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument) } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index b7298f08..d0bd8fba 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -36,6 +36,7 @@ type ExpenseService interface { DeleteOne(ctx *fiber.Ctx, id uint64) error CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) + Pay(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error) @@ -1310,6 +1311,39 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( return responseDTO, nil } +func (s *expenseService) Pay(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, + ); err != nil { + return nil, err + } + + expense, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + if expense.IsPaid { + return nil, fiber.NewError(fiber.StatusBadRequest, "Expense is already paid") + } + + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") + } + if latestApproval == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "No approval found. Please create expense first.") + } + if latestApproval.StepNumber < uint16(utils.ExpenseStepFinance) || latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved { + return nil, fiber.NewError(fiber.StatusBadRequest, "Expense must be approved by Finance (step 4) before payment") + } + + if err := s.Repository.PatchOne(c.Context(), id, map[string]any{"is_paid": true}, nil); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update payment status") + } + + return s.GetOne(c, id) +} + func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil {