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
| Parameter | Type | Required | Description |
|---|---|---|---|
loanQuoteId | long | Yes | ID of the loan application |
userId | long | Yes | ID of the customer |
Authorization
- Required Role:
RELATIONSHIP_MANAGER - Access Level: Must have initiated the loan application
Workflow Actions
When this command executes, it:
-
Validates Loan Application
- Checks all required fields are complete
- Verifies loan product configuration
- Validates loan amount within limits
-
Verifies KYC Status
var kyc = await GetCustomerKycInformation(userId);
// Ensures:
// - Identity verification: VERIFICATION_SUCCESSFUL
// - Address verification: VERIFICATION_SUCCESSFUL
// - Email/Mobile: VERIFICATION_SUCCESSFUL -
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 -
Sets Initial Approval Level
- Sets
CurrentApprovalLevel = SelfServiceApprovalLevel.Branch - Sets
ApprovalState = SelfServiceLoanApprovalState.PendingApproval
- Sets
-
Creates Approval History
- Records initiation timestamp
- Logs initiating user
- Creates audit trail
-
Triggers BPMN Workflow
- Starts loan approval process
- Sends notifications to Branch Manager
- Creates workflow instance
-
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
| Parameter | Type | Required | Description |
|---|---|---|---|
loanQuoteId | long | Yes | ID of the loan application |
approverComment | string | No | Approver's comment/justification |
Authorization
Role-Based Authorization:
The command uses GetAllUserApprovalLevelsAsync to verify:
-
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
- Branch →
-
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
-
Validates Authorization
- Checks user role matches current level
- Verifies branch/area/division access
- Returns error if unauthorized
-
Records Approval
- Creates approval history entry
- Logs approver details
- Stores approval timestamp
- Saves approver comment
-
Advances to Next Level
- Determines next approval level
- Updates
CurrentApprovalLevel - Maintains
ApprovalState = PendingApproval
-
Checks for Final Approval
- If MD approval and amount ≤ ₦4M: Set
ApprovalState = FullyApproved - If GMD approval: Set
ApprovalState = FullyApproved - Triggers disbursement preparation
- If MD approval and amount ≤ ₦4M: Set
-
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
| Parameter | Type | Required | Description |
|---|---|---|---|
loanQuoteId | long | Yes | ID of the loan application |
rejectionReason | string | Yes | Detailed reason for rejection |
Authorization
Same authorization rules as ApproveLoanRequestCommand - user must have appropriate role and access.
Workflow Actions
-
Validates Authorization (same as approve)
-
Records Rejection
- Creates rejection history entry
- Logs rejecting user
- Stores rejection reason
- Records rejection timestamp
-
Updates Loan Status
- Sets
ApprovalState = SelfServiceLoanApprovalState.REJECTED - Stops approval workflow
- Prevents further approvals
- Sets
-
Sends Notifications
- Notifies customer of rejection
- Emails rejection reason
- Alerts relationship manager
-
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
| Parameter | Type | Required | Description |
|---|---|---|---|
loanQuoteId | long | Yes | ID of the loan application |
returnReason | string | Yes | Reason for returning to previous level |
Authorization
Requires: [CommandAuthorisation(CommandAuthorisationType.Admin_Required)]
This is a privileged operation that allows workflow corrections.
Workflow Actions
-
Validates Current Level
- Cannot return if at "Not Started" or "Branch" (no previous level)
-
Moves Back One Level
Current: Area → Returns to: Branch
Current: Division → Returns to: Area
Current: CreditAdmin → Returns to: Division
etc. -
Updates Status
- Sets
ApprovalState = Returned - Updates
CurrentApprovalLevelto previous level
- Sets
-
Records Return
- Creates return history entry
- Logs returning user
- Stores return reason
-
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>
);
}
Related Commands
- GetCustomerLoanDetailsQuery - Get loan application details
- SetRecommendedLoanAmountCommand - Branch Manager sets recommended amount
- SetFinalLoanAmountCommand - MD/GMD sets final amount
- SetDisbursementDetailsCommand - Configure disbursement
- DisburseLoanRequestCommand - Execute disbursement
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