diff --git a/internal/common/repository/common.approval.repository..go b/internal/common/repository/common.approval.repository..go index dc517f21..8c045084 100644 --- a/internal/common/repository/common.approval.repository..go +++ b/internal/common/repository/common.approval.repository..go @@ -84,8 +84,9 @@ func (r *approvalRepositoryImpl) LatestByTargets( result := make(map[uint]entity.Approval, len(approvableIDs)) q := r.DB().WithContext(ctx). + Select("DISTINCT ON (approvable_id) *"). Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs). - Order("action_at DESC") + Order("approvable_id, action_at DESC") if modifier != nil { q = modifier(q) diff --git a/internal/common/repository/common.exists.repository.go b/internal/common/repository/common.exists.repository.go index c6bc11f0..b8206eb9 100644 --- a/internal/common/repository/common.exists.repository.go +++ b/internal/common/repository/common.exists.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "gorm.io/gorm" @@ -9,45 +10,59 @@ import ( // Exists reports whether a record with the given ID exists for type T. func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) { - var count int64 - if err := db.WithContext(ctx). + var marker int + err := db.WithContext(ctx). Model(new(T)). + Select("1"). Where("id = ?", id). - Count(&count).Error; err != nil { + Limit(1). + Take(&marker).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { return false, err } - return count > 0, nil + return true, nil } func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) { - var count int64 q := db.WithContext(ctx). Model(new(T)). + Select("1"). Where("name = ?", name). Where("deleted_at IS NULL") if excludeID != nil { q = q.Where("id <> ?", *excludeID) } - if err := q.Count(&count).Error; err != nil { + var marker int + if err := q.Limit(1).Take(&marker).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } return false, err } - return count > 0, nil + return true, nil } func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) { if field == "" { return false, fmt.Errorf("field is required") } - var count int64 q := db.WithContext(ctx). Model(new(T)). + Select("1"). Where(fmt.Sprintf("%s = ?", field), value). Where("deleted_at IS NULL") if excludeID != nil { q = q.Where("id <> ?", *excludeID) } - if err := q.Count(&count).Error; err != nil { + var marker int + if err := q.Limit(1).Take(&marker).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } return false, err } - return count > 0, nil + return true, nil } diff --git a/internal/database/migrations/20251224033033_create_payment_table.down.sql b/internal/database/migrations/20251224033033_create_payment_table.down.sql new file mode 100644 index 00000000..14bc4ca1 --- /dev/null +++ b/internal/database/migrations/20251224033033_create_payment_table.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_payments_bank_id; +DROP INDEX IF EXISTS payments_party_polymorphic; +DROP TABLE IF EXISTS payments; diff --git a/internal/database/migrations/20251224033033_create_payment_table.up.sql b/internal/database/migrations/20251224033033_create_payment_table.up.sql new file mode 100644 index 00000000..d27c55f4 --- /dev/null +++ b/internal/database/migrations/20251224033033_create_payment_table.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS payments ( + id BIGSERIAL PRIMARY KEY, + payment_code VARCHAR(50) NOT NULL, + reference_number VARCHAR(100) NULL, + transaction_type VARCHAR(50), + party_type VARCHAR(50) NOT NULL, + party_id BIGINT NOT NULL, + payment_date TIMESTAMPTZ NOT NULL, + payment_method VARCHAR(20) NOT NULL, + bank_id BIGINT NULL REFERENCES banks(id) ON DELETE RESTRICT ON UPDATE CASCADE, + direction VARCHAR(5) NOT NULL, + nominal NUMERIC(15, 3) NOT NULL, + notes TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +-- Indexes +CREATE INDEX payments_party_polymorphic ON payments (party_type, party_id); +CREATE INDEX idx_payments_bank_id ON payments (bank_id); diff --git a/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql new file mode 100644 index 00000000..1d55147b --- /dev/null +++ b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql @@ -0,0 +1,18 @@ +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + END LOOP; +END $$; + +DROP FUNCTION IF EXISTS soft_delete_handle_fk(); diff --git a/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql new file mode 100644 index 00000000..161d3d89 --- /dev/null +++ b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql @@ -0,0 +1,126 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)', + fk.child_table, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql b/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql new file mode 100644 index 00000000..50996e8f --- /dev/null +++ b/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql @@ -0,0 +1 @@ +DROP SEQUENCE IF EXISTS payments_code_seq; diff --git a/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql b/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql new file mode 100644 index 00000000..875b0697 --- /dev/null +++ b/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql @@ -0,0 +1 @@ +CREATE SEQUENCE IF NOT EXISTS payments_code_seq START WITH 1 INCREMENT BY 1; diff --git a/internal/entities/initial.go b/internal/entities/initial.go new file mode 100644 index 00000000..c562d748 --- /dev/null +++ b/internal/entities/initial.go @@ -0,0 +1,30 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Initial struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ReferenceNumber string `gorm:"type:varchar(100);not null"` + TransactionType string `gorm:"type:varchar(50);not null"` + InitialBalanceType string `gorm:"type:varchar(20);not null"` + PartyType string `gorm:"type:varchar(50);not null;index:initials_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:initials_party_polymorphic,priority:2"` + BankId *uint `gorm:"index"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedBy uint `gorm:"index" json:"-"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Bank Bank `gorm:"foreignKey:BankId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Customer *Customer `gorm:"foreignKey:PartyId;references:Id"` + Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/payment.go b/internal/entities/payment.go new file mode 100644 index 00000000..e48800fb --- /dev/null +++ b/internal/entities/payment.go @@ -0,0 +1,32 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Payment struct { + Id uint `gorm:"primaryKey;autoIncrement"` + PaymentCode string `gorm:"type:varchar(50);not null"` + ReferenceNumber *string `gorm:"type:varchar(100)"` + TransactionType string `gorm:"type:varchar(50)"` + PartyType string `gorm:"type:varchar(50);not null;index:payments_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:payments_party_polymorphic,priority:2"` + PaymentDate time.Time `gorm:"not null"` + PaymentMethod string `gorm:"type:varchar(20);not null"` + BankId *uint `gorm:"not null;index:idx_payments_bank_id"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedBy uint `gorm:"index" json:"-"` + + BankWarehouse Bank `gorm:"foreignKey:BankId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Customer *Customer `gorm:"foreignKey:PartyId;references:Id"` + Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/transaction.go b/internal/entities/transaction.go new file mode 100644 index 00000000..b099bd08 --- /dev/null +++ b/internal/entities/transaction.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Transaction struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index d384fee7..7a21262c 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -193,6 +193,15 @@ const ( P_PurchaseApprovalManager = "lti.Purchase.approve.manager" ) +const ( + P_FinanceGetAll = "lti.finance.list" + P_FinanceGetOne = "lti.finance.detail" + P_FinanceCreateOne = "lti.finance.create" + P_FinanceUpdateOne = "lti.finance.update" + P_FinanceDeleteOne = "lti.finance.delete" + P_FinanceApproval = "lti.finance.approve" +) + const ( P_UserGetAll = "lti.users.list" P_UserGetOne = "lti.users.detail" diff --git a/internal/modules/finance/initials/controllers/initial.controller.go b/internal/modules/finance/initials/controllers/initial.controller.go new file mode 100644 index 00000000..4aef677a --- /dev/null +++ b/internal/modules/finance/initials/controllers/initial.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type InitialController struct { + InitialService service.InitialService +} + +func NewInitialController(initialService service.InitialService) *InitialController { + return &InitialController{ + InitialService: initialService, + } +} + +func (u *InitialController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.InitialService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} + +func (u *InitialController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InitialService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} + +func (u *InitialController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InitialService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} diff --git a/internal/modules/finance/initials/dto/initial.dto.go b/internal/modules/finance/initials/dto/initial.dto.go new file mode 100644 index 00000000..5eb76e9c --- /dev/null +++ b/internal/modules/finance/initials/dto/initial.dto.go @@ -0,0 +1,163 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type InitialRelationDTO struct { + Id uint `json:"id"` + ReferenceNumber string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + InitialBalanceType string `json:"initial_balance_type"` + InitialBalanceTypeLabel string `json:"initial_balance_type_label"` + Party Party `json:"party"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + Direction string `json:"direction"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` +} + +type InitialListDTO struct { + InitialRelationDTO + CreatedBy uint `json:"created_by"` + CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type InitialDetailDTO struct { + InitialListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToInitialRelationDTO(e entity.Payment) InitialRelationDTO { + reference := "" + if e.ReferenceNumber != nil { + reference = *e.ReferenceNumber + } + + initialBalanceType := initialBalanceTypeFromPayment(e) + return InitialRelationDTO{ + Id: e.Id, + ReferenceNumber: reference, + TransactionType: transactionTypeLabel(e.TransactionType), + InitialBalanceType: initialBalanceType, + InitialBalanceTypeLabel: initialBalanceLabel(initialBalanceType), + Party: partyFromInitial(e), + Bank: bankFromInitial(e), + Direction: e.Direction, + Nominal: e.Nominal, + Notes: e.Notes, + } +} + +func ToInitialListDTO(e entity.Payment) InitialListDTO { + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return InitialListDTO{ + InitialRelationDTO: ToInitialRelationDTO(e), + CreatedBy: e.CreatedBy, + CreatedByUser: userFromInitial(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToInitialListDTOs(e []entity.Payment) []InitialListDTO { + result := make([]InitialListDTO, len(e)) + for i, r := range e { + result[i] = ToInitialListDTO(r) + } + return result +} + +func ToInitialDetailDTO(e entity.Payment) InitialDetailDTO { + return InitialDetailDTO{ + InitialListDTO: ToInitialListDTO(e), + } +} + +func partyFromInitial(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromInitial(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func transactionTypeLabel(transactionType string) string { + if strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) { + return "Saldo Awal" + } + return transactionType +} + +func initialBalanceLabel(balanceType string) string { + switch strings.ToUpper(strings.TrimSpace(balanceType)) { + case "NEGATIVE": + return "Saldo Awal Negatif" + case "POSITIVE": + return "Saldo Awal Positif" + default: + return balanceType + } +} + +func initialBalanceTypeFromPayment(e entity.Payment) string { + if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 { + return "NEGATIVE" + } + return "POSITIVE" +} diff --git a/internal/modules/finance/initials/module.go b/internal/modules/finance/initials/module.go new file mode 100644 index 00000000..051c8d3f --- /dev/null +++ b/internal/modules/finance/initials/module.go @@ -0,0 +1,36 @@ +package initials + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories" + sInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type InitialModule struct{} + +func (InitialModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + initialRepo := rInitial.NewInitialRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register initial approval workflow: %v", err)) + } + + initialService := sInitial.NewInitialService(initialRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + InitialRoutes(router, userService, initialService) +} diff --git a/internal/modules/finance/initials/repositories/initial.repository.go b/internal/modules/finance/initials/repositories/initial.repository.go new file mode 100644 index 00000000..9c285c5c --- /dev/null +++ b/internal/modules/finance/initials/repositories/initial.repository.go @@ -0,0 +1,51 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type InitialRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + CustomerExists(ctx context.Context, customerId uint) (bool, error) + SupplierExists(ctx context.Context, supplierId uint) (bool, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type InitialRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewInitialRepository(db *gorm.DB) InitialRepository { + return &InitialRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *InitialRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *InitialRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, customerId) +} + +func (r *InitialRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, supplierId) +} + +func (r *InitialRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/initials/route.go b/internal/modules/finance/initials/route.go new file mode 100644 index 00000000..21232493 --- /dev/null +++ b/internal/modules/finance/initials/route.go @@ -0,0 +1,21 @@ +package initials + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/controllers" + initial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func InitialRoutes(v1 fiber.Router, u user.UserService, s initial.InitialService) { + ctrl := controller.NewInitialController(s) + + route := v1.Group("/initial-balances") + // route.Use(m.Auth(u)) + + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) +} diff --git a/internal/modules/finance/initials/services/initial.service.go b/internal/modules/finance/initials/services/initial.service.go new file mode 100644 index 00000000..2eb15d3b --- /dev/null +++ b/internal/modules/finance/initials/services/initial.service.go @@ -0,0 +1,336 @@ +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}, + ) +} diff --git a/internal/modules/finance/initials/validations/initial.validation.go b/internal/modules/finance/initials/validations/initial.validation.go new file mode 100644 index 00000000..27df2eea --- /dev/null +++ b/internal/modules/finance/initials/validations/initial.validation.go @@ -0,0 +1,27 @@ +package validation + +type Create struct { + PartyType string `json:"party_type" validate:"required_strict,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"` + InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Note string `json:"note" validate:"required_strict,max=500"` +} + +type Update struct { + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty" validate:"omitempty,max=100"` + InitialBalanceType *string `json:"initial_balance_type,omitempty" validate:"omitempty,oneof=NEGATIVE POSITIVE"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Note *string `json:"note,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/injections/controllers/injection.controller.go b/internal/modules/finance/injections/controllers/injection.controller.go new file mode 100644 index 00000000..8f6c6b6d --- /dev/null +++ b/internal/modules/finance/injections/controllers/injection.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type InjectionController struct { + InjectionService service.InjectionService +} + +func NewInjectionController(injectionService service.InjectionService) *InjectionController { + return &InjectionController{ + InjectionService: injectionService, + } +} + +func (u *InjectionController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.InjectionService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get injection successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} + +func (u *InjectionController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InjectionService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Balance injection created successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} + +func (u *InjectionController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InjectionService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update injection successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} diff --git a/internal/modules/finance/injections/dto/injection.dto.go b/internal/modules/finance/injections/dto/injection.dto.go new file mode 100644 index 00000000..d0be7f3f --- /dev/null +++ b/internal/modules/finance/injections/dto/injection.dto.go @@ -0,0 +1,102 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type InjectionRelationDTO struct { + Id uint `json:"id"` + TransactionType string `json:"transaction_type"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + AdjustmentDate string `json:"adjustment_date"` + Direction string `json:"direction"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` +} + +type InjectionListDTO struct { + InjectionRelationDTO + CreatedBy uint `json:"created_by"` + CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type InjectionDetailDTO struct { + InjectionListDTO +} + +// === Mapper Functions === + +func ToInjectionRelationDTO(e entity.Payment) InjectionRelationDTO { + return InjectionRelationDTO{ + Id: e.Id, + TransactionType: transactionTypeLabel(e.TransactionType), + Bank: bankFromInjection(e), + AdjustmentDate: utils.FormatDate(e.PaymentDate), + Direction: e.Direction, + Nominal: e.Nominal, + Notes: e.Notes, + } +} + +func ToInjectionListDTO(e entity.Payment) InjectionListDTO { + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return InjectionListDTO{ + InjectionRelationDTO: ToInjectionRelationDTO(e), + CreatedBy: e.CreatedBy, + CreatedByUser: userFromInjection(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToInjectionListDTOs(e []entity.Payment) []InjectionListDTO { + result := make([]InjectionListDTO, len(e)) + for i, r := range e { + result[i] = ToInjectionListDTO(r) + } + return result +} + +func ToInjectionDetailDTO(e entity.Payment) InjectionDetailDTO { + return InjectionDetailDTO{ + InjectionListDTO: ToInjectionListDTO(e), + } +} + +func bankFromInjection(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromInjection(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func transactionTypeLabel(transactionType string) string { + if strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) { + return "Injection" + } + return transactionType +} diff --git a/internal/modules/finance/injections/module.go b/internal/modules/finance/injections/module.go new file mode 100644 index 00000000..0c4517e6 --- /dev/null +++ b/internal/modules/finance/injections/module.go @@ -0,0 +1,36 @@ +package injections + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories" + sInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type InjectionModule struct{} + +func (InjectionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + injectionRepo := rInjection.NewInjectionRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) + } + + injectionService := sInjection.NewInjectionService(injectionRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + InjectionRoutes(router, userService, injectionService) +} diff --git a/internal/modules/finance/injections/repositories/injection.repository.go b/internal/modules/finance/injections/repositories/injection.repository.go new file mode 100644 index 00000000..2e6869b7 --- /dev/null +++ b/internal/modules/finance/injections/repositories/injection.repository.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type InjectionRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type InjectionRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewInjectionRepository(db *gorm.DB) InjectionRepository { + return &InjectionRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *InjectionRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *InjectionRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/injections/route.go b/internal/modules/finance/injections/route.go new file mode 100644 index 00000000..cb66ccb7 --- /dev/null +++ b/internal/modules/finance/injections/route.go @@ -0,0 +1,21 @@ +package injections + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/controllers" + injection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func InjectionRoutes(v1 fiber.Router, u user.UserService, s injection.InjectionService) { + ctrl := controller.NewInjectionController(s) + + route := v1.Group("/injections") + // route.Use(m.Auth(u)) + + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) +} diff --git a/internal/modules/finance/injections/services/injection.service.go b/internal/modules/finance/injections/services/injection.service.go new file mode 100644 index 00000000..1b1062b4 --- /dev/null +++ b/internal/modules/finance/injections/services/injection.service.go @@ -0,0 +1,230 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + 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/injections/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/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 InjectionService 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 injectionService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.InjectionRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewInjectionService( + repo repository.InjectionRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) InjectionService { + return &injectionService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowInjection, + } +} + +func (s injectionService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse") +} + +func (s injectionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + injection, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if err != nil { + s.Log.Errorf("Failed get injection by id: %+v", err) + return nil, err + } + if !isInjectionTransaction(injection.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection 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 injection %d: %+v", id, err) + } else { + injection.LatestApproval = approval + } + } + return injection, nil +} + +func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + adjustmentDate, err := utils.ParseDateString(req.AdjustmentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generateInjectionCode(c.Context()) + if err != nil { + return nil, err + } + + createBody := &entity.Payment{ + PaymentCode: code, + TransactionType: string(utils.TransactionTypeInjection), + PartyType: string(utils.PaymentPartyCustomer), + PartyId: 0, + PaymentDate: adjustmentDate, + PaymentMethod: string(utils.PaymentMethodSaldo), + BankId: req.BankId, + Direction: "IN", + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + injectionRepoTx := repository.NewInjectionRepository(dbTransaction) + if err := injectionRepoTx.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.InjectionStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create injection: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s injectionService) 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) + + requiresVerification := req.BankId != nil || req.AdjustmentDate != nil || req.Nominal != nil || req.Notes != nil + if requiresVerification { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if err != nil { + s.Log.Errorf("Failed get injection by id: %+v", err) + return nil, err + } + if !isInjectionTransaction(current.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + } + + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + if req.AdjustmentDate != nil { + parsedDate, err := utils.ParseDateString(*req.AdjustmentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + updateBody["payment_date"] = parsedDate + } + if req.Nominal != nil { + updateBody["nominal"] = *req.Nominal + } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + + 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, "Injection not found") + } + s.Log.Errorf("Failed to update injection: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func isInjectionTransaction(transactionType string) bool { + return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) +} + +func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) { + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("INJ-%05d", sequence), nil +} + +func (s injectionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s injectionService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} diff --git a/internal/modules/finance/injections/validations/injection.validation.go b/internal/modules/finance/injections/validations/injection.validation.go new file mode 100644 index 00000000..eb324525 --- /dev/null +++ b/internal/modules/finance/injections/validations/injection.validation.go @@ -0,0 +1,21 @@ +package validation + +type Create struct { + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + AdjustmentDate string `json:"adjustment_date" validate:"required_strict"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` +} + +type Update struct { + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/module.go b/internal/modules/finance/module.go new file mode 100644 index 00000000..ded5fbae --- /dev/null +++ b/internal/modules/finance/module.go @@ -0,0 +1,13 @@ +package finance + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type FinanceModule struct{} + +func (FinanceModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + RegisterRoutes(router, db, validate) +} diff --git a/internal/modules/finance/payments/controllers/payment.controller.go b/internal/modules/finance/payments/controllers/payment.controller.go new file mode 100644 index 00000000..5bccecf4 --- /dev/null +++ b/internal/modules/finance/payments/controllers/payment.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PaymentController struct { + PaymentService service.PaymentService +} + +func NewPaymentController(paymentService service.PaymentService) *PaymentController { + return &PaymentController{ + PaymentService: paymentService, + } +} + +func (u *PaymentController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.PaymentService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} + +func (u *PaymentController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PaymentService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} + +func (u *PaymentController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PaymentService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} diff --git a/internal/modules/finance/payments/dto/payment.dto.go b/internal/modules/finance/payments/dto/payment.dto.go new file mode 100644 index 00000000..23005e2d --- /dev/null +++ b/internal/modules/finance/payments/dto/payment.dto.go @@ -0,0 +1,189 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type PaymentRelationDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number,omitempty"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"` +} + +type PaymentListDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type PaymentDetailDTO struct { + PaymentListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToPaymentRelationDTO(e entity.Payment) PaymentRelationDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + + return PaymentRelationDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + } +} + +func ToPaymentListDTO(e entity.Payment) PaymentListDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return PaymentListDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToPaymentListDTOs(e []entity.Payment) []PaymentListDTO { + result := make([]PaymentListDTO, len(e)) + for i, r := range e { + result[i] = ToPaymentListDTO(r) + } + return result +} + +func ToPaymentDetailDTO(e entity.Payment) PaymentDetailDTO { + return PaymentDetailDTO{ + PaymentListDTO: ToPaymentListDTO(e), + } +} + +func partyFromPayment(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func paymentCodeFromPayment(e entity.Payment) string { + if e.PaymentCode != "" { + return e.PaymentCode + } + if e.ReferenceNumber != nil { + return *e.ReferenceNumber + } + return "" +} + +func transactionTypeFromPayment(e entity.Payment) string { + if e.TransactionType != "" { + return e.TransactionType + } + return e.Direction +} + +func paymentAmounts(direction string, nominal float64) (float64, float64) { + switch strings.ToUpper(direction) { + case "IN": + return 0, nominal + case "OUT": + return nominal, 0 + default: + return 0, 0 + } +} diff --git a/internal/modules/finance/payments/module.go b/internal/modules/finance/payments/module.go new file mode 100644 index 00000000..fdc0ce47 --- /dev/null +++ b/internal/modules/finance/payments/module.go @@ -0,0 +1,36 @@ +package payments + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gorm.io/gorm" + + rPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories" + sPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type PaymentModule struct{} + +func (PaymentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + paymentRepo := rPayment.NewPaymentRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) + } + + paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + PaymentRoutes(router, userService, paymentService) +} diff --git a/internal/modules/finance/payments/repositories/payment.repository.go b/internal/modules/finance/payments/repositories/payment.repository.go new file mode 100644 index 00000000..b16f8881 --- /dev/null +++ b/internal/modules/finance/payments/repositories/payment.repository.go @@ -0,0 +1,62 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type PaymentRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + CustomerExists(ctx context.Context, customerId uint) (bool, error) + SupplierExists(ctx context.Context, supplierId uint) (bool, error) + SupplierCategory(ctx context.Context, supplierId uint) (string, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type PaymentRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewPaymentRepository(db *gorm.DB) PaymentRepository { + return &PaymentRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *PaymentRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *PaymentRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, customerId) +} + +func (r *PaymentRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, supplierId) +} + +func (r *PaymentRepositoryImpl) SupplierCategory(ctx context.Context, supplierId uint) (string, error) { + var supplier entity.Supplier + if err := r.db.WithContext(ctx). + Select("id", "category"). + First(&supplier, supplierId).Error; err != nil { + return "", err + } + return supplier.Category, nil +} + +func (r *PaymentRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/payments/route.go b/internal/modules/finance/payments/route.go new file mode 100644 index 00000000..4b0e8bd2 --- /dev/null +++ b/internal/modules/finance/payments/route.go @@ -0,0 +1,21 @@ +package payments + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/controllers" + payment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func PaymentRoutes(v1 fiber.Router, u user.UserService, s payment.PaymentService) { + ctrl := controller.NewPaymentController(s) + + route := v1.Group("/payments") + // route.Use(m.Auth(u)) + + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) +} diff --git a/internal/modules/finance/payments/services/payment.service.go b/internal/modules/finance/payments/services/payment.service.go new file mode 100644 index 00000000..356288f1 --- /dev/null +++ b/internal/modules/finance/payments/services/payment.service.go @@ -0,0 +1,362 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + 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/payments/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/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 PaymentService 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 paymentService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.PaymentRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewPaymentService( + repo repository.PaymentRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) PaymentService { + return &paymentService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowPayment, + } +} + +func (s paymentService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s paymentService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + payment, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment by id: %+v", err) + return nil, err + } + 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 payment %d: %+v", id, err) + } else { + payment.LatestApproval = approval + } + } + return payment, nil +} + +func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + //! CHECK PARTY TYPE + party, err := normalizePartyType(req.PartyType) + if err != nil { + return nil, err + } + + //! CHECK EXISTS + 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 + } + + //? NORMALIZE + paymentDate, err := utils.ParseDateString(req.PaymentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + method, err := normalizePaymentMethod(req.PaymentMethod) + if err != nil { + return nil, err + } + transactionType, err := s.resolveTransactionType(c.Context(), party, req.PartyId) + if err != nil { + return nil, err + } + + //? GET CREATED BY + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generatePaymentCode(c.Context(), party) + if err != nil { + return nil, err + } + + createBody := &entity.Payment{ + PaymentCode: code, + ReferenceNumber: req.ReferenceNumber, + TransactionType: transactionType, + PartyType: party, + PartyId: req.PartyId, + PaymentDate: paymentDate, + PaymentMethod: method, + BankId: req.BankId, + Direction: directionForParty(party), + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + paymentRepoTx := repository.NewPaymentRepository(dbTransaction) + if err := paymentRepoTx.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.PaymentStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create payment: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s paymentService) 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.PaymentDate != nil { + parsedDate, err := utils.ParseDateString(*req.PaymentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + updateBody["payment_date"] = parsedDate + } + if req.Nominal != nil { + updateBody["nominal"] = *req.Nominal + } + if req.ReferenceNumber != nil { + updateBody["reference_number"] = *req.ReferenceNumber + } + if req.PaymentMethod != nil { + method, err := normalizePaymentMethod(*req.PaymentMethod) + if err != nil { + return nil, err + } + updateBody["payment_method"] = method + } + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + + if req.PartyType != nil || req.PartyId != nil { + existing, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment by id: %+v", err) + return nil, err + } + + 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 + updateBody["direction"] = directionForParty(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 + } + + transactionType, err := s.resolveTransactionType(c.Context(), partyType, partyId) + if err != nil { + return nil, err + } + updateBody["transaction_type"] = transactionType + } + + 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, "Payment not found") + } + s.Log.Errorf("Failed to update payment: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +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 normalizePaymentMethod(method string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(method)) + if !utils.IsValidPaymentMethod(normalized) { + return "", utils.BadRequest("Invalid payment_method") + } + return normalized, nil +} + +func directionForParty(partyType string) string { + if utils.PaymentParty(partyType) == utils.PaymentPartyCustomer { + return "IN" + } + return "OUT" +} + +func (s paymentService) resolveTransactionType(ctx context.Context, partyType string, partyId uint) (string, error) { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return string(utils.TransactionTypePenjualan), nil + case utils.PaymentPartySupplier: + category, err := s.getSupplierCategory(ctx, partyId) + if err != nil { + return "", err + } + if isSupplierCategoryBiaya(category) { + return string(utils.TransactionTypeBiaya), nil + } + return string(utils.TransactionTypePembelian), nil + default: + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s paymentService) generatePaymentCode(ctx context.Context, partyType string) (string, error) { + prefix := "PAY" + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + prefix = "PAY-IN" + case utils.PaymentPartySupplier: + prefix = "PAY-OUT" + } + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("%s-%05d", prefix, sequence), nil +} + +func (s paymentService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s paymentService) 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 paymentService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} + +func (s paymentService) getSupplierCategory(ctx context.Context, supplierId uint) (string, error) { + category, err := s.Repository.SupplierCategory(ctx, supplierId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", utils.NotFound("Supplier not found") + } + return "", err + } + return strings.ToUpper(strings.TrimSpace(category)), nil +} + +func isSupplierCategoryBiaya(category string) bool { + switch strings.ToUpper(strings.TrimSpace(category)) { + case string(utils.SupplierCategoryBOP), "BIAYA": + return true + default: + return false + } +} diff --git a/internal/modules/finance/payments/validations/payment.validation.go b/internal/modules/finance/payments/validations/payment.validation.go new file mode 100644 index 00000000..14c8f151 --- /dev/null +++ b/internal/modules/finance/payments/validations/payment.validation.go @@ -0,0 +1,29 @@ +package validation + +type Create struct { + PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"` + Nominal float64 `json:"nominal" validate:"required_strict"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"` + BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` +} + +type Update struct { + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/route.go b/internal/modules/finance/route.go new file mode 100644 index 00000000..bc99bf7e --- /dev/null +++ b/internal/modules/finance/route.go @@ -0,0 +1,31 @@ +package finance + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/modules" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + payments "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments" + initials "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials" + injections "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections" + transactions "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions" + // MODULE IMPORTS +) + +func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + group := router.Group("/finance") + + allModules := []modules.Module{ + payments.PaymentModule{}, + initials.InitialModule{}, + injections.InjectionModule{}, + transactions.TransactionModule{}, + // MODULE REGISTRY + } + + for _, m := range allModules { + m.RegisterRoutes(group, db, validate) + } +} diff --git a/internal/modules/finance/transactions/controllers/transaction.controller.go b/internal/modules/finance/transactions/controllers/transaction.controller.go new file mode 100644 index 00000000..fa3e1369 --- /dev/null +++ b/internal/modules/finance/transactions/controllers/transaction.controller.go @@ -0,0 +1,96 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type TransactionController struct { + TransactionService service.TransactionService +} + +func NewTransactionController(transactionService service.TransactionService) *TransactionController { + return &TransactionController{ + TransactionService: transactionService, + } +} + +func (u *TransactionController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.TransactionService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.TransactionListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all transactions successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToTransactionListDTOs(result), + }) +} + +func (u *TransactionController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.TransactionService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get transaction successfully", + Data: dto.ToTransactionListDTO(*result), + }) +} + +func (u *TransactionController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.TransactionService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete transaction successfully", + }) +} diff --git a/internal/modules/finance/transactions/dto/transaction.dto.go b/internal/modules/finance/transactions/dto/transaction.dto.go new file mode 100644 index 00000000..25740344 --- /dev/null +++ b/internal/modules/finance/transactions/dto/transaction.dto.go @@ -0,0 +1,189 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type TransactionRelationDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number,omitempty"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"` +} + +type TransactionListDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type TransactionDetailDTO struct { + TransactionListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToTransactionRelationDTO(e entity.Payment) TransactionRelationDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + + return TransactionRelationDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + } +} + +func ToTransactionListDTO(e entity.Payment) TransactionListDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return TransactionListDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToTransactionListDTOs(e []entity.Payment) []TransactionListDTO { + result := make([]TransactionListDTO, len(e)) + for i, r := range e { + result[i] = ToTransactionListDTO(r) + } + return result +} + +func ToTransactionDetailDTO(e entity.Payment) TransactionDetailDTO { + return TransactionDetailDTO{ + TransactionListDTO: ToTransactionListDTO(e), + } +} + +func partyFromPayment(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func paymentCodeFromPayment(e entity.Payment) string { + if e.PaymentCode != "" { + return e.PaymentCode + } + if e.ReferenceNumber != nil { + return *e.ReferenceNumber + } + return "" +} + +func transactionTypeFromPayment(e entity.Payment) string { + if e.TransactionType != "" { + return e.TransactionType + } + return e.Direction +} + +func paymentAmounts(direction string, nominal float64) (float64, float64) { + switch strings.ToUpper(direction) { + case "IN": + return 0, nominal + case "OUT": + return nominal, 0 + default: + return 0, 0 + } +} diff --git a/internal/modules/finance/transactions/module.go b/internal/modules/finance/transactions/module.go new file mode 100644 index 00000000..c98931a3 --- /dev/null +++ b/internal/modules/finance/transactions/module.go @@ -0,0 +1,42 @@ +package transactions + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories" + sTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type TransactionModule struct{} + +func (TransactionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + transactionRepo := rTransaction.NewTransactionRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) + } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register initial approval workflow: %v", err)) + } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) + } + + transactionService := sTransaction.NewTransactionService(transactionRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + TransactionRoutes(router, userService, transactionService) +} diff --git a/internal/modules/finance/transactions/repositories/transaction.repository.go b/internal/modules/finance/transactions/repositories/transaction.repository.go new file mode 100644 index 00000000..d1629e8b --- /dev/null +++ b/internal/modules/finance/transactions/repositories/transaction.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type TransactionRepository interface { + repository.BaseRepository[entity.Payment] +} + +type TransactionRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] +} + +func NewTransactionRepository(db *gorm.DB) TransactionRepository { + return &TransactionRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + } +} diff --git a/internal/modules/finance/transactions/route.go b/internal/modules/finance/transactions/route.go new file mode 100644 index 00000000..d21f5441 --- /dev/null +++ b/internal/modules/finance/transactions/route.go @@ -0,0 +1,21 @@ +package transactions + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/controllers" + transaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func TransactionRoutes(v1 fiber.Router, u user.UserService, s transaction.TransactionService) { + ctrl := controller.NewTransactionController(s) + + route := v1.Group("/transactions") + // route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Get("/:id", ctrl.GetOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/finance/transactions/services/transaction.service.go b/internal/modules/finance/transactions/services/transaction.service.go new file mode 100644 index 00000000..f7398d43 --- /dev/null +++ b/internal/modules/finance/transactions/services/transaction.service.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "errors" + "strings" + + 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 + } + + 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) + if params.Search != "" { + like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" + return db.Where( + `LOWER(payment_code) LIKE ? OR + LOWER(COALESCE(reference_number, '')) LIKE ? OR + LOWER(COALESCE(transaction_type, '')) LIKE ? OR + LOWER(COALESCE(notes, '')) LIKE ?`, + like, like, like, like, + ) + } + return db.Order("payment_date DESC").Order("created_at DESC") + }) + + 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") + } +} diff --git a/internal/modules/finance/transactions/validations/transaction.validation.go b/internal/modules/finance/transactions/validations/transaction.validation.go new file mode 100644 index 00000000..7d16d3ee --- /dev/null +++ b/internal/modules/finance/transactions/validations/transaction.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index 1584b07f..baea9523 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -84,6 +84,7 @@ func ToKandangListDTO(e entity.Kandang) KandangListDTO { Name: e.Name, Status: e.Status, Location: location, + Capacity: e.Capacity, Pic: pic, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, diff --git a/internal/route/route.go b/internal/route/route.go index 4eb224ac..877ec875 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -12,6 +12,7 @@ import ( closings "gitlab.com/mbugroup/lti-api.git/internal/modules/closings" constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses" + finance "gitlab.com/mbugroup/lti-api.git/internal/modules/finance" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" marketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" @@ -44,7 +45,7 @@ func Routes(app *fiber.App, db *gorm.DB) { ssoModule.Module{}, closings.ClosingModule{}, repports.RepportModule{}, - + finance.FinanceModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 354c9042..85b0cc91 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -148,6 +148,45 @@ const ( ExpenseCategoryNonBOP ExpenseCategory = "NON-BOP" ) +// ------------------------------------------------------------------- +// Payment Method +// ------------------------------------------------------------------- + +type PaymentMethod string + +const ( + PaymentMethodTransfer PaymentMethod = "TRANSFER" + PaymentMethodCash PaymentMethod = "CASH" + PaymentMethodCard PaymentMethod = "CARD" + PaymentMethodCheque PaymentMethod = "CHEQUE" + PaymentMethodSaldo PaymentMethod = "SALDO" +) + +// ------------------------------------------------------------------- +// Trasaction Type +// ------------------------------------------------------------------- + +type TransactionType string + +const ( + TransactionTypePenjualan TransactionType = "PENJUALAN" + TransactionTypePembelian TransactionType = "PEMBELIAN" + TransactionTypeBiaya TransactionType = "BIAYA" + TransactionTypeInjection TransactionType = "INJECTION" + TransactionTypeSaldoAwal TransactionType = "SALDO_AWAL" +) + +// ------------------------------------------------------------------- +// Payment Party +// ------------------------------------------------------------------- + +type PaymentParty string + +const ( + PaymentPartyCustomer PaymentParty = "CUSTOMER" + PaymentPartySupplier PaymentParty = "SUPPLIER" +) + // ------------------------------------------------------------------- // Kandang Status // ------------------------------------------------------------------- @@ -316,6 +355,51 @@ var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepSelesai: "Selesai", } +// ------------------------------------------------------------------- +// Payment Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowPayment approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PAYMENTS") + PaymentStepPengajuan approvalutils.ApprovalStep = 1 + PaymentStepDisetujui approvalutils.ApprovalStep = 2 +) + +var PaymentApprovalSteps = map[approvalutils.ApprovalStep]string{ + PaymentStepPengajuan: "Pengajuan", + PaymentStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Inisial Balance Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInitial approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INITIAL_BALANCES") + InitialStepPengajuan approvalutils.ApprovalStep = 1 + InitialStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InitialApprovalSteps = map[approvalutils.ApprovalStep]string{ + InitialStepPengajuan: "Pengajuan", + InitialStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Injection Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInjection approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INJECTIONS") + InjectionStepPengajuan approvalutils.ApprovalStep = 1 + InjectionStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InjectionApprovalSteps = map[approvalutils.ApprovalStep]string{ + InjectionStepPengajuan: "Pengajuan", + InjectionStepDisetujui: "Disetujui", +} + // ------------------------------------------------------------------- // Document // ------------------------------------------------------------------- @@ -467,7 +551,31 @@ func IsValidExpenseCategory(v string) bool { return false } -// e xample use +func IsValidPaymentMethod(v string) bool { + switch PaymentMethod(v) { + case PaymentMethodTransfer, PaymentMethodCash, PaymentMethodCard, PaymentMethodCheque, PaymentMethodSaldo: + return true + } + return false +} + +func IsValidTransactionType(v string) bool { + switch TransactionType(v) { + case TransactionTypePenjualan, TransactionTypePembelian, TransactionTypeBiaya, TransactionTypeInjection, TransactionTypeSaldoAwal: + return true + } + return false +} + +func IsValidPaymentParty(v string) bool { + switch PaymentParty(v) { + case PaymentPartyCustomer, PaymentPartySupplier: + return true + } + return false +} + +// example use // Recording helper diff --git a/tools/templates/route.tmpl b/tools/templates/route.tmpl index 26958deb..9dea2530 100644 --- a/tools/templates/route.tmpl +++ b/tools/templates/route.tmpl @@ -1,7 +1,7 @@ {{define "route"}}package {{Kebab .Entity}}s import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/controllers" {{Camel .Entity}} "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,17 +13,12 @@ func {{Pascal .Entity}}Routes(v1 fiber.Router, u user.UserService, s {{Camel .En ctrl := controller.New{{Pascal .Entity}}Controller(s) route := v1.Group("/{{Kebab .Entity}}s") + route.Use(m.Auth(u)) - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_FinanceGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_FinanceGetOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_FinanceCreateOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_FinanceUpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_FinanceDeleteOne), ctrl.DeleteOne) } {{end}}