Add multi-branch POS with team roles and branch-scoped cashiers.
Deploy Ladill POS / deploy (push) Successful in 1m58s

Pro and Business users can manage branches, invite cashiers with branch assignment, and switch registers; sales and register flows respect acting location.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-08 06:05:30 +00:00
co-authored by Cursor
parent cac7c60415
commit 9d7dac6c24
34 changed files with 1434 additions and 30 deletions
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class AccountSwitchController extends Controller
{
public function switch(Request $request): RedirectResponse
{
$data = $request->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();
}
}
@@ -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();
@@ -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;
}
}
@@ -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)
@@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers\Pos;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
use App\Models\PosLocation;
use App\Models\PosSale;
use App\Models\PosTable;
use App\Services\Pos\PosLocationService;
use App\Services\Pos\SubscriptionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class LocationController extends Controller
{
use ScopesToAccount;
public function __construct(
private PosLocationService $locations,
private SubscriptionService $subscriptions,
) {}
public function index(Request $request): View
{
$owner = $this->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;
}
}
@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Pos;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
use App\Models\PosLocation;
use App\Models\PosMember;
use App\Models\User;
use App\Services\Identity\IdentityTeamClient;
use App\Services\Pos\SubscriptionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class MemberController extends Controller
{
use ScopesToAccount;
public function __construct(private SubscriptionService $subscriptions) {}
public function index(Request $request): View|RedirectResponse
{
if ($redirect = $this->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;
}
}
@@ -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()) {
+1 -1
View File
@@ -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)
@@ -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,
+3 -3
View File
@@ -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')