Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/Console/Commands/MarkAbandonedTransactions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\Console\Commands;

use App\Models\Transaction;
use Illuminate\Console\Command;

class MarkAbandonedTransactions extends Command
{
protected $signature = 'transactions:mark-abandoned {--older-than=60 : Minutes after which pending transactions are abandoned}';

protected $description = 'Mark pending credit purchase transactions older than the threshold as abandoned';

public function handle(): int
{
$minutes = (int) $this->option('older-than');

$affected = Transaction::where('type', Transaction::TYPE_CREDIT_PURCHASE)
->where('status', Transaction::STATUS_PENDING)
->where('created_at', '<=', now()->subMinutes($minutes))
->get();

foreach ($affected as $transaction) {
$transaction->markAsAbandoned();
}

$this->info("Marked {$affected->count()} pending transaction(s) as abandoned.");

return self::SUCCESS;
}
}
6 changes: 3 additions & 3 deletions app/Console/Commands/PingBulkSms.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

use App\Services\Sms\Providers\MtnSmsProvider;
use Illuminate\Console\Command;
use App\Services\Sms\SmsService;

class PingBulkSms extends Command
{
protected $signature = 'sms:ping';

protected $description = 'Ping MTN BulkSMS session every 5 seconds';

public function handle()
Expand All @@ -18,13 +18,13 @@ public function handle()
$response = $smsService->pingSession();

if ($response) {
$this->info('✅ Session active: ' . json_encode($response));
$this->info('✅ Session active: '.json_encode($response));
} else {
$this->error('⚠️ Session ping failed.');
// TODO: use a post request instead to spark it up
$response = $smsService->getSmsBundle();
if ($response) {
$this->info('✅ Get SMS Bundle to spark session up: ' . json_encode($response));
$this->info('✅ Get SMS Bundle to spark session up: '.json_encode($response));
} else {
$this->error('❌ Fetched SMS Bundle failed! Notify admin!.');
}
Expand Down
2 changes: 2 additions & 0 deletions app/Console/Commands/ProcessRecurringCampaigns.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public function handle()

if ($campaigns->isEmpty()) {
$this->info('No campaigns ready for dispatch.');

return 0;
}

Expand All @@ -55,6 +56,7 @@ public function handle()
}

$this->info("Successfully dispatched {$count} campaign(s).");

return 0;
}
}
8 changes: 3 additions & 5 deletions app/Console/Commands/ReprocessPendingSms.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
namespace App\Console\Commands;

use App\Jobs\RetryPendingSmsJob;
use App\Jobs\SendCampaignSms;
use App\Models\Campaign;
use App\Models\CampaignLog;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

class ReprocessPendingSms extends Command
{
Expand All @@ -27,7 +24,6 @@ class ReprocessPendingSms extends Command

public int $maxRetries = 5; // Maximum retry attempts per message


/**
* Execute the console command.
*/
Expand All @@ -41,12 +37,14 @@ public function handle()
->where('retry_count', '<', $this->maxRetries)
->exists();

if (!$pendingLogs) {
if (! $pendingLogs) {
$this->info('No pending SMS messages to retry');

return 0;
}
RetryPendingSmsJob::dispatch();
$this->info('Dispatched pending SMS messages for retry');

return 0;
}
}
51 changes: 31 additions & 20 deletions app/Contracts/Helpers.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<?php

use Illuminate\Http\Request;

if (!function_exists('get_request_ip')) {
if (! function_exists('get_request_ip')) {
function get_request_ip(Request $request): array|string|null
{
$forwarded = $request->header('x-forwarded-for');
Expand All @@ -14,15 +15,15 @@ function get_request_ip(Request $request): array|string|null
} else {
$ip = $request->ip();
}

return $ip;
}
}


