Skip to main content

Multi-Level Approval System

Dokumentasi lengkap sistem approval multi-level di MStore Backend dengan support delegation, auto-approve SLA, dan role-based workflow.

🎯 Overview

Sistem approval MStore dirancang dengan prinsip:
  • βœ… Multi-Level: Support approval bertingkat (L1, L2, L3, dst)
  • βœ… Role-Based: Approval berdasarkan role (MAKER, CHECKER, APPROVER)
  • βœ… Delegation: Temporary delegation dengan time range
  • βœ… Auto-Approve SLA: Automatic approval jika melewati SLA
  • βœ… Flexible Rules: Approval rules berdasarkan doc type & amount range
  • βœ… Audit Trail: Complete log untuk setiap action
  • βœ… Merchant-Scoped: Isolasi approval per merchant/branch

πŸ—οΈ Architecture


πŸ“Š Data Model

1. Roles (Global RBAC)

CREATE TABLE roles (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    roles_code VARCHAR(64) UNIQUE NOT NULL,
    roles_name VARCHAR(128) NOT NULL,
    description TEXT,
    is_active TINYINT DEFAULT 1,
    
    INDEX idx_roles_code (roles_code)
);

-- Example roles
INSERT INTO roles (roles_code, roles_name) VALUES
('MAKER', 'Maker - Create Request'),
('CHECKER', 'Checker - First Level Approval'),
('APPROVER_L2', 'Approver Level 2'),
('APPROVER_L3', 'Approver Level 3'),
('ADMIN', 'Administrator');

2. Approvers (User-Role Mapping)

CREATE TABLE approvers (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    user_id VARCHAR(64) NOT NULL,
    user_code VARCHAR(64),
    role VARCHAR(64) NOT NULL,  -- FK to roles.roles_code
    role_order INT,
    full_name VARCHAR(128),
    email VARCHAR(128),
    phone VARCHAR(32),
    merchant_id VARCHAR(64),
    merchant_code VARCHAR(64),
    branch_id VARCHAR(64),
    branch_code VARCHAR(64),
    is_active BOOLEAN DEFAULT TRUE,
    
    -- Delegation fields
    delegated_to VARCHAR(64),
    delegation_from DATETIME,
    delegation_to DATETIME,
    
    UNIQUE KEY uq_user_role_scope (
        user_id, role, merchant_id, branch_id, role_order
    ),
    INDEX idx_user_role (user_id, role),
    INDEX idx_role (role),
    
    FOREIGN KEY fk_approvers_role (role) 
        REFERENCES roles(roles_code)
);
Key Concepts:
  • role_order: Urutan approver dalam role yang sama (untuk approval_mode=β€˜all’)
  • Delegation: Temporary delegation ke user lain
  • Scope: Merchant & branch level isolation

3. Approval Flows (Rules Definition)

CREATE TABLE approval_flows (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    merchant_id VARCHAR(64),
    merchant_code VARCHAR(64),
    branch_id VARCHAR(64),
    branch_code VARCHAR(64),
    flow_code VARCHAR(64) NOT NULL,
    flow_type VARCHAR(64) NOT NULL,  -- transfer, purchase_order, adjustment, etc
    min_amount DECIMAL(18,2) NOT NULL,
    max_amount DECIMAL(18,2) NOT NULL,
    required_role VARCHAR(64) NOT NULL,  -- FK to roles.roles_code
    level_order INT NOT NULL,
    flow_type_description TEXT,
    approval_mode ENUM('all', 'any') DEFAULT 'all',
    auto_approve_sla INT,  -- Hours until auto-approve
    valid_from DATE,
    valid_to DATE,
    is_active BOOLEAN DEFAULT TRUE,
    
    UNIQUE KEY uq_flow_level (
        merchant_id, branch_id, flow_code, flow_type, level_order
    ),
    INDEX idx_flow_type (flow_type),
    INDEX idx_merchant (merchant_id),
    
    FOREIGN KEY fk_flows_role (required_role) 
        REFERENCES roles(roles_code)
);
Approval Modes:
  • all: Semua approver di level harus approve
  • any: Salah satu approver di level cukup approve

4. Approval Requests

