Add Ladill POS v1 — register, Pay checkout, and commerce links.
Deploy Ladill Mini / deploy (push) Successful in 23s

Staff-facing counter register at pos.ladill.com with catalog cart, cash and
MoMo/card checkout via Ladill Pay, CRM timeline/import, invoice prefill, and
Merchant catalog import.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-23 22:52:24 +00:00
co-authored by Cursor
commit e5d2b84388
378 changed files with 41419 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
/**
* Native token auth for the Ladill Mini mobile app.
*
* Login and registration proxy to the central Ladill identity API
* (auth.ladill.com /api/identity/auth/*, gated by the shared first-party
* service key) so app accounts are the same single identity used by the website
* and every other Ladill app. We then provision the thin local user mirror
* (keyed by the OIDC `sub`, exactly like the web SSO callback) and hand back a
* Sanctum token the app uses for `auth:sanctum` requests.
*/
class AuthController extends Controller
{
public function login(Request $request): JsonResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'device_name' => ['nullable', 'string', 'max:120'],
]);
$response = $this->identityPost('/api/identity/auth/login', [
'email' => $credentials['email'],
'password' => $credentials['password'],
]);
if ($response->status() === 422) {
throw ValidationException::withMessages([
'email' => ['The email or password is incorrect.'],
]);
}
$user = $this->provisionFromResponse($response);
return $this->issueToken($user, $credentials['device_name'] ?? null);
}
public function register(Request $request): JsonResponse
{
// Mirror the web signup form; the monolith validates authoritatively, but
// we pre-validate so the app gets fast, field-level feedback.
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'company' => ['nullable', 'string', 'max:255'],
'address' => ['required', 'string', 'max:255'],
'city' => ['required', 'string', 'max:255'],
'state' => ['required', 'string', 'max:255'],
'country' => ['required', 'string', 'size:2'],
'zipcode' => ['required', 'string', 'max:32'],
'phone_cc' => ['required', 'string', 'max:8'],
'phone' => ['required', 'string', 'max:32'],
'mobile_cc' => ['nullable', 'string', 'max:8'],
'mobile' => ['nullable', 'string', 'max:32'],
'terms' => ['accepted'],
'device_name' => ['nullable', 'string', 'max:120'],
]);
$response = $this->identityPost('/api/identity/auth/register', array_merge(
$request->only([
'name', 'email', 'password', 'password_confirmation', 'company',
'address', 'city', 'state', 'country', 'zipcode',
'phone_cc', 'phone', 'mobile_cc', 'mobile',
]),
['terms' => $request->boolean('terms')],
));
// Surface the monolith's validation errors (e.g. email already taken).
if ($response->status() === 422) {
throw ValidationException::withMessages(
$response->json('errors') ?: ['email' => [$response->json('message') ?: 'Registration failed.']],
);
}
$user = $this->provisionFromResponse($response);
return $this->issueToken($user, $data['device_name'] ?? null);
}
public function logout(Request $request): JsonResponse
{
$request->user()?->currentAccessToken()?->delete();
return response()->json(['data' => ['message' => 'Signed out.']]);
}
public function me(Request $request): JsonResponse
{
return response()->json(['data' => $this->presentUser($request->user())]);
}
private function identity(): \Illuminate\Http\Client\PendingRequest
{
return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/'))
->withToken((string) config('services.ladill_identity.key'))
->connectTimeout(10)
->timeout(20)
->acceptJson()
->asJson();
}
/** POST to the identity API, turning connection failures into a clean message. */
private function identityPost(string $path, array $payload): HttpResponse
{
try {
return $this->identity()->post($path, $payload);
} catch (ConnectionException $e) {
throw ValidationException::withMessages([
'email' => ['Could not reach Ladill sign-in. Please try again in a moment.'],
]);
}
}
/** Upsert the local mirror from the identity API's OIDC claims. */
private function provisionFromResponse(HttpResponse $response): User
{
if ($response->failed()) {
throw ValidationException::withMessages([
'email' => ['We could not reach Ladill sign-in. Please try again.'],
]);
}
$claims = (array) $response->json('data.user', []);
$sub = (string) ($claims['sub'] ?? '');
if ($sub === '') {
throw ValidationException::withMessages([
'email' => ['We could not verify your Ladill account. Please try again.'],
]);
}
$email = (string) ($claims['email'] ?? '');
return User::updateOrCreate(
['public_id' => $sub],
[
'name' => $claims['name'] ?? null,
'email' => $email !== '' ? $email : $sub.'@users.ladill.com',
'avatar_url' => $claims['picture'] ?? null,
],
);
}
private function issueToken(User $user, ?string $deviceName): JsonResponse
{
QrTeamMember::linkPendingInvitesFor($user);
$user->update(['last_app_active_at' => now()]);
$token = $user->createToken($deviceName ?: 'Ladill Mini Android', ['mini:read', 'mini:write']);
return response()->json([
'data' => [
'token' => $token->plainTextToken,
'user' => $this->presentUser($user),
],
]);
}
/** @return array<string, mixed> */
private function presentUser(User $user): array
{
// On the public login/register routes SetActingAccount hasn't run and
// there's no authenticated guard yet, so ladill_account() is null —
// the acting account at sign-in is simply the user themselves.
$account = ladill_account() ?? $user;
return [
'id' => $user->id,
'public_id' => $user->public_id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $user->avatarUrl(),
'acting_account' => [
'id' => $account->id,
'public_id' => $account->public_id,
'name' => $account->name,
'email' => $account->email,
],
'accessible_account_ids' => $user->accessibleAccounts()->pluck('id')->values(),
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
/**
* Shared helper for talking to the central Ladill identity API
* (auth.ladill.com /api/identity/*) with the first-party service key.
*/
trait CallsIdentityApi
{
protected function identity(): PendingRequest
{
return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/'))
->withToken((string) config('services.ladill_identity.key'))
->connectTimeout(10)
->timeout(20)
->acceptJson()
->asJson();
}
protected function identitySend(string $method, string $path, array $payload): HttpResponse
{
try {
return $this->identity()->send($method, $path, ['json' => $payload]);
} catch (ConnectionException) {
throw ValidationException::withMessages([
'base' => ['Could not reach Ladill. Please try again in a moment.'],
]);
}
}
/** Re-throw a 422 from the identity API as local validation errors. */
protected function rethrowValidation(HttpResponse $response, string $fallbackField = 'base'): void
{
if ($response->status() === 422) {
throw ValidationException::withMessages(
$response->json('errors') ?: [$fallbackField => [$response->json('message') ?: 'Request failed.']],
);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MeController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
$account = ladill_account();
return response()->json([
'data' => [
'id' => $user->id,
'public_id' => $user->public_id,
'name' => $user->name,
'email' => $user->email,
'acting_account' => [
'id' => $account->id,
'public_id' => $account->public_id,
'name' => $account->name,
'email' => $account->email,
],
'accessible_account_ids' => $user->accessibleAccounts()->pluck('id')->values(),
],
]);
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Api\Concerns\CallsIdentityApi;
use App\Http\Controllers\Controller;
use App\Models\QrSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class AccountController extends Controller
{
use CallsIdentityApi;
public function settings(): JsonResponse
{
$account = ladill_account();
$settings = $account->getOrCreateQrSetting();
// Phone lives on the central account, not the local mirror — fetch it,
// but never let a transient identity-API hiccup break settings loading.
$phone = '';
$phoneCc = '';
try {
$profile = $this->identitySend('GET', '/api/identity/profile?user='.urlencode((string) $account->public_id), []);
if ($profile->successful()) {
$phone = (string) $profile->json('data.phone', '');
$phoneCc = (string) $profile->json('data.phone_cc', '');
}
} catch (\Throwable) {
// leave phone blank
}
return response()->json([
'data' => [
'profile' => [
'name' => $account->name,
'email' => $account->email,
'phone' => $phone,
'phone_cc' => $phoneCc,
],
'notifications' => [
'notify_email' => $settings->notify_email ?: $account->email,
'product_updates' => (bool) ($settings->product_updates ?? true),
'notify_registrations' => (bool) ($settings->notify_registrations ?? true),
'notify_payouts' => (bool) ($settings->notify_payouts ?? true),
],
],
]);
}
public function updateSettings(Request $request): JsonResponse
{
$account = ladill_account();
$data = $request->validate([
'notify_email' => ['nullable', 'email', 'max:255'],
'product_updates' => ['sometimes', 'boolean'],
'notify_registrations' => ['sometimes', 'boolean'],
'notify_payouts' => ['sometimes', 'boolean'],
]);
$settings = QrSetting::updateOrCreate(
['user_id' => $account->id],
[
'notify_email' => $data['notify_email'] ?? $account->email,
'product_updates' => $request->boolean('product_updates'),
'notify_registrations' => $request->boolean('notify_registrations'),
'notify_payouts' => $request->boolean('notify_payouts'),
],
);
return response()->json([
'data' => [
'notify_email' => $settings->notify_email,
'product_updates' => (bool) $settings->product_updates,
'notify_registrations' => (bool) $settings->notify_registrations,
'notify_payouts' => (bool) $settings->notify_payouts,
],
]);
}
public function updateProfile(Request $request): JsonResponse
{
$account = ladill_account();
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'phone_cc' => ['nullable', 'string', 'max:8'],
'phone' => ['nullable', 'string', 'max:32'],
]);
$response = $this->identitySend('PUT', '/api/identity/profile', array_merge(
['user' => $account->public_id],
$data,
));
$this->rethrowValidation($response, 'name');
if ($response->successful()) {
$account->update(['name' => $data['name']]);
}
return response()->json([
'data' => ['name' => $account->fresh()->name, 'email' => $account->email],
]);
}
public function uploadAvatar(Request $request): JsonResponse
{
$account = ladill_account();
$request->validate([
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
]);
$file = $request->file('avatar');
$response = Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/'))
->withToken((string) config('services.ladill_identity.key'))
->acceptJson()
->attach('avatar', (string) file_get_contents($file->getRealPath()), $file->getClientOriginalName())
->post('/api/identity/avatar', ['user' => $account->public_id]);
$this->rethrowValidation($response, 'avatar');
if ($response->successful()) {
$picture = (string) $response->json('data.user.picture', '');
if ($picture !== '') {
$account->update(['avatar_url' => $picture]);
}
}
return response()->json(['data' => ['avatar_url' => $account->fresh()->avatarUrl()]]);
}
public function changePassword(Request $request): JsonResponse
{
$account = ladill_account();
$request->validate([
'current_password' => ['required', 'string'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
$response = $this->identitySend('POST', '/api/identity/auth/change-password', [
'user' => $account->public_id,
'current_password' => $request->string('current_password'),
'password' => $request->string('password'),
'password_confirmation' => $request->string('password_confirmation'),
]);
$this->rethrowValidation($response, 'current_password');
return response()->json(['data' => ['message' => 'Password updated.']]);
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Afia\AfiaService;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AfiaController extends Controller
{
public function chat(Request $request, AfiaService $afia): JsonResponse
{
$validated = $request->validate([
'message' => ['required', 'string', 'max:2000'],
'history' => ['nullable', 'array', 'max:20'],
'history.*.role' => ['nullable', 'string'],
'history.*.text' => ['nullable', 'string'],
]);
if (! $afia->enabled()) {
return response()->json(['message' => 'Afia is not available right now.'], 503);
}
try {
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context());
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
}
return response()->json(['reply' => $reply]);
}
/** @return array<string,mixed> */
private function context(): array
{
$account = ladill_account();
$types = QrTypeCatalog::paymentTypes();
$codes = $account->qrCodes()->whereIn('type', $types);
$ctx = [
'signed_in' => 'yes',
'qr_codes_total' => (clone $codes)->count(),
'qr_codes_active' => (clone $codes)->where('is_active', true)->count(),
'scans_total' => (int) (clone $codes)->sum('scans_total'),
'top_code_type' => (clone $codes)
->selectRaw('type, count(*) as total')
->groupBy('type')
->orderByDesc('total')
->value('type') ?: 'none yet',
];
if ($account->public_id) {
try {
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2);
} catch (\Throwable) {
}
}
$recent = $account->qrCodes()
->whereIn('type', $types)
->latest('updated_at')
->limit(3)
->get(['type', 'label', 'short_code', 'scans_total']);
if ($recent->isNotEmpty()) {
$ctx['recent_codes'] = $recent->map(fn (QrCode $code): string => sprintf(
'%s (%s, %d scans)',
$code->label ?: $code->short_code,
QrTypeCatalog::label($code->type),
$code->scans_total,
))->implode('; ');
}
return $ctx;
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
$account = ladill_account();
$notifications = $account->notifications()
->latest()
->limit(100)
->get()
->map(fn ($n) => $this->present($n));
$unread = $account->unreadNotifications()->count();
return response()->json([
'data' => $notifications,
'unread_count' => $unread,
]);
}
public function unreadCount(Request $request): JsonResponse
{
$account = ladill_account();
return response()->json([
'data' => [
'unread_count' => $account->unreadNotifications()->count(),
],
]);
}
public function markAsRead(Request $request, string $id): JsonResponse
{
$notification = ladill_account()
->notifications()
->where('id', $id)
->first();
if ($notification) {
$notification->markAsRead();
}
return response()->json(['data' => ['success' => true]]);
}
public function markAllAsRead(Request $request): JsonResponse
{
ladill_account()->unreadNotifications->markAsRead();
return response()->json(['data' => ['success' => true]]);
}
/** @return array<string, mixed> */
private function present(mixed $notification): array
{
return [
'id' => $notification->id,
'type' => class_basename($notification->type),
'title' => $notification->data['title'] ?? 'Notification',
'message' => $notification->data['message'] ?? '',
'icon' => $notification->data['icon'] ?? 'bell',
'milestone' => $notification->data['milestone'] ?? null,
'url' => $notification->data['url'] ?? null,
'read_at' => $notification->read_at?->toIso8601String(),
'created_at' => $notification->created_at?->toIso8601String(),
];
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use App\Models\MiniPayment;
use App\Models\QrCode;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Throwable;
class OverviewController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function __invoke(): JsonResponse
{
$account = ladill_account();
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->pluck('id');
$todayPayments = MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->where('status', MiniPayment::STATUS_PAID)
->where('paid_at', '>=', now()->startOfDay());
$recentPayments = MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->where('status', MiniPayment::STATUS_PAID)
->with('qrCode')
->latest('paid_at')
->limit(8)
->get();
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Mini API overview could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return response()->json([
'data' => [
'currency' => 'GHS',
'today_takings_minor' => (int) (clone $todayPayments)->sum('merchant_amount_minor'),
'today_count' => (clone $todayPayments)->count(),
'payment_qr_count' => $qrIds->count(),
'wallet_balance_minor' => $balanceMinor,
'recent_payments' => $recentPayments->map(fn (MiniPayment $p) => PaymentPresenter::present($p))->values(),
],
]);
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Models\MiniPayment;
use App\Models\QrCode;
/**
* Shared JSON shape for Mini payments and payment QRs so the Android app and
* the web views stay describing the same records.
*/
class PaymentPresenter
{
/** @return array<string, mixed> */
public static function present(MiniPayment $payment): array
{
return [
'id' => $payment->id,
'reference' => $payment->reference,
'amount_minor' => $payment->amount_minor,
'merchant_amount_minor' => $payment->merchant_amount_minor,
'platform_fee_minor' => $payment->platform_fee_minor,
'currency' => $payment->currency,
'status' => $payment->status,
'payer_name' => $payment->payer_name,
'payer_email' => $payment->payer_email,
'payer_phone' => $payment->payer_phone,
'payer_note' => $payment->payer_note,
'qr_code_id' => $payment->qr_code_id,
'qr_label' => $payment->qrCode?->label,
'paid_at' => $payment->paid_at?->toIso8601String(),
'created_at' => $payment->created_at?->toIso8601String(),
];
}
/** @return array<string, mixed> */
public static function presentQr(QrCode $qr, ?string $previewUrl = null): array
{
$content = $qr->content();
return [
'id' => $qr->id,
'label' => $qr->label,
'business_name' => $content['business_name'] ?? null,
'branch_label' => $content['branch_label'] ?? null,
'currency' => $content['currency'] ?? 'GHS',
'short_code' => $qr->short_code,
'public_url' => $qr->publicUrl(),
'is_active' => $qr->is_active,
'scans_total' => $qr->scans_total,
'preview_url' => $previewUrl,
'created_at' => $qr->created_at?->toIso8601String(),
'updated_at' => $qr->updated_at?->toIso8601String(),
];
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class PaymentQrController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrImageGeneratorService $imageGenerator,
) {}
public function index(): JsonResponse
{
$qrCodes = ladill_account()->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->latest()
->get();
return response()->json([
'data' => $qrCodes->map(fn (QrCode $qr) => PaymentPresenter::presentQr($qr, $this->previewUrl($qr)))->values(),
]);
}
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.');
$data = $request->validate([
'label' => ['required', 'string', 'max:120'],
'business_name' => ['required', 'string', 'max:120'],
'branch_label' => ['nullable', 'string', 'max:80'],
]);
$data['type'] = QrCode::TYPE_PAYMENT;
$data['currency'] = 'GHS';
try {
$qrCode = $this->manager->create(ladill_account(), $data);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json([
'data' => PaymentPresenter::presentQr($qrCode->fresh(), $this->previewUrl($qrCode)),
], 201);
}
public function show(QrCode $paymentQr): JsonResponse
{
$this->authorizePaymentQr($paymentQr);
return response()->json([
'data' => PaymentPresenter::presentQr($paymentQr, $this->previewUrl($paymentQr)),
]);
}
public function update(Request $request, QrCode $paymentQr): JsonResponse
{
abort_unless($request->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.');
$this->authorizePaymentQr($paymentQr);
$data = $request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'business_name' => ['sometimes', 'string', 'max:120'],
'branch_label' => ['nullable', 'string', 'max:80'],
'is_active' => ['sometimes', 'boolean'],
]);
if ($request->has('is_active')) {
$data['is_active'] = $request->boolean('is_active');
}
try {
$this->manager->update($paymentQr, $data);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json([
'data' => PaymentPresenter::presentQr($paymentQr->fresh(), $this->previewUrl($paymentQr)),
]);
}
public function destroy(QrCode $paymentQr): JsonResponse
{
abort_unless(request()->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.');
$this->authorizePaymentQr($paymentQr);
$this->manager->delete($paymentQr);
return response()->json(['data' => ['message' => 'Payment QR deleted.']]);
}
public function preview(QrCode $paymentQr): Response
{
$this->authorizePaymentQr($paymentQr);
$qrCode = $this->imageGenerator->ensureValidImages($paymentQr);
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response($bytes, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'private, max-age=3600',
]);
}
private function previewUrl(QrCode $qr): string
{
return route('api.mini.payment-qrs.preview', $qr);
}
private function authorizePaymentQr(QrCode $paymentQr): void
{
abort_unless($paymentQr->type === QrCode::TYPE_PAYMENT, 404);
abort_unless($paymentQr->user_id === ladill_account()->id, 403);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use App\Models\MiniPayment;
use App\Models\QrCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentsController extends Controller
{
public function index(Request $request): JsonResponse
{
$search = trim((string) $request->query('q', ''));
$qrIds = ladill_account()->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->pluck('id');
$payments = MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('payer_name', 'like', $like)
->orWhere('payer_email', 'like', $like)
->orWhere('payer_note', 'like', $like)
->orWhere('reference', 'like', $like)
->orWhere('payment_reference', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
});
})
->with('qrCode')
->latest('created_at')
->paginate(25)
->withQueryString();
return response()->json([
'data' => collect($payments->items())->map(fn (MiniPayment $p) => PaymentPresenter::present($p))->values(),
'meta' => [
'current_page' => $payments->currentPage(),
'last_page' => $payments->lastPage(),
'per_page' => $payments->perPage(),
'total' => $payments->total(),
],
]);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use App\Models\MiniPayment;
use App\Models\QrCode;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Throwable;
class PayoutsController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function __invoke(): JsonResponse
{
$account = ladill_account();
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_PAYMENT)
->pluck('id');
$revenueMinor = (int) MiniPayment::query()
->whereIn('qr_code_id', $qrIds)
->where('status', MiniPayment::STATUS_PAID)
->sum('merchant_amount_minor');
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Mini API payouts could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return response()->json([
'data' => [
'currency' => 'GHS',
'total_revenue_minor' => $revenueMinor,
'wallet_balance_minor' => $balanceMinor,
'account_wallet_url' => 'https://'.config('app.account_domain').'/wallet',
],
]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Controller;
use App\Models\UserPushToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PushTokenController extends Controller
{
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'token' => ['required', 'string', 'max:512'],
'platform' => ['nullable', 'string', 'max:32'],
'device_name' => ['nullable', 'string', 'max:120'],
]);
$user = $request->user();
$now = now();
UserPushToken::updateOrCreate(
['token' => $data['token']],
[
'user_id' => $user->id,
'platform' => $data['platform'] ?? 'android',
'device_name' => $data['device_name'] ?? $user->currentAccessToken()?->name,
'last_seen_at' => $now,
],
);
$user->update(['last_app_active_at' => $now]);
return response()->json(['data' => ['registered' => true]]);
}
public function destroy(Request $request): JsonResponse
{
$data = $request->validate([
'token' => ['required', 'string', 'max:512'],
]);
$request->user()
->pushTokens()
->where('token', $data['token'])
->delete();
return response()->json(['data' => ['removed' => true]]);
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Api\Mini;
use App\Http\Controllers\Api\Concerns\CallsIdentityApi;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Support tickets proxies to the central support system (auth.ladill.com),
* so tickets land in the same admin support queue as every other Ladill app.
*/
class SupportController extends Controller
{
use CallsIdentityApi;
public function tickets(): JsonResponse
{
$response = $this->identitySend('GET', '/api/identity/support/tickets?user='.urlencode((string) ladill_account()->public_id), []);
return response()->json(['data' => $response->json('data', [])]);
}
public function ticket(int $ticket): JsonResponse
{
$response = $this->identitySend(
'GET',
'/api/identity/support/tickets/'.$ticket.'?user='.urlencode((string) ladill_account()->public_id),
[],
);
if ($response->status() === 404) {
return response()->json(['message' => 'Ticket not found.'], 404);
}
return response()->json(['data' => $response->json('data')]);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'subject' => ['required', 'string', 'max:255'],
'message' => ['required', 'string', 'max:5000'],
'priority' => ['sometimes', 'in:low,normal,high'],
]);
$response = $this->identitySend('POST', '/api/identity/support/tickets', array_merge(
['user' => ladill_account()->public_id],
$data,
));
$this->rethrowValidation($response, 'subject');
return response()->json(['data' => $response->json('data')], 201);
}
}
@@ -0,0 +1,161 @@
<?php
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;
use Illuminate\Support\Facades\Log;
use Throwable;
class WalletController extends Controller
{
use CallsIdentityApi;
public function __construct(
private BillingClient $billing,
private MiniNotificationService $notifications,
private AutoWithdrawService $autoWithdraw,
) {}
public function show(): JsonResponse
{
$account = ladill_account();
$balanceMinor = 0;
$ledger = [];
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
$ledger = $this->billing->serviceLedger($account->public_id, config('billing.service', 'mini'));
} catch (Throwable $e) {
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,
],
]);
}
public function topup(Request $request): JsonResponse
{
$account = ladill_account();
$data = $request->validate([
'amount' => ['required', 'numeric', 'min:1', 'max:10000'],
]);
$response = $this->identitySend('POST', '/api/identity/wallet-topup-account', [
'user' => $account->public_id,
'amount' => (float) $data['amount'],
// Paystack returns the customer here after payment; the app catches the deep link.
'return_url' => 'https://'.config('app.platform_domain').'/mini/wallet/topup-complete',
]);
$this->rethrowValidation($response, 'amount');
$checkoutUrl = (string) $response->json('data.checkout_url', '');
if ($checkoutUrl === '') {
return response()->json(['message' => 'Could not start top-up. Please try again.'], 422);
}
return response()->json(['data' => ['checkout_url' => $checkoutUrl]]);
}
public function banks(Request $request): JsonResponse
{
$type = $request->query('type') === 'mobile_money' ? 'mobile_money' : 'bank';
$response = $this->identitySend('GET', '/api/identity/banks?type='.urlencode($type), []);
return response()->json(['data' => $response->json('data', [])]);
}
public function payoutAccount(): JsonResponse
{
$response = $this->identitySend('GET', '/api/identity/payout-account?user='.urlencode((string) ladill_account()->public_id), []);
return response()->json(['data' => ['payout_account' => $response->json('data.payout_account')]]);
}
public function updatePayoutAccount(Request $request): JsonResponse
{
$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';
$response = $this->identitySend('PUT', '/api/identity/payout-account', array_merge(
['user' => ladill_account()->public_id],
$data,
));
$this->rethrowValidation($response, 'account_number');
return response()->json(['data' => ['payout_account' => $response->json('data.payout_account')]]);
}
public function withdrawals(): JsonResponse
{
$response = $this->identitySend('GET', '/api/identity/wallet/withdrawals?user='.urlencode((string) ladill_account()->public_id), []);
return response()->json(['data' => $response->json('data', [])]);
}
public function withdraw(Request $request): JsonResponse
{
$data = $request->validate([
'amount' => ['required', 'numeric', 'min:1', 'max:50000'],
]);
$response = $this->identitySend('POST', '/api/identity/wallet/withdraw', [
'user' => ladill_account()->public_id,
'amount' => (float) $data['amount'],
]);
$this->rethrowValidation($response, 'amount');
$this->notifications->withdrawalSubmitted(
ladill_account(),
(float) $data['amount'],
);
return response()->json(['data' => $response->json('data', [])]);
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class QrCodeController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrAnalyticsService $analytics,
) {}
public function index(Request $request): JsonResponse
{
$codes = $this->accountQuery()
->latest()
->get()
->map(fn (QrCode $code) => $this->present($code));
return response()->json(['data' => $codes]);
}
public function show(QrCode $qrCode): JsonResponse
{
$this->ensurePlusCode($qrCode);
$this->authorize('view', $qrCode);
return response()->json(['data' => $this->present($qrCode, detailed: true)]);
}
public function analytics(QrCode $qrCode): JsonResponse
{
$this->ensurePlusCode($qrCode);
$this->authorize('view', $qrCode);
return response()->json([
'data' => [
'summary' => $this->analytics->summaryFor($qrCode),
'daily_scans' => $this->analytics->dailyScans($qrCode, 30)->values(),
'devices' => $this->analytics->breakdown($qrCode, 'device_type'),
'browsers' => $this->analytics->breakdown($qrCode, 'browser'),
],
]);
}
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.');
$validated = $request->validate([
'label' => ['required', 'string', 'max:120'],
'type' => ['required', 'in:'.implode(',', QrTypeCatalog::keys())],
'destination_url' => ['nullable', 'url', 'max:2048'],
'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'],
'is_active' => ['sometimes', 'boolean'],
]);
if ($validated['type'] === QrCode::TYPE_DOCUMENT) {
return response()->json([
'message' => 'PDF QR codes must be created in the QR Plus app (file upload required).',
], 422);
}
try {
$qrCode = $this->manager->create(ladill_account(), array_merge(
$request->all(),
$validated,
));
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['data' => $this->present($qrCode->fresh(), detailed: true)], 201);
}
public function update(Request $request, QrCode $qrCode): JsonResponse
{
abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.');
$this->ensurePlusCode($qrCode);
$this->authorize('update', $qrCode);
$request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'destination_url' => ['nullable', 'url', 'max:2048'],
'is_active' => ['sometimes', 'boolean'],
]);
if ($qrCode->isDocumentType() && $request->hasFile('document')) {
return response()->json([
'message' => 'Replace PDF files in the QR Plus app.',
], 422);
}
try {
$updated = $this->manager->update($qrCode, $request->all());
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['data' => $this->present($updated->fresh(), detailed: true)]);
}
/** @return \Illuminate\Database\Eloquent\Builder<QrCode> */
private function accountQuery()
{
return QrCode::query()
->where('user_id', ladill_account()->id)
->whereIn('type', QrTypeCatalog::eventTypes());
}
private function ensurePlusCode(QrCode $qrCode): void
{
abort_unless(
$qrCode->user_id === ladill_account()->id && QrTypeCatalog::isValid($qrCode->type),
404,
);
}
/** @return array<string, mixed> */
private function present(QrCode $qrCode, bool $detailed = false): array
{
$data = [
'id' => $qrCode->id,
'label' => $qrCode->label,
'type' => $qrCode->type,
'type_label' => $qrCode->typeLabel(),
'short_code' => $qrCode->short_code,
'public_url' => $qrCode->publicUrl(),
'is_active' => $qrCode->is_active,
'scans_total' => $qrCode->scans_total,
'unique_scans_total' => $qrCode->unique_scans_total,
'last_scanned_at' => $qrCode->last_scanned_at?->toIso8601String(),
'created_at' => $qrCode->created_at?->toIso8601String(),
'updated_at' => $qrCode->updated_at?->toIso8601String(),
];
if ($detailed) {
$data['destination_url'] = $qrCode->destination_url;
$data['content'] = $qrCode->content();
}
return $data;
}
}