diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..744dd76 --- /dev/null +++ b/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,127 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + 'device_name' => ['nullable', 'string', 'max:120'], + ]); + + $issuer = rtrim((string) config('services.ladill_sso.issuer'), '/'); + + $tokenRes = Http::asForm()->post($issuer.'/oauth/token', [ + 'grant_type' => 'password', + 'client_id' => (string) config('services.ladill_sso.client_id'), + 'client_secret' => (string) config('services.ladill_sso.client_secret'), + 'username' => $credentials['email'], + 'password' => $credentials['password'], + 'scope' => 'openid profile email', + ]); + + if ($tokenRes->failed()) { + throw ValidationException::withMessages([ + 'email' => ['The email or password is incorrect.'], + ]); + } + + $user = $this->provisionFromAccessToken($issuer, (string) $tokenRes->json('access_token')); + + if (! $user) { + throw ValidationException::withMessages([ + 'email' => ['We could not verify your Ladill account. Please try again.'], + ]); + } + + QrTeamMember::linkPendingInvitesFor($user); + + $deviceName = $credentials['device_name'] ?? 'Ladill Mini Android'; + $token = $user->createToken($deviceName, ['mini:read', 'mini:write']); + + return response()->json([ + 'data' => [ + 'token' => $token->plainTextToken, + 'user' => $this->presentUser($user), + ], + ]); + } + + public function logout(Request $request): JsonResponse + { + $token = $request->user()?->currentAccessToken(); + $token?->delete(); + + return response()->json(['data' => ['message' => 'Signed out.']]); + } + + public function me(Request $request): JsonResponse + { + return response()->json(['data' => $this->presentUser($request->user())]); + } + + private function provisionFromAccessToken(string $issuer, string $accessToken): ?User + { + if ($accessToken === '') { + return null; + } + + $claims = Http::withToken($accessToken)->acceptJson()->get($issuer.'/oauth/userinfo'); + + if ($claims->failed() || ! $claims->json('sub')) { + return null; + } + + $email = (string) ($claims->json('email') ?: ''); + + return User::updateOrCreate( + ['public_id' => (string) $claims->json('sub')], + [ + 'name' => $claims->json('name'), + 'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com', + 'avatar_url' => $claims->json('picture'), + ], + ); + } + + /** @return array */ + private function presentUser(User $user): array + { + $account = ladill_account(); + + 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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Mini/OverviewController.php b/app/Http/Controllers/Api/Mini/OverviewController.php new file mode 100644 index 0000000..af105c9 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/OverviewController.php @@ -0,0 +1,59 @@ +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(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Mini/PaymentPresenter.php b/app/Http/Controllers/Api/Mini/PaymentPresenter.php new file mode 100644 index 0000000..b05c1d0 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/PaymentPresenter.php @@ -0,0 +1,56 @@ + */ + 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 */ + 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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Mini/PaymentQrController.php b/app/Http/Controllers/Api/Mini/PaymentQrController.php new file mode 100644 index 0000000..b310fb0 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/PaymentQrController.php @@ -0,0 +1,131 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/Mini/PaymentsController.php b/app/Http/Controllers/Api/Mini/PaymentsController.php new file mode 100644 index 0000000..1bec5a4 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/PaymentsController.php @@ -0,0 +1,49 @@ +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(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Mini/PayoutsController.php b/app/Http/Controllers/Api/Mini/PayoutsController.php new file mode 100644 index 0000000..7903784 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/PayoutsController.php @@ -0,0 +1,49 @@ +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', + ], + ]); + } +} diff --git a/routes/api.php b/routes/api.php index abcace7..3e9827c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,15 +1,38 @@ prefix('v1')->group(function () { - Route::get('/me', MeController::class); +Route::prefix('v1')->group(function () { + // Mobile token login (Ladill Mini Android). Public — issues a Sanctum token. + Route::post('/auth/login', [AuthController::class, 'login'])->name('api.auth.login'); - Route::get('/qr-codes', [QrCodeController::class, 'index']); - Route::post('/qr-codes', [QrCodeController::class, 'store']); - Route::get('/qr-codes/{qrCode}', [QrCodeController::class, 'show'])->whereNumber('qrCode'); - Route::patch('/qr-codes/{qrCode}', [QrCodeController::class, 'update'])->whereNumber('qrCode'); - Route::get('/qr-codes/{qrCode}/analytics', [QrCodeController::class, 'analytics'])->whereNumber('qrCode'); + Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->group(function () { + Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout'); + Route::get('/me', MeController::class); + + Route::get('/qr-codes', [QrCodeController::class, 'index']); + Route::post('/qr-codes', [QrCodeController::class, 'store']); + Route::get('/qr-codes/{qrCode}', [QrCodeController::class, 'show'])->whereNumber('qrCode'); + Route::patch('/qr-codes/{qrCode}', [QrCodeController::class, 'update'])->whereNumber('qrCode'); + Route::get('/qr-codes/{qrCode}/analytics', [QrCodeController::class, 'analytics'])->whereNumber('qrCode'); + + // Ladill Mini — payments product. + Route::get('/mini/overview', MiniOverviewController::class)->name('api.mini.overview'); + Route::get('/mini/payment-qrs', [MiniPaymentQrController::class, 'index'])->name('api.mini.payment-qrs.index'); + Route::post('/mini/payment-qrs', [MiniPaymentQrController::class, 'store'])->name('api.mini.payment-qrs.store'); + Route::get('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'show'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.show'); + Route::patch('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'update'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.update'); + Route::delete('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'destroy'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.destroy'); + Route::get('/mini/payment-qrs/{paymentQr}/preview.png', [MiniPaymentQrController::class, 'preview'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.preview'); + + Route::get('/mini/payments', [MiniPaymentsController::class, 'index'])->name('api.mini.payments.index'); + Route::get('/mini/payouts', MiniPayoutsController::class)->name('api.mini.payouts'); + }); });