package service import ( "context" "errors" "strings" "time" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/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 TransactionService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) DeleteOne(ctx *fiber.Ctx, id uint) error } type transactionService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.TransactionRepository ApprovalSvc commonSvc.ApprovalService approvalWorkflows map[string]approvalutils.ApprovalWorkflowKey } func NewTransactionService( repo repository.TransactionRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) TransactionService { return &transactionService{ Log: utils.Log, Validate: validate, Repository: repo, ApprovalSvc: approvalSvc, approvalWorkflows: map[string]approvalutils.ApprovalWorkflowKey{ string(utils.TransactionTypeSaldoAwal): utils.ApprovalWorkflowInitial, string(utils.TransactionTypeInjection): utils.ApprovalWorkflowInjection, }, } } func (s transactionService) withRelations(db *gorm.DB) *gorm.DB { return db.Preload("CreatedUser"). Preload("BankWarehouse"). Preload("Customer"). Preload("Supplier") } func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } startDate, endDate, err := parseTransactionDateRange(params.StartDate, params.EndDate) if err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) needsPartyJoin := params.Search != "" || params.SortBy == "customer_name" needsBankJoin := params.Search != "" || params.SortBy == "bank" if needsPartyJoin { db = db.Joins( "LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL", string(utils.PaymentPartyCustomer), ).Joins( "LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL", string(utils.PaymentPartySupplier), ) } if needsBankJoin { db = db.Joins("LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL") } if params.Search != "" { like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" db = db.Where( `LOWER(payment_code) LIKE ? OR LOWER(COALESCE(reference_number, '')) LIKE ? OR LOWER(COALESCE(payment_method, '')) LIKE ? OR LOWER(COALESCE(transaction_type, '')) LIKE ? OR LOWER(COALESCE(notes, '')) LIKE ? OR LOWER(COALESCE(customers.name, '')) LIKE ? OR LOWER(COALESCE(suppliers.name, '')) LIKE ? OR LOWER(COALESCE(banks.name, '')) LIKE ? OR CAST(payments.nominal AS TEXT) LIKE ? OR TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?`, like, like, like, like, like, like, like, like, like, like, ) } if len(params.TransactionTypes) > 0 { types := make([]string, 0, len(params.TransactionTypes)) for _, transactionType := range params.TransactionTypes { normalized := strings.ToUpper(strings.TrimSpace(transactionType)) if normalized == "" { continue } types = append(types, normalized) } if len(types) > 0 { db = db.Where("transaction_type IN ?", types) } } if len(params.BankIDs) > 0 { db = db.Where("bank_id IN ?", params.BankIDs) } customerIDs := params.CustomerIDs supplierIDs := params.SupplierIDs if len(customerIDs) > 0 && len(supplierIDs) > 0 { db = db.Where( "(party_type = ? AND party_id IN ?) OR (party_type = ? AND party_id IN ?)", string(utils.PaymentPartyCustomer), customerIDs, string(utils.PaymentPartySupplier), supplierIDs, ) } else if len(customerIDs) > 0 { db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartyCustomer), customerIDs) } else if len(supplierIDs) > 0 { db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartySupplier), supplierIDs) } if startDate != nil { db = db.Where("payment_date >= ?", *startDate) } if endDate != nil { db = db.Where("payment_date < ?", *endDate) } return applyTransactionSort(db, params.SortBy, params.SortOrder, params.SortDate) }) if err != nil { s.Log.Errorf("Failed to get transactions: %+v", err) return nil, 0, err } s.attachApprovals(c.Context(), transactions) return transactions, total, nil } func (s transactionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { transaction, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Transaction not found") } if err != nil { s.Log.Errorf("Failed get transaction by id: %+v", err) return nil, err } if s.ApprovalSvc != nil { approval, err := s.ApprovalSvc.LatestByTarget( c.Context(), s.workflowForTransaction(transaction), id, s.approvalQueryModifier(), ) if err != nil { s.Log.Warnf("Unable to load latest approval for transaction %d: %+v", id, err) } else { transaction.LatestApproval = approval } } return transaction, nil } func (s transactionService) DeleteOne(c *fiber.Ctx, id uint) error { if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Transaction not found") } s.Log.Errorf("Failed to delete transaction: %+v", err) return err } return nil } func (s transactionService) attachApprovals(ctx context.Context, transactions []entity.Payment) { if s.ApprovalSvc == nil || len(transactions) == 0 { return } workflowIDs := map[approvalutils.ApprovalWorkflowKey][]uint{} for _, transaction := range transactions { workflow := s.workflowForTransaction(&transaction) workflowIDs[workflow] = append(workflowIDs[workflow], transaction.Id) } approvalByWorkflow := make(map[approvalutils.ApprovalWorkflowKey]map[uint]*entity.Approval, len(workflowIDs)) for workflow, ids := range workflowIDs { if len(ids) == 0 { continue } approvals, err := s.ApprovalSvc.LatestByTargets(ctx, workflow, ids, s.approvalQueryModifier()) if err != nil { s.Log.Warnf("Unable to load latest approvals for transactions: %+v", err) continue } approvalByWorkflow[workflow] = approvals } for i := range transactions { workflow := s.workflowForTransaction(&transactions[i]) if approvals, ok := approvalByWorkflow[workflow]; ok { transactions[i].LatestApproval = approvals[transactions[i].Id] } } } func (s transactionService) workflowForTransaction(transaction *entity.Payment) approvalutils.ApprovalWorkflowKey { if transaction == nil { return utils.ApprovalWorkflowPayment } transactionType := strings.TrimSpace(strings.ToUpper(transaction.TransactionType)) if transactionType == "" { return utils.ApprovalWorkflowPayment } if workflow, ok := s.approvalWorkflows[transactionType]; ok { return workflow } return utils.ApprovalWorkflowPayment } func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db.Preload("ActionUser") } } func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Time, error) { start := strings.TrimSpace(startDate) end := strings.TrimSpace(endDate) var startPtr *time.Time var endPtr *time.Time var endValue *time.Time if start != "" { parsed, err := utils.ParseDateString(start) if err != nil { return nil, nil, utils.BadRequest("start_date must use format YYYY-MM-DD") } startPtr = &parsed } if end != "" { parsed, err := utils.ParseDateString(end) if err != nil { return nil, nil, utils.BadRequest("end_date must use format YYYY-MM-DD") } endValue = &parsed nextDay := parsed.AddDate(0, 0, 1) endPtr = &nextDay } if startPtr != nil && endValue != nil && startPtr.After(*endValue) { return nil, nil, utils.BadRequest("start_date must be earlier than end_date") } return startPtr, endPtr, nil } func applyTransactionSort(db *gorm.DB, sortBy, sortOrder, sortDate string) *gorm.DB { order := "DESC" if strings.ToUpper(strings.TrimSpace(sortOrder)) == "ASC" { order = "ASC" } switch strings.ToLower(strings.TrimSpace(sortBy)) { case "payment_code": return db.Order("payments.payment_code " + order) case "reference_number": return db.Order("payments.reference_number " + order) case "transaction_type": return db.Order("payments.transaction_type " + order) case "customer_name": return db.Order("COALESCE(customers.name, suppliers.name) " + order) case "payment_date": return db.Order("payments.payment_date " + order) case "created_at": return db.Order("payments.created_at " + order) case "payment_method": return db.Order("payments.payment_method " + order) case "bank": return db.Order("banks.account_number " + order) case "expense_amount": return db.Order("CASE WHEN payments.direction = 'OUT' THEN payments.nominal ELSE 0 END " + order) case "income_amount": return db.Order("CASE WHEN payments.direction = 'IN' THEN payments.nominal ELSE 0 END " + order) } switch strings.ToLower(strings.TrimSpace(sortDate)) { case "created_at": return db.Order("payments.created_at DESC").Order("payments.payment_date DESC") default: return db.Order("payments.payment_date DESC").Order("payments.created_at DESC") } }