Skip to main content

Loan Approval Workflow Commands

Overview

These commands manage the multi-level loan approval process, from initiation through final approval or rejection. The workflow is hierarchical and role-based, ensuring proper authorization at each level.

Approval Hierarchy

Not Started → Branch → Area → Division → Credit Admin → Head Credit Admin → Control Officer → MD/GMD → Fully Approved

Conditional Logic:

  • Loans ≤ ₦4,000,000: Stop at MD approval
  • Loans > ₦4,000,000: Require GMD approval

Commands

1. InitiateApproveLoanRequestCommand

Purpose: Start the loan approval workflow and trigger BPMN process.

Request

{
"cmd": "InitiateApproveLoanRequestCommand",
"data": "{\"loanQuoteId\":\"110\",\"userId\":\"129\"}"
}

Parameters

ParameterTypeRequiredDescription
loanQuoteIdlongYesID of the loan application
userIdlongYesID of the customer

Authorization

  • Required Role: RELATIONSHIP_MANAGER
  • Access Level: Must have initiated the loan application

Workflow Actions

When this command executes, it:

  1. Validates Loan Application

    • Checks all required fields are complete
    • Verifies loan product configuration
    • Validates loan amount within limits
  2. Verifies KYC Status

    var kyc = await GetCustomerKycInformation(userId);
    // Ensures:
    // - Identity verification: VERIFICATION_SUCCESSFUL
    // - Address verification: VERIFICATION_SUCCESSFUL
    // - Email/Mobile: VERIFICATION_SUCCESSFUL
  3. Calculates Approval Route

    var loanProcessingInfo = await GetAllUserApprovalLevelsAsync(loanRequest);
    // Returns:
    // - UserAssociatedRoles: User's approval roles
    // - UserAssociatedBranches: Branch/Area/Division hierarchy
    // - LoanAssociatedBranch: Branch handling this loan
    // - UserApprovalLevels: Levels user can approve
  4. Sets Initial Approval Level

    • Sets CurrentApprovalLevel = SelfServiceApprovalLevel.Branch
    • Sets ApprovalState = SelfServiceLoanApprovalState.PendingApproval
  5. Creates Approval History

    • Records initiation timestamp
    • Logs initiating user
    • Creates audit trail
  6. Triggers BPMN Workflow

    • Starts loan approval process
    • Sends notifications to Branch Manager
    • Creates workflow instance
  7. Sends Notifications

    • Email to Branch Manager
    • SMS notification
    • Dashboard alert

Response

{
"ok": true,
"statusCode": "00",
"message": "Loan approval initiated successfully",
"outData": {
"LoanQuoteId": 110,
"CurrentApprovalLevel": "Branch",
"CurrentApprovalLevelDesc": "Branch Manager Approval",
"ApprovalState": "PendingApproval",
"ApprovalStateDesc": "Pending Approval",
"NextApprover": "Branch Manager",
"NextApproverRole": "BRANCH_MANAGER",
"WorkflowProcessId": "LOAN_APPROVAL_001",
"InitiatedAt": "2026-01-11T10:00:00Z",
"InitiatedBy": "John Doe",
"LoanAmount": 500000,
"ApprovalRoute": [
"Branch",
"Area",
"Division",
"CreditAdmin",
"HeadCreditAdmin",
"ControlOfficer",
"MD"
]
}
}

Error Responses

KYC Incomplete:

{
"ok": false,
"statusCode": "04",
"message": "KYC verification is incomplete. Identity verification pending.",
"outData": {
"MissingKyc": ["IdentityVerification"]
}
}

Unauthorized:

{
"ok": false,
"statusCode": "04",
"message": "You are not authorized to initiate approval. Relationship Manager role required."
}

Usage Example

// React component for initiating approval
async function initiateLoanApproval(loanQuoteId, userId) {
try {
// First, verify KYC is complete
const kycResponse = await api.query({
cmd: "GetCustomerKycInformationQuery",
data: JSON.stringify({ userId })
});

if (!kycResponse.ok) {
throw new Error("Cannot verify KYC status");
}

const kyc = kycResponse.outData.KycInformations;
const isKycComplete =
kyc.IdentityKyc.VerificationStatus === "VERIFICATION_SUCCESSFUL" &&
kyc.AddressKyc.VerificationStatus === "VERIFICATION_SUCCESSFUL";

if (!isKycComplete) {
alert("KYC verification must be complete before initiating approval");
return;
}

// Initiate approval
const response = await api.query({
cmd: "InitiateApproveLoanRequestCommand",
data: JSON.stringify({ loanQuoteId, userId })
});

if (response.ok) {
console.log("✅ Approval workflow started");
console.log(`Next approver: ${response.outData.NextApprover}`);
console.log(`Approval route:`, response.outData.ApprovalRoute);

// Redirect to loan tracking page
navigate(`/loans/${loanQuoteId}/tracking`);
} else {
alert(`${response.message}`);
}
} catch (error) {
console.error("Error initiating approval:", error);
alert("Failed to initiate loan approval");
}
}

