The withdrawal section is a feature that allows users to withdraw funds from their investment goals. The user by default sees the Total Portfolio Balance, Amount Available to Withdraw, and Locked Balance. The user can also see the short term and long term goals they have set up and the current value of each goal.
The withdraw section gets data from the endpoint /api:IaYS-M9j/withdrawal/load which runs App\Http\Controllers\Billing\PaymentsController@withdrawLoad that handles App\Actions\GetPortfolioDataAction::make()->handle(Auth::id()); and displays the user's Portfolio Balance, Available to Withdraw, and Locked Balance cards.
Here is an example of the API response:
{
"portfolioBalance": {
"value": 1456753.13,
"formatted": "1,456,753.13"
},
"availableToWithdraw": {
"value": 1456753.13,
"formatted": "1,456,753.13"
},
"lockedBalance": {
"value": 0,
"formatted": "0.00"
},
"goals": {
"short_term": [
{
...
}
],
"long_term": [
{
...
}
]
},
"total_invested": 1336483,
"total_interest": 120270.13,
"portfolio_value": 1456753.13,
"goals_ff": {
...
},
"savings": 100000,
"goals_ef": {
"ef": "no"
},
"goals_cf": [
{
...
}
],
"goals_completed": [
{
...
}
],
"goals_inactive": []
}
In this section, we take interest in the portfolioBalance,availableToWithdraw, and lockedBalance values. The data is retrieved at:
class GetPortfolioDataAction
{
use AsAction;
public function handle(int $userId): array
{
$portfolioSummary = $this->getPortfolioSummary($userId);
#
#
'portfolioBalance' => [
'value' => $portfolioSummary->totalPortfolioValue,
'formatted' => number_format($portfolioSummary->totalPortfolioValue, 2)
],
'availableToWithdraw' => [
'value' => $portfolioSummary->availableToWithdraw,
'formatted' => number_format($portfolioSummary->availableToWithdraw, 2)
],
'lockedBalance' => [
'value' => $portfolioSummary->lockedBalance,
'formatted' => number_format($portfolioSummary->lockedBalance, 2)
],
'goals' => [
'short_term' => $detailedGoalsData['custom_goals']['short_term'],
'long_term' => $detailedGoalsData['custom_goals']['long_term'],
],
The getPortfolioSummary(int $userId) returns a PortfolioSummaryDTO
To calculate the Summary, we query for all user goals, and for each goal,
foreach ($goals as $goal) {
$currentValue = MaGetGoalCurrentValue::make()->handle($goal->id);
#
#
if ($goal->isLongTerm()) {
$longTermGoals[] = $goalSummary;
$longTermTotal = $longTermTotal->plus($currentValue);
} else {
$shortTermGoals[] = $goalSummary;
$shortTermTotal = $shortTermTotal->plus($currentValue);
}
if ($goal->hasPassedLockInPeriod()) {
$availableToWithdraw = $availableToWithdraw->plus($currentValue);
} else {
$lockedBalance = $lockedBalance->plus($currentValue);
}
}
$totalPortfolioValue = $longTermTotal->plus($shortTermTotal);
The above introduces us to two new Actions, App\Actions\MultiAsset\MaGetGoalCurrentValue which automatically calculates the goal's current value.
Short term goals are goals that the user has set up to achieve within a short period of time. The user can see these goals and the current value of each goal, and whether the goal has a withdrawal ppenalty. The data is retrieved at:
class GetPortfolioDataAction
{
public function getPortfolioSummary(int $userId): PortfolioSummaryDTO
{
$goals = Goal::query()
->where('users_id', $userId)
->get();
#
#
#
$shortTermTotal = BigDecimal::of(0);
#
#
#
$shortTermGoals = [];
#
#
#
foreach ($goals as $goal) {
$currentValue = MaGetGoalCurrentValue::make()->handle($goal->id);
$principle = MaGetPrincipleAmount::make()->handle($goal->id);
$totalInvested = $totalInvested->plus($principle);
$goalSummary = $this->buildGoalSummary($goal, $currentValue, $principle);
if ($goal->isLongTerm()) {
$longTermGoals[] = $goalSummary;
$longTermTotal = $longTermTotal->plus($currentValue);
} else {
$shortTermGoals[] = $goalSummary;
$shortTermTotal = $shortTermTotal->plus($currentValue);
}
The associated response is:
"goals": {
"short_term": [
{
"id": 1470,
"title": "Emergency Fund",
"pool": "Short Term Pool",
"hasPassedLockInPeriod": true,
"principle": {
"value": "418400.0000000000",
"formatted": "418,400.00"
},
"currentValue": {
"value": 454884.99084922095,
"formatted": "454,884.99"
},
"target": {
"value": 420000,
"formatted": "420,000.00"
},
"withdrawable": {
"value": 454884.99084922095,
"formatted": "454,884.99"
},
"penalty": {
"value": 0,
"formatted": "0.00",
"details": [
]
},
"timeToTarget": "-1 year and 12 months",
"originalTimeToTarget": "1 year and 6 months",
"progress": {
"value": 108.30595020219546,
"formatted": "108.3%"
},
"catchUp": {
"amount": 362499.8961788445,
"time": "On track!",
"amount_desc": "362,499.90",
"message": "ON TRACK",
"onTrack": true
}
}
],
Long term goals are goals that the user has set up to achieve within a long period of time. The user can see these goals and the current value of each goal, and whether the goal has a withdrawal penalty. The data is retrieved at:
class GetPortfolioDataAction
{
public function getPortfolioSummary(int $userId): PortfolioSummaryDTO
{
$goals = Goal::query()
->where('users_id', $userId)
->get();
#
#
#
$longTermTotal = BigDecimal::of(0);
#
#
#
$longTermGoals = [];
#
#
#
foreach ($goals as $goal) {
$currentValue = MaGetGoalCurrentValue::make()->handle($goal->id);
$principle = MaGetPrincipleAmount::make()->handle($goal->id);
$totalInvested = $totalInvested->plus($principle);
$goalSummary = $this->buildGoalSummary($goal, $currentValue, $principle);
if ($goal->isLongTerm()) {
$longTermGoals[] = $goalSummary;
$longTermTotal = $longTermTotal->plus($currentValue);
} else {
$shortTermGoals[] = $goalSummary;
$shortTermTotal = $shortTermTotal->plus($currentValue);
}
The associated response is:
"long_term": [
{
"id": 1469,
"title": "Financial Freedom",
"pool": "Long Term Pool",
"hasPassedLockInPeriod": true,
"principle": {
"value": "630000.0000000000",
"formatted": "630,000.00"
},
"currentValue": {
"value": 683867.4944472861,
"formatted": "683,867.49"
},
"target": {
"value": 60000000,
"formatted": "60,000,000.00"
},
"withdrawable": {
"value": 640270.0175999863,
"formatted": "640,270.02"
},
"penalty": {
"value": 43597.47684729984,
"formatted": "43,597.48",
"details": {
"interest": 38597.47684729984,
"logs": {
"dateLastYear": "06/03/2024 05:39:39",
"unitsUptoLastYear": "226547.4528508110",
"unitPriceAsAtLastYear": 1.184167,
"goalValueAsAtLastYear": 268270.0175999863,
"goalValueFromLastYearToDate": 415597.47684729984,
"unitPriceNow": 1.2925,
"currentGoalValue": 683867.4944472861,
"depositsInLastOneYear": "377000.0000000000",
"interestInLastOneYear": 38597.47684729984,
"earlyWithdrawalFee": 5000
}
}
},
"timeToTarget": "19 years and 5 months",
"originalTimeToTarget": "40 years and 4 months",
"progress": {
"value": 1.1397791574121436,
"formatted": "1.1%"
},
"catchUp": {
"amount": 627054.9903835729,
"time": "On track!",
"amount_desc": "627,054.99",
"message": "ON TRACK",
"onTrack": true
}
},
Clicking withdraw on any of the goals opens a modal that allows the user to initiate a withdrawal.
The user sees the current time to goal achievement, the current investment in the goal, and the goal details, which are the target amount, the current value, goal type, and the penalty if the user withdraws before the lock-in period. Clicking on proceed takes the user to the next step.
The user can input the amount they want to withdraw. The user can also see the penalty amount if they withdraw before the lock-in period, and the maximum amount they can withdraw. Clicking calculate impact takes the user to the next step.
The previous step hits /api:IaYS-M9j/withdrawal/calculate which runs App\Http\Controllers\Billing\PaymentsController@withdrawalCalculate that handles App\Actions\Admin\CalculateWithdrawalPenalty::make()->calculatePenaltyForGoal($goal->id); and returns the impact of the withdrawal on the user's goals.
A sample response is:
{
"amount": 8000,
"current": "February 2025",
"future": "February 2025",
"setback": "1 month",
"title": "Emergency Fund",
"penalty": {
"summary": {
"totalPenalty": {
"amount": 0,
"formatted": "KES 0.00"
},
"goalType": "Short Term",
"goalDetails": {
"currentValue": {
"amount": 454884.99084922095,
"formatted": "KES 454,884.99"
},
"targetAmount": {
"amount": 420000,
"formatted": "KES 420,000.00"
}
}
},
"breakdown": {
"withdrawalFee": {
"amount": 0,
"formatted": "KES 0.00",
"description": "Additional withdrawal fee within 3-month period"
}
},
"lockInPeriod": {
"totalDays": 90,
"daysRemaining": 20243.146373339943,
"isLocked": true,
"description": "Short-term goals have a 90-day lock-in period"
}
},
"summary": {
"targetGoalAmount": {
"value": 420000,
"formatted": "420,000.00"
},
"currentGoalBalance": {
"value": "454884.99084922",
"formatted": "454,884.99"
},
"amountToWithdraw": {
"value": 8000,
"formatted": "8,000.00"
},
"earlyWithdrawalPenalty": {
"value": 0,
"formatted": "0.00"
},
"otherPenalties": "KES 0.00",
"closingGoalBalance": {
"value": 446884.99,
"formatted": "446,884.99"
}
}
}
The user can see the target goal amount, the current goal balance, the amount to withdraw, the early withdrawal penalty, and the closing goal balance. This gives the user an overview of the impact of the withdrawal on the goal.
Clicking on proceed takes the user to the next step.
Confirming the withdrawal hits /api:IaYS-M9j/withdrawal/request which runs App\Http\Controllers\Billing\PaymentsController@withdrawalRequest that handles App\Actions\Billing\InitiateWithdrawalRequestAction::run(); and initiates the withdrawal request.
The user sees a success message and the withdrawal is initiated.
In the backend, the InitiateWithdrawalRequestAction is responsible for initiating the withdrawal request. The action is as follows:
class InitiateWithdrawalRequestAction
{
use AsAction;
public function handle(int $user_id, int $goal_id, float $target_goal_amount, float $current_goal_balance, float $penalty, float $amount, bool $send_to_user)
{
$user = User::find($user_id);
$user_transaction = AddTransactionRecordAction::make()->handle(
user_id: $user->id,
allocations: [new UserTransactionAllocationData(id: $goal_id, allocation: $amount)],
transaction_type: TransactionActionEnum::WITHDRAWAL
);
UserWithdrawalRequestJob::dispatch($user, $user_transaction, $goal_id, $target_goal_amount, $current_goal_balance, $penalty, $send_to_user);
}
}
The AddTransactionRecordAction is responsible for adding a transaction record to the user's account.
The UserWithdrawalRequestJob is responsible for dispatching the withdrawal request email to user for confirmation and sending the withdrawal request to the admin for approval.
class UserWithdrawalRequestJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(
private readonly User $user,
private readonly UserTransaction $user_transaction,
private readonly int $goal_id,
private readonly float $target_goal_amount,
private readonly float $current_goal_balance,
private readonly float $penalty,
private readonly bool $send_to_user = false
)
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
if ($this->send_to_user){
$params = [
'firstname' => $this->user->firstname,
'goalid' => $this->goal_id,
'targetGoalAmount' => number_format($this->target_goal_amount, 2),
'currentGoalBalance' => number_format($this->current_goal_balance, 2),
'amount' => number_format($this->user_transaction->amount, 2),
'penalty' => number_format(-abs($this->penalty), 2),
'closingGoalBalance' => number_format($this->current_goal_balance - abs($this->user_transaction->amount) - $this->penalty, 2)
];
// send email to user for confirmation
$mail = new UserWithdrawalRequestMailable($params);
SendEmailFromMailableJob::dispatch($this->user->email, $mail);
}
$params = [
'uid' => $this->user->id,
'goalid' => $this->user_transaction->id,
'amount' => number_format($this->user_transaction->amount, 2),
'fullname' => implode(" ", [$this->user->firstname, $this->user->lastname]),
'emailadd' => $this->user->email,
'phone' => $this->user->kyc ? $this->user->kyc->kyc_contact : "",
'requesttime' => $this->user_transaction->created_at->format('d/m/y H:i:s')
];
// send email to admin for processing
$mail = new AdminUserWithdrawalRequestMailable($params);
SendEmailFromMailableJob::dispatch("[email protected]", $mail);
}
}