From c39e38cbe07520043be2a9eaa87a5d27fd36dbd8 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 11 Jun 2026 11:03:33 +0000 Subject: [PATCH] Mini API: account settings, wallet & support endpoints for the app. Adds /api/v1/mini account (settings, profile, change-password), wallet (balance + ledger, Paystack top-up) and support (Afia chat) endpoints. Account/profile/ password/top-up proxy to the central identity API via a CallsIdentityApi trait; notifications, wallet balance and support chat are served locally. Co-Authored-By: Claude Opus 4.8 --- .../Api/Concerns/CallsIdentityApi.php | 47 ++++++++ .../Api/Mini/AccountController.php | 111 ++++++++++++++++++ .../Api/Mini/SupportController.php | 47 ++++++++ .../Controllers/Api/Mini/WalletController.php | 65 ++++++++++ routes/api.php | 15 +++ 5 files changed, 285 insertions(+) create mode 100644 app/Http/Controllers/Api/Concerns/CallsIdentityApi.php create mode 100644 app/Http/Controllers/Api/Mini/AccountController.php create mode 100644 app/Http/Controllers/Api/Mini/SupportController.php create mode 100644 app/Http/Controllers/Api/Mini/WalletController.php diff --git a/app/Http/Controllers/Api/Concerns/CallsIdentityApi.php b/app/Http/Controllers/Api/Concerns/CallsIdentityApi.php new file mode 100644 index 0000000..d7689fa --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/CallsIdentityApi.php @@ -0,0 +1,47 @@ +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.']], + ); + } + } +} diff --git a/app/Http/Controllers/Api/Mini/AccountController.php b/app/Http/Controllers/Api/Mini/AccountController.php new file mode 100644 index 0000000..9121159 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/AccountController.php @@ -0,0 +1,111 @@ +getOrCreateQrSetting(); + + return response()->json([ + 'data' => [ + 'profile' => [ + 'name' => $account->name, + 'email' => $account->email, + ], + '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 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.']]); + } +} diff --git a/app/Http/Controllers/Api/Mini/SupportController.php b/app/Http/Controllers/Api/Mini/SupportController.php new file mode 100644 index 0000000..874556c --- /dev/null +++ b/app/Http/Controllers/Api/Mini/SupportController.php @@ -0,0 +1,47 @@ +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' => 'Support chat 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' => 'We could not respond right now. Please try again.'], 502); + } + + return response()->json(['data' => ['reply' => $reply]]); + } + + /** @return array */ + private function context(): array + { + $account = ladill_account(); + + return [ + 'app' => 'Ladill Mini', + 'description' => 'Ladill Mini is a payments app: merchants create payment QR codes and links, collect payments, and track takings and payouts.', + 'account_name' => $account?->name, + ]; + } +} diff --git a/app/Http/Controllers/Api/Mini/WalletController.php b/app/Http/Controllers/Api/Mini/WalletController.php new file mode 100644 index 0000000..360fc23 --- /dev/null +++ b/app/Http/Controllers/Api/Mini/WalletController.php @@ -0,0 +1,65 @@ +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()]); + } + + return response()->json([ + 'data' => [ + 'currency' => 'GHS', + 'balance_minor' => $balanceMinor, + 'spent_minor' => (int) ($ledger['spent_minor'] ?? 0), + 'credited_minor' => (int) ($ledger['credited_minor'] ?? 0), + ], + ]); + } + + 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]]); + } +} diff --git a/routes/api.php b/routes/api.php index 8b6d354..dab5b69 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,10 +2,13 @@ use App\Http\Controllers\Api\AuthController; use App\Http\Controllers\Api\MeController; +use App\Http\Controllers\Api\Mini\AccountController as MiniAccountController; use App\Http\Controllers\Api\Mini\OverviewController as MiniOverviewController; use App\Http\Controllers\Api\Mini\PaymentQrController as MiniPaymentQrController; use App\Http\Controllers\Api\Mini\PaymentsController as MiniPaymentsController; use App\Http\Controllers\Api\Mini\PayoutsController as MiniPayoutsController; +use App\Http\Controllers\Api\Mini\SupportController as MiniSupportController; +use App\Http\Controllers\Api\Mini\WalletController as MiniWalletController; use App\Http\Controllers\Api\QrCodeController; use Illuminate\Support\Facades\Route; @@ -36,5 +39,17 @@ Route::prefix('v1')->group(function () { Route::get('/mini/payments', [MiniPaymentsController::class, 'index'])->name('api.mini.payments.index'); Route::get('/mini/payouts', MiniPayoutsController::class)->name('api.mini.payouts'); + + // Account self-service (Settings, Wallet, Support) — keeps Mini usable + // entirely in-app without the website. + Route::get('/mini/account/settings', [MiniAccountController::class, 'settings'])->name('api.mini.account.settings'); + Route::put('/mini/account/settings', [MiniAccountController::class, 'updateSettings'])->name('api.mini.account.settings.update'); + Route::put('/mini/account/profile', [MiniAccountController::class, 'updateProfile'])->name('api.mini.account.profile'); + Route::post('/mini/account/change-password', [MiniAccountController::class, 'changePassword'])->name('api.mini.account.change-password'); + + Route::get('/mini/wallet', [MiniWalletController::class, 'show'])->name('api.mini.wallet'); + Route::post('/mini/wallet/topup', [MiniWalletController::class, 'topup'])->name('api.mini.wallet.topup'); + + Route::post('/mini/support/chat', [MiniSupportController::class, 'chat'])->name('api.mini.support.chat'); }); });