2. ApproveLoanRequestCommand

Purpose: Approve loan at the current approval level and advance to the next level.

Request

{
"cmd": "ApproveLoanRequestCommand",
"data": "{\"loanQuoteId\":\"110\",\"approverComment\":\"Loan approved based on customer profile\"}"
}

Parameters

ParameterTypeRequiredDescription
loanQuoteIdlongYesID of the loan application
approverCommentstringNoApprover's comment/justification

Authorization

Role-Based Authorization:

The command uses GetAllUserApprovalLevelsAsync to verify:

  1. User has required role for current approval level:

    • Branch → BRANCH_MANAGER
    • Area → AREA_MANAGER
    • Division → DIVISION_MANAGER
    • CreditAdmin → CREDIT_ADMIN
    • HeadCreditAdmin → HEAD_CREDIT_ADMIN
    • ControlOfficer → CONTROL_OFFICER
    • MD → MD
    • GMD → GMD
  2. User has access to the organizational unit:

    • Branch level: User must be associated with the loan's branch
    • Area level: User must have access to an area containing the loan's branch
    • Division level: User must have access to a division containing the loan's area
    • Organization level (MD/GMD): No branch restriction

Approval Logic

if (currentApprovalLevel == SelfServiceApprovalLevel.Branch)
{
// Check role
var isRoleAllowed = loanProcessingInfo.UserAssociatedRoles
.Any(s => s.SelfServiceRole.RoleId.Equals(SelfServiceRoleEnum.BRANCH_MANAGER));

// Check branch access
var isBranchAllowed = loanProcessingInfo.UserAssociatedBranches
.Any(s => s.BranchId == loanQuote.SelfServiceBranchId);

// Both must be true
var canApprove = isRoleAllowed && isBranchAllowed;

if (canApprove)
{
// Approve and move to next level
loanQuote.CurrentApprovalLevel = SelfServiceApprovalLevel.Area;
}
}

Workflow Actions

  1. Validates Authorization

    • Checks user role matches current level
    • Verifies branch/area/division access
    • Returns error if unauthorized
  2. Records Approval

    • Creates approval history entry
    • Logs approver details
    • Stores approval timestamp
    • Saves approver comment
  3. Advances to Next Level

    • Determines next approval level
    • Updates CurrentApprovalLevel
    • Maintains ApprovalState = PendingApproval
  4. Checks for Final Approval

    • If MD approval and amount ≤ ₦4M: Set ApprovalState = FullyApproved
    • If GMD approval: Set ApprovalState = FullyApproved
    • Triggers disbursement preparation
  5. Sends Notifications

    • Notifies next approver
    • Updates customer on progress
    • Logs activity

Response

{
"ok": true,
"statusCode": "00",
"message": "Loan approved at Branch level",
"outData": {
"LoanQuoteId": 110,
"PreviousLevel": "Branch",
"PreviousLevelDesc": "Branch Manager",
"CurrentLevel": "Area",
"CurrentLevelDesc": "Area Manager",
"ApprovalState": "PendingApproval",
"NextApprover": "Area Manager",
"ApprovedBy": {
"UserId": 129,
"Name": "John Doe",
"Role": "BRANCH_MANAGER",
"ApprovedAt": "2026-01-11T10:30:00Z",
"Comment": "Loan approved based on customer profile"
},
"TimeInLevel": {
"Hours": 2.5,
"Description": "Spent 2.5 hours at Branch level"
},
"ApprovalHistory": [
{
"ApprovalLevel": "Branch",
"ApprovedBy": "John Doe",
"ApprovedAt": "2026-01-11T10:30:00Z",
"Comment": "Loan approved based on customer profile",
"TimeInLevel": "2.5 hours"
}
]
}
}

Fully Approved Response (at final level):

{
"ok": true,
"statusCode": "00",
"message": "Loan fully approved",
"outData": {
"LoanQuoteId": 110,
"ApprovalState": "FullyApproved",
"ApprovalStateDesc": "Fully Approved",
"FinalApprover": "Managing Director",
"FinalApprovedAt": "2026-01-11T14:00:00Z",
"NextStep": "Set Disbursement Details",
"TotalApprovalTime": {
"Hours": 25.5,
"Days": 1.06,
"Description": "Total time from initiation to final approval"
}
}
}

