package service import ( "context" "errors" "fmt" "math" "strings" "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" ) type InitialService interface { GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) } type initialService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.InitialRepository ApprovalSvc commonSvc.ApprovalService approvalWorkflow approvalutils.ApprovalWorkflowKey } func NewInitialService( repo repository.InitialRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) InitialService { return &initialService{ Log: utils.Log, Validate: validate, Repository: repo, ApprovalSvc: approvalSvc, approvalWorkflow: utils.ApprovalWorkflowInitial, } } func (s initialService) withRelations(db *gorm.DB) *gorm.DB { return db.Preload("CreatedUser"). Preload("BankWarehouse"). Preload("Customer"). Preload("Supplier") } func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { initial, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") } if err != nil { s.Log.Errorf("Failed get initial by id: %+v", err) return nil, err } if !isInitialTransaction(initial.TransactionType) { return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") } if s.ApprovalSvc != nil { approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) if err != nil { s.Log.Warnf("Unable to load latest approval for initial %d: %+v", id, err) } else { initial.LatestApproval = approval } } return initial, nil } func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } party, err := normalizePartyType(req.PartyType) if err != nil { return nil, err } if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil { return nil, err } if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { return nil, err } balanceType, err := normalizeInitialBalanceType(req.InitialBalanceType) if err != nil { return nil, err } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } code, err := s.generateInitialCode(c.Context()) if err != nil { return nil, err } reference := req.ReferenceNumber createBody := &entity.Payment{ PaymentCode: code, ReferenceNumber: &reference, TransactionType: string(utils.TransactionTypeSaldoAwal), PartyType: party, PartyId: req.PartyId, PaymentDate: time.Now(), PaymentMethod: string(utils.PaymentMethodSaldo), BankId: req.BankId, Direction: directionForInitialType(balanceType), Nominal: signedNominal(balanceType, req.Nominal), Notes: req.Note, CreatedBy: actorID, } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { initialRepoTx := repository.NewInitialRepository(dbTransaction) if err := initialRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { return err } if s.ApprovalSvc != nil { action := entity.ApprovalActionCreated approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) _, err := approvalSvcTx.CreateApproval( c.Context(), s.approvalWorkflow, createBody.Id, utils.InitialStepPengajuan, &action, actorID, nil, ) if err != nil { return err } } return nil }) if err != nil { s.Log.Errorf("Failed to create initial: %+v", err) return nil, err } return s.GetOne(c, createBody.Id) } func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } updateBody := make(map[string]any) if req.ReferenceNumber != nil { updateBody["reference_number"] = *req.ReferenceNumber } if req.Note != nil { updateBody["notes"] = *req.Note } if req.BankId != nil { if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { return nil, err } updateBody["bank_id"] = *req.BankId } requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil var existing *entity.Payment if requiresVerification { current, err := s.Repository.GetByID(c.Context(), id, nil) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") } if err != nil { s.Log.Errorf("Failed get initial by id: %+v", err) return nil, err } if !isInitialTransaction(current.TransactionType) { return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") } existing = current } if req.PartyType != nil || req.PartyId != nil { partyType := existing.PartyType partyId := existing.PartyId if req.PartyType != nil { normalized, err := normalizePartyType(*req.PartyType) if err != nil { return nil, err } partyType = normalized updateBody["party_type"] = partyType } if req.PartyId != nil { partyId = *req.PartyId updateBody["party_id"] = partyId } if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { return nil, err } } if req.InitialBalanceType != nil || req.Nominal != nil { balanceType := balanceTypeFromPayment(existing) if req.InitialBalanceType != nil { normalized, err := normalizeInitialBalanceType(*req.InitialBalanceType) if err != nil { return nil, err } balanceType = normalized } nominal := math.Abs(existing.Nominal) if req.Nominal != nil { nominal = *req.Nominal } updateBody["direction"] = directionForInitialType(balanceType) updateBody["nominal"] = signedNominal(balanceType, nominal) } if len(updateBody) == 0 { return s.GetOne(c, id) } if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") } s.Log.Errorf("Failed to update initial: %+v", err) return nil, err } return s.GetOne(c, id) } func isInitialTransaction(transactionType string) bool { return strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) } func balanceTypeFromPayment(payment *entity.Payment) string { if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 { return "NEGATIVE" } return "POSITIVE" } func normalizePartyType(partyType string) (string, error) { party := strings.ToUpper(strings.TrimSpace(partyType)) if !utils.IsValidPaymentParty(party) { return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") } return party, nil } func normalizeInitialBalanceType(balanceType string) (string, error) { normalized := strings.ToUpper(strings.TrimSpace(balanceType)) switch normalized { case "NEGATIVE", "POSITIVE": return normalized, nil default: return "", utils.BadRequest("`initial_balance_type` must be `NEGATIVE` or `POSITIVE`") } } func directionForInitialType(balanceType string) string { if strings.EqualFold(balanceType, "NEGATIVE") { return "OUT" } return "IN" } func signedNominal(balanceType string, nominal float64) float64 { normalized := math.Abs(nominal) if strings.EqualFold(balanceType, "NEGATIVE") { return -normalized } return normalized } func (s initialService) generateInitialCode(ctx context.Context) (string, error) { sequence, err := s.Repository.NextPaymentSequence(ctx) if err != nil { return "", err } return fmt.Sprintf("INIT-%05d", sequence), nil } func (s initialService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db.Preload("ActionUser") } } func (s initialService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error { switch utils.PaymentParty(partyType) { case utils.PaymentPartyCustomer: return commonSvc.EnsureRelations(ctx, commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists}, ) case utils.PaymentPartySupplier: return commonSvc.EnsureRelations(ctx, commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists}, ) default: return utils.BadRequest("`party_type` must be `customer` or `supplier`") } } func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) error { return commonSvc.EnsureRelations(ctx, commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, ) }