CREATE TABLE approval_requests (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    flow_id BIGINT UNSIGNED,
    doc_id VARCHAR(64) NOT NULL,
    doc_type VARCHAR(64) NOT NULL,
    doc_code VARCHAR(255),
    requester_id VARCHAR(64) NOT NULL,
    requester_name VARCHAR(128),
    merchant_id VARCHAR(64),
    merchant_code VARCHAR(64),
    branch_id VARCHAR(64),
    branch_code VARCHAR(64),
    amount DECIMAL(18,2),
    currency VARCHAR(10) DEFAULT 'IDR',
    status ENUM(
        'PENDING',
        'APPROVED',
        'REJECTED',
        'RETURNED',
        'CANCELLED'
    ) DEFAULT 'PENDING',
    current_level INT DEFAULT 1,
    total_levels INT,
    requested_at DATETIME,
    due_at DATETIME,
    last_action_at DATETIME,
    notes TEXT,
    
    INDEX idx_doc (doc_type, doc_id),
    INDEX idx_status (status),
    INDEX idx_due_at (due_at),
    INDEX idx_merchant (merchant_id),
    INDEX idx_status_due (status, due_at),  -- For SLA scheduler
    
    FOREIGN KEY fk_requests_flow (flow_id) 
        REFERENCES approval_flows(id)
);

5. Approval Logs (Audit Trail)

CREATE TABLE approval_logs (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    request_id BIGINT UNSIGNED NOT NULL,
    approver_id VARCHAR(64) NOT NULL,
    approver_name VARCHAR(128),
    role VARCHAR(64) NOT NULL,  -- FK to roles.roles_code
    level_order INT NOT NULL,
    action_type ENUM(
        'APPROVE',
        'REJECT',
        'RETURN',
        'AUTO_APPROVE'
    ) NOT NULL,
    acted_by VARCHAR(64),  -- Actual user who acted (for delegation)
    on_behalf_of VARCHAR(64),  -- Original approver (if delegated)
    acted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    comments TEXT,
    ip_address VARCHAR(45),
    user_agent VARCHAR(255),
    
    INDEX idx_request (request_id),
    INDEX idx_approver (approver_id),
    INDEX idx_role (role),
    
    FOREIGN KEY fk_logs_request (request_id) 
        REFERENCES approval_requests(id),
    FOREIGN KEY fk_logs_role (role) 
        REFERENCES roles(roles_code)
);

πŸ”„ Approval Flow

State Machine


Example Flow: Inventory Transfer

Scenario: Transfer senilai Rp 5.000.000 memerlukan 2 level approval Setup:
-- Flow Level 1: CHECKER (SLA 24 hours)
INSERT INTO approval_flows (
    merchant_id, flow_code, flow_type,
    min_amount, max_amount,
    required_role, level_order,
    approval_mode, auto_approve_sla
) VALUES (
    'MC01', 'TRANSFER_APPROVAL', 'inventory_transfer',
    0, 10000000,
    'CHECKER', 1,
    'any', 24
);

-- Flow Level 2: APPROVER_L2 (SLA 48 hours)
INSERT INTO approval_flows (
    merchant_id, flow_code, flow_type,
    min_amount, max_amount,
    required_role, level_order,
    approval_mode, auto_approve_sla
) VALUES (
    'MC01', 'TRANSFER_APPROVAL', 'inventory_transfer',
    0, 10000000,
    'APPROVER_L2', 2,
    'any', 48
);

-- Assign approvers
INSERT INTO approvers (user_id, role, merchant_id, is_active) VALUES
('user_101', 'CHECKER', 'MC01', TRUE),
('user_102', 'CHECKER', 'MC01', TRUE),
('user_201', 'APPROVER_L2', 'MC01', TRUE);
Flow Execution:

πŸ’» Implementation

1. Create Approval Request

