Add Ladill POS v1 — register, Pay checkout, and commerce links.
Deploy Ladill Mini / deploy (push) Successful in 23s
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:
@@ -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', [])]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user