diff --git a/app/Http/Controllers/AccountSwitchController.php b/app/Http/Controllers/AccountSwitchController.php new file mode 100644 index 0000000..6ed01a6 --- /dev/null +++ b/app/Http/Controllers/AccountSwitchController.php @@ -0,0 +1,23 @@ +validate([ + 'account' => ['required', 'integer'], + ]); + + abort_unless($request->user()->canAccessAccount($data['account']), 403); + + $request->session()->put('ladill_account', $data['account']); + $request->session()->forget('ladill_pos_location'); + + return back(); + } +} diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index 22fcc00..b1ae1e2 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -119,6 +119,7 @@ class SsoLoginController extends Controller } QrTeamMember::linkPendingInvitesFor($user); + app(\App\Services\Pos\TeamMemberProvisioner::class)->sync($user); Auth::login($user, remember: true); $request->session()->regenerate(); diff --git a/app/Http/Controllers/Pos/Concerns/ScopesToAccount.php b/app/Http/Controllers/Pos/Concerns/ScopesToAccount.php index 37517e2..21422a9 100644 --- a/app/Http/Controllers/Pos/Concerns/ScopesToAccount.php +++ b/app/Http/Controllers/Pos/Concerns/ScopesToAccount.php @@ -2,6 +2,11 @@ namespace App\Http\Controllers\Pos\Concerns; +use App\Models\PosLocation; +use App\Models\PosMember; +use App\Models\User; +use App\Services\Pos\PosMemberResolver; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; @@ -14,8 +19,63 @@ trait ScopesToAccount return (string) $user->public_id; } + protected function actor(Request $request): User + { + return $request->user(); + } + + protected function posMember(Request $request): ?PosMember + { + return $request->attributes->get('pos.member') + ?? app(PosMemberResolver::class)->memberFor($this->actor($request), $this->ownerRef($request)); + } + + protected function locationScope(Request $request): ?int + { + return $request->attributes->get('pos.locationScope') + ?? app(PosMemberResolver::class)->locationScope( + $this->posMember($request), + $this->ownerRef($request), + $this->actor($request), + ); + } + + protected function location(Request $request): PosLocation + { + $acting = $request->attributes->get('actingLocation'); + if ($acting instanceof PosLocation) { + return $acting; + } + + return app(\App\Services\Pos\PosLocationService::class)->resolve($request); + } + protected function authorizeOwner(Request $request, Model $model): void { abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404); + $this->authorizeLocation($request, $model); + } + + protected function authorizeLocation(Request $request, Model $model): void + { + $scope = $this->locationScope($request); + if ($scope === null) { + return; + } + + $locationId = $model->getAttribute('location_id'); + if ($locationId !== null) { + abort_unless((int) $locationId === $scope, 404); + } + } + + protected function scopeToLocation(Request $request, Builder $query, string $column = 'location_id'): Builder + { + $scope = $this->locationScope($request); + if ($scope !== null) { + $query->where($column, $scope); + } + + return $query; } } diff --git a/app/Http/Controllers/Pos/DashboardController.php b/app/Http/Controllers/Pos/DashboardController.php index d74a2d8..a89cba8 100644 --- a/app/Http/Controllers/Pos/DashboardController.php +++ b/app/Http/Controllers/Pos/DashboardController.php @@ -21,7 +21,7 @@ class DashboardController extends Controller $owner = $this->ownerRef($request); $todayStart = now()->startOfDay(); - $todaySales = PosSale::owned($owner) + $todaySales = $this->scopeToLocation($request, PosSale::owned($owner)) ->where('status', PosSale::STATUS_PAID) ->where('paid_at', '>=', $todayStart); @@ -29,10 +29,11 @@ class DashboardController extends Controller 'today_total_minor' => (int) (clone $todaySales)->sum('total_minor'), 'today_count' => (clone $todaySales)->count(), 'product_count' => PosProduct::owned($owner)->active()->count(), - 'open_pending' => PosSale::owned($owner)->where('status', PosSale::STATUS_PENDING)->count(), + 'open_pending' => $this->scopeToLocation($request, PosSale::owned($owner)) + ->where('status', PosSale::STATUS_PENDING)->count(), ]; - $recentSales = PosSale::owned($owner) + $recentSales = $this->scopeToLocation($request, PosSale::owned($owner)) ->with('lines') ->latest() ->limit(8) diff --git a/app/Http/Controllers/Pos/LocationController.php b/app/Http/Controllers/Pos/LocationController.php new file mode 100644 index 0000000..ed08183 --- /dev/null +++ b/app/Http/Controllers/Pos/LocationController.php @@ -0,0 +1,207 @@ +ownerRef($request); + $account = ladill_account() ?? $request->user(); + + abort_unless( + app(\App\Services\Pos\PosMemberResolver::class)->canManageBranches( + $this->posMember($request), + $owner, + $this->actor($request), + ), + 403, + ); + + $branches = PosLocation::owned($owner)->orderBy('name')->get(); + + return view('pos.branches.index', [ + 'branches' => $branches, + 'canAddBranch' => $this->subscriptions->canAddLocation($account, $owner), + 'hasMultiLocation' => $this->subscriptions->canUseMultiLocation($account), + ]); + } + + public function create(Request $request): View|RedirectResponse + { + return $this->guardManage($request) ?? view('pos.branches.create'); + } + + public function store(Request $request): RedirectResponse + { + if ($redirect = $this->guardManage($request)) { + return $redirect; + } + + $owner = $this->ownerRef($request); + $account = ladill_account() ?? $request->user(); + + if (! $this->subscriptions->canAddLocation($account, $owner)) { + return redirect()->route('pos.pro.index') + ->with('upsell', 'Multi-branch POS requires Ladill POS Pro or Business.'); + } + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'currency' => ['required', 'string', 'size:3'], + 'service_style' => ['required', 'in:retail,restaurant'], + ]); + + if ($data['service_style'] === 'restaurant' && ! $this->subscriptions->canUseRestaurantMode($account)) { + return back()->withInput()->with('upsell', 'Restaurant mode is part of Ladill POS Pro.'); + } + + $isFirst = $this->subscriptions->locationCount($owner) === 0; + + PosLocation::create([ + 'owner_ref' => $owner, + 'name' => $data['name'], + 'currency' => strtoupper($data['currency']), + 'service_style' => $data['service_style'], + 'is_default' => $isFirst, + ]); + + return redirect()->route('pos.branches.index')->with('success', 'Branch created.'); + } + + public function edit(Request $request, PosLocation $location): View|RedirectResponse + { + $this->authorizeOwner($request, $location); + if ($redirect = $this->guardManage($request)) { + return $redirect; + } + + return view('pos.branches.edit', compact('location')); + } + + public function update(Request $request, PosLocation $location): RedirectResponse + { + $this->authorizeOwner($request, $location); + if ($redirect = $this->guardManage($request)) { + return $redirect; + } + + $account = ladill_account() ?? $request->user(); + $data = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'currency' => ['required', 'string', 'size:3'], + 'service_style' => ['required', 'in:retail,restaurant'], + 'is_default' => ['sometimes', 'boolean'], + ]); + + if ($data['service_style'] === 'restaurant' && ! $this->subscriptions->canUseRestaurantMode($account)) { + return back()->withInput()->with('upsell', 'Restaurant mode is part of Ladill POS Pro.'); + } + + if ($request->boolean('is_default')) { + PosLocation::owned($this->ownerRef($request)) + ->where('id', '!=', $location->id) + ->update(['is_default' => false]); + $location->is_default = true; + } + + $location->update([ + 'name' => $data['name'], + 'currency' => strtoupper($data['currency']), + 'service_style' => $data['service_style'], + ]); + + return redirect()->route('pos.branches.index')->with('success', 'Branch updated.'); + } + + public function destroy(Request $request, PosLocation $location): RedirectResponse + { + $this->authorizeOwner($request, $location); + if ($redirect = $this->guardManage($request)) { + return $redirect; + } + + $owner = $this->ownerRef($request); + + if (PosLocation::owned($owner)->count() <= 1) { + return back()->with('error', 'You must keep at least one branch.'); + } + + if (PosSale::owned($owner)->where('location_id', $location->id)->exists()) { + return back()->with('error', 'This branch has sales history and cannot be removed.'); + } + + if (PosTable::owned($owner)->where('location_id', $location->id)->where('status', '!=', PosTable::STATUS_FREE)->exists()) { + return back()->with('error', 'Settle open tickets on this branch before removing it.'); + } + + $wasDefault = $location->is_default; + $location->delete(); + + if ($wasDefault) { + PosLocation::owned($owner)->orderBy('id')->first()?->update(['is_default' => true]); + } + + if ((int) $request->session()->get('ladill_pos_location') === (int) $location->id) { + $request->session()->forget('ladill_pos_location'); + } + + return redirect()->route('pos.branches.index')->with('success', 'Branch removed.'); + } + + public function switch(Request $request): RedirectResponse + { + $owner = $this->ownerRef($request); + $data = $request->validate([ + 'location' => [ + 'required', + 'integer', + Rule::exists('pos_locations', 'id')->where('owner_ref', $owner), + ], + ]); + + $this->locations->switch($request, (int) $data['location']); + + return back()->with('success', 'Switched branch.'); + } + + private function guardManage(Request $request): ?RedirectResponse + { + $owner = $this->ownerRef($request); + + if (! app(\App\Services\Pos\PosMemberResolver::class)->canManageBranches( + $this->posMember($request), + $owner, + $this->actor($request), + )) { + abort(403); + } + + $account = ladill_account() ?? $request->user(); + if (! $this->subscriptions->canUseMultiLocation($account)) { + return redirect()->route('pos.pro.index') + ->with('upsell', 'Multi-branch POS requires Ladill POS Pro or Business.'); + } + + return null; + } +} diff --git a/app/Http/Controllers/Pos/MemberController.php b/app/Http/Controllers/Pos/MemberController.php new file mode 100644 index 0000000..0998102 --- /dev/null +++ b/app/Http/Controllers/Pos/MemberController.php @@ -0,0 +1,130 @@ +guardManage($request)) { + return $redirect; + } + + $owner = $this->ownerRef($request); + $members = PosMember::owned($owner)->with('location')->orderBy('created_at')->get(); + + return view('pos.team.index', [ + 'members' => $members, + 'roles' => config('pos.roles', []), + 'branches' => PosLocation::owned($owner)->orderBy('name')->get(), + ]); + } + + public function store(Request $request, IdentityTeamClient $identity): RedirectResponse + { + if ($redirect = $this->guardManage($request)) { + return $redirect; + } + + $owner = $this->ownerRef($request); + + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', 'string', Rule::in(array_keys(config('pos.roles', [])))], + 'location_id' => [ + Rule::requiredIf(fn () => $request->input('role') === PosMember::ROLE_CASHIER), + 'nullable', + 'integer', + Rule::exists('pos_locations', 'id')->where('owner_ref', $owner), + ], + ]); + + $email = strtolower(trim($validated['email'])); + $actor = $request->user(); + + if ($email === strtolower((string) $actor->email)) { + return back()->withErrors(['email' => 'You cannot invite yourself.']); + } + + try { + $identity->inviteAppMember( + $owner, + $email, + ['pos'], + [ + 'pos' => [ + 'role' => $validated['role'], + 'location_id' => $validated['location_id'] ?? null, + ], + ], + ); + } catch (\Throwable $e) { + return back()->withInput()->with('error', $e->getMessage()); + } + + PosMember::updateOrCreate( + [ + 'owner_ref' => $owner, + 'user_ref' => $email, + ], + [ + 'role' => $validated['role'], + 'location_id' => $validated['location_id'] ?? null, + ], + ); + + return back()->with('success', 'Invitation sent to '.$email.'.'); + } + + public function destroy(Request $request, PosMember $member): RedirectResponse + { + if ($redirect = $this->guardManage($request)) { + return $redirect; + } + + $this->authorizeOwner($request, $member); + + $actor = $request->user(); + if ($member->user_ref === $actor->public_id || $member->user_ref === strtolower((string) $actor->email)) { + return back()->with('error', 'You cannot remove yourself.'); + } + + $member->delete(); + + return back()->with('success', 'Team member removed.'); + } + + private function guardManage(Request $request): ?RedirectResponse + { + $owner = $this->ownerRef($request); + $resolver = app(\App\Services\Pos\PosMemberResolver::class); + + if (! $resolver->canManageTeam($this->posMember($request), $owner, $this->actor($request))) { + abort(403); + } + + $account = ladill_account() ?? $request->user(); + if (! $this->subscriptions->canManageTeam($account)) { + return redirect()->route('pos.pro.index') + ->with('upsell', 'Team members and branch assignment require Ladill POS Pro or Business.'); + } + + return null; + } +} diff --git a/app/Http/Controllers/Pos/RegisterController.php b/app/Http/Controllers/Pos/RegisterController.php index 1d5843f..e9aba4a 100644 --- a/app/Http/Controllers/Pos/RegisterController.php +++ b/app/Http/Controllers/Pos/RegisterController.php @@ -30,7 +30,7 @@ class RegisterController extends Controller public function index(Request $request): View { $owner = $this->ownerRef($request); - $location = $this->locations->ensureDefault($owner); + $location = $this->location($request); return view('pos.register', [ 'products' => $this->registerProducts($owner, $location), @@ -47,7 +47,7 @@ class RegisterController extends Controller ]); $owner = $this->ownerRef($request); - $location = $this->locations->ensureDefault($owner); + $location = $this->location($request); $product = $this->barcodes->lookup($owner, $location, $data['code']); if (! $product || $product['price_minor'] <= 0 || $product['name'] === '') { @@ -66,7 +66,14 @@ class RegisterController extends Controller private function registerProducts(string $owner, PosLocation $location): array { if ($location->isRestaurant()) { - return PosProduct::owned($owner)->active()->orderBy('name')->get() + return PosProduct::owned($owner) + ->active() + ->where(function ($query) use ($location) { + $query->where('location_id', $location->id) + ->orWhereNull('location_id'); + }) + ->orderBy('name') + ->get() ->map(fn (PosProduct $p) => [ 'id' => $p->id, 'name' => $p->name, @@ -99,7 +106,7 @@ class RegisterController extends Controller { $data = $request->validate(['style' => ['required', 'in:retail,restaurant']]); - $location = $this->locations->ensureDefault($this->ownerRef($request)); + $location = $this->location($request); $location->update(['service_style' => $data['style']]); if ($data['style'] === PosLocation::STYLE_RESTAURANT) { @@ -135,7 +142,7 @@ class RegisterController extends Controller ]); $merchant = ladill_account() ?? $request->user(); - $location = $this->locations->ensureDefault($this->ownerRef($request)); + $location = $this->location($request); $lines = $data['lines']; if (! $location->isRestaurant()) { diff --git a/app/Http/Controllers/Pos/SaleController.php b/app/Http/Controllers/Pos/SaleController.php index 2aeef10..98c5e6a 100644 --- a/app/Http/Controllers/Pos/SaleController.php +++ b/app/Http/Controllers/Pos/SaleController.php @@ -23,7 +23,7 @@ class SaleController extends Controller public function index(Request $request): View { - $sales = PosSale::owned($this->ownerRef($request)) + $sales = $this->scopeToLocation($request, PosSale::owned($this->ownerRef($request))) ->with('lines') ->latest() ->paginate(25) diff --git a/app/Http/Controllers/Pos/SettingsController.php b/app/Http/Controllers/Pos/SettingsController.php index 1a451ef..123670e 100644 --- a/app/Http/Controllers/Pos/SettingsController.php +++ b/app/Http/Controllers/Pos/SettingsController.php @@ -27,12 +27,13 @@ class SettingsController extends Controller public function index(Request $request): View { $owner = $this->ownerRef($request); - $location = $this->locations->ensureDefault($owner); + $location = $this->location($request); return view('pos.settings', [ 'location' => $location, 'merchantImportEnabled' => (bool) config('pos.merchant_import_enabled', true), - 'tables' => PosTable::owned($owner)->orderBy('area')->orderBy('position')->orderBy('label')->get(), + 'tables' => $this->scopeToLocation($request, PosTable::owned($owner)) + ->orderBy('area')->orderBy('position')->orderBy('label')->get(), ]); } @@ -56,7 +57,7 @@ class SettingsController extends Controller ->with('upsell', 'Restaurant mode is part of Ladill POS Pro.'); } - $location = $this->locations->ensureDefault($this->ownerRef($request)); + $location = $this->location($request); if ($request->boolean('remove_receipt_logo') && $location->receipt_logo_path) { Storage::disk('public')->delete($location->receipt_logo_path); @@ -96,7 +97,7 @@ class SettingsController extends Controller ]); $owner = $this->ownerRef($request); - $location = $this->locations->ensureDefault($owner); + $location = $this->location($request); PosTable::create([ 'owner_ref' => $owner, diff --git a/app/Http/Controllers/Pos/TableController.php b/app/Http/Controllers/Pos/TableController.php index e4dab7a..3596f2a 100644 --- a/app/Http/Controllers/Pos/TableController.php +++ b/app/Http/Controllers/Pos/TableController.php @@ -19,16 +19,16 @@ class TableController extends Controller public function index(Request $request): View { $owner = $this->ownerRef($request); - $location = $this->locations->ensureDefault($owner); + $location = $this->location($request); - $tables = PosTable::owned($owner) + $tables = $this->scopeToLocation($request, PosTable::owned($owner)) ->with('currentSale') ->orderBy('area') ->orderBy('position') ->orderBy('label') ->get(); - $openTickets = PosSale::owned($owner) + $openTickets = $this->scopeToLocation($request, PosSale::owned($owner)) ->openTickets() ->with('table') ->withCount('lines') diff --git a/app/Http/Middleware/SetActingAccount.php b/app/Http/Middleware/SetActingAccount.php index c9f4c1a..af486d0 100644 --- a/app/Http/Middleware/SetActingAccount.php +++ b/app/Http/Middleware/SetActingAccount.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Models\PosLocation; use App\Models\User; use Closure; use Illuminate\Http\Request; @@ -26,6 +27,8 @@ class SetActingAccount } } + $accountId = $this->preferEmployerAccount($request, $user, $accountId); + $account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user); $request->attributes->set('actingAccount', $account); @@ -38,4 +41,31 @@ class SetActingAccount return $next($request); } + + private function preferEmployerAccount(Request $request, User $user, int $accountId): int + { + if ($accountId !== $user->id) { + return $accountId; + } + + if (PosLocation::owned($user->public_id)->exists()) { + return $accountId; + } + + $membership = $user->posMemberships()->first(); + if (! $membership) { + return $accountId; + } + + $employer = User::where('public_id', $membership->owner_ref)->first(); + if ($employer && $user->canAccessAccount($employer->id)) { + if (! $request->is('api/*')) { + $request->session()->put('ladill_account', $employer->id); + } + + return $employer->id; + } + + return $accountId; + } } diff --git a/app/Http/Middleware/SetActingLocation.php b/app/Http/Middleware/SetActingLocation.php new file mode 100644 index 0000000..bfd6047 --- /dev/null +++ b/app/Http/Middleware/SetActingLocation.php @@ -0,0 +1,67 @@ +user()) { + return $next($request); + } + + $account = ladill_account() ?? $user; + $ownerRef = (string) $account->public_id; + $member = $this->members->memberFor($user, $ownerRef); + $scope = $this->members->locationScope($member, $ownerRef, $user); + + $request->attributes->set('pos.member', $member); + $request->attributes->set('pos.locationScope', $scope); + + $location = $this->resolveLocation($request, $ownerRef, $scope); + $request->attributes->set('actingLocation', $location); + + if (! $request->is('api/*')) { + View::share('actingLocation', $location); + View::share('accessibleLocations', $this->locations->accessible($ownerRef, $scope)); + View::share('posMember', $member); + View::share('canManageBranches', $this->members->canManageBranches($member, $ownerRef, $user)); + View::share('canManageTeam', $this->members->canManageTeam($member, $ownerRef, $user)); + } + + return $next($request); + } + + private function resolveLocation(Request $request, string $ownerRef, ?int $scope): PosLocation + { + if ($scope !== null) { + return PosLocation::owned($ownerRef)->whereKey($scope)->firstOrFail(); + } + + if (! $request->is('api/*')) { + $sessionId = (int) $request->session()->get('ladill_pos_location', 0); + if ($sessionId > 0) { + $fromSession = PosLocation::owned($ownerRef)->whereKey($sessionId)->first(); + if ($fromSession) { + return $fromSession; + } + } + } + + return $this->locations->ensureDefault($ownerRef); + } +} diff --git a/app/Models/PosLocation.php b/app/Models/PosLocation.php index b6b7238..54b69ba 100644 --- a/app/Models/PosLocation.php +++ b/app/Models/PosLocation.php @@ -17,6 +17,7 @@ class PosLocation extends Model 'owner_ref', 'name', 'currency', + 'is_default', 'service_style', 'receipt_footer', 'receipt_header', @@ -28,6 +29,7 @@ class PosLocation extends Model protected function casts(): array { return [ + 'is_default' => 'boolean', 'printer_paper_mm' => 'integer', 'printer_auto_print' => 'boolean', ]; diff --git a/app/Models/PosMember.php b/app/Models/PosMember.php new file mode 100644 index 0000000..4497d0c --- /dev/null +++ b/app/Models/PosMember.php @@ -0,0 +1,38 @@ +belongsTo(PosLocation::class, 'location_id'); + } + + public function hasRole(string ...$roles): bool + { + return in_array($this->role, $roles, true); + } + + public function scopeOwned(Builder $query, string $ownerRef): Builder + { + return $query->where('owner_ref', $ownerRef); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3e45a80..12b45d3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -37,18 +37,38 @@ class User extends Authenticatable ->where('status', QrTeamMember::STATUS_ACTIVE); } + public function posMemberships(): HasMany + { + return $this->hasMany(PosMember::class, 'user_ref', 'public_id'); + } + public function canAccessAccount(int $accountId): bool { - return $accountId === $this->id - || $this->memberships()->where('account_id', $accountId)->exists(); + if ($accountId === $this->id) { + return true; + } + + if ($this->memberships()->where('account_id', $accountId)->exists()) { + return true; + } + + $account = self::find($accountId); + + return $account !== null + && $this->posMemberships()->where('owner_ref', $account->public_id)->exists(); } /** @return Collection */ public function accessibleAccounts(): Collection { - $ids = $this->memberships()->pluck('account_id')->all(); + $qrIds = $this->memberships()->pluck('account_id')->all(); + $posOwnerRefs = $this->posMemberships()->pluck('owner_ref')->all(); - return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values(); + return collect([$this]) + ->merge(self::whereIn('id', $qrIds)->get()) + ->merge(self::whereIn('public_id', $posOwnerRefs)->get()) + ->unique('id') + ->values(); } public function avatarUrl(): ?string diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1f37066..1ae373b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -27,9 +27,14 @@ class AppServiceProvider extends ServiceProvider $account = ladill_account(); $restaurant = false; if ($account) { - $restaurant = PosLocation::owned((string) $account->public_id) - ->where('service_style', PosLocation::STYLE_RESTAURANT) - ->exists(); + $actingLocation = request()->attributes->get('actingLocation'); + if ($actingLocation instanceof PosLocation) { + $restaurant = $actingLocation->isRestaurant(); + } else { + $restaurant = PosLocation::owned((string) $account->public_id) + ->where('service_style', PosLocation::STYLE_RESTAURANT) + ->exists(); + } $svc = app(SubscriptionService::class); $view->with('isPro', $svc->isPro($account)); $view->with('hasPaidPlan', $svc->hasPaidPlan($account)); diff --git a/app/Services/Identity/IdentityTeamClient.php b/app/Services/Identity/IdentityTeamClient.php new file mode 100644 index 0000000..74d3315 --- /dev/null +++ b/app/Services/Identity/IdentityTeamClient.php @@ -0,0 +1,74 @@ + $apps + * @param array $metadata + * + * @return array + */ + public function inviteAppMember( + string $ownerPublicId, + string $email, + array $apps, + array $metadata = [], + string $role = 'member', + ): array { + $response = $this->request('post', '/identity/team/invite', [ + 'owner' => $ownerPublicId, + 'email' => strtolower(trim($email)), + 'apps' => $apps, + 'role' => $role, + 'metadata' => $metadata, + ]); + + return (array) $response->json('data', []); + } + + public function postAuthRedirect(string $userPublicId, string $intendedUrl): string + { + $response = $this->request('get', '/identity/team/post-auth-redirect', [ + 'user' => $userPublicId, + 'redirect' => $intendedUrl, + ]); + + return (string) $response->json('data.url', $intendedUrl); + } + + /** @return list> */ + public function teamAccess(string $userPublicId): array + { + $response = $this->request('get', '/identity/team/access', [ + 'user' => $userPublicId, + ]); + + return (array) $response->json('data', []); + } + + private function request(string $method, string $path, array $query = []): Response + { + $url = rtrim((string) config('identity.api_url'), '/').$path; + $key = config('identity.api_key'); + + if ($url === '' || ! is_string($key) || $key === '') { + throw new RuntimeException('Identity API is not configured.'); + } + + $response = Http::withToken($key) + ->timeout(10) + ->{$method}($url, $query); + + if (! $response->successful()) { + throw new RuntimeException($response->json('message') ?: 'Identity API request failed.'); + } + + return $response; + } +} diff --git a/app/Services/Pos/PosLocationService.php b/app/Services/Pos/PosLocationService.php index 6e1557a..5a6f776 100644 --- a/app/Services/Pos/PosLocationService.php +++ b/app/Services/Pos/PosLocationService.php @@ -3,14 +3,76 @@ namespace App\Services\Pos; use App\Models\PosLocation; +use Illuminate\Http\Request; +use Illuminate\Support\Collection; class PosLocationService { public function ensureDefault(string $ownerRef): PosLocation { - return PosLocation::owned($ownerRef)->firstOrCreate( - ['owner_ref' => $ownerRef, 'name' => 'Main register'], - ['currency' => config('pos.default_currency', 'GHS')], - ); + $existing = PosLocation::owned($ownerRef) + ->where('is_default', true) + ->first() + ?? PosLocation::owned($ownerRef)->orderBy('id')->first(); + + if ($existing) { + return $existing; + } + + return PosLocation::create([ + 'owner_ref' => $ownerRef, + 'name' => 'Main register', + 'currency' => config('pos.default_currency', 'GHS'), + 'is_default' => true, + ]); + } + + public function resolve(Request $request): PosLocation + { + $acting = $request->attributes->get('actingLocation'); + if ($acting instanceof PosLocation) { + return $acting; + } + + return $this->ensureDefault($this->ownerRefFromRequest($request)); + } + + /** @return Collection */ + public function accessible(string $ownerRef, ?int $locationScope): Collection + { + $query = PosLocation::owned($ownerRef)->orderBy('name'); + + if ($locationScope !== null) { + $query->where('id', $locationScope); + } + + return $query->get(); + } + + public function switch(Request $request, int $locationId): PosLocation + { + $ownerRef = $this->ownerRefFromRequest($request); + $scope = $request->attributes->get('pos.locationScope'); + + $location = PosLocation::owned($ownerRef)->whereKey($locationId)->firstOrFail(); + + if ($scope !== null && (int) $scope !== $location->id) { + abort(403, 'You do not have access to that branch.'); + } + + if (! $request->is('api/*')) { + $request->session()->put('ladill_pos_location', $location->id); + } + + $request->attributes->set('actingLocation', $location); + + return $location; + } + + private function ownerRefFromRequest(Request $request): string + { + $user = ladill_account() ?? $request->user(); + + return (string) $user->public_id; } } diff --git a/app/Services/Pos/PosMemberResolver.php b/app/Services/Pos/PosMemberResolver.php new file mode 100644 index 0000000..cb4456f --- /dev/null +++ b/app/Services/Pos/PosMemberResolver.php @@ -0,0 +1,64 @@ +public_id === $ownerRef; + } + + public function memberFor(User $actor, string $ownerRef): ?PosMember + { + if ($this->isAccountOwner($actor, $ownerRef)) { + return null; + } + + return PosMember::query() + ->where('owner_ref', $ownerRef) + ->where('user_ref', $actor->public_id) + ->first(); + } + + /** Location ID the member may access; null = all branches. */ + public function locationScope(?PosMember $member, string $ownerRef, User $actor): ?int + { + if ($this->isAccountOwner($actor, $ownerRef)) { + return null; + } + + if ($member === null) { + abort(403, 'You do not have access to this POS account.'); + } + + if ($member->role === PosMember::ROLE_CASHIER) { + abort_unless($member->location_id, 403, 'Your cashier account is not assigned to a branch.'); + + return (int) $member->location_id; + } + + return $member->location_id ? (int) $member->location_id : null; + } + + public function canManageBranches(?PosMember $member, string $ownerRef, User $actor): bool + { + if ($this->isAccountOwner($actor, $ownerRef)) { + return true; + } + + return $member?->hasRole(PosMember::ROLE_ADMIN, PosMember::ROLE_MANAGER) ?? false; + } + + public function canManageTeam(?PosMember $member, string $ownerRef, User $actor): bool + { + if ($this->isAccountOwner($actor, $ownerRef)) { + return true; + } + + return $member?->hasRole(PosMember::ROLE_ADMIN) ?? false; + } +} diff --git a/app/Services/Pos/SubscriptionService.php b/app/Services/Pos/SubscriptionService.php index 819478b..1dfcee4 100644 --- a/app/Services/Pos/SubscriptionService.php +++ b/app/Services/Pos/SubscriptionService.php @@ -110,6 +110,25 @@ class SubscriptionService return PosLocation::owned($ownerRef)->count(); } + public function canUseMultiLocation(User $user): bool + { + return $this->isPro($user); + } + + public function canAddLocation(User $user, string $ownerRef): bool + { + if (! $this->canUseMultiLocation($user)) { + return $this->locationCount($ownerRef) < (int) config('pos.free.max_locations', 1); + } + + return true; + } + + public function canManageTeam(User $user): bool + { + return $this->isPro($user); + } + /** @return array{0:bool,1:string} */ public function subscribe(User $user): array { diff --git a/app/Services/Pos/TeamMemberProvisioner.php b/app/Services/Pos/TeamMemberProvisioner.php new file mode 100644 index 0000000..804232e --- /dev/null +++ b/app/Services/Pos/TeamMemberProvisioner.php @@ -0,0 +1,51 @@ +where('user_ref', strtolower((string) $user->email)) + ->update(['user_ref' => $user->public_id]); + + try { + $access = $this->identity->teamAccess($user->public_id); + } catch (\Throwable) { + return; + } + + foreach ($access as $grant) { + if (($grant['self'] ?? false) || ($grant['full_access'] ?? false)) { + continue; + } + + $apps = (array) ($grant['apps'] ?? []); + if (! in_array('pos', $apps, true)) { + continue; + } + + $meta = (array) data_get($grant, 'metadata.pos', []); + + PosMember::updateOrCreate( + [ + 'owner_ref' => (string) ($grant['account'] ?? $user->public_id), + 'user_ref' => $user->public_id, + ], + [ + 'role' => (string) ($meta['role'] ?? PosMember::ROLE_CASHIER), + 'location_id' => $meta['location_id'] ?? null, + ], + ); + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 97dd748..b039890 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -18,6 +18,7 @@ return Application::configure(basePath: dirname(__DIR__)) ])); $middleware->web(append: [ \App\Http\Middleware\SetActingAccount::class, + \App\Http\Middleware\SetActingLocation::class, \App\Http\Middleware\InjectBootSplash::class, ]); $middleware->alias([ diff --git a/config/pos.php b/config/pos.php index c781980..964bbad 100644 --- a/config/pos.php +++ b/config/pos.php @@ -30,6 +30,12 @@ return [ 'max_products' => (int) env('POS_FREE_MAX_PRODUCTS', 50), ], + 'roles' => [ + 'admin' => 'Admin — full access to all branches', + 'manager' => 'Manager — all branches (or one if assigned)', + 'cashier' => 'Cashier — assigned branch only', + ], + 'upgrade_banner' => [ 'title' => 'Unlock POS Pro or Business', 'description' => 'Unlimited registers & catalog, stock tracking & ecosystem sync — from GHS 79/mo.', diff --git a/database/migrations/2026_07_08_140000_create_pos_members_table.php b/database/migrations/2026_07_08_140000_create_pos_members_table.php new file mode 100644 index 0000000..a73cfcb --- /dev/null +++ b/database/migrations/2026_07_08_140000_create_pos_members_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('owner_ref')->index(); + $table->string('user_ref')->index(); + $table->string('role', 30)->default('cashier'); + $table->foreignId('location_id')->nullable()->constrained('pos_locations')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['owner_ref', 'user_ref']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pos_members'); + } +}; diff --git a/database/migrations/2026_07_08_140100_add_is_default_to_pos_locations.php b/database/migrations/2026_07_08_140100_add_is_default_to_pos_locations.php new file mode 100644 index 0000000..a317f47 --- /dev/null +++ b/database/migrations/2026_07_08_140100_add_is_default_to_pos_locations.php @@ -0,0 +1,34 @@ +boolean('is_default')->default(false)->after('currency'); + }); + + $defaults = DB::table('pos_locations') + ->select('owner_ref', DB::raw('MIN(id) as id')) + ->groupBy('owner_ref') + ->get(); + + foreach ($defaults as $row) { + DB::table('pos_locations') + ->where('id', $row->id) + ->update(['is_default' => true]); + } + } + + public function down(): void + { + Schema::table('pos_locations', function (Blueprint $table) { + $table->dropColumn('is_default'); + }); + } +}; diff --git a/resources/views/partials/topbar-desktop-widgets.blade.php b/resources/views/partials/topbar-desktop-widgets.blade.php index 5e00826..2d5835b 100644 --- a/resources/views/partials/topbar-desktop-widgets.blade.php +++ b/resources/views/partials/topbar-desktop-widgets.blade.php @@ -21,6 +21,10 @@ @includeIf('partials.topbar-widgets-mid') +@include('partials.topbar-location-switcher') + +@includeIf('partials.topbar-account-switcher') +