Error Responses

Unauthorized:

{
"ok": false,
"statusCode": "04",
"message": "You are not authorized to approve this request because you do not have the required role.",
"outData": {
"RequiredRole": "BRANCH_MANAGER",
"UserRoles": ["RELATIONSHIP_MANAGER"],
"CurrentLevel": "Branch"
}
}

No Branch Access:

{
"ok": false,
"statusCode": "04",
"message": "You are not authorized to approve this request because you do not have the required branch access.",
"outData": {
"RequiredBranch": "Lagos Branch",
"UserBranches": ["Abuja Branch", "Port Harcourt Branch"]
}
}

Usage Example

// Approve loan with confirmation dialog
async function approveLoan(loanQuoteId) {
const comment = prompt("Enter approval comment (optional):");

const response = await api.query({
cmd: "ApproveLoanRequestCommand",
data: JSON.stringify({
loanQuoteId,
approverComment: comment || "Approved"
})
});

if (response.ok) {
const data = response.outData;

if (data.ApprovalState === "FullyApproved") {
alert(`✅ Loan fully approved! Ready for disbursement.`);
navigate(`/loans/${loanQuoteId}/disbursement`);
} else {
alert(`✅ Approved at ${data.PreviousLevelDesc} level.\nNext: ${data.NextApprover}`);
navigate(`/loans/${loanQuoteId}`);
}
} else {
alert(`${response.message}`);
}
}

3. RejectLoanRequestCommand

Purpose: Reject loan application at current approval level.

Request

{
"cmd": "RejectLoanRequestCommand",
"data": "{\"loanQuoteId\":\"110\",\"rejectionReason\":\"Insufficient income to support loan repayment\"}"
}

Parameters

ParameterTypeRequiredDescription
loanQuoteIdlongYesID of the loan application
rejectionReasonstringYesDetailed reason for rejection

Authorization

Same authorization rules as ApproveLoanRequestCommand - user must have appropriate role and access.

Workflow Actions

  1. Validates Authorization (same as approve)

  2. Records Rejection

    • Creates rejection history entry
    • Logs rejecting user
    • Stores rejection reason
    • Records rejection timestamp
  3. Updates Loan Status

    • Sets ApprovalState = SelfServiceLoanApprovalState.REJECTED
    • Stops approval workflow
    • Prevents further approvals
  4. Sends Notifications

    • Notifies customer of rejection
    • Emails rejection reason
    • Alerts relationship manager
  5. Optional: Allow Re-application

    • Depending on policy, may allow customer to reapply after addressing rejection reasons

Response

{
"ok": true,
"statusCode": "00",
"message": "Loan rejected",
"outData": {
"LoanQuoteId": 110,
"RejectedAt": "Branch",
"RejectedAtDesc": "Branch Manager Level",
"ApprovalState": "Rejected",
"RejectionReason": "Insufficient income to support loan repayment",
"RejectedBy": {
"UserId": 129,
"Name": "Jane Smith",
"Role": "BRANCH_MANAGER",
"RejectedAt": "2026-01-11T11:00:00Z"
},
"TimeBeforeRejection": {
"Hours": 3.0,
"Description": "Loan was rejected after 3.0 hours"
},
"CustomerNotificationSent": true
}
}

Usage Example

// Reject loan with confirmation
async function rejectLoan(loanQuoteId) {
const reason = prompt("Enter detailed rejection reason:");

if (!reason || reason.trim().length < 10) {
alert("Please provide a detailed rejection reason (minimum 10 characters)");
return;
}

const confirmed = confirm(`Are you sure you want to reject this loan?\n\nReason: ${reason}`);

if (!confirmed) return;

const response = await api.query({
cmd: "RejectLoanRequestCommand",
data: JSON.stringify({
loanQuoteId,
rejectionReason: reason
})
});

if (response.ok) {
alert(`✅ Loan rejected at ${response.outData.RejectedAtDesc} level.\nCustomer has been notified.`);
navigate("/loans/pending");
} else {
alert(`${response.message}`);
}
}

4. ReturnToPreviousLevelCommand

Purpose: Return loan to previous approval level for additional review or documentation.

Request

{
"cmd": "ReturnToPreviousLevelCommand",
"data": "{\"loanQuoteId\":\"110\",\"returnReason\":\"Need additional guarantor documentation\"}"
}

Parameters

ParameterTypeRequiredDescription
loanQuoteIdlongYesID of the loan application
returnReasonstringYesReason for returning to previous level

Authorization

Requires: [CommandAuthorisation(CommandAuthorisationType.Admin_Required)]

