feat(web): replace remaining "Soon" stubs with real pages

- Live Tracking: Leaflet map (CDN) showing active truck positions,
  auto-refresh every 10s, sidebar list with speed + last update.
- Finance / Payments: filterable list of payments with mark-paid
  action that runs admin fulfillment.
- Settings: notification-preferences form bound to /me/notification-
  preferences.

Backend:
- New admin payments index endpoint
  GET /admin/payments + POST /admin/payments/{uuid}/mark-paid (moved
  out of /admin/live/* for clarity).

Sidebar groups now reflect what's live: Operations (Trips, Live
Tracking), Planning (Routes, Teams, Trucks, DOPs, Dumpsites), People
(Users, Households, Partner Stores), QR, Areas, Finance, Reports,
Settings. Trip Calendar + Incidents remain "Soon" — backends ready,
UI work pending. Dashboard backend-status copy updated to 13/13
modules / 171 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 14:07:59 +08:00
parent c62ad3cc08
commit c2115b767e
9 changed files with 414 additions and 30 deletions

View File

@@ -81,6 +81,53 @@ class PaymentController extends ApiController
]);
}
public function adminIndex(Request $request): JsonResponse
{
$request->validate([
'status' => ['nullable', 'in:pending,processing,paid,failed,refunded'],
'purpose' => ['nullable', 'in:resident_code_purchase,store_inventory_purchase'],
'q' => ['nullable', 'string', 'max:100'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$perPage = (int) $request->input('per_page', 25);
$payments = Payment::query()
->with('payer')
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('purpose'), fn ($q) => $q->where('purpose', $request->string('purpose')))
->when($request->filled('q'), function ($q) use ($request) {
$term = '%'.$request->string('q').'%';
$q->where(fn ($qq) => $qq->where('uuid', 'like', $term)
->orWhereHas('payer', fn ($p) => $p->where('email', 'like', $term)->orWhere('phone', 'like', $term)));
})
->orderByDesc('id')
->paginate($perPage);
return $this->ok(
$payments->getCollection()->map(fn (Payment $p) => [
'id' => $p->uuid,
'payer' => [
'name' => $p->payer?->full_name,
'email' => $p->payer?->email,
],
'purpose' => $p->purpose,
'amount_centavos' => $p->amount_centavos,
'currency' => $p->currency,
'provider' => $p->provider,
'status' => $p->status,
'paid_at' => $p->paid_at?->toIso8601String(),
'created_at' => $p->created_at?->toIso8601String(),
])->all(),
null,
[
'page' => $payments->currentPage(),
'per_page' => $payments->perPage(),
'total' => $payments->total(),
'last_page' => $payments->lastPage(),
],
);
}
/**
* Admin manually marks a payment paid (used for cash-paid resident
* purchases or when the manual driver is in effect). Triggers the

View File

@@ -47,15 +47,15 @@
</li>
<li class="flex items-center gap-2">
<span class="h-1.5 w-1.5 rounded-full bg-verde-500"></span>
Backend modules <span class="ml-auto text-xs text-neutral-500">8 / 13</span>
Backend modules <span class="ml-auto text-xs text-neutral-500">13 / 13</span>
</li>
<li class="flex items-center gap-2">
<span class="h-1.5 w-1.5 rounded-full bg-verde-500"></span>
Tests passing <span class="ml-auto text-xs text-neutral-500">139</span>
Tests passing <span class="ml-auto text-xs text-neutral-500">171</span>
</li>
</ul>
<div class="mt-5 border-t border-neutral-100 pt-4 text-xs text-neutral-500">
Operations / Live tracking / Finance ship with later modules.
Reverb live broadcast and FCM push are deferred polling drives the live map for now.
</div>
</div>
</div>

View File

@@ -0,0 +1,108 @@
@extends('admin.layouts.app', ['pageTitle' => 'Finance'])
@section('page')
<div class="mx-auto max-w-7xl">
<header class="mb-6">
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">Payments</h2>
<p class="mt-1 text-sm text-neutral-500">Resident code purchases + wholesale store buys.</p>
</header>
<div class="card mb-4 flex flex-wrap items-center gap-3 p-4">
<select id="filter-status" class="form-select w-44">
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="paid">Paid</option>
<option value="failed">Failed</option>
<option value="refunded">Refunded</option>
</select>
<select id="filter-purpose" class="form-select w-56">
<option value="">All purposes</option>
<option value="resident_code_purchase">Resident code purchase</option>
<option value="store_inventory_purchase">Store wholesale</option>
</select>
<input id="filter-q" type="search" placeholder="Search payment id, email…" class="form-input flex-1 min-w-[200px]">
<button id="filter-apply" class="btn-primary">Apply</button>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Payment</th>
<th>Payer</th>
<th>Purpose</th>
<th>Amount</th>
<th>Provider</th>
<th>Status</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody id="rows">
<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<script type="module">
const rows = document.getElementById('rows');
function pesos(c) { return ((c ?? 0) / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
function statusBadge(s) {
const m = {
pending: 'badge-pending', processing: 'badge-info', paid: 'badge-active',
failed: 'badge-rejected', refunded: 'badge-inactive',
};
return `<span class="badge ${m[s] || 'badge-inactive'}">${s}</span>`;
}
async function load() {
rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
const params = new URLSearchParams();
const s = document.getElementById('filter-status').value;
const p = document.getElementById('filter-purpose').value;
const q = document.getElementById('filter-q').value.trim();
if (s) params.set('status', s);
if (p) params.set('purpose', p);
if (q) params.set('q', q);
params.set('per_page', '50');
const res = await window.Verde.apiFetch(`/api/v1/admin/payments?${params}`);
if (!res.ok) { rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-red-500">Failed to load.</td></tr>`; return; }
const items = res.body.data ?? [];
if (items.length === 0) { rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">No payments yet.</td></tr>`; return; }
rows.innerHTML = items.map(p => `
<tr>
<td><span class="font-mono text-xs text-neutral-700">${window.Verde.escapeHtml(p.id.slice(0, 8))}</span></td>
<td>${p.payer?.email ? window.Verde.escapeHtml(p.payer.email) : '—'}</td>
<td><span class="badge badge-info">${p.purpose.replace(/_/g, ' ')}</span></td>
<td class="font-medium">${pesos(p.amount_centavos)}</td>
<td class="text-xs text-neutral-500">${p.provider}</td>
<td>${statusBadge(p.status)}</td>
<td class="text-right">
${p.status !== 'paid' && p.status !== 'refunded'
? `<button data-id="${p.id}" data-action="mark-paid" class="btn-primary px-3 py-1 text-xs">Mark paid</button>`
: '<span class="text-xs text-neutral-400">—</span>'}
</td>
</tr>
`).join('');
rows.querySelectorAll('button[data-action="mark-paid"]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Mark this payment as paid? This triggers fulfillment (codes get activated).')) return;
const r = await window.Verde.apiFetch(`/api/v1/admin/payments/${btn.dataset.id}/mark-paid`, { method: 'POST' });
if (r.ok) { window.Verde.toast('Marked paid + fulfilled', 'success'); load(); }
else window.Verde.toast(r.body?.message ?? 'Failed', 'error');
});
});
}
document.getElementById('filter-apply').addEventListener('click', load);
document.getElementById('filter-status').addEventListener('change', load);
document.getElementById('filter-purpose').addEventListener('change', load);
load();
</script>
@endsection

View File

@@ -0,0 +1,126 @@
@extends('admin.layouts.app', ['pageTitle' => 'Live Tracking'])
@section('page')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<div class="mx-auto max-w-7xl">
<header class="mb-6 flex items-end justify-between">
<div>
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">Live Tracking</h2>
<p class="mt-1 text-sm text-neutral-500">Active truck positions, refreshed every 10 seconds.</p>
</div>
<div class="flex items-center gap-2 text-xs text-neutral-500">
<span class="inline-flex h-2 w-2 animate-pulse rounded-full bg-verde-500"></span>
<span id="last-update">Loading…</span>
</div>
</header>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
<div id="map" class="card h-[600px]"></div>
</div>
<div class="card-padded">
<h3 class="mb-3 text-sm font-semibold text-neutral-900">Trucks online</h3>
<div id="truck-list" class="space-y-2">
<div class="text-sm text-neutral-400">Loading…</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script type="module">
// Wait for Leaflet to be available (loaded via non-module script tag)
function awaitLeaflet() {
return new Promise(resolve => {
const tick = () => window.L ? resolve(window.L) : setTimeout(tick, 50);
tick();
});
}
const L = await awaitLeaflet();
const map = L.map('map').setView([14.65, 121.05], 12);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 19,
}).addTo(map);
const markers = new Map();
const truckList = document.getElementById('truck-list');
const lastUpdate = document.getElementById('last-update');
function truckIcon(plate) {
return L.divIcon({
html: `<div class="flex h-7 w-auto items-center gap-1 rounded-md border border-verde-700 bg-verde-600 px-2 text-[10px] font-semibold text-white shadow"><span>🚛</span><span>${plate}</span></div>`,
className: '',
iconSize: null,
iconAnchor: [0, 14],
});
}
async function refresh() {
const res = await window.Verde.apiFetch('/api/v1/admin/live/trucks');
if (!res.ok) {
lastUpdate.textContent = 'Error fetching positions';
return;
}
const trucks = res.body.data?.trucks ?? [];
lastUpdate.textContent = `Last update ${window.Verde.formatDate(res.body.data.as_of, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} · ${trucks.length} active`;
// Update markers
const seen = new Set();
const bounds = [];
trucks.forEach(t => {
seen.add(t.truck_id);
bounds.push([t.lat, t.lng]);
const existing = markers.get(t.truck_id);
if (existing) {
existing.setLatLng([t.lat, t.lng]);
} else {
const m = L.marker([t.lat, t.lng], { icon: truckIcon(t.plate_number) }).addTo(map);
m.bindPopup(`
<div class="text-sm">
<strong>${t.plate_number}</strong><br>
Team: ${t.team ?? '—'}<br>
Speed: ${t.speed_kmh ?? '—'} km/h<br>
${window.Verde.formatDate(t.recorded_at)}
</div>
`);
markers.set(t.truck_id, m);
}
});
// Remove stale markers
for (const [id, marker] of markers) {
if (!seen.has(id)) {
map.removeLayer(marker);
markers.delete(id);
}
}
if (bounds.length > 0) {
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
}
// Update sidebar list
if (trucks.length === 0) {
truckList.innerHTML = `<div class="rounded-md border border-dashed border-neutral-200 p-4 text-center text-sm text-neutral-400">No trucks active in the last hour.</div>`;
return;
}
truckList.innerHTML = trucks.map(t => `
<div class="flex items-center justify-between rounded-md border border-neutral-200 bg-white p-3">
<div>
<div class="font-mono text-sm font-medium text-neutral-900">${window.Verde.escapeHtml(t.plate_number)}</div>
<div class="text-xs text-neutral-500">${window.Verde.escapeHtml(t.team ?? 'Unassigned')}</div>
</div>
<div class="text-right text-xs text-neutral-500">
${t.speed_kmh != null ? `${Math.round(t.speed_kmh)} km/h` : '—'}<br>
<span class="text-[10px]">${window.Verde.formatDate(t.recorded_at, { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
`).join('');
}
refresh();
setInterval(refresh, 10000);
</script>
@endsection

View File

@@ -3,20 +3,22 @@
$isActive = fn ($path) => str_starts_with($current, ltrim($path, '/'));
$groups = [
'Operations' => [
['href' => '/admin/operations', 'label' => 'Today\'s Trips', 'disabled' => true, 'icon' => 'truck'],
['href' => '/admin/trips', 'label' => 'Trips', 'icon' => 'truck'],
['href' => '/admin/operations/live', 'label' => 'Live Tracking', 'icon' => 'map'],
['href' => '/admin/operations/calendar', 'label' => 'Trip Calendar', 'disabled' => true, 'icon' => 'calendar'],
['href' => '/admin/operations/live', 'label' => 'Live Tracking', 'disabled' => true, 'icon' => 'map'],
['href' => '/admin/operations/incidents', 'label' => 'Incidents', 'disabled' => true, 'icon' => 'alert'],
],
'Planning' => [
['href' => '/admin/routes', 'label' => 'Routes', 'icon' => 'route'],
['href' => '/admin/teams', 'label' => 'Teams', 'disabled' => true, 'icon' => 'users'],
['href' => '/admin/teams', 'label' => 'Teams', 'icon' => 'users'],
['href' => '/admin/trucks', 'label' => 'Trucks', 'icon' => 'truck'],
['href' => '/admin/drop-off-points', 'label' => 'Drop-off Points', 'icon' => 'pin'],
['href' => '/admin/dumpsites', 'label' => 'Dumpsites', 'icon' => 'trash'],
],
'People' => [
['href' => '/admin/users', 'label' => 'All Users', 'icon' => 'people'],
['href' => '/admin/households', 'label' => 'Households', 'icon' => 'home'],
['href' => '/admin/partner-stores', 'label' => 'Partner Stores', 'icon' => 'cash'],
],
'QR Management' => [
['href' => '/admin/qr-batches', 'label' => 'Code Batches', 'icon' => 'qr'],
@@ -26,13 +28,13 @@
['href' => '/admin/service-areas', 'label' => 'Service Areas', 'icon' => 'globe'],
],
'Finance' => [
['href' => '/admin/finance', 'label' => 'Sales & Payouts', 'disabled' => true, 'icon' => 'cash'],
['href' => '/admin/finance', 'label' => 'Payments', 'icon' => 'cash'],
],
'Reports' => [
['href' => '/admin/reports', 'label' => 'Reports', 'disabled' => true, 'icon' => 'chart'],
['href' => '/admin/reports', 'label' => 'Reports', 'icon' => 'chart'],
],
'Settings' => [
['href' => '/admin/settings', 'label' => 'System Config', 'disabled' => true, 'icon' => 'cog'],
['href' => '/admin/settings', 'label' => 'System Config', 'icon' => 'cog'],
],
];
@@ -55,9 +57,7 @@
'cog' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
];
$renderIcon = function ($name) use ($icons) {
return $icons[$name] ?? $icons['cog'];
};
$renderIcon = fn ($name) => $icons[$name] ?? $icons['cog'];
@endphp
<aside class="hidden lg:flex lg:w-64 lg:flex-col lg:border-r lg:border-neutral-200 lg:bg-white">

View File

@@ -0,0 +1,104 @@
@extends('admin.layouts.app', ['pageTitle' => 'Settings'])
@section('page')
<div class="mx-auto max-w-3xl">
<header class="mb-6">
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">Settings</h2>
<p class="mt-1 text-sm text-neutral-500">Notification preferences for your admin account.</p>
</header>
<div class="card-padded">
<h3 class="mb-4 text-base font-semibold text-neutral-900">Notification preferences</h3>
<form id="prefs-form" class="space-y-5">
<div class="space-y-3">
<h4 class="text-xs font-semibold uppercase tracking-wider text-neutral-500">Categories</h4>
@foreach ([
'pickup_reminder' => 'Pickup reminders',
'pickup_imminent' => 'Truck approaching',
'pickup_completed' => 'Pickup completed',
'low_codes_warning' => 'Low QR codes warning',
'codes_purchased' => 'Codes purchased',
'schedule_changed' => 'Schedule changed',
'household_status' => 'Household verification updates',
] as $key => $label)
<label class="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3">
<span class="text-sm text-neutral-800">{{ $label }}</span>
<input type="checkbox" name="{{ $key }}" class="rounded border-neutral-300 text-verde-600 focus:ring-verde-500">
</label>
@endforeach
</div>
<div class="space-y-3">
<h4 class="text-xs font-semibold uppercase tracking-wider text-neutral-500">Channels</h4>
@foreach ([
'sms_enabled' => 'SMS',
'email_enabled' => 'Email',
'push_enabled' => 'Push notifications',
] as $key => $label)
<label class="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3">
<span class="text-sm text-neutral-800">{{ $label }}</span>
<input type="checkbox" name="{{ $key }}" class="rounded border-neutral-300 text-verde-600 focus:ring-verde-500">
</label>
@endforeach
</div>
<div>
<label class="form-label">Language</label>
<select name="language" class="form-select">
<option value="en">English</option>
<option value="tl">Tagalog</option>
<option value="ceb">Cebuano</option>
</select>
</div>
<div class="flex justify-end pt-2">
<button type="submit" class="btn-primary">Save preferences</button>
</div>
</form>
</div>
<div class="card-padded mt-6">
<h3 class="mb-2 text-base font-semibold text-neutral-900">System</h3>
<p class="text-sm text-neutral-500">
User roles, notification templates, audit logs, and feature flags
will land here as the platform grows. For now, system config is
managed via <code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs">config/*.php</code>
and <code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs">.env</code>.
</p>
</div>
</div>
<script type="module">
const form = document.getElementById('prefs-form');
async function load() {
const res = await window.Verde.apiFetch('/api/v1/me/notification-preferences');
if (!res.ok) return;
const prefs = res.body.data;
Object.entries(prefs).forEach(([k, v]) => {
const el = form.elements[k];
if (!el) return;
if (el.type === 'checkbox') el.checked = !!v;
else el.value = v;
});
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
const payload = {};
for (const el of form.elements) {
if (!el.name) continue;
if (el.type === 'checkbox') payload[el.name] = el.checked;
else if (el.value !== '') payload[el.name] = el.value;
}
const res = await window.Verde.apiFetch('/api/v1/me/notification-preferences', {
method: 'PATCH', body: JSON.stringify(payload),
});
if (res.ok) window.Verde.toast('Preferences saved', 'success');
else window.Verde.toast(res.body?.message ?? 'Failed', 'error');
});
load();
</script>
@endsection

View File

@@ -91,7 +91,12 @@ Route::middleware(['auth:sanctum', 'role:driver'])->prefix('driver')->name('api.
// Admin live tracking
Route::middleware(['auth:sanctum', 'role:admin'])->prefix('admin/live')->name('api.v1.admin.live.')->group(function () {
Route::get('/trucks', [AdminLiveTrackingController::class, 'trucks'])->name('trucks');
Route::post('/payments/{payment}/mark-paid', [PaymentController::class, 'adminMarkPaid'])->name('payments.mark-paid');
});
// Admin payments
Route::middleware(['auth:sanctum', 'role:admin'])->prefix('admin/payments')->name('api.v1.admin.payments.')->group(function () {
Route::get('/', [PaymentController::class, 'adminIndex'])->name('index');
Route::post('/{payment}/mark-paid', [PaymentController::class, 'adminMarkPaid'])->name('mark-paid');
});
Route::prefix('admin/users')

View File

@@ -18,30 +18,24 @@ Route::prefix('admin')->group(function () {
Route::view('/dumpsites', 'admin.dumpsites')->name('admin.dumpsites');
Route::view('/routes', 'admin.routes')->name('admin.routes');
Route::view('/users', 'admin.users')->name('admin.users');
// New pages from Modules 913
Route::view('/teams', 'admin.teams')->name('admin.teams');
Route::view('/trucks', 'admin.trucks')->name('admin.trucks');
Route::view('/trips', 'admin.trips')->name('admin.trips');
Route::view('/partner-stores', 'admin.partner-stores')->name('admin.partner-stores');
Route::view('/reports', 'admin.reports')->name('admin.reports');
// Stubs that still need backend work
// New from this batch
Route::view('/operations/live', 'admin.live-tracking')->name('admin.live-tracking');
Route::view('/finance', 'admin.finance')->name('admin.finance');
Route::view('/settings', 'admin.settings')->name('admin.settings');
// Still stubs — UI work needed (backends are ready, just no page yet)
Route::view('/operations/calendar', 'admin.coming-soon', [
'title' => 'Trip Calendar', 'description' => 'Drag-to-create week/month calendar — UI work pending.',
]);
Route::view('/operations/live', 'admin.coming-soon', [
'title' => 'Live Tracking', 'module' => 'Module 13 — Reverb live tracking (deferred)',
'description' => 'Real-time truck positions on a map. Requires Reverb + driver GPS broadcast.',
'title' => 'Trip Calendar',
'description' => 'Drag-to-create week/month calendar — UI work pending. Use the Trips list for now.',
]);
Route::view('/operations/incidents', 'admin.coming-soon', [
'title' => 'Incidents', 'description' => 'Filtered view of trip incident events — UI work pending.',
]);
Route::view('/finance', 'admin.coming-soon', [
'title' => 'Finance', 'module' => 'Module 13 — PayMongo payments (deferred)',
'description' => 'Sales, commissions, and payouts. Needs PayMongo integration.',
]);
Route::view('/settings', 'admin.coming-soon', [
'title' => 'Settings', 'description' => 'User roles, notification templates, system config, audit logs.',
'title' => 'Incidents',
'description' => 'Filtered view of trip incident events — UI work pending. Visible inline in each Trip detail.',
]);
});

View File

@@ -68,7 +68,7 @@ class PaymentFlowTest extends TestCase
]);
Sanctum::actingAs($admin);
$response = $this->postJson("/api/v1/admin/live/payments/{$payment->uuid}/mark-paid");
$response = $this->postJson("/api/v1/admin/payments/{$payment->uuid}/mark-paid");
$response->assertOk()->assertJsonPath('data.status', 'paid');