Prune the upstream Mini/QR mobile subsystem from POS
Deploy Ladill POS / deploy (push) Successful in 33s
Deploy Ladill POS / deploy (push) Successful in 33s
POS was forked from Ladill Mini and carried Mini's entire mobile/QR subsystem (API, QR codes, wallet, payments, push, Afia) which polluted the ladill_pos schema with 8 unused tables and left ~half the test suite red. Remove the dead subsystem: Api/Mini/Qr/Public/Search/WellKnown controllers, Mini/Qr/Afia/Notifications services, the 8 unused models, QrCodePolicy, Support/Qr + Support/Events, the two mini: scheduled commands, the Mini/QR view trees, and their (failing) tests. Empty routes/api.php (POS is web-only) and strip dead schedules from routes/console.php. Keep QrTeamMember — it is the platform team-membership model that POS's SetActingAccount middleware and SSO login depend on for multi-account access. Also keep notifications + personal_access_tokens (used by POS). Drops 7 migrations (qr product/settings, mini_payments, push tokens); the 8 orphan tables are dropped from the live ladill_pos DB separately. Test suite is green (8 passed) and all routes resolve. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
57862acb47
commit
46b5091b1e
@@ -1,196 +0,0 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?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.']],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
<?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.']]);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?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]]);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
<?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', [])]);
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$qrIds = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_PAYMENT)
|
||||
->pluck('id');
|
||||
|
||||
$todayStart = now()->startOfDay();
|
||||
|
||||
$todayPayments = MiniPayment::query()
|
||||
->whereIn('qr_code_id', $qrIds)
|
||||
->where('status', MiniPayment::STATUS_PAID)
|
||||
->where('paid_at', '>=', $todayStart);
|
||||
|
||||
$todayCount = (clone $todayPayments)->count();
|
||||
$todayMinor = (int) (clone $todayPayments)->sum('merchant_amount_minor');
|
||||
|
||||
$recentPayments = MiniPayment::query()
|
||||
->whereIn('qr_code_id', $qrIds)
|
||||
->where('status', MiniPayment::STATUS_PAID)
|
||||
->with('qrCode')
|
||||
->latest('paid_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
$paymentQrCount = $qrIds->count();
|
||||
|
||||
$balanceMinor = 0;
|
||||
try {
|
||||
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Mini dashboard could not load wallet balance', [
|
||||
'user' => $account->public_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('mini.dashboard', [
|
||||
'todayCount' => $todayCount,
|
||||
'todayMinor' => $todayMinor,
|
||||
'paymentQrCount' => $paymentQrCount,
|
||||
'recentPayments' => $recentPayments,
|
||||
'balanceMinor' => $balanceMinor,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
use App\Services\Qr\QrPdfExporter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PaymentQrController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private QrCodeManagerService $manager,
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPdfExporter $pdfExporter,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$qrCodes = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_PAYMENT)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$previewDataUris = $qrCodes->mapWithKeys(function (QrCode $qr) {
|
||||
return [$qr->id => $this->imageGenerator->previewDataUri($qr)];
|
||||
});
|
||||
|
||||
return view('mini.payment-qrs.index', [
|
||||
'qrCodes' => $qrCodes,
|
||||
'previewDataUris' => $previewDataUris,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('mini.payment-qrs.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$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($account, $data);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('mini.payment-qrs.show', $qrCode)->with('success', 'Payment QR created.');
|
||||
}
|
||||
|
||||
public function show(QrCode $paymentQr): View
|
||||
{
|
||||
$this->authorizePaymentQr($paymentQr);
|
||||
|
||||
$previewDataUri = $this->imageGenerator->previewDataUri($paymentQr);
|
||||
|
||||
return view('mini.payment-qrs.show', [
|
||||
'qrCode' => $paymentQr->fresh(),
|
||||
'previewDataUri' => $previewDataUri,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, QrCode $paymentQr): RedirectResponse
|
||||
{
|
||||
$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',
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active', $paymentQr->is_active);
|
||||
|
||||
try {
|
||||
$this->manager->update($paymentQr, $data);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', 'Payment QR updated.');
|
||||
}
|
||||
|
||||
public function destroy(QrCode $paymentQr): RedirectResponse
|
||||
{
|
||||
$this->authorizePaymentQr($paymentQr);
|
||||
|
||||
$this->manager->delete($paymentQr);
|
||||
|
||||
return redirect()
|
||||
->route('mini.payment-qrs.index')
|
||||
->with('success', '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',
|
||||
]);
|
||||
}
|
||||
|
||||
public function download(QrCode $paymentQr, string $format): StreamedResponse
|
||||
{
|
||||
$this->authorizePaymentQr($paymentQr);
|
||||
|
||||
$qrCode = $this->imageGenerator->ensureValidImages($paymentQr);
|
||||
$path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path;
|
||||
$filename = Str::slug($qrCode->label).'-qr.'.($format === 'svg' ? 'svg' : 'png');
|
||||
|
||||
if ($format === 'png') {
|
||||
$bytes = $this->imageGenerator->normalizeStoredPng($path);
|
||||
if ($bytes === null) {
|
||||
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
}
|
||||
|
||||
return response()->streamDownload(fn () => print($bytes), $filename, [
|
||||
'Content-Type' => 'image/png',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($format === 'pdf') {
|
||||
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
|
||||
if ($bytes === null) {
|
||||
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
}
|
||||
|
||||
$pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label);
|
||||
$filename = Str::slug($qrCode->label).'-qr.pdf';
|
||||
|
||||
return response()->streamDownload(fn () => print($pdf), $filename, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
]);
|
||||
}
|
||||
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->download($path, $filename);
|
||||
}
|
||||
|
||||
private function authorizePaymentQr(QrCode $paymentQr): void
|
||||
{
|
||||
abort_unless($paymentQr->type === QrCode::TYPE_PAYMENT, 404);
|
||||
abort_unless($paymentQr->user_id === ladill_account()->id, 403);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PaymentsController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$search = trim((string) $request->query('q', ''));
|
||||
|
||||
$qrIds = $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 view('mini.payments', [
|
||||
'payments' => $payments,
|
||||
'search' => $search,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class PayoutsController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$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 payouts could not load wallet balance', [
|
||||
'user' => $account->public_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$accountWalletUrl = 'https://'.config('app.account_domain').'/wallet';
|
||||
|
||||
return view('mini.payouts', [
|
||||
'revenueMinor' => $revenueMinor,
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'accountWalletUrl' => $accountWalletUrl,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Public;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Mini\MiniPaymentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
|
||||
class PaymentController extends Controller
|
||||
{
|
||||
public function __construct(private MiniPaymentService $payments) {}
|
||||
|
||||
public function pay(Request $request, string $shortCode): JsonResponse|RedirectResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->with('user.qrSetting')
|
||||
->where('short_code', $shortCode)
|
||||
->where('type', QrCode::TYPE_PAYMENT)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
$validated = $request->validate([
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->payments->initiate($qrCode, $validated);
|
||||
} catch (RuntimeException $e) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['error' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['checkout_url' => $result['checkout_url']]);
|
||||
}
|
||||
|
||||
return redirect()->away($result['checkout_url']);
|
||||
}
|
||||
|
||||
public function callback(Request $request, string $shortCode): RedirectResponse|View
|
||||
{
|
||||
$reference = trim((string) $request->query('reference', ''));
|
||||
if ($reference === '') {
|
||||
return redirect('/q/'.$shortCode)->with('error', 'Missing payment reference.');
|
||||
}
|
||||
|
||||
try {
|
||||
$payment = $this->payments->complete($reference);
|
||||
} catch (RuntimeException $e) {
|
||||
return redirect('/q/'.$shortCode)->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return view('public.qr.payment-confirmed', [
|
||||
'payment' => $payment,
|
||||
'qrCode' => $payment->qrCode,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Public;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Qr\QrScanRecorder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class QrScanController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private QrScanRecorder $scanRecorder,
|
||||
) {}
|
||||
|
||||
public function resolve(Request $request, string $shortCode): RedirectResponse|View
|
||||
{
|
||||
$qrCode = QrCode::query()->where('short_code', $shortCode)->firstOrFail();
|
||||
|
||||
$this->scanRecorder->record($qrCode, $request);
|
||||
|
||||
if (! $qrCode->is_active) {
|
||||
return view('public.qr.inactive', ['qrCode' => $qrCode]);
|
||||
}
|
||||
|
||||
if ($qrCode->resolvesToRedirect()) {
|
||||
$url = $qrCode->redirectUrl();
|
||||
abort_unless($url, 404);
|
||||
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
if ($qrCode->isDocumentType()) {
|
||||
return redirect()->route('qr.public.view', $shortCode);
|
||||
}
|
||||
|
||||
if ($qrCode->isBookType()) {
|
||||
return view('public.qr.book-landing', ['qrCode' => $qrCode]);
|
||||
}
|
||||
|
||||
if ($qrCode->isPaymentType()) {
|
||||
return view('public.qr.payment-landing', ['qrCode' => $qrCode]);
|
||||
}
|
||||
|
||||
if ($qrCode->usesLandingPage()) {
|
||||
return view('public.qr.landing', ['qrCode' => $qrCode]);
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public function view(string $shortCode): View
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->with('document')
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->isDocumentType() && $qrCode->document, 404);
|
||||
|
||||
return view('public.qr.document-viewer', [
|
||||
'qrCode' => $qrCode,
|
||||
'fileUrl' => route('qr.public.file', $shortCode),
|
||||
'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function file(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->with('document')
|
||||
->firstOrFail();
|
||||
|
||||
$document = $qrCode->document;
|
||||
abort_unless($document && Storage::disk($document->disk)->exists($document->path), 404);
|
||||
|
||||
return Storage::disk($document->disk)->response(
|
||||
$document->path,
|
||||
($document->title ?: 'document') . '.pdf',
|
||||
['Content-Type' => 'application/pdf'],
|
||||
);
|
||||
}
|
||||
|
||||
public function image(string $shortCode, int $index): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->isImageType(), 404);
|
||||
|
||||
$images = $qrCode->content()['images'] ?? [];
|
||||
abort_unless(isset($images[$index]['path']), 404);
|
||||
|
||||
$path = $images[$index]['path'];
|
||||
abort_unless(Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function itemImage(string $shortCode, int $sectionIndex, int $itemIndex): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
|
||||
|
||||
$sections = $qrCode->content()['sections'] ?? [];
|
||||
$path = $sections[$sectionIndex]['items'][$itemIndex]['image_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function vcard(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === QrCode::TYPE_VCARD, 404);
|
||||
|
||||
$c = $qrCode->content();
|
||||
$lines = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'N:' . ($c['last_name'] ?? '') . ';' . ($c['first_name'] ?? '') . ';;;',
|
||||
'FN:' . trim(($c['first_name'] ?? '') . ' ' . ($c['last_name'] ?? '')),
|
||||
];
|
||||
|
||||
if (! empty($c['company'])) {
|
||||
$lines[] = 'ORG:' . $this->escapeVcard($c['company']);
|
||||
}
|
||||
if (! empty($c['phone'])) {
|
||||
$lines[] = 'TEL;TYPE=CELL:' . $this->escapeVcard($c['phone']);
|
||||
}
|
||||
if (! empty($c['email'])) {
|
||||
$lines[] = 'EMAIL;TYPE=INTERNET:' . $this->escapeVcard($c['email']);
|
||||
}
|
||||
if (! empty($c['website'])) {
|
||||
$lines[] = 'URL:' . $this->escapeVcard($c['website']);
|
||||
}
|
||||
if (! empty($c['address'])) {
|
||||
$lines[] = 'ADR;TYPE=WORK:;;' . $this->escapeVcard($c['address']) . ';;;;';
|
||||
}
|
||||
if (! empty($c['note'])) {
|
||||
$lines[] = 'NOTE:' . $this->escapeVcard($c['note']);
|
||||
}
|
||||
|
||||
$lines[] = 'END:VCARD';
|
||||
$vcf = implode("\r\n", $lines) . "\r\n";
|
||||
$filename = Str::slug($qrCode->label ?: 'contact') . '.vcf';
|
||||
|
||||
return response()->streamDownload(fn () => print($vcf), $filename, [
|
||||
'Content-Type' => 'text/vcard',
|
||||
]);
|
||||
}
|
||||
|
||||
public function vcardAvatar(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === QrCode::TYPE_VCARD, 404);
|
||||
|
||||
$path = $qrCode->content()['avatar_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function bookCover(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->isBookType(), 404);
|
||||
|
||||
$path = $qrCode->content()['cover_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function menuLogo(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
|
||||
|
||||
$path = $qrCode->content()['logo_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function menuCover(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
|
||||
|
||||
$path = $qrCode->content()['cover_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function businessLogo(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === QrCode::TYPE_BUSINESS, 404);
|
||||
|
||||
$path = $qrCode->content()['logo_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function businessCover(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === QrCode::TYPE_BUSINESS, 404);
|
||||
|
||||
$path = $qrCode->content()['cover_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function churchLogo(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === QrCode::TYPE_CHURCH, 404);
|
||||
|
||||
$path = $qrCode->content()['logo_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function churchCover(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === QrCode::TYPE_CHURCH, 404);
|
||||
|
||||
$path = $qrCode->content()['cover_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function paymentLogo(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_PAYMENT, 'logo_path');
|
||||
}
|
||||
|
||||
public function eventLogo(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'logo_path');
|
||||
}
|
||||
|
||||
public function eventCover(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'cover_path');
|
||||
}
|
||||
|
||||
public function itineraryCover(string $shortCode): StreamedResponse
|
||||
{
|
||||
return $this->serveContentImage($shortCode, QrCode::TYPE_ITINERARY, 'cover_path');
|
||||
}
|
||||
|
||||
private function serveContentImage(string $shortCode, string $type, string $key): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === $type, 404);
|
||||
|
||||
$path = $qrCode->content()[$key] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
public function appIcon(string $shortCode): StreamedResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->where('short_code', $shortCode)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($qrCode->type === QrCode::TYPE_APP, 404);
|
||||
|
||||
$path = $qrCode->content()['icon_path'] ?? null;
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->response($path);
|
||||
}
|
||||
|
||||
private function escapeVcard(string $value): string
|
||||
{
|
||||
return str_replace(["\n", "\r", ',', ';'], [' ', ' ', '\\,', '\\;'], $value);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrSetting;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
|
||||
private function topupUrl(): string
|
||||
{
|
||||
return 'https://'.config('app.account_domain').'/wallet';
|
||||
}
|
||||
|
||||
/** @return array{0: int, 1: array<string, mixed>} */
|
||||
private function billingSnapshot(?string $publicId): array
|
||||
{
|
||||
if (! $publicId) {
|
||||
return [0, []];
|
||||
}
|
||||
|
||||
$balanceMinor = $this->billing->balanceMinor($publicId);
|
||||
$ledger = $this->billing->serviceLedger($publicId, config('billing.service', 'mini'));
|
||||
|
||||
return [$balanceMinor, $ledger];
|
||||
}
|
||||
|
||||
public function wallet(): View
|
||||
{
|
||||
$user = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
|
||||
|
||||
return view('qr.account.wallet', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function billing(): View
|
||||
{
|
||||
$user = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
|
||||
|
||||
return view('qr.account.billing', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function settings(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$settings = $account->getOrCreateQrSetting();
|
||||
|
||||
return view('mini.settings', [
|
||||
'account' => $account,
|
||||
'settings' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'notify_email' => ['nullable', 'email', 'max:255'],
|
||||
'product_updates' => ['nullable', 'boolean'],
|
||||
'notify_registrations' => ['nullable', 'boolean'],
|
||||
'notify_payouts' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
QrSetting::updateOrCreate(
|
||||
['user_id' => $account->id],
|
||||
[
|
||||
'notify_email' => $data['notify_email'] ?? null,
|
||||
'product_updates' => $request->boolean('product_updates'),
|
||||
'notify_registrations' => $request->boolean('notify_registrations'),
|
||||
'notify_payouts' => $request->boolean('notify_payouts'),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Settings saved.');
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
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();
|
||||
|
||||
if (! $account) {
|
||||
return ['signed_in' => 'no'];
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Developers — personal API tokens for the Ladill QR Plus API (Sanctum). Tokens
|
||||
* belong to the signed-in user and authorize calls to /api/v1/*. The plaintext
|
||||
* token is shown once on create.
|
||||
*/
|
||||
class DeveloperController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('qr.account.developers', [
|
||||
'tokens' => $request->user()->tokens()->latest()->get(),
|
||||
'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1',
|
||||
'newToken' => session('new_token'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:60'],
|
||||
]);
|
||||
|
||||
$token = $request->user()->createToken($data['name'], ['qr:read', 'qr:write']);
|
||||
|
||||
return redirect()->route('account.developers')
|
||||
->with('new_token', $token->plainTextToken)
|
||||
->with('success', 'Token created — copy it now, it won’t be shown again.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $token): RedirectResponse
|
||||
{
|
||||
$request->user()->tokens()->whereKey($token)->delete();
|
||||
|
||||
return redirect()->route('account.developers')->with('success', 'Token revoked.');
|
||||
}
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Models\QrWallet;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Qr\QrAnalyticsService;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
use App\Services\Qr\QrPdfExporter;
|
||||
use App\Services\Qr\QrWalletBillingService;
|
||||
use App\Support\Qr\QrCornerStyleCatalog;
|
||||
use App\Support\Qr\QrFrameStyleCatalog;
|
||||
use App\Support\Qr\QrModuleStyleCatalog;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class QrCodeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private QrCodeManagerService $manager,
|
||||
private QrWalletBillingService $billing,
|
||||
private QrAnalyticsService $analytics,
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPdfExporter $pdfExporter,
|
||||
private BillingClient $platformBilling,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$wallet = $this->manager->walletFor($account);
|
||||
$qrCodes = $account->qrCodes()
|
||||
->whereIn('type', QrTypeCatalog::eventTypes())
|
||||
->withCount(['eventRegistrations as registrations_count' => fn ($q) => $q->where('status', QrEventRegistration::STATUS_CONFIRMED)])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$totalRegistrations = (int) $qrCodes->sum('registrations_count');
|
||||
|
||||
$ladillWalletBalance = 0.0;
|
||||
try {
|
||||
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Events index could not load Ladill wallet balance', [
|
||||
'user' => $account->public_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('qr-codes.index', [
|
||||
'wallet' => $wallet,
|
||||
'qrCodes' => $qrCodes,
|
||||
'totalRegistrations' => $totalRegistrations,
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
'minTopup' => QrWallet::minTopupGhs(),
|
||||
'ladillWalletBalance' => $ladillWalletBalance,
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$wallet = $this->manager->walletFor($account);
|
||||
$qrSettings = $account->getOrCreateQrSetting();
|
||||
$requestedType = $request->query('type', QrCode::TYPE_EVENT);
|
||||
if (! QrTypeCatalog::isValid($requestedType)) {
|
||||
$requestedType = QrCode::TYPE_EVENT;
|
||||
}
|
||||
|
||||
return view('qr-codes.create', [
|
||||
'wallet' => $wallet,
|
||||
'requestedType' => $requestedType,
|
||||
'moduleStyles' => QrModuleStyleCatalog::visible(),
|
||||
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
|
||||
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
|
||||
'frameStyles' => QrFrameStyleCatalog::visible(),
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
'minTopup' => QrWallet::minTopupGhs(),
|
||||
'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100,
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(),
|
||||
'accountEventDefaults' => $qrSettings->resolvedEventDefaults(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function checkSlug(Request $request): JsonResponse
|
||||
{
|
||||
$code = (string) $request->query('code', '');
|
||||
|
||||
if (! preg_match('/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', $code)) {
|
||||
return response()->json(['available' => false, 'reason' => 'invalid']);
|
||||
}
|
||||
|
||||
$taken = QrCode::where('short_code', $code)->exists();
|
||||
|
||||
return response()->json(['available' => ! $taken]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'label' => ['required', 'string', 'max:120'],
|
||||
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
|
||||
'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'],
|
||||
'destination_url' => ['nullable', 'url', 'max:2048'],
|
||||
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
|
||||
'image' => ['nullable', 'image', 'max:10240'],
|
||||
'images' => ['nullable', 'array'],
|
||||
'images.*' => ['image', 'max:10240'],
|
||||
'item_images' => ['nullable', 'array'],
|
||||
'item_images.*' => ['array'],
|
||||
'item_images.*.*' => ['image', 'max:4096'],
|
||||
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
|
||||
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
...$this->styleRules(),
|
||||
]);
|
||||
|
||||
if ($validated['type'] === QrCode::TYPE_DOCUMENT && ! $request->hasFile('document')) {
|
||||
return back()->withInput()->with('error', 'Upload a PDF for PDF QR codes.');
|
||||
}
|
||||
|
||||
if ($validated['type'] === QrCode::TYPE_IMAGE && ! $request->hasFile('image') && ! $request->hasFile('images')) {
|
||||
return back()->withInput()->with('error', 'Upload at least one image.');
|
||||
}
|
||||
|
||||
try {
|
||||
$qrCode = $this->manager->create(ladill_account(), array_merge(
|
||||
$request->all(),
|
||||
[
|
||||
'style' => $validated['style'] ?? [],
|
||||
'custom_short_code' => $validated['custom_short_code'] ?? null,
|
||||
'avatar' => $request->file('avatar'),
|
||||
'book_file' => $request->file('book_file'),
|
||||
'cover' => $request->file('cover'),
|
||||
'menu_logo' => $request->file('menu_logo'),
|
||||
'menu_cover' => $request->file('menu_cover'),
|
||||
'business_logo' => $request->file('business_logo'),
|
||||
'business_cover' => $request->file('business_cover'),
|
||||
'church_logo' => $request->file('church_logo'),
|
||||
'church_cover' => $request->file('church_cover'),
|
||||
'event_logo' => $request->file('event_logo'),
|
||||
'event_cover' => $request->file('event_cover'),
|
||||
'itinerary_cover' => $request->file('itinerary_cover'),
|
||||
],
|
||||
));
|
||||
} catch (RuntimeException $e) {
|
||||
if (str_contains($e->getMessage(), 'Insufficient') || str_contains($e->getMessage(), 'Add at least')) {
|
||||
return back()->withInput()->with('open_topup_modal', 'qr');
|
||||
}
|
||||
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('events.show', $qrCode)
|
||||
->with('success', QrTypeCatalog::label($qrCode->type).' created.');
|
||||
}
|
||||
|
||||
public function show(Request $request, QrCode $event): View
|
||||
{
|
||||
$this->authorize('view', $event);
|
||||
|
||||
$previewDataUri = $this->imageGenerator->previewDataUri($event);
|
||||
$qrCode = $event->fresh();
|
||||
$qrStyle = $qrCode->style();
|
||||
$logoDataUri = ! empty($qrStyle['logo_path'])
|
||||
? $this->imageGenerator->logoDataUri($qrStyle['logo_path'])
|
||||
: null;
|
||||
$account = ladill_account();
|
||||
$wallet = $this->manager->walletFor($account);
|
||||
$summary = $this->analytics->summaryFor($qrCode);
|
||||
$dailyScans = $this->analytics->dailyScans($qrCode, 30);
|
||||
$devices = $this->analytics->breakdown($qrCode, 'device_type');
|
||||
$browsers = $this->analytics->breakdown($qrCode, 'browser');
|
||||
$recentScans = $this->analytics->recentScans($qrCode);
|
||||
|
||||
$ladillWalletBalance = 0.0;
|
||||
try {
|
||||
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('QR Plus show could not load Ladill wallet balance', [
|
||||
'user' => $account->public_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('qr-codes.show', [
|
||||
'qrCode' => $qrCode,
|
||||
'previewDataUri' => $previewDataUri,
|
||||
'logoDataUri' => $logoDataUri,
|
||||
'types' => QrTypeCatalog::all(),
|
||||
'moduleStyles' => QrModuleStyleCatalog::visible(),
|
||||
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
|
||||
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
|
||||
'frameStyles' => QrFrameStyleCatalog::visible(),
|
||||
'wallet' => $wallet,
|
||||
'summary' => $summary,
|
||||
'dailyScans' => $dailyScans,
|
||||
'devices' => $devices,
|
||||
'browsers' => $browsers,
|
||||
'recentScans' => $recentScans,
|
||||
'orders' => null,
|
||||
'pricePerQr' => QrWallet::pricePerQr(),
|
||||
'minTopup' => QrWallet::minTopupGhs(),
|
||||
'ladillWalletBalance' => $ladillWalletBalance,
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, QrCode $event): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $event);
|
||||
$qrCode = $event;
|
||||
|
||||
$validated = $request->validate([
|
||||
'label' => ['sometimes', 'string', 'max:120'],
|
||||
'destination_url' => ['nullable', 'url', 'max:2048'],
|
||||
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
|
||||
'image' => ['nullable', 'image', 'max:10240'],
|
||||
'images' => ['nullable', 'array'],
|
||||
'images.*' => ['image', 'max:10240'],
|
||||
'item_images' => ['nullable', 'array'],
|
||||
'item_images.*' => ['array'],
|
||||
'item_images.*.*' => ['image', 'max:4096'],
|
||||
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
|
||||
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
||||
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
||||
'remove_logo' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
...$this->styleRules(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->manager->update($qrCode, array_merge(
|
||||
$request->all(),
|
||||
[
|
||||
'document' => $request->file('document'),
|
||||
'logo' => $request->file('logo'),
|
||||
'style' => $validated['style'] ?? null,
|
||||
'is_active' => $request->boolean('is_active', $qrCode->is_active),
|
||||
'remove_logo' => $request->boolean('remove_logo'),
|
||||
'avatar' => $request->file('avatar'),
|
||||
'book_file' => $request->file('book_file'),
|
||||
'cover' => $request->file('cover'),
|
||||
'menu_logo' => $request->file('menu_logo'),
|
||||
'menu_cover' => $request->file('menu_cover'),
|
||||
'business_logo' => $request->file('business_logo'),
|
||||
'business_cover' => $request->file('business_cover'),
|
||||
'church_logo' => $request->file('church_logo'),
|
||||
'church_cover' => $request->file('church_cover'),
|
||||
'event_logo' => $request->file('event_logo'),
|
||||
'event_cover' => $request->file('event_cover'),
|
||||
'itinerary_cover' => $request->file('itinerary_cover'),
|
||||
],
|
||||
));
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', QrTypeCatalog::label($qrCode->type).' updated.');
|
||||
}
|
||||
|
||||
public function stylePreview(Request $request): Response
|
||||
{
|
||||
$validated = $request->validate(array_merge($this->styleRules(), [
|
||||
'short_code' => ['nullable', 'string', 'max:16'],
|
||||
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
|
||||
'use_existing_logo' => ['nullable', 'boolean'],
|
||||
'existing_logo_path' => ['nullable', 'string', 'max:500'],
|
||||
]));
|
||||
|
||||
$shortCode = $validated['short_code'] ?? 'preview';
|
||||
$style = $validated['style'] ?? [];
|
||||
$tempLogoPath = null;
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$tempLogoPath = $request->file('logo')->store('tmp/previews', 'qr');
|
||||
$style['logo_path'] = $tempLogoPath;
|
||||
} elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) {
|
||||
$existingPath = $validated['existing_logo_path'];
|
||||
$userPrefix = ladill_account()->id . '/';
|
||||
if (str_starts_with($existingPath, $userPrefix) && Storage::disk('qr')->exists($existingPath)) {
|
||||
$style['logo_path'] = $existingPath;
|
||||
}
|
||||
}
|
||||
|
||||
$png = $this->imageGenerator->renderPng(QrCode::publicBaseUrl() . '/q/' . $shortCode, $style);
|
||||
|
||||
if ($tempLogoPath) {
|
||||
Storage::disk('qr')->delete($tempLogoPath);
|
||||
}
|
||||
|
||||
return response($png, 200, [
|
||||
'Content-Type' => 'image/png',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function styleRules(): array
|
||||
{
|
||||
return [
|
||||
'style' => ['nullable', 'array'],
|
||||
'style.foreground' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'style.background' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'style.error_correction' => ['nullable', 'in:L,M,Q,H'],
|
||||
'style.margin' => ['nullable', 'integer', 'min:0', 'max:10'],
|
||||
'style.module_style' => ['nullable', 'in:' . implode(',', QrModuleStyleCatalog::keys())],
|
||||
'style.finder_outer' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::outerStyles()))],
|
||||
'style.finder_inner' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::innerStyles()))],
|
||||
'style.frame_style' => ['nullable', 'in:' . implode(',', QrFrameStyleCatalog::keys())],
|
||||
'style.frame_text' => ['nullable', 'string', 'max:100'],
|
||||
'style.frame_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'style.scale' => ['nullable', 'integer', 'min:4', 'max:16'],
|
||||
'style.gradient_type' => ['nullable', 'in:none,linear,radial'],
|
||||
'style.gradient_color1' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'style.gradient_color2' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
|
||||
'style.logo_size' => ['nullable', 'numeric', 'min:0.1', 'max:0.4'],
|
||||
'style.logo_margin' => ['nullable', 'integer', 'min:0', 'max:15'],
|
||||
'style.logo_white_bg' => ['nullable', 'boolean'],
|
||||
'style.logo_shape' => ['nullable', 'in:none,rounded,circle'],
|
||||
];
|
||||
}
|
||||
|
||||
public function preview(QrCode $event): Response
|
||||
{
|
||||
$this->authorize('view', $event);
|
||||
|
||||
$qrCode = $this->imageGenerator->ensureValidImages($event);
|
||||
|
||||
$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',
|
||||
]);
|
||||
}
|
||||
|
||||
public function download(QrCode $event, string $format): StreamedResponse
|
||||
{
|
||||
$this->authorize('view', $event);
|
||||
|
||||
$qrCode = $this->imageGenerator->ensureValidImages($event);
|
||||
$path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path;
|
||||
$filename = Str::slug($qrCode->label) . '-qr.' . ($format === 'svg' ? 'svg' : 'png');
|
||||
|
||||
if ($format === 'png') {
|
||||
$bytes = $this->imageGenerator->normalizeStoredPng($path);
|
||||
if ($bytes === null) {
|
||||
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
}
|
||||
|
||||
return response()->streamDownload(fn () => print($bytes), $filename, [
|
||||
'Content-Type' => 'image/png',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($format === 'pdf') {
|
||||
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
|
||||
if ($bytes === null) {
|
||||
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||
$this->imageGenerator->generateAndStore($qrCode);
|
||||
}
|
||||
|
||||
$pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label);
|
||||
$filename = Str::slug($qrCode->label) . '-qr.pdf';
|
||||
|
||||
return response()->streamDownload(fn () => print($pdf), $filename, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
]);
|
||||
}
|
||||
|
||||
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
||||
|
||||
return Storage::disk('qr')->download($path, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the exact client-rendered QR (qr-code-styling) as the canonical
|
||||
* SVG + PNG so downloads match the preview pixel-for-pixel. The browser
|
||||
* renders with the real /q/{code} URL on the show page, then posts here.
|
||||
*/
|
||||
public function storeCanonicalImage(Request $request, QrCode $event): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $event);
|
||||
$qrCode = $event;
|
||||
|
||||
$validated = $request->validate([
|
||||
'svg' => ['required', 'string', 'max:600000'],
|
||||
'png' => ['required', 'string', 'max:4000000'],
|
||||
]);
|
||||
|
||||
$svg = $this->imageGenerator->sanitizeSvg($validated['svg']);
|
||||
if ($svg === null) {
|
||||
return response()->json(['error' => 'Invalid SVG.'], 422);
|
||||
}
|
||||
|
||||
$pngB64 = $validated['png'];
|
||||
if (str_contains($pngB64, ',')) {
|
||||
$pngB64 = substr($pngB64, strpos($pngB64, ',') + 1);
|
||||
}
|
||||
$png = base64_decode($pngB64, true);
|
||||
if ($png === false || ! $this->imageGenerator->isValidPngBinary($png)) {
|
||||
return response()->json(['error' => 'Invalid PNG.'], 422);
|
||||
}
|
||||
|
||||
$basePath = $qrCode->user_id . '/codes/' . $qrCode->id;
|
||||
Storage::disk('qr')->put($basePath . '/qr.svg', $svg);
|
||||
Storage::disk('qr')->put($basePath . '/qr.png', $png);
|
||||
|
||||
$qrCode->update([
|
||||
'svg_path' => $basePath . '/qr.svg',
|
||||
'png_path' => $basePath . '/qr.png',
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class TeamController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$members = QrTeamMember::query()
|
||||
->where('account_id', $account->id)
|
||||
->with('member')
|
||||
->orderBy('status')->orderBy('email')
|
||||
->get();
|
||||
|
||||
return view('qr.account.team', [
|
||||
'account' => $account,
|
||||
'members' => $members,
|
||||
'canManage' => $this->canManage($request),
|
||||
'isOwner' => $request->user()->id === $account->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'role' => ['required', 'in:admin,member'],
|
||||
]);
|
||||
|
||||
$account = ladill_account();
|
||||
$email = strtolower($validated['email']);
|
||||
|
||||
if ($email === strtolower($account->email)) {
|
||||
return back()->withErrors(['email' => 'The owner is already on the account.']);
|
||||
}
|
||||
|
||||
$member = QrTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]);
|
||||
$member->role = $validated['role'];
|
||||
if (! $member->exists) {
|
||||
$member->status = QrTeamMember::STATUS_INVITED;
|
||||
$member->token = Str::random(40);
|
||||
}
|
||||
$member->save();
|
||||
|
||||
if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) {
|
||||
QrTeamMember::linkPendingInvitesFor($existing);
|
||||
}
|
||||
|
||||
return back()->with('success', $email.' invited.');
|
||||
}
|
||||
|
||||
public function updateRole(Request $request, QrTeamMember $member): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
|
||||
|
||||
$validated = $request->validate(['role' => ['required', 'in:admin,member']]);
|
||||
$member->update(['role' => $validated['role']]);
|
||||
|
||||
return back()->with('success', 'Role updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, QrTeamMember $member): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
|
||||
|
||||
$member->delete();
|
||||
|
||||
return back()->with('success', 'Member removed.');
|
||||
}
|
||||
|
||||
public function switchAccount(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate(['account' => ['required', 'integer']]);
|
||||
|
||||
abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403);
|
||||
|
||||
$request->session()->put('ladill_account', (int) $validated['account']);
|
||||
|
||||
return redirect()->route('mini.dashboard');
|
||||
}
|
||||
|
||||
private function canManage(Request $request): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
$account = ladill_account();
|
||||
|
||||
if ($user->id === $account->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->memberships()
|
||||
->where('account_id', $account->id)
|
||||
->where('role', QrTeamMember::ROLE_ADMIN)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse|View
|
||||
{
|
||||
$q = trim((string) $request->query('q'));
|
||||
$results = mb_strlen($q) >= 2 ? $this->results($q) : [];
|
||||
|
||||
if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) {
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
|
||||
return view('search.index', [
|
||||
'query' => $q,
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array{type: string, title: string, subtitle: string, url: string}> */
|
||||
private function results(string $q): array
|
||||
{
|
||||
$account = ladill_account();
|
||||
$like = '%'.$q.'%';
|
||||
|
||||
$qrIds = $account->qrCodes()
|
||||
->where('type', QrCode::TYPE_PAYMENT)
|
||||
->pluck('id');
|
||||
|
||||
if ($qrIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return MiniPayment::query()
|
||||
->whereIn('qr_code_id', $qrIds)
|
||||
->with('qrCode')
|
||||
->where(function ($query) use ($like) {
|
||||
$query->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));
|
||||
})
|
||||
->latest('created_at')
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(function (MiniPayment $payment): array {
|
||||
$amount = number_format(
|
||||
($payment->status === MiniPayment::STATUS_PAID ? $payment->merchant_amount_minor : $payment->amount_minor) / 100,
|
||||
2,
|
||||
);
|
||||
|
||||
return [
|
||||
'type' => 'payment',
|
||||
'title' => $payment->payer_name ?: 'Walk-in customer',
|
||||
'subtitle' => 'GHS '.$amount.' · '.($payment->qrCode?->label ?? 'Payment QR').' · '.ucfirst($payment->status),
|
||||
'url' => route('mini.payments.index', ['q' => $payment->reference]),
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WellKnown;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class AssetLinksController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$fingerprints = config('android_app_links.sha256_cert_fingerprints', []);
|
||||
|
||||
abort_if($fingerprints === [], 404);
|
||||
|
||||
return response()->json([
|
||||
[
|
||||
'relation' => ['delegate_permission/common.handle_all_urls'],
|
||||
'target' => [
|
||||
'namespace' => 'android_app',
|
||||
'package_name' => config('android_app_links.package_name'),
|
||||
'sha256_cert_fingerprints' => $fingerprints,
|
||||
],
|
||||
],
|
||||
], 200, [], JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user