diff --git a/app/Domains/Accounts/Application/CompanyService.php b/app/Domains/Accounts/Application/CompanyService.php
new file mode 100644
index 00000000..764f1533
--- /dev/null
+++ b/app/Domains/Accounts/Application/CompanyService.php
@@ -0,0 +1,176 @@
+{COMPANY_NAME}. Please download using the button below:';
+
+ private const ESTIMATE_MAIL_BODY = 'You have received a new estimate from {COMPANY_NAME}. Please download using the button below:';
+
+ private const PAYMENT_MAIL_BODY = 'Thank you for the payment. Please download your payment receipt using the button below:';
+
+ /** Address blocks printed on the documents; placeholders filled at render. */
+ private const BILLING_ADDRESS_FORMAT = '
{BILLING_ADDRESS_NAME}
{BILLING_ADDRESS_STREET_1}
{BILLING_ADDRESS_STREET_2}
{BILLING_CITY} {BILLING_STATE}
{BILLING_COUNTRY} {BILLING_ZIP_CODE}
{BILLING_PHONE}
';
+
+ private const SHIPPING_ADDRESS_FORMAT = '{SHIPPING_ADDRESS_NAME}
{SHIPPING_ADDRESS_STREET_1}
{SHIPPING_ADDRESS_STREET_2}
{SHIPPING_CITY} {SHIPPING_STATE}
{SHIPPING_COUNTRY} {SHIPPING_ZIP_CODE}
{SHIPPING_PHONE}
';
+
+ private const COMPANY_ADDRESS_FORMAT = '{COMPANY_NAME}
{COMPANY_ADDRESS_STREET_1}
{COMPANY_ADDRESS_STREET_2}
{COMPANY_CITY} {COMPANY_STATE}
{COMPANY_COUNTRY} {COMPANY_ZIP_CODE}
{COMPANY_PHONE}
';
+
+ /** The payer block on a receipt, spaced differently from the billing one. */
+ private const PAYMENT_CUSTOMER_ADDRESS_FORMAT = '{BILLING_ADDRESS_NAME}
{BILLING_ADDRESS_STREET_1}
{BILLING_ADDRESS_STREET_2}
{BILLING_CITY} {BILLING_STATE} {BILLING_ZIP_CODE}
{BILLING_COUNTRY}
{BILLING_PHONE}
';
+
+ public function __construct(
+ private readonly CompanyDefaultsProvisioner $companyDefaultsProvisioner,
+ private readonly CompanyDataPurger $companyDataPurger,
+ ) {}
+
+ /**
+ * Furnish a newly created company.
+ *
+ * Order is fixed: the owner role first, so the creator has something to be
+ * assigned; then the reference data; then the preference sheet, which is
+ * where the chosen currency lands.
+ */
+ public function setupDefaults(Company $company, int $currencyId = 13): bool
+ {
+ $this->setupRoles($company);
+
+ $this->companyDefaultsProvisioner->provision($company);
+
+ $this->setupDefaultSettings($company, $currencyId);
+
+ return true;
+ }
+
+ /**
+ * Create the company's `owner` role and grant it the whole ability
+ * catalogue — every entry in the configuration, against the subject model
+ * the entry names.
+ *
+ * Roles live inside a company's scope, so the scope is moved onto this
+ * company first and left there for whatever the caller does next.
+ */
+ public function setupRoles(Company $company): void
+ {
+ BouncerFacade::scope()->to($company->id);
+
+ $owner = BouncerFacade::role()->firstOrCreate([
+ 'name' => self::OWNER_ROLE,
+ 'title' => self::OWNER_ROLE_TITLE,
+ 'scope' => $company->id,
+ ]);
+
+ foreach (config('abilities.abilities') as $entry) {
+ BouncerFacade::allow($owner)->to($entry['ability'], $entry['model']);
+ }
+ }
+
+ /**
+ * Wind a company up.
+ *
+ * The purger clears everything filed against the company first; what is
+ * left here is the company's own furniture — its scoped roles, the
+ * memberships pointing at it, its preferences, and the row itself. The
+ * member accounts survive: only the link between them and the company is
+ * cut.
+ */
+ public function delete(Company $company): bool
+ {
+ $this->companyDataPurger->purge($company);
+
+ Role::query()
+ ->when($company->id, function ($query) use ($company) {
+ $query->where('scope', $company->id);
+ })
+ ->get()
+ ->each(function ($role) {
+ $role->delete();
+ });
+
+ $company->users()->detach();
+
+ $company->settings()->delete();
+
+ $company->delete();
+
+ return true;
+ }
+
+ /**
+ * The preference sheet a company starts out with.
+ *
+ * Two of these are historical rather than sensible and are kept on
+ * purpose: the time zone defaults to `Asia/Kolkata`, and outgoing mail is
+ * addressed from the project's own no-reply address until an owner changes
+ * it. `bulk_exchange_rate_configured` starts out done, which keeps fresh
+ * companies out of the exchange-rate backfill.
+ */
+ private function setupDefaultSettings(Company $company, int $currencyId): void
+ {
+ CompanySetting::setSettings([
+ 'invoice_mail_body' => self::INVOICE_MAIL_BODY,
+ 'estimate_mail_body' => self::ESTIMATE_MAIL_BODY,
+ 'payment_mail_body' => self::PAYMENT_MAIL_BODY,
+ 'invoice_company_address_format' => self::COMPANY_ADDRESS_FORMAT,
+ 'invoice_shipping_address_format' => self::SHIPPING_ADDRESS_FORMAT,
+ 'invoice_billing_address_format' => self::BILLING_ADDRESS_FORMAT,
+ 'estimate_company_address_format' => self::COMPANY_ADDRESS_FORMAT,
+ 'estimate_shipping_address_format' => self::SHIPPING_ADDRESS_FORMAT,
+ 'estimate_billing_address_format' => self::BILLING_ADDRESS_FORMAT,
+ 'payment_company_address_format' => self::COMPANY_ADDRESS_FORMAT,
+ 'payment_from_customer_address_format' => self::PAYMENT_CUSTOMER_ADDRESS_FORMAT,
+ 'currency' => $currencyId,
+ 'time_zone' => 'Asia/Kolkata',
+ 'language' => 'en',
+ 'fiscal_year' => '1-12',
+ 'carbon_date_format' => 'Y/m/d',
+ 'moment_date_format' => 'YYYY/MM/DD',
+ 'carbon_time_format' => 'H:i',
+ 'moment_time_format' => 'HH:mm',
+ 'invoice_use_time' => 'NO',
+ 'notification_email' => 'noreply@invoiceshelf.com',
+ 'notify_invoice_viewed' => 'NO',
+ 'notify_estimate_viewed' => 'NO',
+ 'tax_per_item' => 'NO',
+ 'discount_per_item' => 'NO',
+ 'invoice_email_attachment' => 'NO',
+ 'estimate_email_attachment' => 'NO',
+ 'payment_email_attachment' => 'NO',
+ 'retrospective_edits' => 'allow',
+ 'invoice_number_format' => '{{SERIES:INV}}{{DELIMITER:-}}{{SEQUENCE:6}}',
+ 'credit_note_number_format' => '{{SERIES:CN}}{{DELIMITER:-}}{{SEQUENCE:6}}',
+ 'estimate_number_format' => '{{SERIES:EST}}{{DELIMITER:-}}{{SEQUENCE:6}}',
+ 'payment_number_format' => '{{SERIES:PAY}}{{DELIMITER:-}}{{SEQUENCE:6}}',
+ 'estimate_set_expiry_date_automatically' => 'YES',
+ 'estimate_expiry_date_days' => 7,
+ 'invoice_set_due_date_automatically' => 'YES',
+ 'invoice_due_date_days' => 7,
+ 'bulk_exchange_rate_configured' => 'YES',
+ 'estimate_convert_action' => 'no_action',
+ 'automatically_expire_public_links' => 'YES',
+ 'link_expiry_days' => 7,
+ ], $company->id);
+ }
+}
diff --git a/app/Domains/Accounts/Application/MemberService.php b/app/Domains/Accounts/Application/MemberService.php
new file mode 100644
index 00000000..0425e4c9
--- /dev/null
+++ b/app/Domains/Accounts/Application/MemberService.php
@@ -0,0 +1,123 @@
+ $attributes
+ * @param iterable $companies
+ */
+ public function create(array $attributes, iterable $companies): User
+ {
+ $member = User::create($attributes);
+
+ $member->setSettings(['language' => 'default']);
+
+ $memberships = collect($companies);
+
+ $member->companies()->sync($memberships->pluck('id'));
+
+ $this->grantRoles($member, $memberships);
+
+ return $member;
+ }
+
+ /**
+ * Overwrite an account and re-point it at the listed companies.
+ *
+ * Memberships are replaced wholesale, so an edit that omits a company both
+ * detaches the account from it and leaves the roles it held there behind —
+ * the role sync below only visits companies still on the list.
+ *
+ * @param array $attributes
+ * @param iterable $companies
+ */
+ public function update(User $user, array $attributes, iterable $companies): User
+ {
+ $user->update($attributes);
+
+ $memberships = collect($companies);
+
+ $user->companies()->sync($memberships->pluck('id'));
+
+ $this->grantRoles($user, $memberships);
+
+ return $user;
+ }
+
+ /**
+ * Erase the named accounts, one after another.
+ *
+ * An id naming nobody is skipped rather than reported. Everything the
+ * account authored outlives it: invoices, estimates, contacts, recurring
+ * invoices, expenses, payments and catalog entries are left standing with
+ * no author against them, and only the preferences rows and the account
+ * itself actually go.
+ *
+ * @param array $ids
+ */
+ public function delete(array $ids): bool
+ {
+ foreach ($ids as $id) {
+ $member = User::find($id);
+
+ if ($member === null) {
+ continue;
+ }
+
+ $this->memberReferencesCleaner->clear($member);
+
+ if ($member->settings()->exists()) {
+ $member->settings()->delete();
+ }
+
+ $member->delete();
+ }
+
+ return true;
+ }
+
+ /**
+ * Give the account exactly the one role each company named, discarding any
+ * role it already held in that company.
+ *
+ * @param Collection $memberships
+ */
+ private function grantRoles(User $member, Collection $memberships): void
+ {
+ foreach ($memberships as $membership) {
+ BouncerFacade::scope()->to($membership['id']);
+
+ BouncerFacade::sync($member)->roles([$membership['role']]);
+ }
+ }
+}
diff --git a/app/Domains/Accounts/Http/Controllers/Auth/AuthController.php b/app/Domains/Accounts/Http/Controllers/Auth/AuthController.php
new file mode 100644
index 00000000..ab51289a
--- /dev/null
+++ b/app/Domains/Accounts/Http/Controllers/Auth/AuthController.php
@@ -0,0 +1,126 @@
+staffHolding($request->username);
+
+ if ($staff === null || ! Hash::check($request->password, $staff->password)) {
+ throw ValidationException::withMessages(['email' => [self::REJECTED]]);
+ }
+
+ // Deliberately reached only once the pair has been proven, so nobody
+ // can spend an invitation by guessing at somebody else's password.
+ $this->redeemPendingInvitation($request, $staff);
+
+ $minted = $staff->createToken($request->device_name);
+
+ return response()->json([
+ 'type' => 'Bearer',
+ 'token' => $minted->plainTextToken,
+ ]);
+ }
+
+ /**
+ * Drop the token that carried this request.
+ *
+ * Quirk kept as is: exactly one token is revoked, never the account's
+ * whole set, so the caller's other devices stay signed in. And a caller
+ * authenticated by session cookie rather than a bearer token holds a
+ * transient token that has nothing to delete, so that request errors out
+ * instead of closing the session.
+ */
+ public function logout(Request $request)
+ {
+ $carrier = $request->user()->currentAccessToken();
+ $carrier->delete();
+
+ return response()->json(['success' => true]);
+ }
+
+ /**
+ * Report whether the caller is signed in.
+ *
+ * The bare boolean body is deliberate: the route already sits behind the
+ * API guard, so the SPA reads this purely as a liveness ping.
+ */
+ public function check()
+ {
+ return Auth::check();
+ }
+
+ /**
+ * Find the staff account holding the submitted address.
+ *
+ * The comparison runs against the lower-cased column so that capitalising
+ * an address differently from how it was stored still gets the account
+ * in, on every database engine the app supports.
+ */
+ private function staffHolding($submitted): ?User
+ {
+ return User::query()
+ ->whereRaw('LOWER(email) = ?', [strtolower($submitted)])
+ ->first();
+ }
+
+ /**
+ * Accept an invitation carried alongside the credentials, when one is
+ * still live.
+ *
+ * A token that is unknown, already spent or past its expiry is passed
+ * over in silence. Sign-in itself is never held up by it.
+ */
+ private function redeemPendingInvitation(LoginRequest $request, User $staff): void
+ {
+ $offered = $request->input('invitation_token');
+
+ if (! $offered) {
+ return;
+ }
+
+ $invitation = CompanyInvitation::query()
+ ->where('token', $offered)
+ ->pending()
+ ->first();
+
+ if ($invitation !== null) {
+ app(InvitationService::class)->accept($invitation, $staff);
+ }
+ }
+}
diff --git a/app/Domains/Accounts/Http/Controllers/Auth/ForgotPasswordController.php b/app/Domains/Accounts/Http/Controllers/Auth/ForgotPasswordController.php
new file mode 100644
index 00000000..dd3ca79d
--- /dev/null
+++ b/app/Domains/Accounts/Http/Controllers/Auth/ForgotPasswordController.php
@@ -0,0 +1,58 @@
+json(['message' => 'Password reset email sent.', 'data' => $response]);
+ }
+
+ /**
+ * Report that no link went out.
+ *
+ * Quirk kept as is: this refusal is a probing oracle. An address nobody
+ * holds fails here while a known address succeeds, so the difference
+ * between 403 and 200 tells a caller which staff addresses exist. The
+ * throttle on the route is the only thing narrowing that.
+ *
+ * @param string $response
+ */
+ protected function sendResetLinkFailedResponse(Request $request, $response)
+ {
+ return response()->json(['error' => self::UNDELIVERABLE], Response::HTTP_FORBIDDEN);
+ }
+}
diff --git a/app/Domains/Accounts/Http/Controllers/Auth/LoginController.php b/app/Domains/Accounts/Http/Controllers/Auth/LoginController.php
new file mode 100644
index 00000000..df23fbec
--- /dev/null
+++ b/app/Domains/Accounts/Http/Controllers/Auth/LoginController.php
@@ -0,0 +1,67 @@
+middleware('guest', ['except' => 'logout']);
+ }
+
+ /**
+ * Close the session opened by this controller.
+ *
+ * The trait's own version is replaced because it answers with a redirect
+ * or a 204; this one returns nothing at all, which the framework renders
+ * as an empty 200. Flushing the session and then rotating the CSRF token
+ * is what stops the emptied session from being reused.
+ */
+ public function logout(Request $request): void
+ {
+ Auth::guard('web')->logout();
+
+ $session = $request->session();
+ $session->invalidate();
+ $session->regenerateToken();
+ }
+}
diff --git a/app/Domains/Accounts/Http/Controllers/Auth/ResetPasswordController.php b/app/Domains/Accounts/Http/Controllers/Auth/ResetPasswordController.php
new file mode 100644
index 00000000..cd65b520
--- /dev/null
+++ b/app/Domains/Accounts/Http/Controllers/Auth/ResetPasswordController.php
@@ -0,0 +1,96 @@
+setAttribute('password', $password);
+
+ $rotated = Str::random(60);
+ $user->setRememberToken($rotated);
+
+ $user->save();
+
+ Event::dispatch(new PasswordReset($user));
+ }
+
+ /**
+ * Confirm the token was spent and the password replaced.
+ *
+ * @param string $response
+ */
+ protected function sendResetResponse(Request $request, $response)
+ {
+ return response()->json(['message' => 'Password reset successfully.']);
+ }
+
+ /**
+ * Refuse a token that did not check out.
+ *
+ * Quirk kept as is: unlike the JSON everything else on this prefix
+ * answers with, the refusal is a bare plain-text body carrying a 403, so
+ * a client parsing the reply has to special-case this one path.
+ *
+ * @param string $response
+ */
+ protected function sendResetFailedResponse(Request $request, $response)
+ {
+ return response(self::REFUSED, Response::HTTP_FORBIDDEN);
+ }
+}
diff --git a/app/Domains/Accounts/Http/Controllers/Company/AbilitiesController.php b/app/Domains/Accounts/Http/Controllers/Company/AbilitiesController.php
new file mode 100644
index 00000000..2dff06a7
--- /dev/null
+++ b/app/Domains/Accounts/Http/Controllers/Company/AbilitiesController.php
@@ -0,0 +1,27 @@
+json([
+ 'abilities' => config('abilities.abilities'),
+ ]);
+ }
+}
diff --git a/app/Domains/Accounts/Http/Controllers/Company/CompanyController.php b/app/Domains/Accounts/Http/Controllers/Company/CompanyController.php
new file mode 100644
index 00000000..811d6ab2
--- /dev/null
+++ b/app/Domains/Accounts/Http/Controllers/Company/CompanyController.php
@@ -0,0 +1,106 @@
+companyFromHeader($request);
+
+ $this->authorize('manage company', $company);
+
+ $company->update($request->getCompanyPayload());
+
+ $address = (array) $request->input('address');
+
+ $this->companyAddressWriter->upsert($company, $address);
+
+ return new CompanyResource($company);
+ }
+
+ /**
+ * Replace or drop the company logo.
+ *
+ * Two independent switches, in this order: the removal flag wipes whatever
+ * is on file, and a submitted image is then stored — so a payload carrying
+ * both ends up with the new image. The image arrives as a JSON envelope
+ * holding a file name and a data URI, already checked by the form request,
+ * and an envelope that decodes to nothing is simply ignored.
+ */
+ public function uploadCompanyLogo(CompanyLogoRequest $request)
+ {
+ $company = $this->companyFromHeader($request);
+
+ $this->authorize('manage company', $company);
+
+ if ($this->removalRequested($request)) {
+ $this->companyLogoManager->clear($company);
+ }
+
+ $envelope = json_decode((string) $request->input('company_logo'));
+
+ if ($envelope) {
+ $this->companyLogoManager->replaceBase64($company, $envelope->data, $envelope->name);
+ }
+
+ return response()->json([
+ 'success' => true,
+ ]);
+ }
+
+ /**
+ * The company named by the request header, or null when the header names
+ * nothing on file — the gate is then asked about a company that is not
+ * there, exactly as before.
+ */
+ private function companyFromHeader(Request $request): ?Company
+ {
+ return Company::query()->find($request->header('company'));
+ }
+
+ /**
+ * Whether the caller asked for the current logo to be dropped.
+ *
+ * Present-and-not-null, then cast to a boolean: `"0"` and the empty string
+ * read as no, but the string `"false"` reads as yes. Kept as it stands.
+ */
+ private function removalRequested(Request $request): bool
+ {
+ $flag = $request->input('is_company_logo_removed');
+
+ return $flag !== null && (bool) $flag;
+ }
+}
diff --git a/app/Domains/Accounts/Http/Controllers/Company/CompanySettingsController.php b/app/Domains/Accounts/Http/Controllers/Company/CompanySettingsController.php
new file mode 100644
index 00000000..4f07b48d
--- /dev/null
+++ b/app/Domains/Accounts/Http/Controllers/Company/CompanySettingsController.php
@@ -0,0 +1,136 @@
+input('settings');
+
+ return response()->json(
+ CompanySetting::getSettings($wanted, $request->header('company'))
+ );
+ }
+
+ /**
+ * Write a batch of preferences, upserting option by option.
+ *
+ * One of them is guarded: the trading currency is frozen as soon as the
+ * company has anything on its books, and an attempt to move it is refused
+ * with a plain 200 carrying `success: false` — no status code, no error
+ * bag. The comparison against the stored value is strict, so submitting
+ * the current currency as a number when the store holds it as a string
+ * counts as a change and trips the guard.
+ */
+ public function update(UpdateSettingsRequest $request): JsonResponse
+ {
+ $company = Company::query()->find($request->header('company'));
+
+ $this->authorize('manage company', $company);
+
+ $submitted = $request->input('settings');
+
+ if ($this->movesCurrency($submitted, $company) && $company->hasTransactions()) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Cannot update company currency after transactions are created.',
+ ]);
+ }
+
+ CompanySetting::setSettings($submitted, $request->header('company'));
+
+ return response()->json([
+ 'success' => true,
+ ]);
+ }
+
+ /**
+ * Whether the company has anything on its books yet — the flag the SPA
+ * uses to grey out the currency selector before the write is attempted.
+ */
+ public function checkTransactions(Request $request): JsonResponse
+ {
+ $company = Company::query()->find($request->header('company'));
+
+ $this->authorize('manage company', $company);
+
+ return response()->json([
+ 'has_transactions' => $company->hasTransactions(),
+ ]);
+ }
+
+ /**
+ * Hand the active company to one of its members.
+ *
+ * The target has to be a member already; a stranger is turned away with a
+ * 200 carrying `success: false`, in the same shape as the currency guard.
+ * On success the owner column moves and the target's roles in this company
+ * are replaced by `owner` alone. Nothing is taken away from the outgoing
+ * owner beyond the column itself — their role assignments stay, and with
+ * them everything those roles allow.
+ */
+ public function transferOwnership(Request $request, User $user): JsonResponse
+ {
+ $company = Company::query()->find($request->header('company'));
+
+ $this->authorize('transfer company ownership', $company);
+
+ if (! $user->hasCompany($company->id)) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'User does not belong to this company.',
+ ]);
+ }
+
+ $company->update(['owner_id' => $user->id]);
+
+ BouncerFacade::scope()->to($company->id);
+ BouncerFacade::sync($user)->roles(['owner']);
+
+ return response()->json([
+ 'success' => true,
+ ]);
+ }
+
+ /**
+ * Whether the submitted batch carries a currency different from the one on
+ * file. A batch without a currency key never trips the guard, even when
+ * the company is trading.
+ */
+ private function movesCurrency(mixed $submitted, Company $company): bool
+ {
+ if (! Arr::exists($submitted, 'currency')) {
+ return false;
+ }
+
+ return CompanySetting::getSetting('currency', $company->id) !== $submitted['currency'];
+ }
+}
diff --git a/app/Domains/Accounts/Http/Controllers/Company/MembersController.php b/app/Domains/Accounts/Http/Controllers/Company/MembersController.php
new file mode 100644
index 00000000..82f77417
--- /dev/null
+++ b/app/Domains/Accounts/Http/Controllers/Company/MembersController.php
@@ -0,0 +1,149 @@
+authorize('viewAny', User::class);
+
+ $perPage = $request->has('limit') ? $request->limit : 10;
+ $viewer = $request->user();
+
+ $members = User::whereCompany()
+ ->applyFilters($request->all())
+ ->where('id', '<>', $viewer->id)
+ ->latest()
+ ->paginate($perPage);
+
+ return UserResource::collection($members)->additional([
+ 'meta' => ['user_total_count' => User::whereCompany()->count()],
+ ]);
+ }
+
+ /**
+ * Open a staff account and place it in the companies the form listed.
+ *
+ * Note the gate: only the active company is weighed, so an owner may file
+ * an account into any company whose id they care to submit.
+ *
+ * @return JsonResponse
+ */
+ public function store(MemberRequest $request)
+ {
+ $this->authorize('create', User::class);
+
+ $member = $this->memberService->create(
+ $request->getUserPayload(),
+ $request->validated('companies'),
+ );
+
+ return new UserResource($member);
+ }
+
+ /**
+ * One colleague, provided they share the active company with the caller.
+ *
+ * @return JsonResponse
+ */
+ public function show(User $member)
+ {
+ $this->authorize('view', $member);
+
+ return new UserResource($member);
+ }
+
+ /**
+ * Overwrite a colleague's account and re-point their memberships.
+ *
+ * @return JsonResponse
+ */
+ public function update(MemberRequest $request, User $member)
+ {
+ $this->authorize('update', $member);
+
+ $this->memberService->update(
+ $member,
+ $request->getUserPayload(),
+ $request->validated('companies'),
+ );
+
+ return new UserResource($member);
+ }
+
+ /**
+ * Erase a batch of accounts.
+ *
+ * The submitted ids were checked against the users table installation-wide,
+ * then narrowed to members of the active company here, so an id belonging
+ * to somebody else's tenant clears validation and is quietly dropped from
+ * the batch — the call still answers success. Kept as it stands.
+ *
+ * The gate is the bulk ability rather than the per-account policy, so it
+ * asks nothing about the individual targets; the narrowing above is what
+ * keeps one company out of another's accounts.
+ *
+ * @param Request $request
+ * @return JsonResponse
+ */
+ public function delete(DeleteMemberRequest $request)
+ {
+ $this->authorize('delete multiple users', User::class);
+
+ $submitted = $request->users;
+
+ if ($submitted) {
+ $targets = User::whereCompany()
+ ->whereIn('id', $submitted)
+ ->pluck('id')
+ ->toArray();
+
+ if ($targets) {
+ $this->memberService->delete($targets);
+ }
+ }
+
+ return response()->json([
+ 'success' => true,
+ ]);
+ }
+}
diff --git a/app/Domains/Accounts/Http/Controllers/Company/RolesController.php b/app/Domains/Accounts/Http/Controllers/Company/RolesController.php
new file mode 100644
index 00000000..8ea7dd59
--- /dev/null
+++ b/app/Domains/Accounts/Http/Controllers/Company/RolesController.php
@@ -0,0 +1,134 @@
+authorize('viewAny', Role::class);
+
+ $query = Role::query();
+
+ if ($request->has('orderByField')) {
+ $query->orderBy($request['orderByField'], $request['orderBy']);
+ }
+
+ if ($request->company_id) {
+ $query->where('scope', $request->company_id);
+ }
+
+ return RoleResource::collection($query->get());
+ }
+
+ /**
+ * Define a role and settle its abilities in one go.
+ */
+ public function store(RoleRequest $request)
+ {
+ $this->authorize('create', Role::class);
+
+ $role = Role::query()->create($request->getRolePayload());
+
+ $this->writeCatalogGrants($role, $request->abilities);
+
+ return RoleResource::make($role);
+ }
+
+ /**
+ * One role with its current grants.
+ */
+ public function show(Role $role)
+ {
+ $this->authorize('view', $role);
+
+ return RoleResource::make($role);
+ }
+
+ /**
+ * Rename a role and rewrite its grants.
+ */
+ public function update(RoleRequest $request, Role $role)
+ {
+ $this->authorize('update', $role);
+
+ $role->fill($request->getRolePayload())->save();
+
+ $this->writeCatalogGrants($role, $request->abilities);
+
+ return RoleResource::make($role);
+ }
+
+ /**
+ * Drop a role, unless somebody in this company still holds it.
+ */
+ public function destroy(Role $role)
+ {
+ $this->authorize('delete', $role);
+
+ if (User::whereIs($role->name)->exists()) {
+ return respondJson(self::IN_USE_ERROR, self::IN_USE_MESSAGE);
+ }
+
+ $role->delete();
+
+ return response()->json(['success' => true]);
+ }
+
+ /**
+ * Walk the whole ability catalog and make the role match the submission.
+ *
+ * The submission is read as a set of names: a catalog entry named in it is
+ * granted, every other entry is revoked, so a role never keeps a grant the
+ * caller left out. Names that match no catalog entry are simply never
+ * looked at.
+ */
+ private function writeCatalogGrants($role, $submitted): void
+ {
+ $wanted = array_column($submitted, 'ability');
+
+ foreach (config('abilities.abilities') as $entry) {
+ if (in_array($entry['ability'], $wanted)) {
+ BouncerFacade::allow($role)->to($entry['ability'], $entry['model']);
+
+ continue;
+ }
+
+ BouncerFacade::disallow($role)->to($entry['ability'], $entry['model']);
+ }
+ }
+}
diff --git a/app/Domains/Accounts/Http/Middleware/Authenticate.php b/app/Domains/Accounts/Http/Middleware/Authenticate.php
new file mode 100644
index 00000000..9b0474fb
--- /dev/null
+++ b/app/Domains/Accounts/Http/Middleware/Authenticate.php
@@ -0,0 +1,30 @@
+expectsJson() ? null : route('login');
+ }
+}
diff --git a/app/Domains/Accounts/Http/Middleware/CompanyMiddleware.php b/app/Domains/Accounts/Http/Middleware/CompanyMiddleware.php
new file mode 100644
index 00000000..9f23376d
--- /dev/null
+++ b/app/Domains/Accounts/Http/Middleware/CompanyMiddleware.php
@@ -0,0 +1,52 @@
+user();
+
+ if ($actor === null) {
+ return $next($request);
+ }
+
+ $fallback = $actor->companies()->first();
+
+ if ($fallback === null) {
+ return $next($request);
+ }
+
+ $requested = $request->header('company');
+
+ if ($actor->isSuperAdmin() && ! $requested) {
+ return $next($request);
+ }
+
+ if (! $requested || ! $actor->hasCompany($requested)) {
+ $request->headers->set('company', $fallback->id);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Domains/Accounts/Http/Middleware/RedirectIfAuthenticated.php b/app/Domains/Accounts/Http/Middleware/RedirectIfAuthenticated.php
new file mode 100644
index 00000000..354a94b3
--- /dev/null
+++ b/app/Domains/Accounts/Http/Middleware/RedirectIfAuthenticated.php
@@ -0,0 +1,35 @@
+check()
+ ? redirect(RouteServiceProvider::HOME)
+ : $next($request);
+ }
+}
diff --git a/app/Domains/Accounts/Http/Middleware/RedirectIfUnauthorized.php b/app/Domains/Accounts/Http/Middleware/RedirectIfUnauthorized.php
new file mode 100644
index 00000000..879e25e0
--- /dev/null
+++ b/app/Domains/Accounts/Http/Middleware/RedirectIfUnauthorized.php
@@ -0,0 +1,45 @@
+check()) {
+ return redirect(self::SIGN_IN_PATH);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Domains/Accounts/Http/Middleware/ScopeBouncer.php b/app/Domains/Accounts/Http/Middleware/ScopeBouncer.php
new file mode 100644
index 00000000..308a6279
--- /dev/null
+++ b/app/Domains/Accounts/Http/Middleware/ScopeBouncer.php
@@ -0,0 +1,43 @@
+header('company');
+
+ if (! $scope) {
+ $fallback = $request->user()->companies()->first();
+
+ if ($fallback === null) {
+ return $next($request);
+ }
+
+ $scope = $fallback->id;
+ }
+
+ $this->bouncer->scope()->to($scope);
+
+ return $next($request);
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/AvatarRequest.php b/app/Domains/Accounts/Http/Requests/AvatarRequest.php
new file mode 100644
index 00000000..3a0e4e11
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/AvatarRequest.php
@@ -0,0 +1,50 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'admin_avatar' => [
+ 'nullable',
+ 'file',
+ 'mimes:'.implode(',', self::ACCEPTED_FORMATS),
+ 'max:'.self::MAX_KILOBYTES,
+ ],
+ 'avatar' => [
+ 'nullable',
+ new Base64Mime(self::ACCEPTED_FORMATS),
+ ],
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/CompaniesRequest.php b/app/Domains/Accounts/Http/Requests/CompaniesRequest.php
new file mode 100644
index 00000000..6dd85780
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/CompaniesRequest.php
@@ -0,0 +1,104 @@
+>
+ */
+ public function rules(): array
+ {
+ $rules = [
+ 'name' => [
+ 'required',
+ Rule::unique('companies'),
+ 'string',
+ ],
+ 'currency' => [
+ 'required',
+ ],
+ ];
+
+ foreach (self::OPTIONAL_ADDRESS_FIELDS as $field) {
+ $rules['address.'.$field] = ['nullable'];
+ }
+
+ $rules['address.country_id'] = ['required'];
+
+ foreach (self::TRAILING_ADDRESS_FIELDS as $field) {
+ $rules['address.'.$field] = ['nullable'];
+ }
+
+ return $rules;
+ }
+
+ /**
+ * The row to insert: the allow-listed columns, with the caller stamped on
+ * as owner and a slug derived from the name.
+ *
+ * The two tax identifiers carry no rule of their own, so they are never
+ * part of the validated payload and can never be written through here.
+ * Listed all the same, as found.
+ */
+ public function getCompanyPayload()
+ {
+ return array_merge(
+ Arr::only($this->validated(), self::COMPANY_FIELDS),
+ [
+ 'owner_id' => $this->user()->id,
+ 'slug' => Str::slug($this->name),
+ ]
+ );
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/CompanyLogoRequest.php b/app/Domains/Accounts/Http/Requests/CompanyLogoRequest.php
new file mode 100644
index 00000000..244561e3
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/CompanyLogoRequest.php
@@ -0,0 +1,43 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'company_logo' => [
+ 'nullable',
+ new Base64Mime(self::ALLOWED_TYPES),
+ ],
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/CompanyRequest.php b/app/Domains/Accounts/Http/Requests/CompanyRequest.php
new file mode 100644
index 00000000..6a6a0d27
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/CompanyRequest.php
@@ -0,0 +1,89 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'name' => [
+ 'required',
+ $this->unclaimedName(),
+ ],
+ 'vat_id' => [
+ 'nullable',
+ ],
+ 'tax_id' => [
+ 'nullable',
+ ],
+ 'address.country_id' => [
+ 'required',
+ ],
+ ];
+ }
+
+ /**
+ * The name has to be free across the whole installation, with one company
+ * excused: the one named by the request header.
+ *
+ * The exception is keyed on the header value rather than on a loaded model
+ * — nothing here checks that the header names a company that exists, so a
+ * header pointing at nothing simply excuses no row at all.
+ */
+ private function unclaimedName(): Unique
+ {
+ return Rule::unique('companies')->ignore($this->header('company'), 'id');
+ }
+
+ /**
+ * The columns to write, with a slug rebuilt from the submitted name.
+ *
+ * Both tax identifiers are declared as nullable rules here, so unlike the
+ * creation form they do survive into the validated payload and are written
+ * through. The slug is derived from the raw input rather than the
+ * validated set, which is the same string either way.
+ */
+ public function getCompanyPayload()
+ {
+ return array_merge(
+ Arr::only($this->validated(), self::COMPANY_FIELDS),
+ ['slug' => Str::slug($this->name)]
+ );
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/DeleteMemberRequest.php b/app/Domains/Accounts/Http/Requests/DeleteMemberRequest.php
new file mode 100644
index 00000000..b87741e0
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/DeleteMemberRequest.php
@@ -0,0 +1,40 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'users' => ['required'],
+ 'users.*' => ['required', Rule::exists('users', 'id')],
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/GetSettingsRequest.php b/app/Domains/Accounts/Http/Requests/GetSettingsRequest.php
new file mode 100644
index 00000000..529bb807
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/GetSettingsRequest.php
@@ -0,0 +1,40 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'settings' => [
+ 'required',
+ ],
+ 'settings.*' => [
+ 'required',
+ 'string',
+ ],
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/LoginRequest.php b/app/Domains/Accounts/Http/Requests/LoginRequest.php
new file mode 100644
index 00000000..000a54d6
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/LoginRequest.php
@@ -0,0 +1,41 @@
+ ['required'],
+ 'password' => ['required'],
+ 'device_name' => ['required'],
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/MemberRequest.php b/app/Domains/Accounts/Http/Requests/MemberRequest.php
new file mode 100644
index 00000000..79990b11
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/MemberRequest.php
@@ -0,0 +1,82 @@
+>
+ */
+ public function rules(): array
+ {
+ $editing = $this->getMethod() == 'PUT';
+
+ $address = Rule::unique('users');
+
+ if ($editing) {
+ $address->ignore($this->member);
+ }
+
+ return [
+ 'name' => ['required'],
+ 'email' => ['required', new IdnEmail, $address],
+ 'phone' => ['nullable'],
+ 'password' => $editing ? ['nullable', 'min:8'] : ['required', 'min:8'],
+ 'companies' => ['required'],
+ 'companies.*.id' => ['required'],
+ 'companies.*.role' => ['required'],
+ ];
+ }
+
+ /**
+ * The account row on its own, stamped with whoever is filing it.
+ *
+ * On an edit the stamp is written again, so the column records the last
+ * person to save the form rather than the one who opened the account.
+ *
+ * @return array
+ */
+ public function getUserPayload()
+ {
+ return collect($this->validated())
+ ->only(self::ACCOUNT_FIELDS)
+ ->merge(['creator_id' => $this->user()->id])
+ ->toArray();
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/ProfileRequest.php b/app/Domains/Accounts/Http/Requests/ProfileRequest.php
new file mode 100644
index 00000000..25a17f90
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/ProfileRequest.php
@@ -0,0 +1,45 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'name' => ['required'],
+ 'password' => ['nullable', 'min:8'],
+ 'email' => [
+ 'required',
+ new IdnEmail,
+ Rule::unique('users')->ignore(Auth::id(), 'id'),
+ ],
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/RoleRequest.php b/app/Domains/Accounts/Http/Requests/RoleRequest.php
new file mode 100644
index 00000000..1ebe147a
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/RoleRequest.php
@@ -0,0 +1,58 @@
+where('scope', $this->header('company'));
+
+ if ($this->getMethod() === 'PUT') {
+ $name->ignore($this->route('role')->id, 'id');
+ }
+
+ return [
+ 'name' => ['required', 'string', $name],
+ 'abilities' => ['required'],
+ 'abilities.*' => ['required'],
+ ];
+ }
+
+ /**
+ * The submitted attributes, minus the abilities, stamped with the scope.
+ *
+ * Everything the caller sent survives the trip; the role model's own
+ * fillable list decides what is actually written.
+ */
+ public function getRolePayload()
+ {
+ $attributes = $this->except('abilities');
+
+ $attributes['scope'] = $this->header('company');
+
+ return $attributes;
+ }
+}
diff --git a/app/Domains/Accounts/Http/Requests/UpdateSettingsRequest.php b/app/Domains/Accounts/Http/Requests/UpdateSettingsRequest.php
new file mode 100644
index 00000000..1003a436
--- /dev/null
+++ b/app/Domains/Accounts/Http/Requests/UpdateSettingsRequest.php
@@ -0,0 +1,35 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'settings' => [
+ 'required',
+ ],
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Http/Resources/CompanyResource.php b/app/Domains/Accounts/Http/Resources/CompanyResource.php
new file mode 100644
index 00000000..b1357d40
--- /dev/null
+++ b/app/Domains/Accounts/Http/Resources/CompanyResource.php
@@ -0,0 +1,78 @@
+resource;
+
+ return [
+ 'id' => $company->id,
+ 'name' => $company->name,
+ 'vat_id' => $company->vat_id,
+ 'tax_id' => $company->tax_id,
+ 'logo' => $company->logo,
+ 'logo_path' => $company->logo_path,
+ 'unique_hash' => $company->unique_hash,
+ 'owner_id' => $company->owner_id,
+ 'slug' => $company->slug,
+ 'created_at' => $company->created_at,
+ 'updated_at' => $company->updated_at,
+ 'address' => $this->when(
+ $company->address()->exists(),
+ fn () => new AddressResource($company->address)
+ ),
+ 'owner' => $this->when(
+ $company->relationLoaded('owner'),
+ fn () => new UserResource($company->owner)
+ ),
+ 'roles' => RoleResource::collection($company->roles),
+ 'user_role' => $this->assignedRoleTitle(),
+ ];
+ }
+
+ /**
+ * Title of the role the signed-in account holds inside this company.
+ *
+ * Read off the assignment table by company id, so it stays right for a
+ * company other than the active one. Null when nobody is signed in, and
+ * null when the account has no assignment here.
+ */
+ private function assignedRoleTitle(): ?string
+ {
+ $viewer = Auth::user();
+
+ if ($viewer === null) {
+ return null;
+ }
+
+ return DB::query()
+ ->from('assigned_roles')
+ ->join('roles', 'assigned_roles.role_id', '=', 'roles.id')
+ ->where([
+ ['assigned_roles.entity_type', '=', $viewer->getMorphClass()],
+ ['assigned_roles.entity_id', '=', $viewer->id],
+ ['assigned_roles.scope', '=', $this->id],
+ ])
+ ->value('roles.title');
+ }
+}
diff --git a/app/Domains/Accounts/Http/Resources/CustomerPortal/CompanyResource.php b/app/Domains/Accounts/Http/Resources/CustomerPortal/CompanyResource.php
new file mode 100644
index 00000000..ee611436
--- /dev/null
+++ b/app/Domains/Accounts/Http/Resources/CustomerPortal/CompanyResource.php
@@ -0,0 +1,39 @@
+resource;
+
+ return [
+ 'id' => $company->id,
+ 'name' => $company->name,
+ 'slug' => $company->slug,
+ 'logo' => $company->logo,
+ 'logo_path' => $company->logo_path,
+ 'unique_hash' => $company->unique_hash,
+ 'owner_id' => $company->owner_id,
+ 'address' => $this->when(
+ $company->address()->exists(),
+ fn () => new AddressResource($company->address)
+ ),
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Http/Resources/CustomerPortal/UserResource.php b/app/Domains/Accounts/Http/Resources/CustomerPortal/UserResource.php
new file mode 100644
index 00000000..c39b2481
--- /dev/null
+++ b/app/Domains/Accounts/Http/Resources/CustomerPortal/UserResource.php
@@ -0,0 +1,56 @@
+resource;
+
+ return [
+ 'id' => $user->id,
+ 'name' => $user->name,
+ 'email' => $user->email,
+ 'phone' => $user->phone,
+ 'role' => $user->role,
+ 'contact_name' => $user->contact_name,
+ 'company_name' => $user->company_name,
+ 'website' => $user->website,
+ 'enable_portal' => $user->enable_portal,
+ 'currency_id' => $user->currency_id,
+ 'facebook_id' => $user->facebook_id,
+ 'google_id' => $user->google_id,
+ 'github_id' => $user->github_id,
+ 'created_at' => $user->created_at,
+ 'updated_at' => $user->updated_at,
+ 'avatar' => $user->avatar,
+ 'is_owner' => $user->isOwner(),
+ 'roles' => $user->roles,
+ 'formatted_created_at' => $user->formattedCreatedAt,
+ 'currency' => $this->when(
+ $user->currency()->exists(),
+ fn () => new CurrencyResource($user->currency)
+ ),
+ 'companies' => $this->when(
+ $user->companies()->exists(),
+ fn () => CompanyResource::collection($user->companies)
+ ),
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Http/Resources/RoleResource.php b/app/Domains/Accounts/Http/Resources/RoleResource.php
new file mode 100644
index 00000000..9e9706e5
--- /dev/null
+++ b/app/Domains/Accounts/Http/Resources/RoleResource.php
@@ -0,0 +1,49 @@
+resource;
+ $createdAt = $this->getFormattedAt();
+
+ return [
+ 'id' => $role->id,
+ 'name' => $role->name,
+ 'title' => $role->title,
+ 'level' => $role->level,
+ 'formatted_created_at' => $createdAt,
+ 'abilities' => $role->getAbilities(),
+ ];
+ }
+
+ /**
+ * The creation date in the date format of the company owning the role.
+ *
+ * The format follows the role's own scope, not the company the reader is
+ * looking in from.
+ */
+ public function getFormattedAt()
+ {
+ $format = CompanySetting::getSetting('carbon_date_format', $this->scope);
+
+ return Carbon::parse($this->created_at)->translatedFormat($format);
+ }
+}
diff --git a/app/Domains/Accounts/Http/Resources/UserResource.php b/app/Domains/Accounts/Http/Resources/UserResource.php
new file mode 100644
index 00000000..01d7ef68
--- /dev/null
+++ b/app/Domains/Accounts/Http/Resources/UserResource.php
@@ -0,0 +1,59 @@
+resource;
+
+ return [
+ 'id' => $user->id,
+ 'name' => $user->name,
+ 'email' => $user->email,
+ 'phone' => $user->phone,
+ 'role' => $user->role,
+ 'contact_name' => $user->contact_name,
+ 'company_name' => $user->company_name,
+ 'website' => $user->website,
+ 'enable_portal' => $user->enable_portal,
+ 'currency_id' => $user->currency_id,
+ 'facebook_id' => $user->facebook_id,
+ 'google_id' => $user->google_id,
+ 'github_id' => $user->github_id,
+ 'created_at' => $user->created_at,
+ 'updated_at' => $user->updated_at,
+ 'avatar' => $user->avatar,
+ 'is_owner' => $user->isOwner(),
+ 'is_super_admin' => $user->isSuperAdmin(),
+ 'roles' => $user->roles,
+ 'formatted_created_at' => $user->formattedCreatedAt,
+ 'currency' => $this->when(
+ $user->currency()->exists(),
+ fn () => new CurrencyResource($user->currency)
+ ),
+ 'companies' => $this->when(
+ $user->companies()->exists(),
+ fn () => CompanyResource::collection($user->companies)
+ ),
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Models/Company.php b/app/Domains/Accounts/Models/Company.php
new file mode 100644
index 00000000..3dbab233
--- /dev/null
+++ b/app/Domains/Accounts/Models/Company.php
@@ -0,0 +1,303 @@
+addMediaCollection('logo')->useDisk('public')->singleFile();
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Relationships
+ |--------------------------------------------------------------------------
+ */
+
+ /**
+ * The account holding positional ownership of this company.
+ */
+ public function owner(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'owner_id');
+ }
+
+ /**
+ * Every account with a membership in this company.
+ */
+ public function users(): BelongsToMany
+ {
+ return $this->belongsToMany(User::class, 'user_company', 'company_id', 'user_id');
+ }
+
+ /**
+ * The postal address printed on this company's documents.
+ */
+ public function address(): HasOne
+ {
+ return $this->hasOne(Address::class);
+ }
+
+ /**
+ * Per-company preference rows, addressed by their `option` column.
+ */
+ public function settings(): HasMany
+ {
+ return $this->hasMany(CompanySetting::class);
+ }
+
+ /**
+ * Contacts filed under this company.
+ */
+ public function customers(): HasMany
+ {
+ return $this->hasMany(Customer::class);
+ }
+
+ /**
+ * Catalog entries filed under this company.
+ */
+ public function items(): HasMany
+ {
+ return $this->hasMany(Item::class);
+ }
+
+ /**
+ * Units of measure available to this company's catalog.
+ */
+ public function units(): HasMany
+ {
+ return $this->hasMany(Unit::class);
+ }
+
+ /**
+ * Tax rates this company can apply.
+ */
+ public function taxTypes(): HasMany
+ {
+ return $this->hasMany(TaxType::class);
+ }
+
+ /**
+ * Invoices issued by this company.
+ */
+ public function invoices(): HasMany
+ {
+ return $this->hasMany(Invoice::class);
+ }
+
+ /**
+ * Estimates issued by this company.
+ */
+ public function estimates(): HasMany
+ {
+ return $this->hasMany(Estimate::class);
+ }
+
+ /**
+ * Recurring invoice schedules owned by this company.
+ */
+ public function recurringInvoices(): HasMany
+ {
+ return $this->hasMany(RecurringInvoice::class);
+ }
+
+ /**
+ * Payments received by this company.
+ */
+ public function payments(): HasMany
+ {
+ return $this->hasMany(Payment::class);
+ }
+
+ /**
+ * Ways this company accepts being paid.
+ */
+ public function paymentMethods(): HasMany
+ {
+ return $this->hasMany(PaymentMethod::class);
+ }
+
+ /**
+ * Expenses booked against this company.
+ */
+ public function expenses(): HasMany
+ {
+ return $this->hasMany(Expense::class);
+ }
+
+ /**
+ * Buckets this company sorts its expenses into.
+ */
+ public function expenseCategories(): HasMany
+ {
+ return $this->hasMany(ExpenseCategory::class);
+ }
+
+ /**
+ * Custom field definitions declared by this company.
+ */
+ public function customFields(): HasMany
+ {
+ return $this->hasMany(CustomField::class);
+ }
+
+ /**
+ * Answers recorded for this company's custom fields.
+ */
+ public function customFieldValues(): HasMany
+ {
+ return $this->hasMany(CustomFieldValue::class);
+ }
+
+ /**
+ * Recorded exchange rate lookups for this company.
+ */
+ public function exchangeRateLogs(): HasMany
+ {
+ return $this->hasMany(ExchangeRateLog::class);
+ }
+
+ /**
+ * Configured exchange rate sources for this company.
+ */
+ public function exchangeRateProviders(): HasMany
+ {
+ return $this->hasMany(ExchangeRateProvider::class);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Accessors
+ |--------------------------------------------------------------------------
+ */
+
+ /**
+ * The roles defined inside this company's authorization scope.
+ */
+ public function getRolesAttribute()
+ {
+ return Role::query()->where('scope', $this->id)->get();
+ }
+
+ /**
+ * Publicly reachable address of the branding image, null when none is
+ * attached.
+ */
+ public function getLogoAttribute()
+ {
+ $logo = $this->logoMedia();
+
+ return $logo ? $logo->getFullUrl() : null;
+ }
+
+ /**
+ * Where the branding image lives.
+ *
+ * A local filesystem path while the default file disk is a system disk,
+ * and a public address for every other kind of disk - the asymmetry is
+ * deliberate, PDF rendering needs the path and the SPA needs the address.
+ * The default disk is resolved whether or not an image is attached.
+ */
+ public function getLogoPathAttribute()
+ {
+ $logo = $this->logoMedia();
+
+ $isSystem = FileDisk::query()->where('set_as_default', true)->first()->isSystem();
+
+ if (! $logo) {
+ return null;
+ }
+
+ return $isSystem ? $logo->getPath() : $logo->getFullUrl();
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Helpers
+ |--------------------------------------------------------------------------
+ */
+
+ /**
+ * Whether any business record has been filed under this company yet.
+ *
+ * Contacts, catalog entries, invoices, estimates, expenses, payments and
+ * recurring schedules all count; the first one found ends the search.
+ */
+ public function hasTransactions(): bool
+ {
+ $ledgers = [
+ 'customers',
+ 'items',
+ 'invoices',
+ 'estimates',
+ 'expenses',
+ 'payments',
+ 'recurringInvoices',
+ ];
+
+ foreach ($ledgers as $ledger) {
+ if ($this->{$ledger}()->exists()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * The one media row behind the branding collection, if there is one.
+ */
+ private function logoMedia()
+ {
+ return $this->getMedia('logo')->first();
+ }
+}
diff --git a/app/Domains/Accounts/Models/CompanySetting.php b/app/Domains/Accounts/Models/CompanySetting.php
new file mode 100644
index 00000000..f7355388
--- /dev/null
+++ b/app/Domains/Accounts/Models/CompanySetting.php
@@ -0,0 +1,102 @@
+belongsTo(Company::class);
+ }
+
+ /**
+ * Narrow a query to one company's rows.
+ */
+ public function scopeWhereCompany($query, $company_id)
+ {
+ $query->where('company_id', $company_id);
+ }
+
+ /**
+ * Write a batch of preferences for one company, replacing the value of any
+ * option already on file and inserting the rest.
+ */
+ public static function setSettings(array $settings, mixed $company_id): void
+ {
+ foreach ($settings as $option => $value) {
+ self::updateOrCreate(
+ ['option' => $option, 'company_id' => $company_id],
+ ['option' => $option, 'company_id' => $company_id, 'value' => $value]
+ );
+ }
+ }
+
+ /**
+ * Every preference on file for a company, keyed by option name.
+ */
+ public static function getAllSettings(mixed $company_id): Collection
+ {
+ return self::flatten(
+ static::whereCompany($company_id)->get()
+ );
+ }
+
+ /**
+ * The named preferences only; options with no row on file are left out.
+ */
+ public static function getSettings(array $settings, mixed $company_id): Collection
+ {
+ return self::flatten(
+ static::whereIn('option', $settings)->whereCompany($company_id)->get()
+ );
+ }
+
+ /**
+ * One preference value, or null when the company has no row for it.
+ */
+ public static function getSetting(string $key, mixed $company_id): mixed
+ {
+ $setting = static::query()
+ ->where('option', $key)
+ ->whereCompany($company_id)
+ ->first();
+
+ if ($setting) {
+ return $setting->value;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Reduce preference rows to an option => value collection.
+ */
+ private static function flatten(Collection $rows): Collection
+ {
+ return $rows->mapWithKeys(function ($row) {
+ return [$row['option'] => $row['value']];
+ });
+ }
+}
diff --git a/app/Domains/Accounts/Models/User.php b/app/Domains/Accounts/Models/User.php
new file mode 100644
index 00000000..a28a7411
--- /dev/null
+++ b/app/Domains/Accounts/Models/User.php
@@ -0,0 +1,621 @@
+addMediaCollection('admin_avatar')->useDisk('public')->singleFile();
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Relationships
+ |--------------------------------------------------------------------------
+ */
+
+ /**
+ * Preferred currency, eager loaded on every query.
+ */
+ public function currency(): BelongsTo
+ {
+ return $this->belongsTo(Currency::class, 'currency_id');
+ }
+
+ /**
+ * Whoever created this account, when it was not self-registered.
+ */
+ public function creator(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'creator_id');
+ }
+
+ /**
+ * Companies this account is a member of.
+ */
+ public function companies(): BelongsToMany
+ {
+ return $this->belongsToMany(Company::class, 'user_company', 'user_id', 'company_id');
+ }
+
+ /**
+ * Per-user preference rows, addressed by their `key` column.
+ */
+ public function settings(): HasMany
+ {
+ return $this->hasMany(UserSetting::class, 'user_id');
+ }
+
+ /**
+ * Contacts this account authored.
+ */
+ public function customers(): HasMany
+ {
+ return $this->hasMany(Customer::class, 'creator_id');
+ }
+
+ /**
+ * Catalog entries this account authored.
+ */
+ public function items(): HasMany
+ {
+ return $this->hasMany(Item::class, 'creator_id');
+ }
+
+ /**
+ * Estimates this account authored.
+ */
+ public function estimates(): HasMany
+ {
+ return $this->hasMany(Estimate::class, 'creator_id');
+ }
+
+ /**
+ * Invoices this account authored.
+ */
+ public function invoices(): HasMany
+ {
+ return $this->hasMany(Invoice::class, 'creator_id');
+ }
+
+ /**
+ * Recurring invoice schedules this account authored.
+ */
+ public function recurringInvoices(): HasMany
+ {
+ return $this->hasMany(RecurringInvoice::class, 'creator_id');
+ }
+
+ /**
+ * Payments this account recorded.
+ */
+ public function payments(): HasMany
+ {
+ return $this->hasMany(Payment::class, 'creator_id');
+ }
+
+ /**
+ * Expenses this account recorded.
+ */
+ public function expenses(): HasMany
+ {
+ return $this->hasMany(Expense::class, 'creator_id');
+ }
+
+ /**
+ * Every postal address filed against this account.
+ */
+ public function addresses(): HasMany
+ {
+ return $this->hasMany(Address::class);
+ }
+
+ /**
+ * The address flagged for billing.
+ */
+ public function billingAddress(): HasOne
+ {
+ return $this->hasOne(Address::class)->where('type', Address::BILLING_TYPE);
+ }
+
+ /**
+ * The address flagged for shipping.
+ */
+ public function shippingAddress(): HasOne
+ {
+ return $this->hasOne(Address::class)->where('type', Address::SHIPPING_TYPE);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Accessors and mutators
+ |--------------------------------------------------------------------------
+ */
+
+ /**
+ * Hash a password on assignment.
+ *
+ * A blank value is skipped so that saving a form which left the field empty
+ * keeps the hash already on file.
+ */
+ public function setPasswordAttribute(string $value): void
+ {
+ if ($value === '') {
+ return;
+ }
+
+ $this->attributes['password'] = bcrypt($value);
+ }
+
+ /**
+ * Public URL of the avatar, or the number zero when none is attached.
+ */
+ public function getAvatarAttribute()
+ {
+ $image = $this->getMedia('admin_avatar')->first();
+
+ return $image ? asset($image->getUrl()) : 0;
+ }
+
+ /**
+ * Signup timestamp rendered with the date format of the company the
+ * request is acting on.
+ */
+ public function getFormattedCreatedAtAttribute($value)
+ {
+ return Carbon::parse($this->created_at)->format($this->contextDateFormat());
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Query scopes
+ |--------------------------------------------------------------------------
+ */
+
+ /**
+ * Sort by a caller-supplied column, sanitised before it reaches SQL.
+ */
+ public function scopeWhereOrder($query, $orderByField, $orderBy)
+ {
+ return SafeOrderBy::apply($query, $orderByField, $orderBy, 'created_at');
+ }
+
+ /**
+ * Keep only accounts matching every whitespace-separated term, a term
+ * counting as matched when it turns up in the name, the email or the phone.
+ */
+ public function scopeWhereSearch($query, $search)
+ {
+ $terms = explode(' ', $search);
+
+ foreach ($terms as $term) {
+ $needle = self::wildcard($term);
+
+ $query->where(function ($match) use ($needle) {
+ $match->where('name', 'LIKE', $needle)
+ ->orWhere('email', 'LIKE', $needle)
+ ->orWhere('phone', 'LIKE', $needle);
+ });
+ }
+ }
+
+ /**
+ * Partial match on the contact person.
+ */
+ public function scopeWhereContactName($query, $contactName)
+ {
+ return $query->where('contact_name', 'LIKE', self::wildcard($contactName));
+ }
+
+ /**
+ * Partial match on the name the account is displayed under.
+ */
+ public function scopeWhereDisplayName($query, $displayName)
+ {
+ return $query->where('name', 'LIKE', self::wildcard($displayName));
+ }
+
+ /**
+ * Partial match on the phone number.
+ */
+ public function scopeWherePhone($query, $phone)
+ {
+ return $query->where('phone', 'LIKE', self::wildcard($phone));
+ }
+
+ /**
+ * Partial match on the email address.
+ */
+ public function scopeWhereEmail($query, $email)
+ {
+ return $query->where('email', 'LIKE', self::wildcard($email));
+ }
+
+ /**
+ * Keep only members of the company the request is acting on.
+ */
+ public function scopeWhereCompany($query)
+ {
+ $company = request()->header('company');
+
+ return $query->whereHas('companies', function ($membership) use ($company) {
+ $membership->where('company_id', $company);
+ });
+ }
+
+ /**
+ * Widen a listing to also take in the platform administrator.
+ */
+ public function scopeWhereSuperAdmin($query)
+ {
+ $query->orWhere('role', 'super admin');
+ }
+
+ /**
+ * Return the whole result set for the sentinel limit "all", otherwise a
+ * page of the requested size.
+ */
+ public function scopePaginateData($query, $limit)
+ {
+ return $limit == 'all' ? $query->get() : $query->paginate($limit);
+ }
+
+ /**
+ * Run every listed filter that carries a value.
+ */
+ public function scopeApplyFilters($query, array $filters)
+ {
+ $scopes = [
+ 'search' => 'whereSearch',
+ 'display_name' => 'whereDisplayName',
+ 'email' => 'whereEmail',
+ 'phone' => 'wherePhone',
+ ];
+
+ foreach ($scopes as $filter => $scope) {
+ $value = $filters[$filter] ?? null;
+
+ if ($value) {
+ $query->{$scope}($value);
+ }
+ }
+
+ $role = $filters['role'] ?? null;
+
+ if ($role) {
+ $query->whereHas('roles', function ($assigned) use ($role) {
+ $assigned->where('roles.id', $role);
+ });
+ }
+
+ $sortField = $filters['orderByField'] ?? null;
+ $sortDirection = $filters['orderBy'] ?? null;
+
+ if ($sortField || $sortDirection) {
+ $query->whereOrder($sortField ?: 'name', $sortDirection ?: 'asc');
+ }
+ }
+
+ /**
+ * Restrict to accounts who authored an invoice inside a date range, when
+ * the caller supplied both ends of it.
+ */
+ public function scopeApplyInvoiceFilters($query, array $filters)
+ {
+ $from = $filters['from_date'] ?? null;
+ $to = $filters['to_date'] ?? null;
+
+ if ($from && $to) {
+ $query->invoicesBetween(
+ Carbon::createFromFormat('Y-m-d', $from),
+ Carbon::createFromFormat('Y-m-d', $to)
+ );
+ }
+ }
+
+ /**
+ * Restrict to accounts holding at least one invoice dated inside the
+ * inclusive range.
+ */
+ public function scopeInvoicesBetween($query, $start, $end)
+ {
+ $range = [$start->format('Y-m-d'), $end->format('Y-m-d')];
+
+ $query->whereHas('invoices', function ($invoices) use ($range) {
+ $invoices->whereBetween('invoice_date', $range);
+ });
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Settings
+ |--------------------------------------------------------------------------
+ */
+
+ /**
+ * Write a batch of preferences, replacing the value of any key already on
+ * file and inserting the rest.
+ */
+ public function setSettings(array $settings): void
+ {
+ foreach ($settings as $option => $value) {
+ $this->settings()->updateOrCreate(['key' => $option], ['key' => $option, 'value' => $value]);
+ }
+ }
+
+ /**
+ * Every preference on file for this account, keyed by setting name.
+ */
+ public function getAllSettings(): Collection
+ {
+ return $this->flattenSettings($this->settings()->get());
+ }
+
+ /**
+ * The named preferences only; keys with no row on file are left out.
+ */
+ public function getSettings(array $settings): Collection
+ {
+ return $this->flattenSettings($this->settings()->whereIn('key', $settings)->get());
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Identity and access
+ |--------------------------------------------------------------------------
+ */
+
+ /**
+ * Resolve an account from the identifier a token grant was asked for.
+ */
+ public function findForPassport(string $username): ?self
+ {
+ return $this->newQuery()->where('email', $username)->first();
+ }
+
+ /**
+ * Start a session from a request-like object carrying the credentials.
+ */
+ public static function login(object $request): bool
+ {
+ $credentials = [
+ 'email' => $request->email,
+ 'password' => $request->password,
+ ];
+
+ return Auth::attempt($credentials, $request->remember);
+ }
+
+ /**
+ * Deliver a password reset link pointing at the SPA reset screen.
+ */
+ public function sendPasswordResetNotification($token)
+ {
+ $notification = new MailResetPasswordNotification($token);
+
+ $this->notify($notification);
+ }
+
+ /**
+ * Whether this account is the platform administrator.
+ */
+ public function isSuperAdmin(): bool
+ {
+ return $this->role === 'super admin';
+ }
+
+ /**
+ * Whether the pre-Bouncer `role` column marks this account as privileged.
+ */
+ public function isSuperAdminOrAdmin(): bool
+ {
+ return $this->hasLegacyAdminRole();
+ }
+
+ /**
+ * Whether this account is a member of the given company.
+ */
+ public function hasCompany(int $company_id): bool
+ {
+ return $this->companies()->pluck('company_id')->contains($company_id);
+ }
+
+ /**
+ * Whether this account owns the company the request is acting on.
+ *
+ * Ownership is positional: it is read from the company's owner column, so
+ * transferring ownership flips authorization immediately. Installs that
+ * have not run the migration adding that column fall back to the old role
+ * strings.
+ */
+ public function isOwner(): bool
+ {
+ if (! Schema::hasColumn('companies', 'owner_id')) {
+ return $this->hasLegacyAdminRole();
+ }
+
+ $active = Company::find(request()->header('company'));
+
+ return $active && $this->id == $active->owner_id;
+ }
+
+ /**
+ * Decide whether a navigation entry's requirements are met.
+ *
+ * Entries reserved for the platform administrator are settled by that gate
+ * alone. Owners of the active company clear everything else. Everyone else
+ * needs the named ability, checked against the entry's subject model first
+ * and against the bare ability afterwards; an entry naming no ability at
+ * all is open.
+ */
+ public function checkAccess(object $data): bool
+ {
+ $meta = $data->data;
+
+ if (! empty($meta['super_admin_only'])) {
+ return $this->isSuperAdmin();
+ }
+
+ if ($this->isOwner()) {
+ return true;
+ }
+
+ if ($meta['owner_only']) {
+ return false;
+ }
+
+ if (empty($meta['ability'])) {
+ return true;
+ }
+
+ if (! empty($meta['model']) && $this->can($meta['ability'], $meta['model'])) {
+ return true;
+ }
+
+ return $this->can($meta['ability']);
+ }
+
+ /*
+ |--------------------------------------------------------------------------
+ | Internals
+ |--------------------------------------------------------------------------
+ */
+
+ /**
+ * Date format of the company the request is acting on, falling back to the
+ * first company this account belongs to and to an ISO-style date when it
+ * belongs to none.
+ */
+ private function contextDateFormat(): mixed
+ {
+ $scope = request()->header('company');
+
+ $configured = $scope
+ && CompanySetting::query()->where('company_id', $scope)->exists();
+
+ if (! $configured) {
+ $home = $this->companies()->first();
+
+ if (! $home) {
+ return 'Y-m-d';
+ }
+
+ $scope = $home->id;
+ }
+
+ return CompanySetting::getSetting('carbon_date_format', $scope);
+ }
+
+ /**
+ * The pre-Bouncer administrator test, kept for installs whose companies
+ * table has not gained its ownership column yet.
+ */
+ private function hasLegacyAdminRole(): bool
+ {
+ return in_array($this->role, ['super admin', 'admin']);
+ }
+
+ /**
+ * Reduce preference rows to a name => value collection.
+ */
+ private function flattenSettings(Collection $rows): Collection
+ {
+ return $rows->mapWithKeys(function ($row) {
+ return [$row['key'] => $row['value']];
+ });
+ }
+
+ /**
+ * Wrap a term for a substring LIKE comparison.
+ */
+ private static function wildcard($term): string
+ {
+ return '%'.$term.'%';
+ }
+}
diff --git a/app/Domains/Accounts/Models/UserSetting.php b/app/Domains/Accounts/Models/UserSetting.php
new file mode 100644
index 00000000..47ee8f67
--- /dev/null
+++ b/app/Domains/Accounts/Models/UserSetting.php
@@ -0,0 +1,32 @@
+belongsTo(User::class);
+ }
+}
diff --git a/app/Domains/Accounts/Notifications/MailResetPasswordNotification.php b/app/Domains/Accounts/Notifications/MailResetPasswordNotification.php
new file mode 100644
index 00000000..a207f9ce
--- /dev/null
+++ b/app/Domains/Accounts/Notifications/MailResetPasswordNotification.php
@@ -0,0 +1,61 @@
+token);
+
+ return (new MailMessage)
+ ->subject('Reset Password Notification')
+ ->line('Hello! You are receiving this email because we received a password reset request for your account.')
+ ->action('Reset Password', $resetUrl)
+ ->line('This password reset link will expire in '.config('auth.passwords.users.expire').' minutes')
+ ->line('If you did not request a password reset, no further action is required.');
+ }
+
+ /**
+ * Nothing is stored for the database channel.
+ */
+ public function toArray($notifiable): array
+ {
+ return [
+ //
+ ];
+ }
+}
diff --git a/app/Domains/Accounts/Policies/CompanyPolicy.php b/app/Domains/Accounts/Policies/CompanyPolicy.php
new file mode 100644
index 00000000..30f61b26
--- /dev/null
+++ b/app/Domains/Accounts/Policies/CompanyPolicy.php
@@ -0,0 +1,64 @@
+isOwner();
+ }
+
+ /**
+ * Winding a company up.
+ */
+ public function delete(User $user, Company $company): bool
+ {
+ return $this->ownsOutright($user, $company);
+ }
+
+ /**
+ * Handing a company to somebody else.
+ *
+ * Declared without a return type, as found.
+ */
+ public function transferOwnership(User $user, Company $company)
+ {
+ return $this->ownsOutright($user, $company);
+ }
+
+ /**
+ * The actor is the company's recorded owner.
+ *
+ * Compared loosely, so an owner column holding a numeric string still
+ * matches the id it names.
+ */
+ private function ownsOutright(User $user, Company $company): bool
+ {
+ return $user->id == $company->owner_id;
+ }
+}
diff --git a/app/Domains/Accounts/Policies/OwnerPolicy.php b/app/Domains/Accounts/Policies/OwnerPolicy.php
new file mode 100644
index 00000000..dcb6909a
--- /dev/null
+++ b/app/Domains/Accounts/Policies/OwnerPolicy.php
@@ -0,0 +1,27 @@
+isOwner();
+ }
+}
diff --git a/app/Domains/Accounts/Policies/RolePolicy.php b/app/Domains/Accounts/Policies/RolePolicy.php
new file mode 100644
index 00000000..7d6b016e
--- /dev/null
+++ b/app/Domains/Accounts/Policies/RolePolicy.php
@@ -0,0 +1,78 @@
+isOwner();
+ }
+
+ /**
+ * Reading one role. The role itself is not examined.
+ */
+ public function view(User $user, Role $role): bool
+ {
+ return $user->isOwner();
+ }
+
+ /**
+ * Defining a role.
+ */
+ public function create(User $user): bool
+ {
+ return $user->isOwner();
+ }
+
+ /**
+ * Renaming a role or resyncing its abilities.
+ */
+ public function update(User $user, Role $role): bool
+ {
+ return $user->isOwner();
+ }
+
+ /**
+ * Dropping a role. Whether anybody still holds it is settled downstream,
+ * not here.
+ */
+ public function delete(User $user, Role $role): bool
+ {
+ return $user->isOwner();
+ }
+
+ /**
+ * Bringing a role back — unreachable, as roles are not soft-deleted.
+ */
+ public function restore(User $user, Role $role): bool
+ {
+ return $user->isOwner();
+ }
+
+ /**
+ * Erasing a role for good — unreachable for the same reason.
+ */
+ public function forceDelete(User $user, Role $role): bool
+ {
+ return $user->isOwner();
+ }
+}
diff --git a/app/Domains/Accounts/Policies/SettingsPolicy.php b/app/Domains/Accounts/Policies/SettingsPolicy.php
new file mode 100644
index 00000000..a9a7a731
--- /dev/null
+++ b/app/Domains/Accounts/Policies/SettingsPolicy.php
@@ -0,0 +1,30 @@
+id == $company->owner_id;
+ }
+}
diff --git a/app/Domains/Accounts/Policies/UserPolicy.php b/app/Domains/Accounts/Policies/UserPolicy.php
new file mode 100644
index 00000000..25a8f7c6
--- /dev/null
+++ b/app/Domains/Accounts/Policies/UserPolicy.php
@@ -0,0 +1,139 @@
+isOwner();
+ }
+
+ /**
+ * Reading one member.
+ */
+ public function view(User $user, User $model): bool
+ {
+ return $this->mayActOn($user, $model);
+ }
+
+ /**
+ * Adding a member.
+ *
+ * No target exists yet, so ownership of the header company is the whole
+ * decision.
+ */
+ public function create(User $user): bool
+ {
+ return $user->isOwner();
+ }
+
+ /**
+ * Editing one member.
+ */
+ public function update(User $user, User $model): bool
+ {
+ return $this->mayActOn($user, $model);
+ }
+
+ /**
+ * Removing one member.
+ *
+ * Nothing routes here today — member removal arrives through the bulk gate
+ * below — but the tenant half is applied all the same.
+ */
+ public function delete(User $user, User $model): bool
+ {
+ return $this->mayActOn($user, $model);
+ }
+
+ /**
+ * Bringing back a removed member.
+ *
+ * Unreachable: accounts are erased outright rather than soft-deleted. Note
+ * that the target is ignored, so ownership alone would answer this.
+ */
+ public function restore(User $user, User $model): bool
+ {
+ return $user->isOwner();
+ }
+
+ /**
+ * Erasing a member for good.
+ *
+ * Unreachable for the same reason, and likewise blind to the target.
+ */
+ public function forceDelete(User $user, User $model): bool
+ {
+ return $user->isOwner();
+ }
+
+ /**
+ * Inviting a member.
+ *
+ * Nothing calls this. The declaration is kept as found, return type
+ * included — that is, without one — and the target goes unexamined.
+ */
+ public function invite(User $user, User $model)
+ {
+ return $user->isOwner();
+ }
+
+ /**
+ * Removing members in bulk.
+ *
+ * Reached as a gate rather than through a model, so there is no target to
+ * confine: ownership of the header company opens the whole operation, and
+ * the ids it is handed are resolved installation-wide.
+ */
+ public function deleteMultiple(User $user)
+ {
+ return $user->isOwner();
+ }
+
+ /**
+ * Both halves: own the header company, and have the target inside it.
+ */
+ private function mayActOn(User $user, User $target): bool
+ {
+ return $user->isOwner() && $this->isMemberOfActiveCompany($target);
+ }
+
+ /**
+ * Membership of the company carried by the request header.
+ *
+ * Without this the target would be looked up by installation-wide id, and
+ * one company's owner could reach another company's people.
+ */
+ private function isMemberOfActiveCompany(User $target): bool
+ {
+ $activeCompanyId = request()->header('company');
+
+ if (! $activeCompanyId) {
+ return false;
+ }
+
+ return $target->companies()->whereKey($activeCompanyId)->exists();
+ }
+}