/**
* General Conversions for phone numbers
*/
if (!function_exists('formatPhoneNumber')) {
if (! function_exists('formatPhoneNumber')) {
/**
* Formats a phone number into a standardized Nigerian format.
*
Expand All @@ -46,44 +47,54 @@ function get_request_ip(Request $request): array|string|null
* - If number starts with '+234' and is 14 digits - removes the plus
* - Any other format returns original number
*
* @param string $phoneNumber The phone number to format
* @param bool $startWithZero Whether to format number to start with '0' (true) or '234' (false)
* @param string $phoneNumber The phone number to format
* @param bool $startWithZero Whether to format number to start with '0' (true) or '234' (false)
* @return string The formatted phone number
*/
function formatPhoneNumber(string $phoneNumber, bool $startWithZero = true): string
{
$phoneNumber = trim($phoneNumber);
if ($startWithZero) {
if (str_starts_with($phoneNumber, '0') && strlen($phoneNumber) == 11)
if (str_starts_with($phoneNumber, '0') && strlen($phoneNumber) == 11) {
return $phoneNumber;
if (str_starts_with($phoneNumber, '234') && strlen($phoneNumber) == 13)
return '0' . substr($phoneNumber, 3);
if (str_starts_with($phoneNumber, '+234') && strlen($phoneNumber) == 14)
return '0' . substr($phoneNumber, 4);
if (strlen($phoneNumber) == 10)
return '0' . $phoneNumber;
}
if (str_starts_with($phoneNumber, '234') && strlen($phoneNumber) == 13) {
return '0'.substr($phoneNumber, 3);
}
if (str_starts_with($phoneNumber, '+234') && strlen($phoneNumber) == 14) {
return '0'.substr($phoneNumber, 4);
}
if (strlen($phoneNumber) == 10) {
return '0'.$phoneNumber;
}

return $phoneNumber;
} else {
if (!str_starts_with($phoneNumber, '0') && strlen($phoneNumber) == 10)
return '234' . $phoneNumber;
if (str_starts_with($phoneNumber, '0') && strlen($phoneNumber) == 11)
return '234' . substr($phoneNumber, 1);
if (str_starts_with($phoneNumber, '234') && strlen($phoneNumber) == 13)
if (! str_starts_with($phoneNumber, '0') && strlen($phoneNumber) == 10) {
return '234'.$phoneNumber;
}
if (str_starts_with($phoneNumber, '0') && strlen($phoneNumber) == 11) {
return '234'.substr($phoneNumber, 1);
}
if (str_starts_with($phoneNumber, '234') && strlen($phoneNumber) == 13) {
return $phoneNumber;
if (str_starts_with($phoneNumber, '+234') && strlen($phoneNumber) == 14)
}
if (str_starts_with($phoneNumber, '+234') && strlen($phoneNumber) == 14) {
return substr($phoneNumber, 1);
}

return $phoneNumber;
}
}
}

if (!function_exists('determineNumberOfSms'))
{
if (! function_exists('determineNumberOfSms')) {
function determineNumberOfSms(int $recipientCount, string $message): float|int
{
// get the count of sms unit required
$messageCount = mb_strlen($message) / 160;
$smsUnitRequired = max(ceil($messageCount), 1);

return $smsUnitRequired * $recipientCount;
}
}
101 changes: 101 additions & 0 deletions app/Http/Controllers/Admin/CreditBandController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\AppSettings;
use App\Models\AppSettingsEnum;
use App\Models\CreditBand;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;

class CreditBandController extends Controller
{
public function index()
{
return Inertia::render('admin/credit-bands/index', [
'bands' => CreditBand::orderBy('min_units')->get()->map(function ($band) {
return [
'id' => $band->id,
'min_units' => $band->min_units,
'price_per_unit' => $band->price_per_unit,
'is_active' => $band->is_active,
];
}),
'settings' => [
'sms_price_per_part' => AppSettings::get(AppSettingsEnum::SMS_PRICE_PER_PART),
'credit_markup_percent' => AppSettings::get(AppSettingsEnum::CREDIT_MARKUP_PERCENT),
],
]);
}

public function store(Request $request)
{
$validated = $request->validate([
'min_units' => [
'required', 'integer', 'min:1',
Rule::unique('credit_bands', 'min_units')->where('is_active', true),
],
'price_per_unit' => 'required|numeric|min:0.01',
]);

CreditBand::create([
'min_units' => $validated['min_units'],
'price_per_unit' => $validated['price_per_unit'],
'is_active' => true,
]);

return redirect()->back()->with('success', 'Price band created successfully.');
}

public function update(Request $request, CreditBand $creditBand)
{
$validated = $request->validate([
'min_units' => [
'required', 'integer', 'min:1',
Rule::unique('credit_bands', 'min_units')->where('is_active', true)->ignore($creditBand->id),
],
'price_per_unit' => 'required|numeric|min:0.01',
'is_active' => 'sometimes|boolean',
]);

$creditBand->update([
'min_units' => $validated['min_units'],
'price_per_unit' => $validated['price_per_unit'],
'is_active' => $request->boolean('is_active'),
]);

// Also allow updating the pricing settings in the same request
if ($request->has('settings')) {
$this->updateSettings($request->input('settings', []));
}

return redirect()->back()->with('success', 'Price band updated successfully.');
}

public function destroy(CreditBand $creditBand)
{
$creditBand->delete();

return redirect()->back()->with('success', 'Price band deleted successfully.');
}

public function updateSettings(Request $request)
{
$validated = $request->validate([
'sms_price_per_part' => 'required|numeric|min:0.01',
'credit_markup_percent' => 'required|numeric|min:0',
]);

$this->storeSettings($validated);

return redirect()->back()->with('success', 'Pricing settings updated successfully.');
}

private function storeSettings(array $validated): void
{
AppSettings::set(AppSettingsEnum::SMS_PRICE_PER_PART, $validated['sms_price_per_part']);
AppSettings::set(AppSettingsEnum::CREDIT_MARKUP_PERCENT, $validated['credit_markup_percent']);
}
}
33 changes: 20 additions & 13 deletions app/Http/Controllers/Admin/SettingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Services\SettingsService;
use App\Models\AppSettings;
use Illuminate\Http\Request;
use Inertia\Inertia;

class SettingController extends Controller
{
//TODO: settings should be cached for performance and encrypted where necessary
public function __construct(
private readonly SettingsService $settingsService
) {}

public function index()
{
$all = AppSettings::allSettings();

// Never leak secrets to the UI; expose a placeholder instead.
foreach (AppSettings::enumCases() as $case) {
if ($case->encrypt()) {
$all[$case->value] = '';
}
}

return Inertia::render('admin/Settings/Index', [
'settings' => $this->settingsService->all(),
'settings' => $all,
]);
}

Expand All @@ -31,12 +35,15 @@ public function update(Request $request)
]);

foreach ($validated['settings'] as $setting) {
$this->settingsService->set(
$setting['key'],
$setting['value'],
'string', // Defaulting to string for now, can be enhanced
$setting['group'] ?? 'general'
);
$case = AppSettings::caseForKey($setting['key']);

// Skip blank values for sensitive settings so a masked field
// does not wipe the stored secret.
if ($case?->encrypt() && ($setting['value'] === '' || $setting['value'] === null)) {
continue;
}

AppSettings::setByKey($setting['key'], $setting['value']);
}

return redirect()->back()->with('success', 'Settings updated successfully.');
Expand Down
Loading
Loading