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