diff --git a/app/Console/Commands/ProcessAutoWithdrawals.php b/app/Console/Commands/ProcessAutoWithdrawals.php new file mode 100644 index 0000000..c686913 --- /dev/null +++ b/app/Console/Commands/ProcessAutoWithdrawals.php @@ -0,0 +1,21 @@ +processAll(); + $this->info("Processed {$count} auto-withdrawal(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/Mini/WalletController.php b/app/Http/Controllers/Api/Mini/WalletController.php index e3ce5ec..876f4c4 100644 --- a/app/Http/Controllers/Api/Mini/WalletController.php +++ b/app/Http/Controllers/Api/Mini/WalletController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Mini; use App\Http\Controllers\Api\Concerns\CallsIdentityApi; use App\Http\Controllers\Controller; use App\Services\Billing\BillingClient; +use App\Services\Mini\AutoWithdrawService; use App\Services\Notifications\MiniNotificationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -18,6 +19,7 @@ class WalletController extends Controller public function __construct( private BillingClient $billing, private MiniNotificationService $notifications, + private AutoWithdrawService $autoWithdraw, ) {} public function show(): JsonResponse @@ -33,12 +35,39 @@ class WalletController extends Controller Log::warning('Mini API wallet load failed', ['user' => $account->public_id, 'error' => $e->getMessage()]); } + $settings = $account->getOrCreateQrSetting(); + return response()->json([ 'data' => [ 'currency' => 'GHS', 'balance_minor' => $balanceMinor, 'spent_minor' => (int) ($ledger['spent_minor'] ?? 0), 'credited_minor' => (int) ($ledger['credited_minor'] ?? 0), + 'auto_withdraw_amount_minor' => $settings->auto_withdraw_amount_minor, + ], + ]); + } + + public function updateAutoWithdraw(Request $request): JsonResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'amount' => ['nullable', 'numeric', 'min:1', 'max:50000'], + ]); + + $amountMinor = isset($data['amount']) ? (int) round((float) $data['amount'] * 100) : null; + + $settings = $account->getOrCreateQrSetting(); + $settings->update(['auto_withdraw_amount_minor' => $amountMinor]); + + if ($amountMinor !== null) { + $this->autoWithdraw->attemptForUser($account); + } + + return response()->json([ + 'data' => [ + 'auto_withdraw_amount_minor' => $settings->fresh()->auto_withdraw_amount_minor, ], ]); } diff --git a/app/Models/QrSetting.php b/app/Models/QrSetting.php index 8e35010..ebe6e5f 100644 --- a/app/Models/QrSetting.php +++ b/app/Models/QrSetting.php @@ -17,6 +17,7 @@ class QrSetting extends Model 'low_balance_alerts', 'notify_registrations', 'notify_payouts', + 'auto_withdraw_amount_minor', 'default_type', 'default_style', 'event_defaults', @@ -27,6 +28,7 @@ class QrSetting extends Model 'low_balance_alerts' => 'boolean', 'notify_registrations' => 'boolean', 'notify_payouts' => 'boolean', + 'auto_withdraw_amount_minor' => 'integer', 'default_style' => 'array', 'event_defaults' => 'array', ]; diff --git a/app/Services/Mini/AutoWithdrawService.php b/app/Services/Mini/AutoWithdrawService.php new file mode 100644 index 0000000..d5756ca --- /dev/null +++ b/app/Services/Mini/AutoWithdrawService.php @@ -0,0 +1,148 @@ +getOrCreateQrSetting(); + $thresholdMinor = $settings->auto_withdraw_amount_minor; + + if ($thresholdMinor === null || $thresholdMinor < 100) { + return false; + } + + try { + $balanceMinor = $this->billing->balanceMinor($user->public_id); + } catch (Throwable $e) { + Log::warning('Auto-withdraw balance check failed', [ + 'user' => $user->public_id, + 'error' => $e->getMessage(), + ]); + + return false; + } + + if ($balanceMinor < $thresholdMinor) { + return false; + } + + if (! $this->hasPayoutAccount($user)) { + return false; + } + + if ($this->hasPendingWithdrawal($user)) { + return false; + } + + $amountMajor = round($balanceMinor / 100, 2); + if ($amountMajor < 1) { + return false; + } + + try { + $response = $this->identitySend('POST', '/api/identity/wallet/withdraw', [ + 'user' => $user->public_id, + 'amount' => $amountMajor, + ]); + + if (! $response->successful()) { + Log::warning('Auto-withdraw failed', [ + 'user' => $user->public_id, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return false; + } + + $this->notifications->withdrawalSubmitted($user, $amountMajor); + + return true; + } catch (Throwable $e) { + Log::warning('Auto-withdraw exception', [ + 'user' => $user->public_id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + public function processAll(): int + { + $count = 0; + + QrSetting::query() + ->whereNotNull('auto_withdraw_amount_minor') + ->where('auto_withdraw_amount_minor', '>=', 100) + ->with('user') + ->chunkById(50, function ($settings) use (&$count) { + foreach ($settings as $setting) { + $user = $setting->user; + if ($user && $this->attemptForUser($user)) { + $count++; + } + } + }); + + return $count; + } + + private function hasPayoutAccount(User $user): bool + { + $response = $this->identitySend( + 'GET', + '/api/identity/payout-account?user='.urlencode((string) $user->public_id), + [], + ); + + if (! $response->successful()) { + return false; + } + + return filled($response->json('data.payout_account.account_number')); + } + + private function hasPendingWithdrawal(User $user): bool + { + $response = $this->identitySend( + 'GET', + '/api/identity/wallet/withdrawals?user='.urlencode((string) $user->public_id), + [], + ); + + if (! $response->successful()) { + return false; + } + + return collect($response->json('data', [])) + ->contains(fn ($withdrawal) => in_array($withdrawal['status'] ?? '', ['pending', 'processing'], true)); + } + + private function identitySend(string $method, string $path, array $payload): HttpResponse + { + return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/')) + ->withToken((string) config('services.ladill_identity.key')) + ->connectTimeout(10) + ->timeout(20) + ->acceptJson() + ->asJson() + ->send($method, $path, ['json' => $payload]); + } +} diff --git a/app/Services/Mini/MiniPaymentService.php b/app/Services/Mini/MiniPaymentService.php index 9752685..0e371ce 100644 --- a/app/Services/Mini/MiniPaymentService.php +++ b/app/Services/Mini/MiniPaymentService.php @@ -20,6 +20,7 @@ class MiniPaymentService private BillingClient $billing, private SmsService $sms, private MiniNotificationService $notifications, + private AutoWithdrawService $autoWithdraw, ) {} /** @@ -124,6 +125,7 @@ class MiniPaymentService $payment = $payment->fresh(['qrCode', 'merchant']); $this->notifyPayer($payment); $this->notifications->paymentReceived($payment); + $this->autoWithdraw->attemptForUser($payment->merchant); return $payment; } @@ -169,6 +171,7 @@ class MiniPaymentService $payment = $payment->fresh(['qrCode', 'merchant']); $this->notifyPayer($payment); $this->notifications->paymentReceived($payment); + $this->autoWithdraw->attemptForUser($payment->merchant); return $payment; } diff --git a/database/migrations/2026_06_12_120000_add_auto_withdraw_to_qr_settings_table.php b/database/migrations/2026_06_12_120000_add_auto_withdraw_to_qr_settings_table.php new file mode 100644 index 0000000..b17ca7e --- /dev/null +++ b/database/migrations/2026_06_12_120000_add_auto_withdraw_to_qr_settings_table.php @@ -0,0 +1,22 @@ +unsignedBigInteger('auto_withdraw_amount_minor')->nullable()->after('notify_payouts'); + }); + } + + public function down(): void + { + Schema::table('qr_settings', function (Blueprint $table) { + $table->dropColumn('auto_withdraw_amount_minor'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index a0e2450..2b6fcb0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -67,6 +67,7 @@ Route::prefix('v1')->group(function () { Route::put('/mini/wallet/payout-account', [MiniWalletController::class, 'updatePayoutAccount'])->name('api.mini.wallet.payout-account.update'); Route::get('/mini/wallet/withdrawals', [MiniWalletController::class, 'withdrawals'])->name('api.mini.wallet.withdrawals'); Route::post('/mini/wallet/withdraw', [MiniWalletController::class, 'withdraw'])->name('api.mini.wallet.withdraw'); + Route::put('/mini/wallet/auto-withdraw', [MiniWalletController::class, 'updateAutoWithdraw'])->name('api.mini.wallet.auto-withdraw'); Route::get('/mini/support/tickets', [MiniSupportController::class, 'tickets'])->name('api.mini.support.tickets'); Route::post('/mini/support/tickets', [MiniSupportController::class, 'store'])->name('api.mini.support.tickets.store'); diff --git a/routes/console.php b/routes/console.php index 7a016ba..c63efc4 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,6 +9,7 @@ Artisan::command('inspire', function () { })->purpose('Display an inspiring quote'); Schedule::command('mini:cancel-stale-payments')->hourly()->withoutOverlapping(); +Schedule::command('mini:process-auto-withdrawals')->everyFiveMinutes()->withoutOverlapping(); Schedule::command('hosting:check-node-health --all')->everyFiveMinutes()->withoutOverlapping(); Schedule::command('hosting:sync-account-usage')->hourly()->withoutOverlapping();