This is a privileged operation that allows workflow corrections.

Workflow Actions

  1. Validates Current Level

    • Cannot return if at "Not Started" or "Branch" (no previous level)
  2. Moves Back One Level

    Current: Area → Returns to: Branch
    Current: Division → Returns to: Area
    Current: CreditAdmin → Returns to: Division
    etc.
  3. Updates Status

    • Sets ApprovalState = Returned
    • Updates CurrentApprovalLevel to previous level
  4. Records Return

    • Creates return history entry
    • Logs returning user
    • Stores return reason
  5. Notifies Previous Approver

    • Sends notification to previous level approver
    • Includes return reason
    • Requests re-review

Response

{
"ok": true,
"statusCode": "00",
"message": "Loan returned to previous level",
"outData": {
"LoanQuoteId": 110,
"ReturnedFrom": "Area",
"ReturnedFromDesc": "Area Manager",
"ReturnedTo": "Branch",
"ReturnedToDesc": "Branch Manager",
"ReturnReason": "Need additional guarantor documentation",
"ReturnedBy": {
"UserId": 145,
"Name": "Admin User",
"Role": "ADMIN",
"ReturnedAt": "2026-01-11T12:00:00Z"
},
"PreviousApproverNotified": true
}
}

Usage Example

// Admin function to return loan
async function returnLoanToPreviousLevel(loanQuoteId) {
const reason = prompt("Enter reason for returning loan to previous level:");

if (!reason) {
alert("Return reason is required");
return;
}

const response = await api.query({
cmd: "ReturnToPreviousLevelCommand",
data: JSON.stringify({
loanQuoteId,
returnReason: reason
})
});

if (response.ok) {
const data = response.outData;
alert(`✅ Loan returned from ${data.ReturnedFromDesc} to ${data.ReturnedToDesc}\nPrevious approver has been notified.`);
navigate(`/loans/${loanQuoteId}`);
} else {
alert(`${response.message}`);
}
}

Approval State Visualization

React Component Example

function LoanApprovalTracker({ loanQuoteId }) {
const [loanDetails, setLoanDetails] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
async function fetchLoanDetails() {
const response = await api.query({
cmd: "GetCustomerLoanDetailsQuery",
data: JSON.stringify({ loanQuoteId })
});

if (response.ok) {
setLoanDetails(response.outData);
}
setLoading(false);
}

fetchLoanDetails();
}, [loanQuoteId]);

if (loading) return <div>Loading...</div>;

const approvalLevels = ["Branch", "Area", "Division", "CreditAdmin", "HeadCreditAdmin", "ControlOfficer", "MD", "GMD"];
const currentLevel = loanDetails.CurrentApprovalLevel;
const approvalState = loanDetails.ApprovalState;

return (
<div className="approval-tracker">
<h3>Approval Progress</h3>

<div className="approval-status">
<span className={`status-badge ${approvalState.toLowerCase()}`}>
{approvalState}
</span>
</div>

<div className="approval-timeline">
{approvalLevels.map((level, index) => {
const isPast = approvalLevels.indexOf(currentLevel) > index;
const isCurrent = currentLevel === level;
const isFuture = approvalLevels.indexOf(currentLevel) < index;

return (
<div key={level} className={`timeline-item ${
isPast ? 'completed' : isCurrent ? 'current' : 'pending'
}`}>
<div className="timeline-marker">
{isPast ? '✓' : isCurrent ? '●' : '○'}
</div>
<div className="timeline-content">
<div className="level-name">{level}</div>
{isCurrent && (
<div className="level-actions">
<button onClick={() => approveLoan(loanQuoteId)}>
Approve
</button>
<button onClick={() => rejectLoan(loanQuoteId)}>
Reject
</button>
</div>
)}
</div>
</div>
);
})}
</div>

{loanDetails.ApprovalHistory && (
<div className="approval-history">
<h4>Approval History</h4>
{loanDetails.ApprovalHistory.map((history, index) => (
<div key={index} className="history-item">
<strong>{history.ApprovalLevel}</strong>: {history.Action} by {history.ApprovedBy}
<br />
<small>{new Date(history.ApprovedAt).toLocaleString()}</small>
{history.Comment && <p>{history.Comment}</p>}
</div>
))}
</div>
)}
</div>
);
}


Best Practices

DO verify KYC is complete before initiating approval
DO provide detailed comments when approving
DO provide clear rejection reasons
DO check user permissions before showing approval buttons
DO show approval history to users

DON'T allow approval without proper authorization
DON'T skip approval levels
DON'T reject without detailed reason
DON'T approve loans with incomplete KYC


Last Updated: January 11, 2026
Version: 1.0