func (s *approvalService) CreateRequest(ctx context.Context, req CreateApprovalRequest) (*ApprovalRequest, error) {
    // 1. Find matching approval flow
    flows, err := s.repo.FindApprovalFlows(ctx, FindFlowsFilter{
        MerchantID: req.MerchantID,
        FlowType:   req.DocType,
        Amount:     req.Amount,
        IsActive:   true,
    })
    if err != nil {
        return nil, err
    }
    
    if len(flows) == 0 {
        return nil, errors.New("no approval flow found for this document")
    }
    
    // 2. Create approval request
    now := time.Now()
    firstFlow := flows[0]
    
    request := &ApprovalRequest{
        FlowID:       firstFlow.ID,
        DocID:        req.DocID,
        DocType:      req.DocType,
        DocCode:      req.DocCode,
        RequesterID:  req.RequesterID,
        MerchantID:   req.MerchantID,
        BranchID:     req.BranchID,
        Amount:       req.Amount,
        Status:       "PENDING",
        CurrentLevel: 1,
        TotalLevels:  len(flows),
        RequestedAt:  now,
        DueAt:        now.Add(time.Duration(firstFlow.AutoApproveSLA) * time.Hour),
        Notes:        req.Notes,
    }
    
    if err := s.repo.CreateRequest(ctx, request); err != nil {
        return nil, err
    }
    
    // 3. Notify approvers for level 1
    if err := s.notifyApprovers(ctx, request, firstFlow); err != nil {
        // Log error but don't fail request creation
        log.Printf("[approval] failed to notify approvers: %v", err)
    }
    
    return request, nil
}

2. Process Approval Action

func (s *approvalService) ProcessAction(ctx context.Context, req ProcessActionRequest) error {
    // 1. Get approval request
    request, err := s.repo.GetRequestByID(ctx, req.RequestID)
    if err != nil {
        return err
    }
    
    if request.Status != "PENDING" {
        return errors.New("request is not pending")
    }
    
    // 2. Verify approver has permission
    approver, err := s.repo.GetEffectiveApprover(ctx, req.ApproverID, request.CurrentLevel)
    if err != nil {
        return errors.New("approver not found or not authorized")
    }
    
    // 3. Create approval log
    log := &ApprovalLog{
        RequestID:    req.RequestID,
        ApproverID:   req.ApproverID,
        ApproverName: approver.FullName,
        Role:         approver.Role,
        LevelOrder:   request.CurrentLevel,
        ActionType:   req.ActionType,
        ActedBy:      req.ActedBy,
        OnBehalfOf:   approver.DelegatedTo,  // If delegated
        ActedAt:      time.Now(),
        Comments:     req.Comments,
        IPAddress:    req.IPAddress,
        UserAgent:    req.UserAgent,
    }
    
    return s.repo.Transaction(ctx, func(tx *gorm.DB) error {
        // 4. Save log
        if err := s.repo.CreateLog(ctx, log); err != nil {
            return err
        }
        
        // 5. Update request based on action
        switch req.ActionType {
        case "APPROVE":
            return s.handleApprove(ctx, request)
        case "REJECT":
            return s.handleReject(ctx, request)
        case "RETURN":
            return s.handleReturn(ctx, request)
        default:
            return errors.New("invalid action type")
        }
    })
}

func (s *approvalService) handleApprove(ctx context.Context, request *ApprovalRequest) error {
    // Check if all approvers at current level have approved
    flow, _ := s.repo.GetFlowByID(ctx, request.FlowID)
    
    if flow.ApprovalMode == "all" {
        // Count approvals at current level
        count, _ := s.repo.CountApprovalsAtLevel(ctx, request.ID, request.CurrentLevel)
        required, _ := s.repo.CountApproversAtLevel(ctx, flow.ID, request.CurrentLevel)
        
        if count < required {
            // Not all approved yet, just update last_action_at
            return s.repo.UpdateRequest(ctx, request.ID, map[string]interface{}{
                "last_action_at": time.Now(),
            })
        }
    }
    
    // Move to next level or complete
    if request.CurrentLevel < request.TotalLevels {
        // Move to next level
        nextFlow, _ := s.repo.GetFlowByLevel(ctx, request.FlowID, request.CurrentLevel+1)
        
        return s.repo.UpdateRequest(ctx, request.ID, map[string]interface{}{
            "current_level":   request.CurrentLevel + 1,
            "due_at":          time.Now().Add(time.Duration(nextFlow.AutoApproveSLA) * time.Hour),
            "last_action_at":  time.Now(),
        })
    } else {
        // Final approval
        return s.repo.UpdateRequest(ctx, request.ID, map[string]interface{}{
            "status":         "APPROVED",
            "last_action_at": time.Now(),
        })
    }
}

