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:
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
@props([
|
||||
'payoutAccount' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$pa = is_array($payoutAccount) ? $payoutAccount : null;
|
||||
$submitLabel = $pa ? 'Update payout account' : 'Save payout account';
|
||||
@endphp
|
||||
|
||||
<div x-data="{
|
||||
accountType: '{{ $pa['account_type'] ?? 'mobile_money' }}',
|
||||
selectedBankCode: '{{ $pa['bank_code'] ?? '' }}',
|
||||
banks: [],
|
||||
loading: false,
|
||||
get selectedBankName() {
|
||||
const b = this.banks.find(b => b.code === this.selectedBankCode);
|
||||
return b ? b.name : '{{ addslashes($pa['bank_name'] ?? '') }}';
|
||||
},
|
||||
async loadBanks() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(@js(route('events.payouts.banks')) + '?type=' + encodeURIComponent(this.accountType), {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
const data = await res.json();
|
||||
this.banks = data.data ?? [];
|
||||
} catch (e) { this.banks = [] }
|
||||
this.loading = false;
|
||||
},
|
||||
switchAccountType(type) {
|
||||
this.accountType = type;
|
||||
this.selectedBankCode = '';
|
||||
this.banks = [];
|
||||
this.loadBanks();
|
||||
},
|
||||
init() { this.loadBanks() },
|
||||
}">
|
||||
<form action="{{ route('events.payouts.payout-account') }}" method="POST" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">Account type</label>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
<label class="cursor-pointer rounded-xl border-2 p-3 transition-all"
|
||||
:class="accountType === 'mobile_money' ? 'border-indigo-500 bg-indigo-50' : 'border-slate-200 hover:border-slate-300'">
|
||||
<input type="radio" name="account_type" value="mobile_money" x-model="accountType"
|
||||
@change="switchAccountType('mobile_money')" class="sr-only">
|
||||
<p class="text-sm font-semibold text-slate-900">Mobile Money</p>
|
||||
<p class="mt-0.5 text-[11px] text-slate-400">MTN, Vodafone, etc.</p>
|
||||
</label>
|
||||
<label class="cursor-pointer rounded-xl border-2 p-3 transition-all"
|
||||
:class="accountType === 'bank_account' ? 'border-indigo-500 bg-indigo-50' : 'border-slate-200 hover:border-slate-300'">
|
||||
<input type="radio" name="account_type" value="bank_account" x-model="accountType"
|
||||
@change="switchAccountType('bank_account')" class="sr-only">
|
||||
<p class="text-sm font-semibold text-slate-900">Bank Account</p>
|
||||
<p class="mt-0.5 text-[11px] text-slate-400">GH banks</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">Account name</label>
|
||||
<input type="text" name="account_name" value="{{ old('account_name', $pa['account_name'] ?? '') }}" required
|
||||
class="mt-1.5 w-full rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"
|
||||
placeholder="Full name on account">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">Account number / phone</label>
|
||||
<input type="text" name="account_number" value="{{ old('account_number', $pa['account_number'] ?? '') }}" required
|
||||
class="mt-1.5 w-full rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"
|
||||
placeholder="e.g. 0241234567 or account number">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">
|
||||
<span x-text="accountType === 'mobile_money' ? 'Network / Provider' : 'Bank'"></span>
|
||||
</label>
|
||||
<div x-show="loading" class="mt-1.5 flex items-center gap-2 py-2 text-xs text-slate-400">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
|
||||
Loading...
|
||||
</div>
|
||||
<select x-show="!loading" name="bank_code" x-model="selectedBankCode" required
|
||||
class="mt-1.5 w-full rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm text-slate-700 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<option value="">Select...</option>
|
||||
<template x-for="bank in banks" :key="bank.code">
|
||||
<option :value="bank.code" x-text="bank.name"></option>
|
||||
</template>
|
||||
@if(!empty($pa['bank_code']))
|
||||
<option value="{{ $pa['bank_code'] }}" x-show="banks.length === 0">{{ $pa['bank_name'] }}</option>
|
||||
@endif
|
||||
</select>
|
||||
<input type="hidden" name="bank_name" :value="selectedBankName">
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="currency" value="GHS">
|
||||
|
||||
@error('account_type') <p class="text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
@error('account_number') <p class="text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
@error('bank_code') <p class="text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
|
||||
<button type="submit" class="btn-primary w-full">{{ $submitLabel }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,24 +1,183 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Payments & Payouts</x-slot>
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="space-y-6">
|
||||
@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
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
@foreach(['success', 'error'] as $flash)
|
||||
@if(session($flash))
|
||||
<div class="rounded-xl border px-4 py-3 text-sm {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
|
||||
{{ session($flash) }}
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Payments & Payouts</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Ticket and contribution revenue settles into your Ladill wallet (5.5% tickets / 3.5% contributions).</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Total event revenue</p>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Total event revenue (net)</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-slate-900">{{ $fmt($revenueMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Wallet balance</p>
|
||||
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-6">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Available in wallet</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
|
||||
<a href="{{ route('account.wallet') }}" class="mt-4 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-800">View wallet →</a>
|
||||
<a href="{{ route('account.wallet') }}" class="mt-4 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-800">Full wallet →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-amber-100 bg-amber-50/60 px-6 py-4 text-sm text-amber-800">
|
||||
Per-event transaction history and bank/MoMo withdrawals will appear here as Ladill Pay integration completes.
|
||||
|
||||
{{-- Withdraw --}}
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white" x-data="{ open: {{ $pa ? 'true' : 'false' }} }">
|
||||
<button type="button" @click="open = !open" class="flex w-full items-center justify-between gap-3 px-5 py-4 text-left">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-slate-900">Withdraw to bank or MoMo</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-500">
|
||||
@if($pa)
|
||||
To {{ $pa['bank_name'] }} · {{ $pa['account_number'] }}
|
||||
@else
|
||||
Add a payout account to withdraw from your wallet.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<svg class="h-4 w-4 shrink-0 text-slate-400 transition" :class="open && 'rotate-180'" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak class="space-y-4 border-t border-slate-100 px-5 py-4">
|
||||
@if($pa)
|
||||
<div class="flex items-start gap-3 rounded-xl border border-slate-100 bg-slate-50/80 p-4">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-100">
|
||||
<svg class="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z"/></svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-widest text-slate-400">{{ ($pa['account_type'] ?? '') === 'mobile_money' ? 'Mobile Money' : 'Bank Account' }}</p>
|
||||
<p class="text-sm font-semibold text-slate-900">{{ $pa['account_name'] }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $pa['bank_name'] }} · {{ $pa['account_number'] }}</p>
|
||||
</div>
|
||||
<button type="button" @click="$dispatch('open-modal', 'events-payout-account')" class="shrink-0 text-xs font-semibold text-indigo-600 hover:text-indigo-800">Change</button>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('events.payouts.withdraw') }}" method="POST" class="space-y-3">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">Amount (GHS)</label>
|
||||
<div class="relative mt-1.5">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3.5 text-sm text-slate-400">GHS</span>
|
||||
<input type="number" name="amount" min="1" max="{{ $balanceGhs }}" step="0.01" value="{{ old('amount') }}" placeholder="10.00"
|
||||
class="block w-full rounded-xl border border-slate-200 py-2.5 pl-12 pr-4 text-sm text-slate-900 placeholder-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30" required>
|
||||
</div>
|
||||
@error('amount') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-slate-800">Request withdrawal</button>
|
||||
</form>
|
||||
@else
|
||||
<div class="flex flex-col items-center gap-3 py-2 text-center">
|
||||
<p class="text-sm text-slate-600">Add a payout account to withdraw from your wallet.</p>
|
||||
<button type="button" @click="$dispatch('open-modal', 'events-payout-account')" class="btn-primary">Add payout account</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-modal name="events-payout-account" :show="$hasPayoutErrors" maxWidth="md" focusable>
|
||||
<div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14">
|
||||
<h3 class="text-base font-semibold text-slate-900">Payout account</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Mobile money or bank account for withdrawals.</p>
|
||||
</div>
|
||||
<div class="px-5 py-4 sm:px-6 sm:py-5">
|
||||
@include('events.partials.payout-account-form', ['payoutAccount' => $pa])
|
||||
</div>
|
||||
</x-modal>
|
||||
|
||||
{{-- Event payments --}}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Event payments</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Recent ticket sales and contributions collected through Ladill Pay.</p>
|
||||
</div>
|
||||
|
||||
@if($transactions->isEmpty())
|
||||
<div class="px-6 py-10 text-center">
|
||||
<svg class="mx-auto h-10 w-10 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z"/></svg>
|
||||
<h3 class="mt-3 text-sm font-semibold text-slate-900">No payments yet</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Paid registrations will appear here once attendees complete checkout.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="border-b border-slate-100 bg-slate-50/80 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-5 py-3 font-medium">Event</th>
|
||||
<th class="px-5 py-3 font-medium">Attendee</th>
|
||||
<th class="px-5 py-3 font-medium">Type</th>
|
||||
<th class="px-5 py-3 font-medium text-right">Gross</th>
|
||||
<th class="px-5 py-3 font-medium text-right">Fee</th>
|
||||
<th class="px-5 py-3 font-medium text-right">Net</th>
|
||||
<th class="px-5 py-3 font-medium">Paid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@foreach($transactions as $tx)
|
||||
@php
|
||||
$eventName = $tx->qrCode?->content()['name'] ?? $tx->qrCode?->label ?? 'Event';
|
||||
$isContribution = ($tx->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions';
|
||||
@endphp
|
||||
<tr class="hover:bg-slate-50/50">
|
||||
<td class="px-5 py-3 font-medium text-slate-900">{{ $eventName }}</td>
|
||||
<td class="px-5 py-3 text-slate-600">{{ $tx->attendee_name }}</td>
|
||||
<td class="px-5 py-3 text-slate-600">{{ $tx->tier_name }}</td>
|
||||
<td class="px-5 py-3 text-right text-slate-600">{{ $fmt($tx->amount_minor) }}</td>
|
||||
<td class="px-5 py-3 text-right text-slate-400">−{{ $fmt($tx->platformFeeMinor()) }}</td>
|
||||
<td class="px-5 py-3 text-right font-medium text-slate-900">{{ $fmt($tx->netAmountMinor()) }}</td>
|
||||
<td class="px-5 py-3 text-slate-500">
|
||||
{{ $tx->paid_at?->format('M j, Y g:i A') ?? '—' }}
|
||||
@if($tx->payment_reference)
|
||||
<span class="block text-[11px] text-slate-400">{{ $tx->payment_reference }}</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Withdrawal history --}}
|
||||
@if(!empty($withdrawals))
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Withdrawal history</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Recent requests to your bank or mobile money account.</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
@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
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 px-5 py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">GHS {{ number_format((float) ($withdrawal['amount_ghs'] ?? 0), 2) }}</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
@if(!empty($withdrawal['created_at']))
|
||||
{{ \Illuminate\Support\Carbon::parse($withdrawal['created_at'])->format('M j, Y g:i A') }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium capitalize {{ $statusClass }}">{{ $status }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-user-layout>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventPayoutsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user