From d5dc446b245f002032b7b73397dd4d64dbf1fa79 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Thu, 20 Aug 2026 23:48:22 +0200 Subject: [PATCH] feat(accounts): fresh accounts implementation --- .../Accounts/Application/CompanyService.php | 176 +++++ .../Accounts/Application/MemberService.php | 123 ++++ .../Http/Controllers/Auth/AuthController.php | 126 ++++ .../Auth/ForgotPasswordController.php | 58 ++ .../Http/Controllers/Auth/LoginController.php | 67 ++ .../Auth/ResetPasswordController.php | 96 +++ .../Company/AbilitiesController.php | 27 + .../Controllers/Company/CompanyController.php | 106 +++ .../Company/CompanySettingsController.php | 136 ++++ .../Controllers/Company/MembersController.php | 149 +++++ .../Controllers/Company/RolesController.php | 134 ++++ .../Accounts/Http/Middleware/Authenticate.php | 30 + .../Http/Middleware/CompanyMiddleware.php | 52 ++ .../Middleware/RedirectIfAuthenticated.php | 35 + .../Middleware/RedirectIfUnauthorized.php | 45 ++ .../Accounts/Http/Middleware/ScopeBouncer.php | 43 ++ .../Accounts/Http/Requests/AvatarRequest.php | 50 ++ .../Http/Requests/CompaniesRequest.php | 104 +++ .../Http/Requests/CompanyLogoRequest.php | 43 ++ .../Accounts/Http/Requests/CompanyRequest.php | 89 +++ .../Http/Requests/DeleteMemberRequest.php | 40 ++ .../Http/Requests/GetSettingsRequest.php | 40 ++ .../Accounts/Http/Requests/LoginRequest.php | 41 ++ .../Accounts/Http/Requests/MemberRequest.php | 82 +++ .../Accounts/Http/Requests/ProfileRequest.php | 45 ++ .../Accounts/Http/Requests/RoleRequest.php | 58 ++ .../Http/Requests/UpdateSettingsRequest.php | 35 + .../Http/Resources/CompanyResource.php | 78 +++ .../CustomerPortal/CompanyResource.php | 39 ++ .../Resources/CustomerPortal/UserResource.php | 56 ++ .../Accounts/Http/Resources/RoleResource.php | 49 ++ .../Accounts/Http/Resources/UserResource.php | 59 ++ app/Domains/Accounts/Models/Company.php | 303 +++++++++ .../Accounts/Models/CompanySetting.php | 102 +++ app/Domains/Accounts/Models/User.php | 621 ++++++++++++++++++ app/Domains/Accounts/Models/UserSetting.php | 32 + .../MailResetPasswordNotification.php | 61 ++ .../Accounts/Policies/CompanyPolicy.php | 64 ++ app/Domains/Accounts/Policies/OwnerPolicy.php | 27 + app/Domains/Accounts/Policies/RolePolicy.php | 78 +++ .../Accounts/Policies/SettingsPolicy.php | 30 + app/Domains/Accounts/Policies/UserPolicy.php | 139 ++++ 42 files changed, 3768 insertions(+) create mode 100644 app/Domains/Accounts/Application/CompanyService.php create mode 100644 app/Domains/Accounts/Application/MemberService.php create mode 100644 app/Domains/Accounts/Http/Controllers/Auth/AuthController.php create mode 100644 app/Domains/Accounts/Http/Controllers/Auth/ForgotPasswordController.php create mode 100644 app/Domains/Accounts/Http/Controllers/Auth/LoginController.php create mode 100644 app/Domains/Accounts/Http/Controllers/Auth/ResetPasswordController.php create mode 100644 app/Domains/Accounts/Http/Controllers/Company/AbilitiesController.php create mode 100644 app/Domains/Accounts/Http/Controllers/Company/CompanyController.php create mode 100644 app/Domains/Accounts/Http/Controllers/Company/CompanySettingsController.php create mode 100644 app/Domains/Accounts/Http/Controllers/Company/MembersController.php create mode 100644 app/Domains/Accounts/Http/Controllers/Company/RolesController.php create mode 100644 app/Domains/Accounts/Http/Middleware/Authenticate.php create mode 100644 app/Domains/Accounts/Http/Middleware/CompanyMiddleware.php create mode 100644 app/Domains/Accounts/Http/Middleware/RedirectIfAuthenticated.php create mode 100644 app/Domains/Accounts/Http/Middleware/RedirectIfUnauthorized.php create mode 100644 app/Domains/Accounts/Http/Middleware/ScopeBouncer.php create mode 100644 app/Domains/Accounts/Http/Requests/AvatarRequest.php create mode 100644 app/Domains/Accounts/Http/Requests/CompaniesRequest.php create mode 100644 app/Domains/Accounts/Http/Requests/CompanyLogoRequest.php create mode 100644 app/Domains/Accounts/Http/Requests/CompanyRequest.php create mode 100644 app/Domains/Accounts/Http/Requests/DeleteMemberRequest.php create mode 100644 app/Domains/Accounts/Http/Requests/GetSettingsRequest.php create mode 100644 app/Domains/Accounts/Http/Requests/LoginRequest.php create mode 100644 app/Domains/Accounts/Http/Requests/MemberRequest.php create mode 100644 app/Domains/Accounts/Http/Requests/ProfileRequest.php create mode 100644 app/Domains/Accounts/Http/Requests/RoleRequest.php create mode 100644 app/Domains/Accounts/Http/Requests/UpdateSettingsRequest.php create mode 100644 app/Domains/Accounts/Http/Resources/CompanyResource.php create mode 100644 app/Domains/Accounts/Http/Resources/CustomerPortal/CompanyResource.php create mode 100644 app/Domains/Accounts/Http/Resources/CustomerPortal/UserResource.php create mode 100644 app/Domains/Accounts/Http/Resources/RoleResource.php create mode 100644 app/Domains/Accounts/Http/Resources/UserResource.php create mode 100644 app/Domains/Accounts/Models/Company.php create mode 100644 app/Domains/Accounts/Models/CompanySetting.php create mode 100644 app/Domains/Accounts/Models/User.php create mode 100644 app/Domains/Accounts/Models/UserSetting.php create mode 100644 app/Domains/Accounts/Notifications/MailResetPasswordNotification.php create mode 100644 app/Domains/Accounts/Policies/CompanyPolicy.php create mode 100644 app/Domains/Accounts/Policies/OwnerPolicy.php create mode 100644 app/Domains/Accounts/Policies/RolePolicy.php create mode 100644 app/Domains/Accounts/Policies/SettingsPolicy.php create mode 100644 app/Domains/Accounts/Policies/UserPolicy.php 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(); + } +}