diff --git a/app/Http/Controllers/Events/PayoutsController.php b/app/Http/Controllers/Events/PayoutsController.php index d1f79d6..70843d0 100644 --- a/app/Http/Controllers/Events/PayoutsController.php +++ b/app/Http/Controllers/Events/PayoutsController.php @@ -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.'); + } } diff --git a/app/Models/QrEventRegistration.php b/app/Models/QrEventRegistration.php index 6ed9568..039d1b2 100644 --- a/app/Models/QrEventRegistration.php +++ b/app/Models/QrEventRegistration.php @@ -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; diff --git a/app/Services/Identity/IdentityClient.php b/app/Services/Identity/IdentityClient.php index 6033f57..c982993 100644 --- a/app/Services/Identity/IdentityClient.php +++ b/app/Services/Identity/IdentityClient.php @@ -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> */ + 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|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 $data + * @return array + */ + 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> */ + 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 */ + 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')) diff --git a/resources/views/events/partials/payout-account-form.blade.php b/resources/views/events/partials/payout-account-form.blade.php new file mode 100644 index 0000000..1500fe6 --- /dev/null +++ b/resources/views/events/partials/payout-account-form.blade.php @@ -0,0 +1,105 @@ +@props([ + 'payoutAccount' => null, +]) + +@php + $pa = is_array($payoutAccount) ? $payoutAccount : null; + $submitLabel = $pa ? 'Update payout account' : 'Save payout account'; +@endphp + +
+
+ @csrf + @method('PUT') + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Loading... +
+ + +
+ + + + @error('account_type')

{{ $message }}

@enderror + @error('account_number')

{{ $message }}

@enderror + @error('bank_code')

{{ $message }}

@enderror + + +
+
diff --git a/resources/views/events/payouts.blade.php b/resources/views/events/payouts.blade.php index c054d06..c42a594 100644 --- a/resources/views/events/payouts.blade.php +++ b/resources/views/events/payouts.blade.php @@ -1,24 +1,183 @@ Payments & Payouts - @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp -
+ @php + $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); + $pa = is_array($payoutAccount ?? null) ? $payoutAccount : null; + $hasPayoutErrors = $errors->hasAny(['account_type', 'account_name', 'account_number', 'bank_code', 'bank_name']); + @endphp +
+ @foreach(['success', 'error'] as $flash) + @if(session($flash)) +
+ {{ session($flash) }} +
+ @endif + @endforeach +

Payments & Payouts

Ticket and contribution revenue settles into your Ladill wallet (5.5% tickets / 3.5% contributions).

+
-

Total event revenue

+

Total event revenue (net)

{{ $fmt($revenueMinor) }}

-
-

Wallet balance

+
+

Available in wallet

{{ $fmt($balanceMinor) }}

- View wallet → + Full wallet →
-
- Per-event transaction history and bank/MoMo withdrawals will appear here as Ladill Pay integration completes. + + {{-- Withdraw --}} +
+ + +
+ @if($pa) +
+
+ +
+
+

{{ ($pa['account_type'] ?? '') === 'mobile_money' ? 'Mobile Money' : 'Bank Account' }}

+

{{ $pa['account_name'] }}

+

{{ $pa['bank_name'] }} · {{ $pa['account_number'] }}

+
+ +
+ +
+ @csrf +
+ +
+ GHS + +
+ @error('amount')

{{ $message }}

@enderror +
+ +
+ @else +
+

Add a payout account to withdraw from your wallet.

+ +
+ @endif +
+ + +
+

Payout account

+

Mobile money or bank account for withdrawals.

+
+
+ @include('events.partials.payout-account-form', ['payoutAccount' => $pa]) +
+
+ + {{-- Event payments --}} +
+
+

Event payments

+

Recent ticket sales and contributions collected through Ladill Pay.

+
+ + @if($transactions->isEmpty()) +
+ +

No payments yet

+

Paid registrations will appear here once attendees complete checkout.

