Complete Ladill Pay payouts UI for Events.
Deploy Ladill Events / deploy (push) Successful in 38s

Show per-event payment history with fees and net amounts, and let organizers manage payout accounts and withdrawals without leaving the app.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-04 12:06:43 +00:00
co-authored by Cursor
parent 0aafb14836
commit d25682bc6c
7 changed files with 594 additions and 11 deletions
@@ -6,18 +6,24 @@ use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Identity\IdentityClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PayoutsController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function __construct(
private BillingClient $billing,
private IdentityClient $identity,
) {}
public function index(): View
{
$account = ladill_account();
$eventIds = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->pluck('id');
@@ -25,9 +31,24 @@ class PayoutsController extends Controller
$revenueMinor = (int) QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->sum('amount_minor');
->where('amount_minor', '>', 0)
->get()
->sum(fn (QrEventRegistration $r) => $r->netAmountMinor());
$transactions = QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->where('amount_minor', '>', 0)
->with('qrCode:id,label,payload')
->orderByDesc('paid_at')
->orderByDesc('id')
->limit(50)
->get();
$balanceMinor = 0;
$payoutAccount = null;
$withdrawals = [];
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
@@ -37,9 +58,68 @@ class PayoutsController extends Controller
]);
}
try {
$payoutAccount = $this->identity->payoutAccount($account->public_id);
$withdrawals = $this->identity->withdrawals($account->public_id);
} catch (Throwable $e) {
Log::warning('Events payouts could not load payout account or withdrawals', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('events.payouts', [
'revenueMinor' => $revenueMinor,
'balanceMinor' => $balanceMinor,
'balanceGhs' => round($balanceMinor / 100, 2),
'transactions' => $transactions,
'payoutAccount' => $payoutAccount,
'withdrawals' => $withdrawals,
]);
}
public function banks(Request $request): JsonResponse
{
$type = $request->query('type') === 'bank_account' ? 'bank' : 'mobile_money';
try {
return response()->json(['data' => $this->identity->banks($type)]);
} catch (Throwable $e) {
Log::warning('Events payouts could not load banks', ['error' => $e->getMessage()]);
return response()->json(['data' => []]);
}
}
public function updatePayoutAccount(Request $request): RedirectResponse
{
$data = $request->validate([
'account_type' => ['required', 'in:mobile_money,bank_account'],
'account_name' => ['required', 'string', 'max:200'],
'account_number' => ['required', 'string', 'max:30'],
'bank_code' => ['required', 'string', 'max:30'],
'bank_name' => ['required', 'string', 'max:200'],
'currency' => ['sometimes', 'string', 'size:3'],
]);
$data['currency'] = $data['currency'] ?? 'GHS';
$this->identity->updatePayoutAccount(ladill_account()->public_id, $data);
return redirect()
->route('events.payouts')
->with('success', 'Payout account saved.');
}
public function withdraw(Request $request): RedirectResponse
{
$data = $request->validate([
'amount' => ['required', 'numeric', 'min:1', 'max:50000'],
]);
$this->identity->withdraw(ladill_account()->public_id, (float) $data['amount']);
return redirect()
->route('events.payouts')
->with('success', 'Withdrawal request submitted. Funds are sent to your payout account once processed.');
}
}
+23
View File
@@ -55,6 +55,29 @@ class QrEventRegistration extends Model
return round($this->amount_minor / 100, 2);
}
public function platformFeeMinor(): int
{
$metadata = (array) ($this->metadata ?? []);
if (isset($metadata['ladill_pay']['platform_fee_minor'])) {
return (int) $metadata['ladill_pay']['platform_fee_minor'];
}
if (isset($metadata['platform_fee_ghs'])) {
return (int) round((float) $metadata['platform_fee_ghs'] * 100);
}
$mode = $this->qrCode?->content()['mode'] ?? 'ticketing';
$rate = $mode === 'contributions' ? 0.035 : 0.055;
return (int) round($this->amount_minor * $rate);
}
public function netAmountMinor(): int
{
return max(0, $this->amount_minor - $this->platformFeeMinor());
}
public function isPaid(): bool
{
return $this->amount_minor > 0;
+75
View File
@@ -2,7 +2,9 @@
namespace App\Services\Identity;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class IdentityClient
{
@@ -43,6 +45,79 @@ class IdentityClient
return (array) $response->json('data', []);
}
/** @return array<int, array<string, mixed>> */
public function banks(string $type = 'mobile_money'): array
{
$queryType = $type === 'mobile_money' ? 'mobile_money' : 'bank';
$response = $this->request()->get($this->url('/identity/banks'), [
'type' => $queryType,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed>|null */
public function payoutAccount(string $publicId): ?array
{
$response = $this->request()->get($this->url('/identity/payout-account'), [
'user' => $publicId,
]);
$response->throw();
$account = $response->json('data.payout_account');
return is_array($account) ? $account : null;
}
/** @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function updatePayoutAccount(string $publicId, array $data): array
{
$response = $this->request()->put($this->url('/identity/payout-account'), array_merge(
['user' => $publicId],
$data,
));
$this->throwValidation($response, 'account_number');
$account = $response->json('data.payout_account');
return is_array($account) ? $account : [];
}
/** @return array<int, array<string, mixed>> */
public function withdrawals(string $publicId): array
{
$response = $this->request()->get($this->url('/identity/wallet/withdrawals'), [
'user' => $publicId,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed> */
public function withdraw(string $publicId, float $amountGhs): array
{
$response = $this->request()->post($this->url('/identity/wallet/withdraw'), [
'user' => $publicId,
'amount' => $amountGhs,
]);
$this->throwValidation($response, 'amount');
return (array) $response->json('data', []);
}
private function throwValidation(Response $response, string $fallbackField = 'base'): void
{
if ($response->status() === 422) {
throw ValidationException::withMessages(
$response->json('errors') ?: [$fallbackField => [$response->json('message') ?: 'Request failed.']],
);
}
}
private function request()
{
return Http::withToken((string) config('identity.api_key'))