func (s *approvalService) handleReject(ctx context.Context, request *ApprovalRequest) error {
    return s.repo.UpdateRequest(ctx, request.ID, map[string]interface{}{
        "status":         "REJECTED",
        "last_action_at": time.Now(),
    })
}

func (s *approvalService) handleReturn(ctx context.Context, request *ApprovalRequest) error {
    return s.repo.UpdateRequest(ctx, request.ID, map[string]interface{}{
        "status":         "RETURNED",
        "current_level":  1,  // Reset to level 1
        "last_action_at": time.Now(),
    })
}

3. Auto-Approve Scheduler

func (s *approvalService) RunAutoApproveScheduler(ctx context.Context) {
    ticker := time.NewTicker(1 * time.Hour)
    defer ticker.Stop()
    
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            s.processAutoApprovals(ctx)
        }
    }
}

func (s *approvalService) processAutoApprovals(ctx context.Context) {
    // Find pending requests with exceeded SLA
    requests, err := s.repo.FindPendingRequestsExceededSLA(ctx)
    if err != nil {
        log.Printf("[auto-approve] error finding requests: %v", err)
        return
    }
    
    for _, request := range requests {
        if err := s.autoApproveRequest(ctx, request); err != nil {
            log.Printf("[auto-approve] error processing request %d: %v", request.ID, err)
        }
    }
}

func (s *approvalService) autoApproveRequest(ctx context.Context, request *ApprovalRequest) error {
    // Create auto-approve log
    log := &ApprovalLog{
        RequestID:  request.ID,
        ApproverID: "SYSTEM",
        Role:       "SYSTEM",
        LevelOrder: request.CurrentLevel,
        ActionType: "AUTO_APPROVE",
        ActedAt:    time.Now(),
        Comments:   "Auto-approved due to SLA exceeded",
    }
    
    return s.repo.Transaction(ctx, func(tx *gorm.DB) error {
        if err := s.repo.CreateLog(ctx, log); err != nil {
            return err
        }
        
        // Move to next level or complete
        if request.CurrentLevel < request.TotalLevels {
            nextFlow, _ := s.repo.GetFlowByLevel(ctx, request.FlowID, request.CurrentLevel+1)
            
            return s.repo.UpdateRequest(ctx, request.ID, map[string]interface{}{
                "current_level":   request.CurrentLevel + 1,
                "due_at":          time.Now().Add(time.Duration(nextFlow.AutoApproveSLA) * time.Hour),
                "last_action_at":  time.Now(),
            })
        } else {
            return s.repo.UpdateRequest(ctx, request.ID, map[string]interface{}{
                "status":         "APPROVED",
                "last_action_at": time.Now(),
            })
        }
    })
}

4. Delegation Handling

func (s *approvalService) GetEffectiveApprover(ctx context.Context, userID string, level int) (*Approver, error) {
    approver, err := s.repo.GetApproverByUserAndLevel(ctx, userID, level)
    if err != nil {
        return nil, err
    }
    
    // Check if delegation is active
    now := time.Now()
    if approver.DelegatedTo != "" {
        // Check delegation time range
        if (approver.DelegationFrom == nil || now.After(*approver.DelegationFrom)) &&
           (approver.DelegationTo == nil || now.Before(*approver.DelegationTo)) {
            // Delegation is active, get delegated user
            delegated, err := s.repo.GetApproverByUserID(ctx, approver.DelegatedTo)
            if err == nil && delegated.IsActive {
                // Return delegated approver but keep original info
                delegated.OnBehalfOf = approver.UserID
                return delegated, nil
            }
        }
    }
    
    return approver, nil
}

πŸ“‘ API Endpoints

1. Create Approval Request

POST /api/v1/approvals/requests
Content-Type: application/json

{
  "doc_id": "TRANS-001",
  "doc_type": "inventory_transfer",
  "doc_code": "TRANS-MC01-001",
  "requester_id": "user_001",
  "merchant_id": "MC01",
  "branch_id": "BR001",
  "amount": 5000000,
  "notes": "Transfer for branch restocking"
}

2. Get Pending Approvals (for Approver)

