Add wallet auto-withdraw threshold support for Ladill Mini.
Deploy Ladill Mini / deploy (push) Failing after 42s
Deploy Ladill Mini / deploy (push) Failing after 42s
Store per-user auto-withdraw amounts, expose them via the wallet API, and process withdrawals when balances reach the configured threshold after payments or on a schedule. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Mini\AutoWithdrawService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ProcessAutoWithdrawals extends Command
|
||||
{
|
||||
protected $signature = 'mini:process-auto-withdrawals';
|
||||
|
||||
protected $description = 'Withdraw wallet balances that have reached each user\'s auto-withdraw threshold.';
|
||||
|
||||
public function handle(AutoWithdrawService $autoWithdraw): int
|
||||
{
|
||||
$count = $autoWithdraw->processAll();
|
||||
$this->info("Processed {$count} auto-withdrawal(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Mini;
|
||||
|
||||
use App\Models\QrSetting;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Notifications\MiniNotificationService;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class AutoWithdrawService
|
||||
{
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
private MiniNotificationService $notifications,
|
||||
) {}
|
||||
|
||||
public function attemptForUser(User $user): bool
|
||||
{
|
||||
$settings = $user->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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('qr_settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user