Add wallet auto-withdraw threshold support for Ladill Mini.
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:
isaacclad
2026-06-12 07:44:42 +00:00
co-authored by Cursor
parent 9790788d42
commit 2c4eaf1241
8 changed files with 227 additions and 0 deletions
@@ -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,
],
]);
}
+2
View File
@@ -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',
];
+148
View File
@@ -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]);
}
}
+3
View File
@@ -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');
});
}
};
+1
View File
@@ -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');
+1
View File
@@ -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();