GET /api/v1/approvals/pending?approver_id=user_101
Response:
{
  "code": 200,
  "data": [
    {
      "id": 123,
      "doc_code": "TRANS-MC01-001",
      "doc_type": "inventory_transfer",
      "requester_name": "John Doe",
      "amount": 5000000,
      "currency": "IDR",
      "current_level": 1,
      "total_levels": 2,
      "requested_at": "2025-10-13T10:00:00Z",
      "due_at": "2025-10-14T10:00:00Z",
      "notes": "Transfer for branch restocking"
    }
  ]
}

3. Process Approval Action

POST /api/v1/approvals/requests/123/action
Content-Type: application/json

{
  "approver_id": "user_101",
  "action_type": "APPROVE",
  "comments": "Approved for processing"
}

4. Get Approval History

GET /api/v1/approvals/requests/123/logs
Response:
{
  "code": 200,
  "data": [
    {
      "id": 1,
      "approver_name": "Jane Smith",
      "role": "CHECKER",
      "level_order": 1,
      "action_type": "APPROVE",
      "acted_at": "2025-10-13T11:00:00Z",
      "comments": "Approved for processing"
    }
  ]
}

πŸ§ͺ Testing Scenarios

Scenario 1: Two-Level Approval

func TestTwoLevelApproval(t *testing.T) {
    // 1. Create request
    req := createApprovalRequest(docID, "inventory_transfer", 5000000)
    assert.Equal(t, "PENDING", req.Status)
    assert.Equal(t, 1, req.CurrentLevel)
    
    // 2. Level 1 approve
    processAction(req.ID, "user_101", "APPROVE")
    
    req = getRequest(req.ID)
    assert.Equal(t, "PENDING", req.Status)
    assert.Equal(t, 2, req.CurrentLevel)
    
    // 3. Level 2 approve
    processAction(req.ID, "user_201", "APPROVE")
    
    req = getRequest(req.ID)
    assert.Equal(t, "APPROVED", req.Status)
}

Scenario 2: Rejection at Level 1

func TestRejectionAtLevel1(t *testing.T) {
    req := createApprovalRequest(docID, "inventory_transfer", 5000000)
    
    // Reject at level 1
    processAction(req.ID, "user_101", "REJECT")
    
    req = getRequest(req.ID)
    assert.Equal(t, "REJECTED", req.Status)
    assert.Equal(t, 1, req.CurrentLevel)
}

Scenario 3: Auto-Approve SLA

func TestAutoApproveSLA(t *testing.T) {
    req := createApprovalRequest(docID, "inventory_transfer", 5000000)
    
    // Set due_at to past
    updateDueAt(req.ID, time.Now().Add(-1*time.Hour))
    
    // Run scheduler
    runAutoApproveScheduler()
    
    req = getRequest(req.ID)
    assert.Equal(t, 2, req.CurrentLevel)  // Moved to level 2
    
    // Check log
    logs := getApprovalLogs(req.ID)
    assert.Equal(t, "AUTO_APPROVE", logs[0].ActionType)
}

πŸ’‘ Best Practices

DO βœ…

  • Define clear approval flows per document type & amount range
  • Use role-based approvers untuk flexibility
  • Implement delegation untuk coverage saat approver unavailable
  • Set reasonable SLA untuk auto-approve
  • Log semua actions dengan IP & user agent
  • Notify approvers via email/in-app notification
  • Implement approval dashboard untuk monitoring
  • Use transaction untuk atomic operations

DON’T ❌

  • Jangan hardcode approver user IDs
  • Jangan skip audit logging
  • Jangan allow approval tanpa authentication
  • Jangan ignore SLA monitoring
  • Jangan allow self-approval (requester = approver)
  • Jangan skip validation untuk approval permission

πŸ†˜ Troubleshooting

Problem: Approval Stuck

Symptoms: Request tidak bergerak meski sudah approve Solution:
-- Check approval logs
SELECT * FROM approval_logs 
WHERE request_id = ? 
ORDER BY acted_at DESC;

-- Check current level vs total levels
SELECT current_level, total_levels, status 
FROM approval_requests 
WHERE id = ?;

-- Check approval mode
SELECT approval_mode, required_role 
FROM approval_flows 
WHERE id = (SELECT flow_id FROM approval_requests WHERE id = ?);

Inventory Flow

Approval untuk inventory transfers

Transaction Flow

Approval untuk void transactions

RBAC System

Role-based access control

Need Help? Contact backend team atau check GitHub Issues