+
+ @else +
+ + + + + + + + + + + + + + @foreach($transactions as $tx) + @php + $eventName = $tx->qrCode?->content()['name'] ?? $tx->qrCode?->label ?? 'Event'; + $isContribution = ($tx->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions'; + @endphp + + + + + + + + + + @endforeach + +
EventAttendeeTypeGrossFeeNetPaid
{{ $eventName }}{{ $tx->attendee_name }}{{ $tx->tier_name }}{{ $fmt($tx->amount_minor) }}−{{ $fmt($tx->platformFeeMinor()) }}{{ $fmt($tx->netAmountMinor()) }} + {{ $tx->paid_at?->format('M j, Y g:i A') ?? '—' }} + @if($tx->payment_reference) + {{ $tx->payment_reference }} + @endif +
+
+ @endif +
+ + {{-- Withdrawal history --}} + @if(!empty($withdrawals)) +
+
+

Withdrawal history

+

Recent requests to your bank or mobile money account.

+
+
+ @foreach($withdrawals as $withdrawal) + @php + $status = (string) ($withdrawal['status'] ?? 'pending'); + $statusClass = match ($status) { + 'completed', 'success' => 'text-emerald-700 bg-emerald-50', + 'failed' => 'text-red-700 bg-red-50', + default => 'text-amber-700 bg-amber-50', + }; + @endphp +
+
+

GHS {{ number_format((float) ($withdrawal['amount_ghs'] ?? 0), 2) }}

+

+ @if(!empty($withdrawal['created_at'])) + {{ \Illuminate\Support\Carbon::parse($withdrawal['created_at'])->format('M j, Y g:i A') }} + @endif +

+
+ {{ $status }} +
+ @endforeach +
+
+ @endif
diff --git a/routes/web.php b/routes/web.php index b50554f..6cedb1b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -99,6 +99,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/events/{event}/speakers/invite', [SpeakerController::class, 'sendInvite'])->name('speakers.invite'); Route::get('/payouts', [PayoutsController::class, 'index'])->name('events.payouts'); + Route::get('/payouts/banks', [PayoutsController::class, 'banks'])->name('events.payouts.banks'); + Route::put('/payouts/payout-account', [PayoutsController::class, 'updatePayoutAccount'])->name('events.payouts.payout-account'); + Route::post('/payouts/withdraw', [PayoutsController::class, 'withdraw'])->name('events.payouts.withdraw'); Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('account.wallet'); Route::get('/billing', fn () => redirect()->away(ladill_account_url('/billing')))->name('account.billing'); diff --git a/tests/Feature/EventPayoutsTest.php b/tests/Feature/EventPayoutsTest.php new file mode 100644 index 0000000..eaf066d --- /dev/null +++ b/tests/Feature/EventPayoutsTest.php @@ -0,0 +1,138 @@ +withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class); + + config([ + 'billing.api_url' => 'https://ladill.com/api/billing', + 'identity.api_url' => 'https://ladill.com/api', + 'identity.api_key' => 'test-identity-key', + ]); + + Http::fake([ + 'https://ladill.com/api/billing/balance*' => Http::response(['balance_minor' => 12500]), + 'https://ladill.com/api/identity/payout-account*' => Http::response([ + 'data' => [ + 'payout_account' => [ + 'account_type' => 'mobile_money', + 'account_name' => 'Jane Host', + 'account_number' => '0241234567', + 'bank_code' => 'MTN', + 'bank_name' => 'MTN Mobile Money', + 'currency' => 'GHS', + ], + ], + ]), + 'https://ladill.com/api/identity/wallet/withdrawals*' => Http::response([ + 'data' => [ + [ + 'id' => 1, + 'amount_ghs' => 50.0, + 'status' => 'completed', + 'created_at' => now()->subDay()->toIso8601String(), + ], + ], + ]), + ]); + } + + private function user(): User + { + return User::create([ + 'public_id' => 'usr_payout_'.Str::random(8), + 'name' => 'Event Host', + 'email' => 'host+'.uniqid().'@example.com', + ]); + } + + public function test_payouts_page_shows_transactions_and_withdrawals(): void + { + $owner = $this->user(); + $event = QrCode::create([ + 'user_id' => $owner->id, + 'short_code' => 'summit-2026', + 'type' => QrCode::TYPE_EVENT, + 'label' => 'Annual summit', + 'payload' => [ + 'content' => [ + 'name' => 'Annual summit', + 'mode' => 'ticketing', + ], + ], + 'is_active' => true, + ]); + + QrEventRegistration::create([ + 'qr_code_id' => $event->id, + 'user_id' => $owner->id, + 'reference' => 'QRE-TEST1234567890', + 'badge_code' => 'BADGE001', + 'tier_name' => 'VIP', + 'amount_minor' => 10000, + 'currency' => 'GHS', + 'attendee_name' => 'Alex Buyer', + 'attendee_email' => 'alex@example.com', + 'status' => QrEventRegistration::STATUS_CONFIRMED, + 'payment_reference' => 'LP-TESTREF001', + 'paid_at' => now(), + 'metadata' => [ + 'platform_fee_ghs' => 5.50, + 'ladill_pay' => ['platform_fee_minor' => 550], + ], + ]); + + $this->actingAs($owner) + ->get(route('events.payouts')) + ->assertOk() + ->assertSee('Payments & Payouts') + ->assertSee('Annual summit') + ->assertSee('Alex Buyer') + ->assertSee('VIP') + ->assertSee('LP-TESTREF001') + ->assertSee('MTN Mobile Money') + ->assertSee('Withdrawal history') + ->assertDontSee('Ladill Pay integration completes'); + } + + public function test_can_submit_withdrawal(): void + { + Http::fake([ + 'https://ladill.com/api/billing/balance*' => Http::response(['balance_minor' => 12500]), + 'https://ladill.com/api/identity/payout-account*' => Http::response(['data' => ['payout_account' => []]]), + 'https://ladill.com/api/identity/wallet/withdrawals*' => Http::response(['data' => []]), + 'https://ladill.com/api/identity/wallet/withdraw' => Http::response([ + 'data' => ['id' => 9, 'amount_ghs' => 25.0, 'status' => 'pending'], + ]), + ]); + + $owner = $this->user(); + + $this->actingAs($owner) + ->post(route('events.payouts.withdraw'), ['amount' => 25]) + ->assertRedirect(route('events.payouts')) + ->assertSessionHas('success'); + + Http::assertSent(function ($request) use ($owner) { + return $request->url() === 'https://ladill.com/api/identity/wallet/withdraw' + && $request['user'] === $owner->public_id + && (float) $request['amount'] === 25.0; + }); + } +}